Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f035fa274f | ||
|
|
69fd9d7c41 | ||
|
|
8e1b9d8dd2 | ||
|
|
91740fb44c | ||
|
|
b445b089c8 | ||
|
|
98f91e5d74 | ||
|
|
fddf330ab2 | ||
|
|
dcb412c7e4 | ||
|
|
21cf0e97af | ||
|
|
4a82e90fcb | ||
|
|
f240059d1e | ||
|
|
08649f027e | ||
|
|
a26a626d21 | ||
|
|
4a0fc1bf42 | ||
|
|
1870171e45 | ||
|
|
6c6db59ba9 | ||
|
|
21fa7cffd2 | ||
|
|
51c292f25b | ||
|
|
520b4084eb | ||
|
|
ce1f69c9bb | ||
|
|
f88eca848b | ||
|
|
653a50f737 | ||
|
|
429aaedba9 | ||
|
|
cf9798be32 | ||
|
|
c84bf79d9d | ||
|
|
bc2fca4e97 | ||
|
|
542c038801 | ||
|
|
c2295fba2c | ||
|
|
f808efdc80 | ||
|
|
8290b813f1 | ||
|
|
4268f8df6b | ||
|
|
919f75cf9e | ||
|
|
28dcea5fb6 | ||
|
|
778726f943 | ||
|
|
0b8bad8879 | ||
|
|
575b2138c1 | ||
|
|
9a7e0481ef | ||
|
|
f337ebedfd | ||
|
|
e929f65948 | ||
|
|
57e4898e87 | ||
|
|
85eca0c867 | ||
|
|
a76d818580 |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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 '文件管理表';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 '报装申请表';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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-已完成';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
@@ -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/; }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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个片区历史数据迁移
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
@@ -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">×</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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: '© 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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: '营收'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ? '登录中...' : '登录'))),
|
||||
])),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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": ["安全", "监控", "警报"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -1,4 +0,0 @@
|
||||
"""
|
||||
BI模块 - 自助BI看板和数据可视化
|
||||
集成Superset/Metabase功能
|
||||
"""
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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.
Binary file not shown.
-177
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
Reference in New Issue
Block a user