chore(repo): 导入 M-Z 业务底座 V1.9 基线并建立仓库规范

按周工交付的 SEC-Z-01 清理后源码建立 M-Z 主干,并按 Git 仓库惯例重排目录。

- aomin-gateway/  后端工程 87 文件,原样保留,路径与构建命令未变
- demo/           演示前端与设备模拟器 9 文件(原 02_前端与设备端),非正式三端
- docs/handover/  交付与安全清理材料 6 份,内容未改
- docs/contract/  J-00 规格、OpenAPI、内部五接口、状态机、Mock 样例
- docs/reference/ 指纹与校验清单,含 84 个源码文件的 sha256 清单

目录重排只动位置不动内容:源码树 84 文件哈希与交付一致,逐文件自校验通过;
12 条 Flyway 迁移与交付包内镜像逐字节一致。

未纳入:迁移镜像目录(与工程内一套重复,改留指纹)、target/、证据 raw、
应急恢复脚本、评审用虚构台账数据。

安全:SEC-Z-01 15 处明文已清零,导入前重新扫描无新增命中,本仓库无历史污染。
行尾按交付字节原样保存(.gitattributes 设 * -text),不做归一化以免整树摘要失效。
This commit is contained in:
aomin committed 2026-11-13 17:32:37 +08:00
commit a7605ef8b8
121 files changed
+23629

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
# 前端与设备端源码(演示用)
> **这不是主干三端。** 按 AM-S1-GOV-020 §6.2(3),正式三端的接入由陈工统一完成。
> 这份是 M-Z 配套的**演示前端 + 设备模拟器**,用途只有两个:跑通链路、看接口效果。
---
## 一、目录
```
web/
├── index.html 首页(三个入口 + 账号与地址)
├── common.js 公共库:token 命名空间、fetch 封装、toast
├── admin/index.html 运营后台(登录、设备、告警、住户、用户、台账…)
└── family/
├── index.html 家属端 H5(告警、探视、设置)
├── map-baidu.html 百度地图选点
├── map-offline.html 离线地图(无 AK 时自动降级到这里)
└── map-picker.html 天地图选点(含运行时占位符)
sim/
└── sim_device.py 设备模拟器:扮演网关 + 7 台设备,只调 I-01/I-02/I-12
```
| 文件 | 行数 | 说明 |
|---|---|---|
| `web/admin/index.html` | 1110 | 体量最大,六个业务页签 |
| `web/family/index.html` | 1763 | 家属端全部功能 |
| `sim/sim_device.py` | 578 | 纯 Python 标准库,无第三方依赖 |
---
## 二、怎么跑
### 方式一:跟着后端跑(推荐)
后端把 `/app/**` 映射到 `biz.web.dir` 指向的目录:
```bash
set DB_PASS=<口令>
set BIZ_DEMO_ENABLED=true
java -Dbiz.web.dir="<本目录>/web" -jar aomin-gateway/target/aomin-gateway-0.0.1-SNAPSHOT.jar ^
--server.port=8080
```
打开:
| 地址 | 页面 |
|---|---|
| `http://127.0.0.1:8080/app/index.html` | 首页 |
| `http://127.0.0.1:8080/app/family/index.html` | 家属端 |
| `http://127.0.0.1:8080/app/admin/index.html` | 运营后台 |
> 页面里写的接口地址是**相对路径**(`/api/v1/...`),所以必须经后端提供,不能双击 html 直接开——那样会 404。
### 方式二:设备模拟器
```bash
python sim/sim_device.py # 默认 9901 控制台,往 8080 发事件
python sim/sim_device.py --fast # 快节奏(每轮 3 秒)
python sim/sim_device.py --seed # 启动先补历史告警
python sim/sim_device.py --quiet # 只开控制台,不自动产生事件
```
可覆盖的环境变量:
| 变量 | 默认 | 说明 |
|---|---|---|
| `SIM_BASE` | `http://127.0.0.1:8080` | 目标 M 层地址 |
| `SIM_PORT` | `9901` | 模拟器控制台端口 |
---
## 三、运行时占位符(4 个,**不要改成具体值**)
| 占位符 | 出现位置 | 部署时替换成 | 不替换会怎样 |
|---|---|---|---|
| `__BAIDU_AK__` | `family/map-baidu.html`(2 处) | 自己的百度地图 AK;或启动前 `window.BAIDU_AK = 'xxx'` 注入 | 地图加载失败,**自动降级到离线地图**,不影响其他功能 |
| `__WB_HTTP_PORT__` | `family/map-picker.html` | 天地图服务端口 | 选点页不可用 |
| `__WB_TMAP_SECRET__` | `family/map-picker.html` | 天地图服务密钥 | 选点页不可用 |
| `__PORT__` | `sim_device.py` 内置控制台页面 | 由脚本自己按 `SIM_PORT` 替换,不用手改 | — |
> `map-picker.html` 第 10 行原本就写着「`__WB_HTTP_PORT__` / `__WB_TMAP_SECRET__` 是运行时占位符,禁止改成具体值、禁止内嵌任何 key」——按这条执行。
---
## 四、本次做了什么清理(2026-11-13)
| 改动 | 原因 |
|---|---|
| 删掉后台登录页「密码」输入框里的预填值 | 原来 `value` 里写死了演示口令,打开页面就明文可见 |
| 两处"演示账号 admin / 口令"的提示改为"口令按部署说明" | 页面里不再出现任何口令字面值 |
| 百度 AK 由真实值改为 `__BAIDU_AK__` 占位符 | 原 AK 是真实密钥,进 git 会永久留痕、可被盗刷配额 |
| 首页"flyway 1.0.18 / 83 表"改为"1.0.28 / 87 表" | 旧数字,与当前迁移不一致 |
**没动的**:家属端演示账号 `13800000000 / 123456` 的提示保留——那是 `BIZ_DEMO_ENABLED=true` 时库里真实播种的演示数据,不是系统凭据;只在不显示演示数据的环境才会失效。
---
## 五、边界
| 项 | 说明 |
|---|---|
| 不是正式三端 | 演示页,不做兼容性、无障碍、国际化;正式三端由陈工按 §6.2(3) 接入 |
| 接口是相对路径 | 必须由后端 `/app/**` 提供,不能独立部署到别的域名下直接调 |
| 地图依赖外部服务 | 百度/天地图都在公网;内网环境会走离线地图降级 |
| 权限只做前端遮挡 | 真正的数据权限在后端(A-Z-03 / AUTH-01),页面隐藏按钮不等于没权限 |
| 模拟器是"网关+设备" | 它只调 I-01/I-02/I-12,不碰任何业务接口,和真设备走同一条路径 |
---
*配套:后端源码与契约见 `周工_SEC-Z-01清理后_源码与说明_20261113.zip`。*
+578
View File
@@ -0,0 +1,578 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
鹤仆云 · 设备模拟器(演示用)
它扮演的角色是「网关 + 设备」,不是业务层。数据路径和真设备完全一致:
sim_device.py ──HTTP──→ M 层 /internal/iot/v1/** ──→ iot_event
│
AlarmEngine(业务层)
↓
alarm + notify_task
↓
家属端 H5
所以模拟器只调三个内部接口,一个业务接口都不碰:
I-01 POST /internal/iot/v1/events/batch 事件
I-02 POST /internal/iot/v1/device-status/batch 状态快照(电量/信号/在线)
I-12 POST /internal/iot/v1/raw-frames 原文帧(Base64)
用法:
python sim_device.py 正常节奏(门磁、水振、电流随机冒出)
python sim_device.py --fast 快节奏,演示时用
python sim_device.py --seed 启动先补几条历史,页面一打开就有内容
python sim_device.py --quiet 只开控制台,不自动产生事件
控制台:http://127.0.0.1:9901
—— 按钮按设备类型出现(门磁只有开门/关门,水浸只有漏水/已止),
选错设备发不出对应的告警——真实设备也是这样。
"""
import argparse
import base64
import hashlib
import json
import os
import random
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
BASE = os.environ.get("SIM_BASE", "http://127.0.0.1:8080")
PORT = int(os.environ.get("SIM_PORT", "9901"))
CST = timezone(timedelta(hours=8))
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
# ---------------------------------------------------------------- 设备台账
# sensor_id / source_channel 必须和 01_初始化_设备台账.sql 对得上,
# 对不上就会被 M 层判成「未建档」丢进 unregistered_device,家属端看不见。
#
# ★ type 必须和库里 device.type_code 一致(2026-11-09 修):
# 之前 3F7A9C23 在这里写成「厨房水浸」,库里其实是「床头SOS」,
# 结果控制台选门磁点 SOS 也能发出去、还生成告警——纯属模拟器在骗自己。
# 通道名必须是后端 Channel 枚举的四个之一:OWN_GW / HORN_HOST / HORN_CELL / MIR_CELL。
# (曾经写成 H_MQTT,后端直接 400 "sourceChannel 非法",断电/来电永远发不出去)
DEVICES = [
dict(sn="3F7A9C21", dev="T-G-01", name="入户门磁", type="DOOR", ch="OWN_GW", hh=9001, loc="入户门"),
dict(sn="3F7A9C22", dev="T-G-02", name="卧室门磁", type="DOOR", ch="OWN_GW", hh=9003, loc="卧室"),
dict(sn="3F7A9C23", dev="T-G-03", name="床头SOS", type="SOS", ch="OWN_GW", hh=9001, loc="卧室床头"),
dict(sn="3F7A9C24", dev="T-G-04", name="厨房水浸", type="WATER", ch="OWN_GW", hh=9003, loc="厨房水槽下"),
dict(sn="MIR:861234567890123", dev="T-S-01", name="卫生间水浸", type="WATER", ch="MIR_CELL", hh=9001, loc="卫生间"),
dict(sn="HORN:SN12345678", dev="T-H-01", name="客厅烟感", type="SMOKE", ch="HORN_HOST", hh=9001, loc="客厅"),
# 水振(麦乐克那台 HORN:999111 也在库里,型号 VIB)。它报的是"用水活动",
# 不是告警——放进来是为了能演示"这类事件只进基线、不弹窗"。
dict(sn="HORN:999111", dev="D1794213326404263", name="水振", type="VIB", ch="HORN_HOST", hh=None, loc="厨房水管"),
# 验收专用:一台"已入库、还没人绑"的门磁。账号 A 手填 A1B2C3D4 绑上之后,
# 在控制台把设备选成它再点「门打开了」,就能看见完整闭环。
dict(sn="A1B2C3D4", dev="T-G-09", name="验收门磁", type="DOOR", ch="OWN_GW", hh=9001, loc="验收"),
]
DEV_BY_SN = {d["sn"]: d for d in DEVICES}
# 设备类型码(J-00 §1.3 payload.deviceTypeRaw)
TYPE_RAW = {"DOOR": 1, "VIB": 2, "CURRENT": 3, "WATER": 4, "SOS": 5, "SMOKE": 6}
# 每种设备能干什么。列表里没有的动作,控制台不会显示,后端也会拒。
# 这不是"模拟器偷懒"——真实设备也一样:门磁没有水浸探针,物理上就发不出漏水。
ACTIONS = {
"DOOR": [("door", "🚪 门打开了", "warn"), ("door_close", "✅ 门关上了", "ok")],
"WATER": [("leak", "💧 漏水了", "hot"), ("leak_clear", "✅ 漏水已止", "ok")],
"SOS": [("sos", "🆘 按下 SOS", "hot")],
# 烟感:J-00 §1.5.1 是 SMOKE / SMOKE_CLEAR,规则表里 SECOND + URGENT,与漏水同一套
"SMOKE": [("smoke", "🔥 烟感报警", "hot"), ("smoke_clear", "✅ 烟雾已散", "ok")],
# 水振:J-00 §1.5.3 里它报的是"用水活动" WATER_ACTIVITY / _STOP,不是告警事件。
# 它喂给基线和「水振+电流」组合研判,本身不弹窗——所以按钮上直接写清楚。
"VIB": [("vib", "💧 用水活动(不告警)", ""), ("vib_stop", "用水停止", "")],
}
# 任何设备都能做的:断电/来电(网关级事件)、心跳
COMMON_ACTIONS = [("power_off", "⚡ 设备断电", "warn"),
("power_on", "🔌 设备来电", "ok"),
("status", "📊 上报心跳", "")]
# 每台设备的运行态。seq 必须单调递增,否则 M 层会判成重传或断档。
STATE = {}
LOCK = threading.Lock()
COUNTER = {"n": 0}
LOG = [] # 最近 60 条动作,控制台轮询显示
LOG_CAP = 60
def now_iso(dt=None):
dt = dt or datetime.now(CST)
return dt.strftime("%Y-%m-%dT%H:%M:%S+08:00")
def log(msg):
line = time.strftime("%H:%M:%S ") + msg
with LOCK:
LOG.append(line)
del LOG[:-LOG_CAP]
print(line, flush=True)
# ---------------------------------------------------------------- HTTP
def post(path, body):
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(BASE + path, data=data, method="POST",
headers={"Content-Type": "application/json"})
try:
with OPENER.open(req, timeout=10) as r:
return json.loads(r.read().decode("utf-8") or "{}")
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "ignore")[:200]
return {"code": e.code, "msg": body}
except Exception as e: # noqa: BLE001
return {"code": -1, "msg": str(e)}
def next_seq(dev):
STATE[dev]["seq"] += 1
return STATE[dev]["seq"]
def raw_ref():
return hashlib.md5(str(random.random()).encode()).hexdigest()
# ---------------------------------------------------------------- 发事件
def emit(dev_sn, event_type, payload=None, when=None, channel=None, with_raw=True):
"""发一条事件。when 可传 datetime,用来补历史和制造断档场景。"""
d = DEV_BY_SN[dev_sn]
st = STATE[d["dev"]]
with LOCK:
COUNTER["n"] += 1
n = COUNTER["n"]
seq = next_seq(d["dev"])
ref = raw_ref()
ev = {
"eventId": "EVT-%s-%05d" % (time.strftime("%m%d%H%M%S"), n),
"sensorId": dev_sn,
"sourceChannel": channel or d["ch"],
"eventType": event_type,
"deviceTime": now_iso(when),
"receivedAt": now_iso(),
"tsSource": 3, # 3 = 网关打戳(G 线恒定)
"protocolVersion": "gw-1.2",
"seq": str(seq),
"payload": payload or {},
"rawRef": ref,
}
r = post("/internal/iot/v1/events/batch", {"sourceChannel": channel or d["ch"], "events": [ev]})
ok = r.get("code") == 0
acc = (r.get("data") or {}).get("accepted", 0) if ok else 0
if ok:
if acc:
log("✓ %s(%s)→ %s seq=%s" % (d["name"], dev_sn, event_type, seq))
else:
reason = json.dumps(r.get("data", {}), ensure_ascii=False)[:300]
log("○ %s(%s)→ %s 未被收下:%s" % (d["name"], dev_sn, event_type, reason))
else:
log("✗ %s(%s)→ %s 被 M 层拒绝:%s" % (d["name"], dev_sn, event_type, r.get("msg")))
if with_raw:
frame = base64.b64encode(b"\xAA\xBB" + bytes([seq % 256]) + dev_sn.encode()[:12]).decode()
post("/internal/iot/v1/raw-frames", {"sourceChannel": channel or d["ch"], "frames": [{
"rawRef": ref, "sensorId": dev_sn, "sourceChannel": channel or d["ch"],
"receivedAt": now_iso(), "rawFrame": frame, "frameBytes": len(frame),
"gatewayDevId": "GW-SIM-01", "protocolVersion": "gw-1.2",
}]})
return {"ok": bool(ok and acc), "accepted": acc,
"msg": None if ok else r.get("msg")}
def emit_status():
"""状态快照:电量、信号、在线。走 I-02,覆盖写 device_tech_cache。"""
statuses = []
for d in DEVICES:
st = STATE[d["dev"]]
statuses.append({
"sensorId": d["sn"],
"sourceChannel": d["ch"],
"online": st["online"],
"lastSeenAt": now_iso(),
"batteryMv": st["battery"],
"csq": st["csq"],
"lastSeq": st["seq"],
"lostCnt": 0,
"protocolVersion": "gw-1.2",
"reportedAt": now_iso(),
})
post("/internal/iot/v1/device-status/batch", {"sourceChannel": "OWN_GW", "statuses": statuses})
# ---------------------------------------------------------------- 场景
def init_state():
for d in DEVICES:
STATE[d["dev"]] = dict(
seq=random.randint(100, 400),
battery=random.randint(3450, 3900),
csq=random.randint(16, 28),
online=True,
door_open_at=None,
leak_at=None,
power_off_at=None,
)
def tick(fast=False):
"""一轮随机行为。概率按 5 秒一轮算,故意压低——真设备的动静比演示脚本小得多。"""
speed = 3.0 if fast else 1.0
g = DEV_BY_SN["3F7A9C21"]
st = STATE["T-G-01"]
# 门磁:开 → 过一会儿关
if st["door_open_at"] is None and random.random() < 0.10 * speed:
st["door_open_at"] = time.time()
st["battery"] -= random.randint(1, 3)
emit("3F7A9C21", "DOOR_OPEN", {"deviceTypeRaw": 1, "rawEventCode": 1, "batteryMv": st["battery"]})
elif st["door_open_at"] and time.time() - st["door_open_at"] > random.randint(20, 50):
st["door_open_at"] = None
emit("3F7A9C21", "DOOR_CLOSE", {"deviceTypeRaw": 1, "rawEventCode": 2, "batteryMv": st["battery"]})
# 水浸:漏了就等它自己解除
w = STATE["T-G-04"]
if w["leak_at"] is None and random.random() < 0.02 * speed:
w["leak_at"] = time.time()
emit("3F7A9C24", "WATER_LEAK", {"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": w["battery"]})
elif w["leak_at"] and time.time() - w["leak_at"] > random.randint(40, 100):
w["leak_at"] = None
emit("3F7A9C24", "WATER_LEAK_CLEAR", {"deviceTypeRaw": 4, "rawEventCode": 2, "batteryMv": w["battery"]})
# S 线水浸:裸 TCP 直连,没有网关打戳,tsSource 恒 2
s = STATE["T-S-01"]
if s["leak_at"] is None and random.random() < 0.012 * speed:
s["leak_at"] = time.time()
emit("MIR:861234567890123", "WATER_LEAK",
{"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": s["battery"]}, channel="MIR_CELL")
elif s["leak_at"] and time.time() - s["leak_at"] > random.randint(40, 90):
s["leak_at"] = None
emit("MIR:861234567890123", "WATER_LEAK_CLEAR",
{"deviceTypeRaw": 4, "rawEventCode": 2, "batteryMv": s["battery"]}, channel="MIR_CELL")
# SOS:真设备极少误触,这里给 0.4%。必须由 SOS 那台发,门磁发不出来。
if random.random() < 0.004 * speed:
st_sos = STATE["T-G-03"]
emit("3F7A9C23", "SOS_PRESS", {"deviceTypeRaw": 5, "rawEventCode": 1, "batteryMv": st_sos["battery"]})
def seed_history():
"""补几条历史,让页面一打开就有东西看。时间戳往回拨,走设备时间。"""
now = datetime.now(CST)
log("补历史:3 条(40 分钟前水浸、25 分钟前门磁、8 分钟前 SOS)")
emit("3F7A9C24", "WATER_LEAK", {"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": 3610},
when=now - timedelta(minutes=40))
emit("3F7A9C21", "DOOR_OPEN", {"deviceTypeRaw": 1, "rawEventCode": 1, "batteryMv": 3720},
when=now - timedelta(minutes=25))
emit("3F7A9C23", "SOS_PRESS", {"deviceTypeRaw": 5, "rawEventCode": 1, "batteryMv": 3720},
when=now - timedelta(minutes=8))
# ---------------------------------------------------------------- 控制台
PAGE = """<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备模拟器 · 鹤仆云</title>
<style>
:root{--ink:#1b2430;--sub:#6b7784;--line:#e4eaef;--brand:#146b7a;--red:#d94b4b}
*{box-sizing:border-box}
body{margin:0;background:#eef2f5;color:var(--ink);font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px}
.wrap{max-width:860px;margin:0 auto;padding:22px 18px 50px}
h1{font-size:20px;margin:0 0 4px}
.sub{color:var(--sub);font-size:12.5px;margin-bottom:18px}
.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px}
.card h2{font-size:14px;margin:0 0 12px;font-weight:600}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:9px}
button{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px 8px;font-size:13px;
cursor:pointer;font-weight:500;transition:.15s}
button:hover{border-color:var(--brand);color:var(--brand)}
button.hot{background:#fdeaea;border-color:#f5c6c6;color:#c0392b}
button.warn{background:#fdf3e3;border-color:#f3ddb8;color:#a9741c}
button.ok{background:#e9f5ef;border-color:#c6e5d5;color:#2f9e68}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:var(--sub);font-weight:500;padding:6px 8px;border-bottom:1px solid var(--line);font-size:12px}
td{padding:7px 8px;border-bottom:1px solid #f0f4f6}
.mono{font-family:Consolas,monospace;font-size:12px}
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px}
.on{background:#2f9e68}.off{background:#b9c3cb}
#log{background:#16262f;color:#b8cdd6;border-radius:10px;padding:12px;font-family:Consolas,monospace;
font-size:12px;line-height:1.75;height:210px;overflow-y:auto;white-space:pre-wrap}
.tip{font-size:12px;color:var(--sub);line-height:1.8}
</style></head><body><div class="wrap">
<h1>设备模拟器</h1>
<div class="sub">扮演网关和 7 台设备,往 M 层发真实格式的报文 · 控制台 <b>127.0.0.1:__PORT__</b></div>
<div class="card"><h2>按设备发事件</h2>
<div style="margin:0 0 10px;font-size:13px;color:#5a6b76">
从哪台设备发:
<select id="snsel" style="padding:6px 10px;border-radius:8px;border:1px solid #dfe8ec;
font-size:13px;background:#fff;color:#1b2430;margin-left:6px"></select>
<span style="font-size:12px;color:#8b9aa1"> 验收新绑的设备时记得切到这里</span>
</div>
<div id="devhint" style="font-size:12.5px;color:#8b9aa1;line-height:1.7;margin-bottom:10px"></div>
<div class="grid" id="acts"></div>
<div id="tmsg" style="font-size:12.5px;line-height:1.7;margin-top:10px;min-height:18px"></div>
</div>
<div class="card"><h2>设备状态</h2>
<table id="devs"><thead><tr><th>设备</th><th>设备编号</th><th>类型</th><th>电量</th><th>信号</th><th>seq</th></tr></thead><tbody></tbody></table></div>
<div class="card"><h2>最近动作</h2><div id="log"></div></div>
<div class="card tip">
数据流:本页 → <span class="mono">POST /internal/iot/v1/events/batch</span> → M 层入库 iot_event
→ 告警引擎(入库即触发,30 秒兜底扫一次)→ alarm → 家属端。<br>
关掉这个窗口不影响已产生的数据;想看新数据得让它继续跑。
</div>
</div>
<script>
// 每种设备只显示它发得出的动作。门磁没有水浸探针,界面上就不该出现"漏水"按钮——
// 以前 8 个按钮常驻,选门磁也能点漏水,纯属误导(2026-11-09 改)。
var ACTIONS={
DOOR:[['door','🚪 门打开了','warn'],['door_close','✅ 门关上了','ok']],
WATER:[['leak','💧 漏水了','hot'],['leak_clear','✅ 漏水已止','ok']],
SOS:[['sos','🆘 按下 SOS','hot']],
SMOKE:[['smoke','🔥 烟感报警','hot'],['smoke_clear','✅ 烟雾已散','ok']],
VIB:[['vib','💧 用水活动(不告警)',''],['vib_stop','用水停止','']]
};
var TYPE_NAME={DOOR:'门磁',WATER:'水浸',SOS:'紧急按钮',SMOKE:'烟感',VIB:'水振'};
var COMMON=[['power_off','⚡ 设备断电','warn'],['power_on','🔌 设备来电','ok'],['status','📊 上报心跳','']];
var DEVS=[];
function curDev(){
var sel=document.getElementById('snsel');
var sn=sel?sel.value:'';
return DEVS.filter(function(x){return x.sn===sn;})[0]||DEVS[0]||null;
}
function renderBtns(){
var d=curDev(); if(!d) return;
var list=(ACTIONS[d.type]||[]).concat(COMMON);
document.getElementById('acts').innerHTML=list.map(function(a){
return '<button class="'+a[2]+'" onclick="t(\\''+a[0]+'\\')">'+a[1]+'</button>';
}).join('');
var hint='当前:<b>'+d.name+'</b> · '+d.sn+' · '+(TYPE_NAME[d.type]||d.type);
if(d.type==='VIB'){
hint+=' <span style="color:#a9741c">水振报的是"用水活动",不是告警——只进基线和组合研判,不会弹窗</span>';
}
hint+='<br><span style="color:#8b9aa1">恢复类事件(门关上 / 漏水已止 / 来电)只关闭"还没恢复"的那条告警,不会新建告警。</span>';
document.getElementById('devhint').innerHTML=hint;
}
function t(k){
var sel=document.getElementById('snsel'), sn=sel?sel.value:'';
var box=document.getElementById('tmsg');
box.style.color='#5a6b76'; box.textContent='发送中…';
fetch('/api/trigger?k='+k+(sn?'&sn='+encodeURIComponent(sn):''),{method:'POST'})
.then(function(r){return r.json()})
.then(function(d){
box.style.color = d.code===0 ? '#2f9e68' : '#c0392b';
box.textContent = (d.code===0?'✓ ':'✗ ') + (d.msg||'');
poll();
})
.catch(function(e){box.style.color='#c0392b';box.textContent='✗ 请求失败:'+e});
}
var SN_FILLED=false;
function fillSn(list){
if(SN_FILLED||!list) return;
DEVS=list;
document.getElementById('snsel').innerHTML=list.map(function(x){
return '<option value="'+x.sn+'">'+x.name+' · '+x.sn+'</option>';
}).join('');
document.getElementById('snsel').onchange=renderBtns;
SN_FILLED=true;
renderBtns();
}
function poll(){
fetch('/api/state').then(function(r){return r.json()}).then(function(d){
fillSn(d.devices);
document.getElementById('devs').querySelector('tbody').innerHTML = d.devices.map(function(x){
return '<tr><td><span class="dot '+(x.online?'on':'off')+'"></span>'+x.name+'</td>'+
'<td class="mono">'+x.sn+'</td><td>'+(TYPE_NAME[x.type]||x.type)+'</td>'+
'<td>'+x.battery+' mV</td><td>CSQ '+x.csq+'</td>'+
'<td class="mono">seq '+x.seq+'</td></tr>';
}).join('');
const box=document.getElementById('log');
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 10;
box.textContent = d.log.slice().reverse().join('\\n');
if(atBottom) box.scrollTop = 0;
})
}
poll(); setInterval(poll, 3000);
</script></body></html>"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a): # 关掉默认访问日志,别刷屏
pass
def _send(self, code, ctype, body):
b = body.encode("utf-8") if isinstance(body, str) else body
self.send_response(code)
self.send_header("Content-Type", ctype + "; charset=utf-8")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def do_GET(self):
if self.path in ("/", "/index.html"):
self._send(200, "text/html", PAGE.replace("__PORT__", str(PORT)))
elif self.path.startswith("/api/state"):
with LOCK:
devs = [dict(name=d["name"], sn=d["sn"], type=d["type"],
battery=STATE[d["dev"]]["battery"],
csq=STATE[d["dev"]]["csq"], seq=STATE[d["dev"]]["seq"],
online=STATE[d["dev"]]["online"]) for d in DEVICES]
lg = list(LOG)
self._send(200, "application/json",
json.dumps({"devices": devs, "log": lg}, ensure_ascii=False))
else:
self._send(404, "text/plain", "not found")
def do_POST(self):
if not self.path.startswith("/api/trigger"):
return self._send(404, "text/plain", "not found")
k = ""
sn = None
if "k=" in self.path:
k = self.path.split("k=", 1)[1].split("&")[0]
if "sn=" in self.path:
sn = urllib.parse.unquote(self.path.split("sn=", 1)[1].split("&")[0])
try:
res = trigger(k, sn)
body = json.dumps({"code": 0 if res.get("ok") else 1,
"msg": res.get("msg") or ""}, ensure_ascii=False)
self._send(200, "application/json", body)
except Exception as e: # noqa: BLE001
self._send(500, "application/json", json.dumps({"code": 1, "msg": str(e)}, ensure_ascii=False))
def trigger(k, sn=None):
"""控制台按钮 → 一条事件。全部走真实接口,不直接改库。
★ <b>必须按设备类型把关</b>(2026-11-09 改):门磁没有水浸探针,物理上就发不出
WATER_LEAK;SOS 按钮也不该拿门磁的编号发。以前不校验,选「入户门磁」点 SOS
照样生成 S1 告警——不是系统聪明,是模拟器在骗自己。
返回 {"ok": bool, "msg": 给页面弹的话}
"""
d = DEV_BY_SN.get(sn) if sn else DEVICES[0]
if d is None:
return {"ok": False, "msg": "未知设备编号 " + str(sn)}
st = STATE[d["dev"]]
raw = TYPE_RAW.get(d["type"], 1)
allowed = [a[0] for a in ACTIONS.get(d["type"], [])]
all_actions = {a[0]: a[1] for a in ACTIONS.get(d["type"], []) + COMMON_ACTIONS}
if k not in allowed and k not in [a[0] for a in COMMON_ACTIONS]:
return {"ok": False,
"msg": "%s 是「%s」,没有这个动作。(%s 只能发:%s)" % (
d["sn"], d["name"], d["name"],
"、".join(all_actions.get(x, x) for x in allowed) or "只能断电/来电/心跳")}
if k == "sos":
r = emit(d["sn"], "SOS_PRESS", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]})
elif k == "leak":
st["leak_at"] = time.time()
r = emit(d["sn"], "WATER_LEAK", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]})
elif k == "leak_clear":
st["leak_at"] = None
r = emit(d["sn"], "WATER_LEAK_CLEAR", {"deviceTypeRaw": raw, "rawEventCode": 2, "batteryMv": st["battery"]})
elif k == "door":
st["door_open_at"] = time.time()
r = emit(d["sn"], "DOOR_OPEN", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]})
elif k == "door_close":
st["door_open_at"] = None
r = emit(d["sn"], "DOOR_CLOSE", {"deviceTypeRaw": raw, "rawEventCode": 2, "batteryMv": st["battery"]})
elif k == "power_off":
st["online"] = False
r = emit(d["sn"], "POWER_OFF", {"deviceTypeRaw": raw, "rawEventCode": 5})
elif k == "power_on":
st["online"] = True
r = emit(d["sn"], "POWER_ON", {"deviceTypeRaw": raw, "rawEventCode": 6})
elif k == "smoke":
r = emit(d["sn"], "SMOKE", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]})
elif k == "smoke_clear":
r = emit(d["sn"], "SMOKE_CLEAR", {"deviceTypeRaw": raw, "rawEventCode": 2, "batteryMv": st["battery"]})
elif k == "vib":
r = emit(d["sn"], "WATER_ACTIVITY", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]})
elif k == "vib_stop":
r = emit(d["sn"], "WATER_ACTIVITY_STOP", {"deviceTypeRaw": raw, "rawEventCode": 2, "batteryMv": st["battery"]})
elif k == "status":
emit_status()
log("○ 心跳已上报(%d 台)" % len(DEVICES))
return {"ok": True, "msg": "已上报 " + str(len(DEVICES)) + " 台设备的心跳"}
else:
return {"ok": False, "msg": "未知指令 " + k}
if not r.get("ok"):
return {"ok": False, "msg": "M 层没接收:" + str(r.get("msg") or "看左侧日志")}
# 恢复类事件不新建告警,只关闭"还没清过"的同类告警——没单子可关时看起来"没反应",
# 这里当场说清楚,省得以为是坏了。
if k in ("door_close", "leak_clear", "power_on", "smoke_clear"):
return {"ok": True, "msg": "已发送(%s)。恢复事件只关闭「%s」这一类、且还没恢复的告警,"
"不新建告警,也不会碰别的类型" % (d["name"], d["name"])}
if k in ("vib", "vib_stop"):
return {"ok": True, "msg": "已记录为「用水活动」(%s)。这类事件不产生告警,"
"只进基线和「水振+电流」组合研判" % d["name"]}
return {"ok": True, "msg": "已发送(%s · %s)" % (d["name"], d["sn"])}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--fast", action="store_true", help="快节奏(每轮 3 秒,概率翻倍)")
ap.add_argument("--seed", action="store_true", help="启动先补历史告警")
ap.add_argument("--quiet", action="store_true", help="只开控制台,不自动产生事件")
args = ap.parse_args()
init_state()
print("=" * 62)
print(" 鹤仆云 · 设备模拟器")
print(" 目标 M 层:%s" % BASE)
print(" 控制台 :http://127.0.0.1:%d" % PORT)
print(" 节奏 :%s" % ("静默(只响应按钮)" if args.quiet else ("快" if args.fast else "正常")))
print("=" * 62)
# 先探活。不能用空 statuses 去试 I-02——后端会按"空批"返回 400,
# 那是正确的校验行为,不是服务没起来。
try:
with OPENER.open(BASE + "/health", timeout=8) as h:
h.read()
except Exception as e: # noqa: BLE001
print("!! M 层没响应(%s)。先确认 :8080 起来了。" % e)
print(" 启动命令:G:\\aomin-dev\\app\\aomin-gateway\\start-gateway.bat")
return
log("✓ M 层在线:" + BASE)
if args.seed:
seed_history()
emit_status()
srv = HTTPServer(("127.0.0.1", PORT), Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
step = 3 if args.fast else 5
n = 0
try:
while True:
time.sleep(step)
n += 1
if not args.quiet:
tick(args.fast)
if n % 6 == 0:
emit_status()
# 电量缓慢下降,模拟真实电池
for d in DEVICES:
STATE[d["dev"]]["battery"] -= random.randint(0, 1)
STATE[d["dev"]]["csq"] = max(12, min(31, STATE[d["dev"]]["csq"] + random.randint(-1, 1)))
except KeyboardInterrupt:
print("\n已停止。")
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
+206
View File
@@ -0,0 +1,206 @@
/* 鹤仆云 H5 演示端 · 共用工具
* 纯原生,不引框架:老板电脑上双击就能开,不需要 npm。
*/
const HB = (() => {
// 家属端和后台端跑在同一个域下,localStorage 是共用的——
// 不加命名空间的话,在后台登录会把家属端的 token 顶掉。
let KEY = 'hb_family_token';
let UKEY = 'hb_family_user';
function setNS(ns) { KEY = 'hb_' + ns + '_token'; UKEY = 'hb_' + ns + '_user'; }
async function req(method, path, body, opt = {}) {
const headers = { 'Content-Type': 'application/json' };
const t = localStorage.getItem(KEY);
if (t) headers.Authorization = 'Bearer ' + t;
const res = await fetch(path, {
method,
headers,
body: body === undefined || body === null ? undefined : JSON.stringify(body),
});
let json = {};
try { json = await res.json(); } catch (e) { /* 空响应 */ }
if (res.status === 401 && !opt.noRedirect) {
localStorage.removeItem(KEY);
location.hash = '';
location.reload();
throw new Error('登录已过期');
}
if (json.code !== 0 && json.code !== undefined) {
throw new Error(json.msg || json.message || '请求失败(' + res.status + ')');
}
if (!res.ok) throw new Error('请求失败(' + res.status + ')');
return json.data;
}
const get = (p) => req('GET', p);
const post = (p, b) => req('POST', p, b);
const put = (p, b) => req('PUT', p, b);
const del = (p) => req('DELETE', p);
function setSession(token, user) {
localStorage.setItem(KEY, token);
localStorage.setItem(UKEY, JSON.stringify(user || {}));
}
function user() {
try { return JSON.parse(localStorage.getItem(UKEY) || '{}'); } catch (e) { return {}; }
}
function logged() { return !!localStorage.getItem(KEY); }
function logout() {
localStorage.removeItem(KEY);
localStorage.removeItem(UKEY);
location.reload();
}
/* ---------------- 展示辅助 ---------------- */
function ago(v) {
if (!v) return '—';
const t = new Date(String(v).replace(' ', 'T')).getTime();
if (isNaN(t)) return String(v);
const d = Math.floor((Date.now() - t) / 1000);
if (d < 0) return '刚刚';
if (d < 60) return d + ' 秒前';
if (d < 3600) return Math.floor(d / 60) + ' 分钟前';
if (d < 86400) return Math.floor(d / 3600) + ' 小时前';
return Math.floor(d / 86400) + ' 天前';
}
function dt(v) {
if (!v) return '—';
return String(v).replace('T', ' ').slice(0, 19);
}
function hhmm(v) {
if (!v) return '—';
return String(v).replace('T', ' ').slice(11, 16);
}
const TYPE = {
DOOR: { icon: '🚪', name: '门磁', color: '#4a7ebb' },
VIB: { icon: '📳', name: '水振', color: '#7a6ad1' },
CURRENT: { icon: '⚡', name: '电流', color: '#d99a2b' },
WATER: { icon: '💧', name: '水浸', color: '#2e9bb5' },
SOS: { icon: '🆘', name: '紧急按钮', color: '#d9534f' },
SMOKE: { icon: '🔥', name: '烟感', color: '#c25a3c' },
};
const typeInfo = (t) => TYPE[t] || { icon: '📦', name: t || '设备', color: '#8a949e' };
const LEVEL = {
S1: { name: '紧急', cls: 'lv1' },
S2: { name: '重要', cls: 'lv2' },
S3: { name: '提示', cls: 'lv3' },
};
const levelInfo = (l) => LEVEL[l] || { name: l || '—', cls: 'lv3' };
const STATUS = {
NEW: '新告警', PUSHED: '已推送', CONFIRMED: '已确认', ESCALATED: '已升级',
HANDLED: '已处置', CLOSED: '已关闭', FALSE: '误报', DELAYED: '免打扰',
SUPERSEDED: '已被替代',
};
// 这几种都不再"待处理":CLOSED 关闭、FALSE 误报、SUPERSEDED 已被新告警替代、
// HANDLED 家属已经点过"已处理"(后端 OPEN 里仍留 HANDLED 表示"处置未关闭",
// 但家属视角点完就不该再挂角标,否则点了没反应会以为没生效。2026-11-09 修)
const CLOSED = ['CLOSED', 'FALSE', 'SUPERSEDED', 'HANDLED'];
const isOpen = (s) => !CLOSED.includes(s);
const EVENT = {
DOOR_OPEN: '门打开', DOOR_CLOSE: '门关闭', WATER_LEAK: '检测到漏水',
WATER_LEAK_CLEAR: '漏水已解除', SOS_PRESS: '紧急按钮按下', SOS: '紧急呼叫',
POWER_OFF: '设备断电', POWER_ON: '设备来电', MSG_UNDELIVERED: '补传:曾断开',
POWER_CUT: '设备断电', POWER_RESTORE: '设备来电',
PRE_ALARM_0X04: '断档告警', VIB_ALARM: '震动异常', CURRENT_ALARM: '电流异常',
SMOKE: '烟感报警', SMOKE_CLEAR: '烟雾已散', SOS_CLEAR: '呼叫解除',
WATER_ACTIVITY: '用水活动', WATER_ACTIVITY_STOP: '用水停止',
HEARTBEAT: '心跳',
};
const evName = (t) => EVENT[t] || t || '事件';
function toast(msg, kind) {
let box = document.getElementById('hb-toast');
if (!box) {
box = document.createElement('div');
box.id = 'hb-toast';
document.body.appendChild(box);
}
box.textContent = msg;
box.className = 'show' + (kind === 'err' ? ' err' : '');
clearTimeout(box._t);
box._t = setTimeout(() => { box.className = ''; }, 2400);
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
function maskPhone(p) {
if (!p || p.length < 7) return p || '—';
return p.slice(0, 3) + '****' + p.slice(-4);
}
/* ------------------------------------------------------------------
命令状态回显
------------------------------------------------------------------
七态(J-00 V1.7 §3.4)翻成人话。家属端和后台共用一份,避免两边说法不一致。
为什么需要它:下发只是写一条 PENDING,真正到设备要等接入端来领(I-18),
设备回执再走 I-05。中间可能几秒,也可能几小时(设备离线)。
以前点完只弹"已排队",家属永远不知道成没成,按钮看着像摆设。
*/
const CMD_TEXT = {
PENDING: ['已排队,等设备连上后下发', '#b8860b'],
CLAIMED: ['正在下发…', '#0a6ebd'],
SENT: ['已发出,等设备回应', '#0a6ebd'],
ACKED: ['设备已回应', '#1a7f37'],
FAILED: ['设备没能执行', '#c0392b'],
TIMEOUT: ['超时没有回应', '#c0392b'],
CANCELLED: ['已取消', '#6b7784'],
};
const CMD_DONE = ['ACKED', 'FAILED', 'TIMEOUT', 'CANCELLED'];
/**
* 盯一条命令,直到终态或轮询次数用完。
*
* @param devId 设备
* @param commandNo 刚下发的命令号(拿不到就取最新一条)
* @param boxId 显示状态的那个 div
* @param times 轮询次数,默认 10 次 × 3 秒
*/
async function watchCommand(devId, commandNo, boxId, times) {
const max = times || 10;
let n = 0;
const tick = async () => {
n++;
let row = null;
try {
const list = await get('/api/v1/devices/' + devId + '/commands');
const arr = list || [];
row = arr.find((x) => (x.command_no || x.commandNo) === commandNo) || arr[0];
} catch (e) { /* 查询失败保持上一行,不要打断用户 */ }
const box = document.getElementById(boxId);
if (!box) return;
if (row) {
const st = String(row.status || '');
const meta = CMD_TEXT[st] || [st, '#6b7784'];
let html = '<span style="color:' + meta[1] + '">' + esc(meta[0]) + '</span>';
if (row.result_msg) {
html += '<span style="color:#98a4b0"> · ' + esc(row.result_msg) + '</span>';
}
if (st === 'PENDING' && n > 1) {
html += '<span style="color:#98a4b0">(不用一直等,设备连上会自动下发)</span>';
}
box.innerHTML = html;
if (CMD_DONE.includes(st)) return;
}
if (n < max) setTimeout(tick, 3000);
};
tick();
}
return {
req, get, post, put, del, setNS,
setSession, user, logged, logout,
ago, dt, hhmm, typeInfo, levelInfo, STATUS, isOpen, evName, toast, esc, maskPhone,
CMD_TEXT, CMD_DONE, watchCommand,
};
})();
File diff suppressed because it is too large. Load diff
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>选择家的位置</title>
<!--
百度地图选点(在线底图 + 离线兜底)
─────────────────────────────────────────────────────────
· 底图用百度 JS API v2.0,ak 从下方 AK 常量读;
· **坐标系必须转**:百度用 BD-09,我们库里存 GCJ-02,两者差 500~600 米。
取点后 bd09ToGcj02() 转换再回传,否则存进去的家会落在隔壁街道;
· ak 失效 / 没网 / 白名单没放行时,3 秒内自动跳 map-offline.html,不让用户看白屏。
-->
<style>
:root{--brand:#146b7a;--ink:#1b2430;--sub:#6b7784;--line:#e4eaef}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;overflow:hidden}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:#eef2f4;color:var(--ink);font-size:14px;display:flex;flex-direction:column}
.top{flex:0 0 auto;background:var(--brand);color:#fff;padding:11px 12px;display:flex;align-items:center;gap:9px;z-index:10}
.top button{background:rgba(255,255,255,.16);border:0;color:#fff;border-radius:9px;padding:7px 11px;font-size:13px;cursor:pointer}
.top h1{font-size:15px;margin:0;flex:1;font-weight:600}
.stage{flex:1;min-height:200px;position:relative}
#map{width:100%;height:100%}
/* 中心准星:拖地图选点,比"点哪儿算哪儿"在手机上准得多 */
#pin{position:absolute;left:50%;top:50%;transform:translate(-50%,-100%);pointer-events:none;z-index:5}
#pin svg{filter:drop-shadow(0 2px 4px rgba(0,0,0,.35))}
#pinTip{position:absolute;left:50%;top:50%;transform:translate(-50%,6px);font-size:10.5px;
color:#fff;background:rgba(20,40,55,.62);padding:2px 8px;border-radius:10px;pointer-events:none;z-index:5}
#loading{position:absolute;inset:0;background:#f2f6f8;display:flex;align-items:center;justify-content:center;
flex-direction:column;gap:10px;z-index:8;font-size:13px;color:var(--sub)}
#loading .sp{width:26px;height:26px;border:3px solid #d6e2e6;border-top-color:var(--brand);border-radius:50%;
animation:spin .9s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.bottom{flex:0 0 auto;background:#fff;border-top:1px solid var(--line);padding:10px 12px 12px;z-index:10}
.addr{font-size:13px;color:var(--ink);margin-bottom:5px;min-height:18px;line-height:1.5;font-weight:600}
.sub{font-size:11.5px;color:var(--sub);margin-bottom:9px;line-height:1.5}
.coord{font-family:Consolas,Menlo,monospace;font-size:11.5px;color:var(--brand);margin-bottom:9px}
.row{display:flex;gap:7px;align-items:center;margin-bottom:8px}
.row input{flex:1;min-width:0;border:1px solid var(--line);border-radius:8px;padding:8px 9px;font-size:12.5px;outline:none}
.btn{border:0;border-radius:9px;padding:11px 14px;font-size:14px;font-weight:600;cursor:pointer}
.btn-main{background:var(--brand);color:#fff;width:100%}
.btn-main:disabled{background:#c3d3d7}
.btn-line{background:#fff;border:1px solid var(--line);color:var(--ink);white-space:nowrap}
.BMap_cpyCtrl,.anchorBL{display:none!important}
</style>
</head>
<body>
<div class="top">
<button onclick="cancel()">← 返回</button>
<h1>选择家的位置</h1>
<button onclick="locate()">定位</button>
</div>
<div class="stage">
<div id="map"></div>
<div id="pin"><svg width="30" height="40" viewBox="0 0 30 40">
<path d="M15 0C6.7 0 0 6.7 0 15c0 11 15 25 15 25s15-14 15-25C30 6.7 23.3 0 15 0z" fill="#146b7a"/>
<circle cx="15" cy="15" r="5.5" fill="#fff"/></svg></div>
<div id="pinTip">拖动地图,把准星对准你家</div>
<div id="loading"><div class="sp"></div><span id="ldTxt">正在加载地图…</span></div>
</div>
<div class="bottom">
<div class="addr" id="addr">拖动地图选择位置</div>
<div class="sub" id="sub">地址会自动识别,选不准可以手动改</div>
<div class="coord" id="coord">坐标:—(GCJ-02)</div>
<div class="row">
<input id="mlng" placeholder="经度 113.39" inputmode="decimal">
<input id="mlat" placeholder="纬度 22.52" inputmode="decimal">
<button class="btn btn-line" onclick="applyManual()">填入</button>
</div>
<button class="btn btn-main" id="ok" onclick="confirmPick()">确定这个位置</button>
</div>
<script>
/* 百度 ak —— 不写死在源码里:部署时把 __BAIDU_AK__ 替换成自己的 AK,
或用 window.BAIDU_AK 注入;没有 AK 时本页会自动降级到离线地图。 */
var AK = window.BAIDU_AK || '__BAIDU_AK__';
/* ================= 坐标转换(必须) =================
百度 BD-09 / 国测局 GCJ-02 相差 500~600 米。
库里存 GCJ-02,所以「取点 → 转 GCJ」,「显示已知点 → 转 BD」。 */
function bd09ToGcj02(bdLng, bdLat) {
var x = bdLng - 0.0065, y = bdLat - 0.006;
var z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * Math.PI * 3000 / 180);
var t = Math.atan2(y, x) - 0.000003 * Math.cos(x * Math.PI * 3000 / 180);
return { lng: z * Math.cos(t), lat: z * Math.sin(t) };
}
function gcj02ToBd09(lng, lat) {
var z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * Math.PI * 3000 / 180);
var t = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * Math.PI * 3000 / 180);
return { lng: z * Math.cos(t) + 0.0065, lat: z * Math.sin(t) + 0.006 };
}
var map = null, marker = null, geocoder = null;
var cur = null; // 当前选的 GCJ-02 坐标
var geoTimer = null;
var HOME = { lng: 113.3926, lat: 22.5176 }; // 中山市政府(GCJ-02)
function fallback(reason) {
var t = document.getElementById('ldTxt');
if (t) t.textContent = reason + ',切换到离线地图…';
setTimeout(function () { location.replace('map-offline.html'); }, 700);
}
function initMap() {
if (map) return; // callback 和轮询都可能触发,只认第一次
try {
map = new BMap.Map('map', { enableMapClick: false });
var c = gcj02ToBd09(HOME.lng, HOME.lat);
map.centerAndZoom(new BMap.Point(c.lng, c.lat), 16);
map.enableScrollWheelZoom(true);
map.enableDragging();
geocoder = new BMap.Geocoder();
document.getElementById('loading').style.display = 'none';
map.addEventListener('moveend', onMoveEnd);
onMoveEnd();
} catch (e) {
fallback('地图初始化失败');
}
}
/* 地图停稳后取中心点,转成 GCJ-02 并反查地址 */
function onMoveEnd() {
if (!map) return;
var c = map.getCenter();
var g = bd09ToGcj02(c.lng, c.lat);
cur = { lng: +g.lng.toFixed(6), lat: +g.lat.toFixed(6) };
document.getElementById('coord').textContent =
'坐标:' + cur.lng + ', ' + cur.lat + '(GCJ-02)';
document.getElementById('mlng').value = cur.lng;
document.getElementById('mlat').value = cur.lat;
// 逆地理编码要联网,慢一点,节流 400ms,别拖一下发十个请求
clearTimeout(geoTimer);
geoTimer = setTimeout(function () {
if (!geocoder) return;
geocoder.getLocation(new BMap.Point(c.lng, c.lat), function (r) {
if (!r) { return; }
var a = r.address || {};
var parts = [a.province, a.city, a.district, a.street, a.streetNumber].filter(Boolean);
var poi = (r.surroundingPois && r.surroundingPois[0]) ? r.surroundingPois[0].title : '';
// 街道名常常拼不出完整地址(百度在小区内部只回商圈),所以依次退:街道串 → 最近POI → 商圈
var main = parts.join('') || poi || a.business || '已选位置';
document.getElementById('addr').textContent = main;
document.getElementById('sub').textContent = poi && main !== poi
? '附近:' + poi
: '地址已识别,可手动补充门牌';
});
}, 400);
}
function applyManual() {
var lng = parseFloat(document.getElementById('mlng').value);
var lat = parseFloat(document.getElementById('mlat').value);
if (!isFinite(lng) || !isFinite(lat)) { alert('请填入有效的经纬度数字'); return; }
if (lng < 112.5 || lng > 114.5 || lat < 21.5 || lat > 23.5) {
alert('这个坐标不在中山附近,请填 113.x / 22.x'); return;
}
cur = { lng: +lng.toFixed(6), lat: +lat.toFixed(6) };
var b = gcj02ToBd09(lng, lat);
if (map) { map.centerAndZoom(new BMap.Point(b.lng, b.lat), 17); }
}
function locate() {
if (!navigator.geolocation) { alert('这个浏览器不支持定位'); return; }
navigator.geolocation.getCurrentPosition(function (p) {
// 浏览器给的是 WGS-84,百度要 BD-09:先粗转 GCJ 再转 BD(市域内误差 <10m)
var g = wgs84ToGcj02(p.coords.longitude, p.coords.latitude);
var b = gcj02ToBd09(g.lng, g.lat);
if (map) map.centerAndZoom(new BMap.Point(b.lng, b.lat), 17);
}, function () {
alert('定位失败(可能是没授权或信号不好),直接拖地图选吧');
}, { timeout: 8000 });
}
/* WGS-84 → GCJ-02(国内加密偏移的常用近似实现) */
function wgs84ToGcj02(lng, lat) {
var a = 6378245.0, ee = 0.00669342162296594323;
var dLat = tfLat(lng - 105, lat - 35), dLng = tfLng(lng - 105, lat - 35);
var radLat = lat / 180 * Math.PI, magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
var sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI);
dLng = (dLng * 180) / (a / sqrtMagic * Math.cos(radLat) * Math.PI);
return { lng: lng + dLng, lat: lat + dLat };
}
function tfLat(x, y) {
var r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
r += (20 * Math.sin(6 * x * Math.PI) + 20 * Math.sin(2 * x * Math.PI)) * 2 / 3;
r += (20 * Math.sin(y * Math.PI) + 40 * Math.sin(y / 3 * Math.PI)) * 2 / 3;
r += (160 * Math.sin(y / 12 * Math.PI) + 320 * Math.sin(y * Math.PI / 30)) * 2 / 3;
return r;
}
function tfLng(x, y) {
var r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
r += (20 * Math.sin(6 * x * Math.PI) + 20 * Math.sin(2 * x * Math.PI)) * 2 / 3;
r += (20 * Math.sin(x * Math.PI) + 40 * Math.sin(x / 3 * Math.PI)) * 2 / 3;
r += (150 * Math.sin(x / 12 * Math.PI) + 300 * Math.sin(x / 30 * Math.PI)) * 2 / 3;
return r;
}
function confirmPick() {
if (!cur) { alert('还没选位置'); return; }
var addr = document.getElementById('addr').textContent || '';
var d = {
type: 'heserv:pick',
lng: cur.lng, lat: cur.lat,
name: addr.length > 24 ? addr.slice(0, 24) : addr,
addr: addr,
coordSys: 'GCJ-02',
source: 'baidu'
};
if (window.parent && window.parent !== window) {
window.parent.postMessage(d, '*');
} else {
alert('已选:' + d.lng + ', ' + d.lat);
}
}
function cancel() {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'heserv:pick-cancel' }, '*');
} else { history.back(); }
}
/* ================= 加载百度脚本 =================
注意:百度 v2.0 的 loader 内部用 document.write 注入主脚本。
如果用 createElement 动态插入,write 在页面加载完后调用会被浏览器忽略,
BMap 永远不出现 —— 表现就是"一直加载中,最后跳离线版"。
所以这里必须用**静态 script 标签**,并靠 &callback= 拿加载完成的通知。 */
function onBMapReady() {
window.__bmapOk = true;
setTimeout(initMap, 80);
}
(function () {
var timer = setInterval(function () {
if (window.BMap && window.__bmapOk !== false) {
clearInterval(timer);
if (!map) initMap();
return;
}
if (window.__bmapTries === undefined) window.__bmapTries = 0;
if (++window.__bmapTries > 22) { // 约 4.4 秒还没起来
clearInterval(timer);
if (!map) fallback('地图加载超时');
}
}, 200);
})();
</script>
<script src="https://api.map.baidu.com/api?v=2.0&ak=__BAIDU_AK__&callback=onBMapReady"></script>
</body>
</html>
+422
View File
@@ -0,0 +1,422 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>选择家的位置(离线)</title>
<!--
离线示意底图 · 中山市 · GCJ-02
· 零第三方依赖:不加载任何在线瓦片 / 不请求 SDK / 不需要 key,断网可用;
· 边界为中山市行政区划轮廓(DataV 公开数据,Douglas-Peucker 简化至 206 点),仅作示意;
· 地标为示意位置,实际选点以回传经纬度为准;
· 坐标系 GCJ-02,与腾讯/高德一致,可直接入 household.longitude / latitude。
-->
<style>
:root{--brand:#146b7a;--ink:#1b2430;--sub:#6b7784;--line:#e4eaef}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;overflow:hidden}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:#eef2f4;color:var(--ink);font-size:14px;display:flex;flex-direction:column}
.top{flex:0 0 auto;background:var(--brand);color:#fff;padding:11px 12px;display:flex;align-items:center;gap:9px}
.top button{background:rgba(255,255,255,.16);border:0;color:#fff;border-radius:9px;padding:7px 11px;font-size:13px;cursor:pointer}
.top h1{font-size:15px;margin:0;flex:1;font-weight:600}
.stage{flex:1;min-height:200px;position:relative;overflow:hidden}
#cv{display:block;width:100%;height:100%;touch-action:none;cursor:crosshair}
.hud{position:absolute;left:10px;top:10px;background:rgba(255,255,255,.92);border:1px solid var(--line);
border-radius:8px;padding:6px 9px;font-size:11px;color:#5c6b76;line-height:1.55;max-width:64%;pointer-events:none}
.hud b{color:var(--brand)}
.zoom{position:absolute;right:10px;bottom:44px;display:flex;flex-direction:column;gap:6px}
.zoom button{width:34px;height:34px;border:1px solid var(--line);background:rgba(255,255,255,.95);
border-radius:8px;font-size:17px;line-height:1;cursor:pointer;color:var(--ink);padding:0}
.zoom button:active{background:#e8f2f4}
.scalebar{position:absolute;right:10px;bottom:12px;background:rgba(255,255,255,.9);border-radius:6px;
padding:3px 7px;font-size:10.5px;color:#5c6b76;font-family:Consolas,monospace}
.bottom{flex:0 0 auto;background:#fff;border-top:1px solid var(--line);padding:10px 12px 12px}
.addr{font-size:12.5px;color:var(--sub);margin-bottom:6px;min-height:17px;line-height:1.5}
.coord{font-family:Consolas,Menlo,monospace;font-size:11.5px;color:var(--brand);margin-bottom:9px}
.row{display:flex;gap:7px;align-items:center;margin-bottom:8px}
.row input{flex:1;min-width:0;border:1px solid var(--line);border-radius:8px;padding:8px 9px;font-size:12.5px;outline:none}
.row input:focus{border-color:var(--brand)}
.btn{border:0;border-radius:9px;padding:11px 14px;font-size:14px;font-weight:600;cursor:pointer}
.btn-main{background:var(--brand);color:#fff;width:100%}
.btn-main:disabled{background:#c3d3d7;cursor:not-allowed}
.btn-line{background:#fff;border:1px solid var(--line);color:var(--ink);white-space:nowrap}
</style>
</head>
<body>
<div class="top">
<button onclick="cancel()">← 返回</button>
<h1>选择家的位置</h1>
<button onclick="recenter()">回中山</button>
</div>
<div class="stage">
<canvas id="cv"></canvas>
<div class="hud"><b>离线示意底图</b> · 中山市 · GCJ-02<br>拖动平移 · 滚轮/双指缩放 · 点一下落点</div>
<div class="zoom">
<button onclick="zoomBy(1.4)" title="放大">+</button>
<button onclick="zoomBy(1/1.4)" title="缩小">-</button>
</div>
<div class="scalebar" id="sb">—</div>
</div>
<div class="bottom">
<div class="addr" id="addr">点一下地图,落在你家的位置</div>
<div class="coord" id="coord">坐标:未选择(GCJ-02)</div>
<div class="row">
<input id="mlng" placeholder="经度 113.39" inputmode="decimal">
<input id="mlat" placeholder="纬度 22.52" inputmode="decimal">
<button class="btn btn-line" onclick="applyManual()">填入</button>
</div>
<button class="btn btn-main" id="ok" disabled onclick="confirmPick()">确定这个位置</button>
</div>
<script>
/* ---------- 底图数据 ---------- */
var RING = [[113.56875,22.41193],[113.55253,22.41263],[113.53410,22.41640],[113.53042,22.41545],[113.52798,22.41236],[113.52620,22.41180],[113.52365,22.41337],[113.52061,22.41303],[113.51771,22.40835],[113.51432,22.40872],[113.50892,22.41338],[113.50695,22.41329],[113.50372,22.41067],[113.49589,22.40755],[113.49500,22.40566],[113.49523,22.39541],[113.48956,22.39253],[113.48700,22.39020],[113.48588,22.38502],[113.48803,22.38003],[113.49134,22.37694],[113.49130,22.37042],[113.48923,22.36746],[113.48917,22.36582],[113.49244,22.36082],[113.49324,22.35404],[113.48967,22.34973],[113.49155,22.34591],[113.48964,22.34327],[113.49033,22.34072],[113.49478,22.33971],[113.49611,22.33815],[113.49634,22.33370],[113.50074,22.33182],[113.50074,22.32920],[113.50410,22.32431],[113.50313,22.32285],[113.49336,22.31800],[113.49029,22.31784],[113.48686,22.31932],[113.48302,22.31878],[113.48400,22.31703],[113.48202,22.31499],[113.47779,22.31363],[113.47501,22.31493],[113.47048,22.31035],[113.47579,22.30664],[113.47480,22.30521],[113.47939,22.30301],[113.48042,22.30386],[113.48232,22.30099],[113.48292,22.30150],[113.48793,22.29630],[113.48669,22.29544],[113.49139,22.29028],[113.49286,22.28822],[113.49216,22.28750],[113.49639,22.28225],[113.49507,22.27997],[113.49882,22.27468],[113.50126,22.27421],[113.49964,22.27293],[113.50473,22.26698],[113.50826,22.26971],[113.51250,22.26467],[113.51438,22.26154],[113.51303,22.24989],[113.50938,22.25137],[113.50675,22.24673],[113.48628,22.24203],[113.47888,22.23832],[113.48004,22.23364],[113.48614,22.22913],[113.48635,22.22710],[113.47778,22.22498],[113.46353,22.21938],[113.45835,22.21379],[113.44256,22.20705],[113.43260,22.20398],[113.43056,22.20129],[113.42462,22.20104],[113.41576,22.20290],[113.40045,22.22018],[113.39411,22.22897],[113.39036,22.23230],[113.38639,22.23019],[113.37949,22.23591],[113.37693,22.23445],[113.35611,22.24628],[113.34931,22.25263],[113.34287,22.26324],[113.33660,22.28013],[113.32251,22.30867],[113.32130,22.31218],[113.32038,22.32959],[113.31736,22.33573],[113.27383,22.38478],[113.26264,22.39207],[113.25634,22.41081],[113.25587,22.44063],[113.25137,22.44987],[113.24516,22.45727],[113.24015,22.47615],[113.22501,22.49708],[113.21538,22.51310],[113.20940,22.51948],[113.20181,22.52478],[113.19224,22.53551],[113.18711,22.53993],[113.18872,22.55278],[113.18510,22.57429],[113.17640,22.59001],[113.16556,22.60192],[113.16223,22.60966],[113.15701,22.61424],[113.15838,22.61810],[113.15705,22.62357],[113.15722,22.63205],[113.16008,22.64432],[113.16306,22.65136],[113.16967,22.65076],[113.17063,22.65167],[113.16999,22.65870],[113.16617,22.65808],[113.16365,22.66379],[113.16185,22.66349],[113.16084,22.66541],[113.16610,22.66796],[113.16140,22.67370],[113.16408,22.67558],[113.16684,22.67518],[113.17200,22.68067],[113.17315,22.67977],[113.17382,22.68069],[113.17651,22.67911],[113.17318,22.68450],[113.17415,22.68489],[113.17967,22.67882],[113.18182,22.67863],[113.18938,22.67388],[113.19451,22.67541],[113.20104,22.67596],[113.19983,22.67788],[113.19926,22.68254],[113.20437,22.68948],[113.20502,22.69307],[113.20423,22.69732],[113.20955,22.69874],[113.22223,22.71226],[113.23176,22.72820],[113.23632,22.74077],[113.23912,22.74371],[113.24303,22.74535],[113.24807,22.74486],[113.25407,22.74084],[113.26089,22.73033],[113.26392,22.72667],[113.26654,22.72547],[113.27056,22.72648],[113.27828,22.73514],[113.28127,22.73755],[113.28488,22.73888],[113.29124,22.73896],[113.30144,22.73627],[113.31308,22.73764],[113.32912,22.74126],[113.32826,22.74630],[113.33030,22.75051],[113.33746,22.75398],[113.34600,22.76014],[113.35874,22.76487],[113.36516,22.77260],[113.38145,22.76156],[113.41219,22.74283],[113.42614,22.73801],[113.44112,22.73722],[113.44778,22.73585],[113.46797,22.72852],[113.46481,22.72096],[113.47131,22.71499],[113.49191,22.69979],[113.52338,22.67930],[113.54072,22.66621],[113.53951,22.65794],[113.53309,22.65637],[113.53297,22.65498],[113.56163,22.60751],[113.56514,22.60348],[113.56740,22.60593],[113.57192,22.60280],[113.57472,22.60572],[113.57786,22.60391],[113.57855,22.60461],[113.58998,22.59520],[113.59962,22.59439],[113.61501,22.58529],[113.62078,22.57953],[113.63932,22.54828],[113.65161,22.51572],[113.68234,22.51436],[113.69204,22.51515],[113.66843,22.48043],[113.63148,22.47597],[113.61011,22.44571],[113.57372,22.41172],[113.56875,22.41193]];
// 地标:lv1=全域可见(大区域),lv2=放大后才出现(市区细点)
// 坐标为示意位置(误差 1km 内),实际选点以回传经纬度为准
var MARKS = [
{n:"中山市人民政府", lng:113.3926, lat:22.5176, big:1, lv:1},
{n:"小榄镇", lng:113.2450, lat:22.6700, lv:1},
{n:"三乡镇", lng:113.4300, lat:22.3580, lv:1},
{n:"坦洲镇", lng:113.4700, lat:22.2900, lv:1},
{n:"火炬开发区", lng:113.4392, lat:22.5495, lv:1},
{n:"孙文西路步行街", lng:113.3794, lat:22.5186, lv:2},
{n:"中山纪念堂", lng:113.3836, lat:22.5205, lv:2},
{n:"市人民医院", lng:113.3893, lat:22.5245, lv:2},
{n:"中山北站", lng:113.3902, lat:22.5567, lv:2},
{n:"中山站", lng:113.4470, lat:22.5320, lv:2}
];
var DIRS = [
{t:"↑ 广州 / 佛山", lng:113.40, lat:22.80},
{t:"← 江门", lng:113.13, lat:22.57},
{t:"↓ 珠海 / 澳门", lng:113.45, lat:22.17},
{t:"深圳 / 东莞 →", lng:113.72, lat:22.60}
];
/* ---------- 视图 ---------- */
var BBOX = (function(){
var x1=1e9,y1=1e9,x2=-1e9,y2=-1e9;
for(var i=0;i<RING.length;i++){ var p=RING[i];
if(p[0]<x1)x1=p[0]; if(p[0]>x2)x2=p[0];
if(p[1]<y1)y1=p[1]; if(p[1]>y2)y2=p[1]; }
return {cx:(x1+x2)/2, cy:(y1+y2)/2, w:x2-x1, h:y2-y1};
})();
var HOME = {lng:BBOX.cx, lat:BBOX.cy, scale:900};
var view = {lng:HOME.lng, lat:HOME.lat, scale:HOME.scale};
var K = Math.cos(22.5*Math.PI/180);
var cv = document.getElementById('cv'), ctx = cv.getContext('2d');
var W = 0, H = 0, dpr = Math.max(1, Math.min(3, window.devicePixelRatio||1));
var picked = null;
function toScreen(lng, lat){
return [ (lng - view.lng)*view.scale*K + W/2, (view.lat - lat)*view.scale + H/2 ];
}
function toLL(x, y){
return [ view.lng + (x - W/2)/(view.scale*K), view.lat - (y - H/2)/view.scale ];
}
function distKm(a, b){
var dLat=(b.lat-a.lat)*111.32, dLng=(b.lng-a.lng)*111.32*Math.cos((a.lat+b.lat)/2*Math.PI/180);
return Math.sqrt(dLat*dLat + dLng*dLng);
}
/* ---------- 绘制 ---------- */
function draw(){
if(!W) return;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,W,H);
ctx.fillStyle = '#e9eff1';
ctx.fillRect(0,0,W,H);
drawGrid();
ctx.beginPath();
for(var i=0;i<RING.length;i++){
var p = toScreen(RING[i][0], RING[i][1]);
i ? ctx.lineTo(p[0],p[1]) : ctx.moveTo(p[0],p[1]);
}
ctx.closePath();
ctx.fillStyle = '#f8f6ef';
ctx.fill();
ctx.lineWidth = 1.4; ctx.strokeStyle = '#9fb6b2'; ctx.stroke();
ctx.lineWidth = 5; ctx.strokeStyle = 'rgba(20,107,122,.07)'; ctx.stroke();
drawMarks();
drawDirs();
drawPick();
drawScale();
}
function drawGrid(){
// 网格步长按像素自适应:保证每格 70~140px,避免全览时刻度挤成一团
var steps = [0.005,0.01,0.02,0.05,0.1,0.2,0.5];
var stepDeg = 0.5;
for(var i=0;i<steps.length;i++){
if(steps[i]*view.scale*K >= 72){ stepDeg = steps[i]; break; }
}
var tl = toLL(0,0), br = toLL(W,H);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(140,160,168,.32)';
ctx.font = '9px Consolas,monospace';
ctx.fillStyle = 'rgba(110,130,140,.6)';
var lastX = -999;
for(var lng=Math.floor(tl[0]/stepDeg)*stepDeg; lng<=br[0]; lng+=stepDeg){
var px = toScreen(lng, 0)[0];
ctx.beginPath(); ctx.moveTo(px,0); ctx.lineTo(px,H); ctx.stroke();
if(px - lastX > 62){ ctx.fillText(lng.toFixed(2)+'°E', px+2, H-3); lastX = px; }
}
var lastY = -999;
for(var lat=Math.floor(br[1]/stepDeg)*stepDeg; lat<=tl[1]; lat+=stepDeg){
var py = toScreen(0, lat)[1];
ctx.beginPath(); ctx.moveTo(0,py); ctx.lineTo(W,py); ctx.stroke();
if(py - lastY > 34){ ctx.fillText(lat.toFixed(2)+'°N', 3, py-3); lastY = py; }
}
}
// 标签避让:放进不来就不画字,只留点
var placed = [];
function overlaps(r){
for(var i=0;i<placed.length;i++){
var p=placed[i];
if(r.x < p.x+p.w && r.x+r.w > p.x && r.y < p.y+p.h && r.y+r.h > p.y) return true;
}
return false;
}
function putLabel(x, y, text, bold, color){
ctx.font = (bold?'600 ':'') + '11px -apple-system,"PingFang SC","Microsoft YaHei",sans-serif';
var w = ctx.measureText(text).width, h = 13;
var tries = [[x+8, y-4], [x-8-w, y-4], [x-w/2, y-19], [x-w/2, y+7]];
for(var i=0;i<tries.length;i++){
var r = {x:tries[i][0]-2, y:tries[i][1]-2, w:w+5, h:h+4};
if(r.x > -2 && r.x+r.w < W+2 && r.y > 0 && r.y+r.h < H-2 && !overlaps(r)){
placed.push(r);
ctx.fillStyle = 'rgba(255,255,255,.86)';
ctx.fillRect(r.x, r.y, r.w, r.h);
ctx.fillStyle = color;
ctx.fillText(text, tries[i][0], tries[i][1]+10);
return true;
}
}
return false;
}
function drawMarks(){
placed = [];
var lv = view.scale > 2400 ? 2 : 1; // 全览只标大区域,放大后才出细地标
for(var i=0;i<MARKS.length;i++){
var m = MARKS[i], p = toScreen(m.lng, m.lat);
if((m.lv||1) > lv) continue;
if(p[0]<-60||p[0]>W+60||p[1]<-30||p[1]>H+30) continue;
var r = m.big ? 5 : 3.6;
ctx.beginPath(); ctx.arc(p[0],p[1],r+2.5,0,7); ctx.fillStyle='rgba(255,255,255,.9)'; ctx.fill();
ctx.beginPath(); ctx.arc(p[0],p[1],r,0,7);
ctx.fillStyle = m.big ? '#c2410c' : '#146b7a'; ctx.fill();
putLabel(p[0], p[1], m.n, !!m.big, m.big ? '#9a3412' : '#334a55');
}
}
function drawDirs(){
ctx.font = '10px -apple-system,"PingFang SC",sans-serif';
ctx.fillStyle = 'rgba(110,130,140,.5)';
ctx.textAlign='center';
for(var i=0;i<DIRS.length;i++){
var d = DIRS[i], p = toScreen(d.lng, d.lat);
if(p[0]>24 && p[0]<W-24 && p[1]>18 && p[1]<H-10) ctx.fillText(d.t, p[0], p[1]);
}
ctx.textAlign='left';
}
function drawPick(){
if(!picked) return;
var p = toScreen(picked.lng, picked.lat);
ctx.strokeStyle='rgba(20,107,122,.35)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(p[0]-14,p[1]); ctx.lineTo(p[0]+14,p[1]);
ctx.moveTo(p[0],p[1]-14); ctx.lineTo(p[0],p[1]+14); ctx.stroke();
ctx.save(); ctx.translate(p[0],p[1]);
ctx.beginPath();
ctx.moveTo(0,0); ctx.bezierCurveTo(-11,-9,-11,-22,0,-22); ctx.bezierCurveTo(11,-22,11,-9,0,0);
ctx.closePath(); ctx.fillStyle='#146b7a'; ctx.fill();
ctx.beginPath(); ctx.arc(0,-15,4.2,0,7); ctx.fillStyle='#fff'; ctx.fill();
ctx.restore();
}
function drawScale(){
var perPx = 111320 / view.scale; // 每像素多少米(纬度方向)
var m = perPx * 78;
var nice = [50,100,200,500,1000,2000,5000,10000,20000,50000];
var pick = nice[0];
for(var i=0;i<nice.length;i++){ if(nice[i] <= m) pick = nice[i]; }
var px = Math.round(pick / perPx);
var txt = pick >= 1000 ? (pick/1000)+' km' : pick+' m';
document.getElementById('sb').innerHTML =
'<span style="display:inline-block;width:'+px+'px;height:5px;border:1.5px solid #7d929a;border-top:0;'
+ 'vertical-align:middle;margin-right:6px"></span><span style="vertical-align:middle">'+txt+'</span>';
}
/* ---------- 交互 ---------- */
var fitted = false;
function resize(){
var r = cv.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W*dpr); cv.height = Math.round(H*dpr);
if(!fitted && W > 0 && H > 0){ fitView(); fitted = true; }
draw();
}
window.addEventListener('resize', resize);
// 按视口把整个中山市装进去
function fitView(){
var s = Math.min((W*0.90)/(BBOX.w*K), (H*0.90)/BBOX.h);
HOME = {lng:BBOX.cx, lat:BBOX.cy, scale:clamp(s, 380, 26000)};
view = {lng:HOME.lng, lat:HOME.lat, scale:HOME.scale};
}
var drag = null;
function pos(e){
var r = cv.getBoundingClientRect();
return [ e.clientX - r.left, e.clientY - r.top ];
}
cv.addEventListener('pointerdown', function(e){
cv.setPointerCapture(e.pointerId);
drag = {x:e.clientX, y:e.clientY, lng:view.lng, lat:view.lat, moved:0};
});
cv.addEventListener('pointermove', function(e){
if(!drag) return;
var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
drag.moved = Math.max(drag.moved, Math.abs(dx)+Math.abs(dy));
view.lng = drag.lng - dx/(view.scale*K);
view.lat = drag.lat + dy/view.scale;
draw();
});
cv.addEventListener('pointerup', function(e){
if(!drag) return;
var moved = drag.moved; drag = null;
if(moved > 6) return;
var p = pos(e), ll = toLL(p[0], p[1]);
setPick(ll[0], ll[1]);
});
cv.addEventListener('wheel', function(e){
e.preventDefault();
var p = pos(e);
zoomAt(p[0], p[1], e.deltaY < 0 ? 1.18 : 1/1.18);
}, {passive:false});
var pinch = null;
cv.addEventListener('touchstart', function(e){
if(e.touches.length === 2) pinch = {d: tdist(e), scale: view.scale};
}, {passive:true});
cv.addEventListener('touchmove', function(e){
if(pinch && e.touches.length === 2){
e.preventDefault();
var r = pinch.d>0 ? tdist(e)/pinch.d : 1;
view.scale = clamp(pinch.scale * r, 380, 26000);
draw();
}
}, {passive:false});
cv.addEventListener('touchend', function(){ pinch = null; }, {passive:true});
function tdist(e){
var a=e.touches[0], b=e.touches[1];
return Math.hypot(a.clientX-b.clientX, a.clientY-b.clientY);
}
function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }
function zoomAt(px, py, k){
var before = toLL(px, py);
view.scale = clamp(view.scale*k, 380, 26000);
var after = toLL(px, py);
view.lng += before[0]-after[0];
view.lat += before[1]-after[1];
draw();
}
function zoomBy(k){ zoomAt(W/2, H/2, k); }
function recenter(){
fitView();
draw();
}
/* ---------- 选点 ---------- */
function setPick(lng, lat){
picked = {lng:lng, lat:lat};
document.getElementById('mlng').value = lng.toFixed(6);
document.getElementById('mlat').value = lat.toFixed(6);
document.getElementById('coord').textContent = '坐标:' + lng.toFixed(6) + ', ' + lat.toFixed(6) + '(GCJ-02)';
document.getElementById('addr').textContent = describe(lng, lat);
document.getElementById('ok').disabled = false;
draw();
}
function nearest(lng, lat){
var best=null, bd=1e9;
for(var i=0;i<MARKS.length;i++){
var d = distKm({lng:lng,lat:lat}, MARKS[i]);
if(d<bd){ bd=d; best=MARKS[i]; }
}
return {m:best, d:bd};
}
function describe(lng, lat){
var r = nearest(lng, lat);
var city = {lng:113.3926, lat:22.5176};
var dc = distKm({lng:lng,lat:lat}, city);
var bearing = azimuth(city, {lng:lng,lat:lat});
var part;
if(r.d < 0.8) part = r.m.n + '附近';
else if(r.d < 6) part = '距' + r.m.n + '约 ' + r.d.toFixed(1) + ' 公里';
else part = '在' + r.m.n + '方向约 ' + r.d.toFixed(1) + ' 公里';
var tail = dc < 0.25 ? '(已选位置)' : '(' + bearing + ',离市政府 ' + dc.toFixed(1) + ' 公里)';
return '中山市 · ' + part + tail;
}
function azimuth(a, b){
var dLng = (b.lng-a.lng)*Math.cos((a.lat+b.lat)/2*Math.PI/180), dLat = b.lat-a.lat;
var ang = Math.atan2(dLng, dLat)*180/Math.PI;
var dirs = ['正北','东北','正东','东南','正南','西南','正西','西北'];
return dirs[Math.round(((ang+360)%360)/45) % 8];
}
function applyManual(){
var lng = parseFloat(document.getElementById('mlng').value);
var lat = parseFloat(document.getElementById('mlat').value);
if(!isFinite(lng) || !isFinite(lat)){ alert('请填入有效的经纬度数字'); return; }
if(lng < 112.5 || lng > 114.5 || lat < 21.5 || lat > 23.5){
alert('这个坐标不在中山附近。\n请填 113.x / 22.x(GCJ-02)。'); return;
}
setPick(lng, lat);
view.lng = lng; view.lat = lat;
if(view.scale < 3000) view.scale = 6000;
draw();
}
/* ---------- 回传 ---------- */
function payload(){
if(!picked) return null;
var r = nearest(picked.lng, picked.lat);
return {
type: 'heserv:pick',
lng: +picked.lng.toFixed(6),
lat: +picked.lat.toFixed(6),
name: r.d < 2 ? r.m.n : '自选位置',
addr: describe(picked.lng, picked.lat),
coordSys: 'GCJ-02',
source: 'offline-zhongshan'
};
}
function confirmPick(){
var d = payload();
if(!d) return;
if(window.parent && window.parent !== window){
window.parent.postMessage(d, '*');
} else {
alert('已选:' + d.lng + ', ' + d.lat + '\n(当前是单独打开,无法回传给主页面)');
}
}
function cancel(){
if(window.parent && window.parent !== window){
window.parent.postMessage({type:'heserv:pick-cancel'}, '*');
} else {
history.back();
}
}
resize();
setTimeout(resize, 60);
</script>
</body>
</html>
+285
View File
@@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>选择家的位置</title>
<!--
地图合规说明(务必保留):
· 只用腾讯地图 GL JS,SDK 地址不带 key,通过 _TMapSecurityConfig 走 WorkBuddy 本地代理;
· __WB_HTTP_PORT__ / __WB_TMAP_SECRET__ 是运行时占位符,禁止改成具体值、禁止内嵌任何 key;
· 坐标系 GCJ-02;浏览器 GPS 拿到的 WGS-84 必须换算后再用(见 wgs84ToGcj02);
· 占位符没被运行时替换时(例如从业务服务器 :8080 直接打开),
自动降级为「演示坐标板」——不加载任何第三方瓦片,功能仍可用。
-->
<script type="text/javascript">
window._TMapSecurityConfig = {
serviceHost: 'http://127.0.0.1:__WB_HTTP_PORT__/_TMapService/_wbt/__WB_TMAP_SECRET__',
};
</script>
<style>
:root{--brand:#146b7a;--ink:#1b2430;--sub:#6b7784;--line:#e4eaef}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;overflow:hidden}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:#f2f5f8;color:var(--ink);font-size:14px;display:flex;flex-direction:column}
.top{flex:0 0 auto;background:var(--brand);color:#fff;padding:11px 12px;display:flex;align-items:center;gap:9px}
.top button{background:rgba(255,255,255,.16);border:0;color:#fff;border-radius:9px;padding:7px 11px;
font-size:13px;cursor:pointer}
.top h1{font-size:15px;margin:0;flex:1;font-weight:600}
.search{flex:0 0 auto;padding:9px 12px;background:#fff;border-bottom:1px solid var(--line);position:relative}
.search input{width:100%;border:1px solid var(--line);border-radius:9px;padding:9px 12px;
font-size:13.5px;outline:none}
.search input:focus{border-color:var(--brand)}
#suggest{position:absolute;left:12px;right:12px;top:48px;background:#fff;border:1px solid var(--line);
border-radius:9px;box-shadow:0 8px 20px rgba(0,0,0,.1);z-index:8;display:none;max-height:190px;overflow-y:auto}
#suggest div{padding:9px 12px;font-size:13px;border-bottom:1px solid #f2f5f8;cursor:pointer}
#suggest div:last-child{border-bottom:0}
#suggest div:hover{background:#f2f8fa}
#suggest small{display:block;color:var(--sub);font-size:11px;margin-top:1px}
#map{flex:1;min-height:260px;position:relative;background:#e8eef0}
#board{width:100%;height:100%;display:none;cursor:crosshair;position:relative;
background:linear-gradient(0deg,#e7eef0 1px,transparent 1px) 0 0/32px 32px,
linear-gradient(90deg,#e7eef0 1px,transparent 1px) 0 0/32px 32px,#f5f9fa}
#mode{position:absolute;left:10px;top:10px;background:rgba(255,255,255,.94);border-radius:8px;
padding:6px 10px;font-size:11px;color:#60707c;z-index:5;max-width:82%;line-height:1.6}
#pin{position:absolute;width:26px;height:34px;transform:translate(-50%,-100%);pointer-events:none;
display:none;z-index:6}
.bottom{flex:0 0 auto;background:#fff;border-top:1px solid var(--line);padding:11px 12px}
.addr{font-size:12.5px;color:var(--sub);margin-bottom:8px;min-height:18px;line-height:1.5}
.coord{font-family:Consolas,Menlo,monospace;font-size:11.5px;color:var(--brand);margin-bottom:9px}
.row{display:flex;gap:8px;align-items:center}
.row input{flex:1;min-width:0;border:1px solid var(--line);border-radius:8px;padding:8px 9px;
font-size:12.5px;outline:none}
.btn{border:0;border-radius:9px;padding:10px 14px;font-size:14px;font-weight:600;cursor:pointer}
.btn-main{background:var(--brand);color:#fff;flex:1}
.btn-line{background:#fff;border:1px solid var(--line);color:var(--ink)}
</style>
</head>
<body>
<div class="top">
<button onclick="cancel()">← 返回</button>
<h1>选择家的位置</h1>
<button onclick="locate()">📍 定位</button>
</div>
<div class="search">
<input id="q" placeholder="搜小区 / 大厦名称" autocomplete="off">
<div id="suggest"></div>
</div>
<div id="map">
<div id="board"></div>
<div id="mode"></div>
<img id="pin" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='34' viewBox='0 0 26 34'%3E%3Cpath d='M13 0C6 0 1 5 1 12c0 8 12 22 12 22s12-14 12-22C25 5 20 0 13 0z' fill='%23146b7a'/%3E%3Ccircle cx='13' cy='12' r='5' fill='white'/%3E%3C/svg%3E">
</div>
<div class="bottom">
<div class="addr" id="addr">点一下地图,或按右上角「定位」用当前位置</div>
<div class="coord" id="coord">坐标:未选择(GCJ-02)</div>
<div class="row">
<input id="mlng" placeholder="经度" inputmode="decimal">
<input id="mlat" placeholder="纬度" inputmode="decimal">
<button class="btn btn-line" onclick="applyManual()">填入</button>
</div>
<div class="row" style="margin-top:8px">
<button class="btn btn-main" onclick="confirmPick()">确定这个位置</button>
</div>
</div>
<script>
/* ===== 坐标换算:WGS-84 → GCJ-02(国内地图必须用火星坐标) ===== */
const PI = 3.1415926535897932384626, A = 6378245.0, EE = 0.00669342162296594323;
function outOfChina(lng, lat){ return !(lng > 73.66 && lng < 135.05 && lat > 3.86 && lat < 53.55); }
function tLat(x, y){
let r = -100 + 2*x + 3*y + .2*y*y + .1*x*y + .2*Math.sqrt(Math.abs(x));
r += (20*Math.sin(6*x*PI) + 20*Math.sin(2*x*PI)) * 2/3;
r += (20*Math.sin(y*PI) + 40*Math.sin(y/3*PI)) * 2/3;
r += (160*Math.sin(y/12*PI) + 320*Math.sin(y*PI/30)) * 2/3;
return r;
}
function tLng(x, y){
let r = 300 + x + 2*y + .1*x*x + .1*x*y + .1*Math.sqrt(Math.abs(x));
r += (20*Math.sin(6*x*PI) + 20*Math.sin(2*x*PI)) * 2/3;
r += (20*Math.sin(x*PI) + 40*Math.sin(x/3*PI)) * 2/3;
r += (150*Math.sin(x/12*PI) + 300*Math.sin(x/30*PI)) * 2/3;
return r;
}
function wgs84ToGcj02(lng, lat){
if (outOfChina(lng, lat)) return [lng, lat];
let dLat = tLat(lng-105, lat-35), dLng = tLng(lng-105, lat-35);
const rad = lat/180*PI, magic = Math.sin(rad), sq = 1 - EE*magic*magic;
dLat = (dLat*180)/((A*(1-EE))/(sq*Math.sqrt(sq))*PI);
dLng = (dLng*180)/(A/Math.sqrt(sq)*Math.cos(rad)*PI);
return [+(lng+dLng).toFixed(7), +(lat+dLat).toFixed(7)];
}
/* ===== 演示用的虚构小区(地图服务不可用时给几个可选点) ===== */
const DEMO_POIS = [
{ name:'鹤仆云示范小区', addr:'广东省中山市东区 示范路 1 号', lng:113.3821, lat:22.5213 },
{ name:'幸福里', addr:'广东省深圳市南山区 测试路 1 号', lng:113.9301, lat:22.5321 },
{ name:'演示花园', addr:'广东省广州市天河区 演示大道 8 号', lng:113.3308, lat:23.1288 },
{ name:'安居苑', addr:'广东省佛山市禅城区 安居街 6 号', lng:113.1214, lat:23.0218 },
];
let PICK = null; // {lng, lat, name, addr}
let CENTER = [113.3821, 22.5213];
let MAP = null, MARKER = null, TENCENT_READY = false;
const proxied = !String(window._TMapSecurityConfig.serviceHost).includes('__WB_');
/* ===== 降级坐标板:不加载任何第三方瓦片,只是给演示选一个坐标 ===== */
function boardToLngLat(px, py, el){
const w = el.clientWidth, h = el.clientHeight;
return [+(CENTER[0] + (px/w - .5)*0.08).toFixed(7),
+(CENTER[1] - (py/h - .5)*0.06).toFixed(7)];
}
function lngLatToBoard(lng, lat, el){
return [( (lng-CENTER[0])/0.08 + .5 ) * el.clientWidth,
( .5 - (lat-CENTER[1])/0.06 ) * el.clientHeight];
}
/* ===== 选点 ===== */
function setPick(lng, lat, name, addr){
PICK = { lng:+lng.toFixed(7), lat:+lat.toFixed(7), name:name||'', addr:addr||'' };
document.getElementById('coord').textContent =
'坐标:' + PICK.lng + ', ' + PICK.lat + '(GCJ-02)';
document.getElementById('addr').textContent =
(name ? name + ' · ' : '') + (addr || '已选择这个位置,回填到地址表单');
document.getElementById('mlng').value = PICK.lng;
document.getElementById('mlat').value = PICK.lat;
const board = document.getElementById('board');
const [x,y] = lngLatToBoard(lng, lat, board);
const pin = document.getElementById('pin');
pin.style.display = 'block';
pin.style.left = Math.max(13, Math.min(board.clientWidth-13, x)) + 'px';
pin.style.top = Math.max(34, Math.min(board.clientHeight, y)) + 'px';
if (TENCENT_READY && MARKER) {
MARKER.setGeometries([{ id:'pick', styleId:'pin', position:new TMap.LatLng(lat, lng) }]);
MAP.setCenter(new TMap.LatLng(lat, lng));
}
}
/* ===== 搜索建议 ===== */
function renderSuggest(items){
const box = document.getElementById('suggest');
if (!items.length){ box.style.display='none'; return; }
box.innerHTML = items.map((p,i) =>
'<div data-i="'+i+'">'+p.name+'<small>'+p.addr+'</small></div>').join('');
box.style.display='block';
box.querySelectorAll('div[data-i]').forEach(el=>el.onclick=()=>{
const p = items[+el.dataset.i];
box.style.display='none';
document.getElementById('q').value = p.name;
CENTER = [p.lng, p.lat];
setPick(p.lng, p.lat, p.name, p.addr);
});
}
document.getElementById('q').addEventListener('input', e=>{
const k = e.target.value.trim();
if (!k) return renderSuggest([]);
const local = DEMO_POIS.filter(p => (p.name+p.addr).includes(k));
if (TENCENT_READY && window.TMap && TMap.service && TMap.service.Search) {
// 正式地图模式:走 SDK 的地点搜索(SDK 内部经代理,不手拼 URL)
try {
const search = new TMap.service.Search({ pageSize: 8 });
search.searchKeyword({ keyword: k }).then(res => {
const list = (res.data || []).map(x => ({
name: x.title || x.address, addr: x.address || '',
lng: x.location.lng, lat: x.location.lat,
}));
renderSuggest(local.concat(list).slice(0, 8));
}).catch(()=>renderSuggest(local));
return;
} catch(e){ /* 退回本地 */ }
}
renderSuggest(local.length ? local : DEMO_POIS);
});
document.addEventListener('click', e=>{
if (!e.target.closest('.search')) document.getElementById('suggest').style.display='none';
});
/* ===== GPS 定位 ===== */
function locate(){
if (!navigator.geolocation) return setMode('这个浏览器不支持定位,请点地图选点');
setMode('正在定位…');
navigator.geolocation.getCurrentPosition(pos=>{
const [lng, lat] = wgs84ToGcj02(pos.coords.longitude, pos.coords.latitude);
CENTER = [lng, lat];
setPick(lng, lat, '当前位置', 'GPS 定位(WGS-84 已换算为 GCJ-02)');
setMode('已用 GPS 定位');
}, err=>{
setMode('定位失败或未被授权;可直接点地图,或点下面「试一个位置」');
const p = DEMO_POIS[0];
CENTER = [p.lng, p.lat];
setPick(p.lng, p.lat, p.name, p.addr + '(演示位置)');
}, { enableHighAccuracy:true, timeout:8000, maximumAge:60000 });
}
function setMode(t){ document.getElementById('mode').textContent = t; }
function applyManual(){
const lng = parseFloat(document.getElementById('mlng').value);
const lat = parseFloat(document.getElementById('mlat').value);
if (!isFinite(lng) || !isFinite(lat)) return setMode('请填有效经纬度');
CENTER = [lng, lat]; setPick(lng, lat, '', '手工输入坐标');
}
function confirmPick(){
if (!PICK) return setMode('先点一下地图选个位置');
const msg = { type:'heserv:pick', ...PICK };
if (window.parent && window.parent !== window) window.parent.postMessage(msg, '*');
else if (window.opener) window.opener.postMessage(msg, '*');
else { localStorage.setItem('heserv_last_pick', JSON.stringify(PICK)); setMode('已保存位置'); }
}
function cancel(){
if (window.parent && window.parent !== window) window.parent.postMessage({type:'heserv:pick-cancel'}, '*');
else if (window.opener) window.opener.postMessage({type:'heserv:pick-cancel'}, '*');
}
/* ===== 启动:能用腾讯地图就用,不能就降级 ===== */
(function init(){
const board = document.getElementById('board');
if (!proxied) {
board.style.display = 'block';
setMode('演示坐标板(腾讯地图代理未启用):点网格选点、用上面的搜索选小区,或直接按「定位」。'
+ ' 正式接入时在 WorkBuddy 预览里打开本页即可显示真实地图。');
document.getElementById('pin').style.zIndex = 7;
board.addEventListener('click', e=>{
const r = board.getBoundingClientRect();
const [lng, lat] = boardToLngLat(e.clientX-r.left, e.clientY-r.top, board);
setPick(lng, lat, '', '地图选点(演示坐标板)');
});
renderSuggest([]);
setPick(CENTER[0], CENTER[1], '鹤仆云示范小区', DEMO_POIS[0].addr);
document.getElementById('addr').textContent = '默认演示位置,点地图可改';
return;
}
// 代理可用:加载官方 SDK(不带 key),失败就降级
const s = document.createElement('script');
s.src = 'https://map.qq.com/api/gljs?v=1.exp&libraries=service';
s.onload = ()=>{
try {
MAP = new TMap.Map('map', { zoom:15, center:new TMap.LatLng(CENTER[1], CENTER[0]) });
MARKER = new TMap.MultiMarker({
map:MAP,
styles:{ pin:new TMap.MarkerStyle({ width:26, height:34, anchor:{x:13,y:34} }) },
geometries:[],
});
MAP.on('click', e=> setPick(e.latLng.lng, e.latLng.lat, '', '地图选点'));
TENCENT_READY = true;
setMode('腾讯地图(GCJ-02)· 点地图选点');
setPick(CENTER[0], CENTER[1], '鹤仆云示范小区', DEMO_POIS[0].addr);
} catch(err){ fallback('地图初始化失败:' + err.message); }
};
s.onerror = ()=> fallback('地图 SDK 没加载成功');
document.head.appendChild(s);
function fallback(reason){
TENCENT_READY = false;
board.style.display = 'block';
setMode(reason + ',已切到演示坐标板');
board.onclick = e=>{
const r=board.getBoundingClientRect();
const [lng,lat]=boardToLngLat(e.clientX-r.left,e.clientY-r.top,board);
setPick(lng,lat,'','地图选点(演示坐标板)');
};
setPick(CENTER[0], CENTER[1], '鹤仆云示范小区', DEMO_POIS[0].addr);
}
})();
</script>
</body>
</html>
+146
View File
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>鹤仆云 · 守望 S 线一期 演示环境</title>
<style>
:root{--ink:#1b2430;--sub:#6b7784;--line:#e4eaef;--brand:#146b7a;--brand2:#1d8fa3}
*{box-sizing:border-box}
body{margin:0;background:#f2f5f8;color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px}
.hero{background:linear-gradient(150deg,#0d3f4a,#146b7a 45%,#22a2a8);color:#fff;padding:42px 24px 68px}
.wrap{max-width:1020px;margin:0 auto}
.hero h1{font-size:27px;margin:0 0 8px;font-weight:600;letter-spacing:.5px}
.hero p{margin:0;font-size:13.5px;opacity:.86;line-height:1.8}
.hero .tagline{display:inline-block;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.2);
border-radius:20px;padding:5px 14px;font-size:12px;margin-bottom:16px}
.content{max-width:1020px;margin:-42px auto 60px;padding:0 24px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:14px;margin-bottom:22px}
.card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:20px;
box-shadow:0 8px 24px rgba(18,40,60,.07);text-decoration:none;color:inherit;display:block;transition:.18s}
.card:hover{transform:translateY(-3px);box-shadow:0 14px 32px rgba(18,40,60,.12)}
.card .ic{width:44px;height:44px;border-radius:12px;background:#e8f3f6;color:var(--brand);
display:flex;align-items:center;justify-content:center;font-size:22px;margin-bottom:12px}
.card h2{margin:0 0 5px;font-size:16.5px;font-weight:600}
.card .d{font-size:12.5px;color:var(--sub);line-height:1.75;min-height:44px}
.card .go{margin-top:12px;color:var(--brand);font-size:12.5px;font-weight:600}
.panel{background:#fff;border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin-bottom:18px;
box-shadow:0 2px 8px rgba(18,40,60,.04)}
.panel h2{font-size:16px;margin:0 0 4px;font-weight:600}
.panel .lead{font-size:12.5px;color:var(--sub);margin-bottom:16px}
.flow{display:flex;align-items:stretch;gap:10px;flex-wrap:wrap}
.node{flex:1;min-width:150px;border:1px solid var(--line);border-radius:11px;padding:13px;background:#fafcfd}
.node b{display:block;font-size:13.5px;margin-bottom:5px;color:var(--brand)}
.node .m{font-family:Consolas,Menlo,monospace;font-size:11px;color:#43536b;line-height:1.7}
.node .n{font-size:11.5px;color:var(--sub);margin-top:6px;line-height:1.6}
.arrow{display:flex;align-items:center;font-size:20px;color:#9fb6bf}
@media(max-width:760px){.arrow{transform:rotate(90deg);justify-content:center;width:100%}}
.steps{counter-reset:s}
.step{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid #f0f4f6;align-items:flex-start}
.step:last-child{border-bottom:0}
.step .num{counter-increment:s;flex:0 0 24px;height:24px;border-radius:7px;background:#e8f3f6;color:var(--brand);
display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700}
.step .num::before{content:counter(s)}
.step .tx{flex:1;font-size:13px;line-height:1.7}
.step .tx b{font-weight:600}
code{font-family:Consolas,Menlo,monospace;background:#f2f5f8;border:1px solid #e7ecf0;border-radius:4px;
padding:1px 6px;font-size:12px;color:#0a4c6b}
.kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px}
.kv div{background:#fafcfd;border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-size:12.5px}
.kv span{color:var(--sub);display:block;font-size:11px;margin-bottom:2px}
.note{background:#fffaf0;border:1px solid #f3e3bd;border-radius:11px;padding:13px 16px;font-size:12.5px;
color:#7a6224;line-height:1.85}
.foot{text-align:center;font-size:11.5px;color:#9aa6b2;padding:6px 0 20px}
</style>
</head>
<body>
<div class="hero"><div class="wrap">
<div class="tagline">演示环境 · 2026-11-07</div>
<h1>鹤仆云 · 守望 S 线一期</h1>
<p>老人「生活痕迹」监测 · 设备 → 网关 → 平台 → 家属<br>
前中后三段全部可点:设备是模拟的,接口是真的,数据是真的,页面是 H5(暂代小程序)</p>
</div></div>
<div class="content">
<div class="cards">
<a class="card" href="family/index.html" target="_blank">
<div class="ic">📱</div><h2>家属端(小程序 H5 版)</h2>
<div class="d">微信一键登录、老人状态、家里设备、告警处理、联系人、通知偏好</div>
<div class="go">打开 →</div>
</a>
<a class="card" href="admin/index.html" target="_blank">
<div class="ic">🖥</div><h2>运营后台</h2>
<div class="d">总览、告警中心、设备台账、住户、账号权限、字典、工单、审计</div>
<div class="go">打开 →</div>
</a>
<a class="card" href="http://127.0.0.1:9901/" target="_blank">
<div class="ic">📡</div><h2>设备模拟器</h2>
<div class="d">点一下 SOS / 漏水 / 断电,看数据怎样一路走到家属手机</div>
<div class="go">打开 →</div>
</a>
</div>
<div class="panel">
<h2>三段是怎么串起来的</h2>
<div class="lead">所有箭头都走真实 HTTP 接口,没有一处是页面里写死的假数据。</div>
<div class="flow">
<div class="node"><b>① 设备 / 网关</b>
<div class="m">sim_device.py<br>:9901</div>
<div class="n">5 台设备按真实帧格式上报;按钮触发 SOS、水浸、断电</div></div>
<div class="arrow">→</div>
<div class="node"><b>② 接入层(M 层)</b>
<div class="m">/internal/iot/v1/**<br>:8080</div>
<div class="n">I-01 事件 / I-02 状态 / I-12 原文,验签限流幂等后落 iot_event</div></div>
<div class="arrow">→</div>
<div class="node"><b>③ 业务层</b>
<div class="m">AlarmEngine + 53 API<br>:8080</div>
<div class="n">把原始事件翻成告警和通知,供家属端、后台查询与处置</div></div>
<div class="arrow">→</div>
<div class="node"><b>④ 前端</b>
<div class="m">/api/v1/**<br>H5</div>
<div class="n">家属端看与确认,后台看全量与闭环</div></div>
</div>
</div>
<div class="panel">
<h2>演示动线(8 步,约 7 分钟)</h2>
<div class="steps">
<div class="step"><div class="num"></div><div class="tx"><b>打开家属端</b>,用「微信一键授权(模拟)」进去。首页能看到老人、在线设备数、待处理告警。验证码那条链路可以点「注册」试:随机 6 位数字直接填好,不真发短信。</div></div>
<div class="step"><div class="num"></div><div class="tx"><b>打开设备模拟器</b>,点「🆘 按下 SOS」或「💧 卫生间漏水」。</div></div>
<div class="step"><div class="num"></div><div class="tx"><b>等 8 秒</b>(告警引擎扫描周期),回家属端点刷新 → 新告警出现 → 点开 → 「我知道了 / 已处理 / 误报」都能点,处理记录会留痕。</div></div>
<div class="step"><div class="num"></div><div class="tx"><b>打开运营后台</b>,总览的 KPI、近 7 天告警柱状、最新告警会跟着变;告警中心可关闭工单,审计日志能看到刚才所有写操作。</div></div>
<div class="step"><div class="num"></div><div class="tx"><b>家属端 →「设备」→ + 绑定设备</b>:扫码(或用演示设备码)/手填编号,能把一台没建档的设备激活并挂到家里。</div></div>
<div class="step"><div class="num"></div><div class="tx"><b>家属端 →「我的」→ + 创建我的家</b>:像美团新增收货地址一样,地图选点或 GPS 定位 → 省市区 → 小区 → 楼栋单元房号 → 提交;建好后顶部 ⌄ 可以在多个家之间切换。</div></div>
<div class="step"><div class="num"></div><div class="tx"><b>填写老人信息</b>:「我的」→ + 填写老人信息 → 姓名/性别/生日/照护等级/是否独居/画像授权 → 保存后点开老人,可以改资料、加紧急联系人(姓名手机号加密,列表只显示打码号码)。</div></div>
<div class="step"><div class="num"></div><div class="tx"><b>一家人怎么进来</b>:户主点「🎟️ 邀请家人」拿 6 位码;另一个浏览器 / 无痕窗口注册新账号 → 「我的 → 加入别人的家」→ 输码直接进,或搜小区房号「申请加入」。回到户主窗口点「📥 审批」,能看到申请并点通过 / 拒绝。</div></div>
</div>
</div>
<div class="panel">
<h2>账号与地址</h2>
<div class="kv">
<div><span>家属端 · 演示账号</span>13800000000 / 123456,或直接点微信一键</div>
<div><span>运营后台</span>admin(口令按部署说明)</div>
<div><span>业务服务</span><code>http://127.0.0.1:8080</code></div>
<div><span>模拟控制台</span><code>http://127.0.0.1:9901</code></div>
<div><span>健康检查</span><code>/health</code> → flyway 1.0.28 / 87 表</div>
<div><span>本页地址</span><code>/app/index.html</code></div>
</div>
</div>
<div class="note">
<b>这是演示环境,不是生产版本。</b>三件事还没做:真微信换 openid、真短信通道、告警规则表(目前只有 SOS / 水浸 / 门磁 / 断电 4 条规则,阈值、静默期、升级链要产品定)。
角色数据范围也还没收紧——后台账号现在能看全部小区,上线前必须由程序员补。<br>
地图只用腾讯地图 GL JS(SDK 不带 key、走代理、GCJ-02 坐标);
从 :8080 打开时代理未启用,会自动降级成「演示坐标板」,功能仍可用,但没有真实底图。
</div>
<div class="foot">鹤仆云 · 守望 S 线一期 · 三段贯通演示</div>
</div>
</body>
</html>