diff --git a/docker/ci/build.sh b/docker/ci/build.sh
new file mode 100644
index 00000000..7c963a3a
--- /dev/null
+++ b/docker/ci/build.sh
@@ -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"
diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf
new file mode 100644
index 00000000..d75850d9
--- /dev/null
+++ b/docker/frontend/nginx.conf
@@ -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/; }
+}
diff --git a/docker/geoserver/init.sh b/docker/geoserver/init.sh
new file mode 100644
index 00000000..efdeddf9
--- /dev/null
+++ b/docker/geoserver/init.sh
@@ -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 'water_management' || true
+
+echo "GeoServer init done"
diff --git a/docker/wm-base/Dockerfile b/docker/wm-base/Dockerfile
new file mode 100644
index 00000000..d6d5f60e
--- /dev/null
+++ b/docker/wm-base/Dockerfile
@@ -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"]
diff --git a/docker/wm-bi/Dockerfile b/docker/wm-bi/Dockerfile
new file mode 100644
index 00000000..dcf58e47
--- /dev/null
+++ b/docker/wm-bi/Dockerfile
@@ -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"]
diff --git a/docker/wm-bpm/Dockerfile b/docker/wm-bpm/Dockerfile
new file mode 100644
index 00000000..323c1d09
--- /dev/null
+++ b/docker/wm-bpm/Dockerfile
@@ -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"]
diff --git a/docker/wm-data-engine/Dockerfile b/docker/wm-data-engine/Dockerfile
new file mode 100644
index 00000000..d9b54e08
--- /dev/null
+++ b/docker/wm-data-engine/Dockerfile
@@ -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"]
diff --git a/docker/wm-iot/Dockerfile b/docker/wm-iot/Dockerfile
new file mode 100644
index 00000000..5b3adcf2
--- /dev/null
+++ b/docker/wm-iot/Dockerfile
@@ -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"]
diff --git a/docker/wm-job/Dockerfile b/docker/wm-job/Dockerfile
new file mode 100644
index 00000000..cdcc5020
--- /dev/null
+++ b/docker/wm-job/Dockerfile
@@ -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"]
diff --git a/docker/wm-notify/Dockerfile b/docker/wm-notify/Dockerfile
new file mode 100644
index 00000000..b093a9f8
--- /dev/null
+++ b/docker/wm-notify/Dockerfile
@@ -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"]
diff --git a/docker/wm-patrol/Dockerfile b/docker/wm-patrol/Dockerfile
new file mode 100644
index 00000000..365890a8
--- /dev/null
+++ b/docker/wm-patrol/Dockerfile
@@ -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"]
diff --git a/docker/wm-production/Dockerfile b/docker/wm-production/Dockerfile
new file mode 100644
index 00000000..7f652e0d
--- /dev/null
+++ b/docker/wm-production/Dockerfile
@@ -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"]
diff --git a/docker/wm-revenue/Dockerfile b/docker/wm-revenue/Dockerfile
new file mode 100644
index 00000000..5c0ab69b
--- /dev/null
+++ b/docker/wm-revenue/Dockerfile
@@ -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"]
diff --git a/frontend/src/components/charts/MapView.vue b/frontend/src/components/charts/MapView.vue
new file mode 100644
index 00000000..6f41536d
--- /dev/null
+++ b/frontend/src/components/charts/MapView.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
diff --git a/wm-iot/src/main/java/com/water/iot/consumer/IotTelemetryConsumer.java b/wm-iot/src/main/java/com/water/iot/consumer/IotTelemetryConsumer.java
new file mode 100644
index 00000000..8a2bc53c
--- /dev/null
+++ b/wm-iot/src/main/java/com/water/iot/consumer/IotTelemetryConsumer.java
@@ -0,0 +1,22 @@
+package com.water.iot.consumer;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class IotTelemetryConsumer {
+
+ @KafkaListener(topics = "iot.telemetry", groupId = "wm-iot-consumer")
+ public void consumeTelemetry(String message) {
+ log.debug("Received telemetry: {}", message);
+ // TODO: 解析 json -> 写入 TDengine
+ }
+
+ @KafkaListener(topics = "iot.event", groupId = "wm-iot-consumer")
+ public void consumeEvent(String message) {
+ log.info("Device event: {}", message);
+ // TODO: 处理上下线/故障事件
+ }
+}
diff --git a/wm-iot/src/main/java/com/water/iot/controller/DeviceController.java b/wm-iot/src/main/java/com/water/iot/controller/DeviceController.java
new file mode 100644
index 00000000..5b3702bd
--- /dev/null
+++ b/wm-iot/src/main/java/com/water/iot/controller/DeviceController.java
@@ -0,0 +1,52 @@
+package com.water.iot.controller;
+
+import com.water.common.core.result.R;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "设备管理")
+@RestController
+@RequestMapping("/device")
+@RequiredArgsConstructor
+public class DeviceController {
+
+ private final JdbcTemplate jdbcTemplate;
+
+ @Operation(summary = "设备列表")
+ @GetMapping("/list")
+ public R>> list(@RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "10") int size) {
+ int offset = (page - 1) * size;
+ String sql = "SELECT id, device_sn, device_name, device_type, area, status, last_report_time FROM iot_device ORDER BY id LIMIT ? OFFSET ?";
+ return R.ok(jdbcTemplate.queryForList(sql, size, offset));
+ }
+
+ @Operation(summary = "设备详情")
+ @GetMapping("/{id}")
+ public R