rack:单板页 Hub 行与拓扑图、单路操作拆成 RailOps;开机按键改 5 s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -195,6 +195,16 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
|
|||||||
"断电自动退出" if a == protocol.CHG_EV_DROPPED else
|
"断电自动退出" if a == protocol.CHG_EV_DROPPED else
|
||||||
"加钟 %d 分钟" % (a & 0x7F) if a & protocol.CHG_EV_EXTEND else
|
"加钟 %d 分钟" % (a & 0x7F) if a & protocol.CHG_EV_EXTEND else
|
||||||
"开 %d 分钟" % a)
|
"开 %d 分钟" % a)
|
||||||
|
elif d["type"] == 22:
|
||||||
|
a = d["arg"]
|
||||||
|
if a & protocol.HUB_EV_MODE:
|
||||||
|
m = protocol.HUB_MODES[a & 0x0F] if (a & 0x0F) < len(protocol.HUB_MODES) else str(a & 0x0F)
|
||||||
|
d["name"] += "(模式改为%s)" % protocol.HUB_MODE_NAMES.get(m, m)
|
||||||
|
elif a == protocol.HUB_EV_ON:
|
||||||
|
d["name"] += "(唤醒:%s)" % protocol.HUB_WHY_NAMES.get(d["mv"], d["mv"])
|
||||||
|
else:
|
||||||
|
d["name"] += "(休眠)"
|
||||||
|
d["mv"] = None # mV 列装的是理由码,不是电压 —— 别让界面显示成「0.002 V」
|
||||||
elif d["type"] == 19:
|
elif d["type"] == 19:
|
||||||
failed = bool(d["arg"] & protocol.BOOT_CHECK_FAILED)
|
failed = bool(d["arg"] & protocol.BOOT_CHECK_FAILED)
|
||||||
d["name"] += "(按了 %d 次,%s)" % (d["arg"] & 0x0F, "没开起来" if failed else "开起来了")
|
d["name"] += "(按了 %d 次,%s)" % (d["arg"] & 0x0F, "没开起来" if failed else "开起来了")
|
||||||
|
|||||||
@@ -39,8 +39,17 @@ EVENT_TYPES: Dict[int, Tuple[str, str]] = {
|
|||||||
19: ("BOOT_CHECK", "开机重按"), # 0.1.2:arg bit0~3 = 按了几次,bit7 = 最终没开起来(电源键弹片接触不良)
|
19: ("BOOT_CHECK", "开机重按"), # 0.1.2:arg bit0~3 = 按了几次,bit7 = 最终没开起来(电源键弹片接触不良)
|
||||||
20: ("BLADE_MUTE", "刀片不应答关路"), # 0.1.4:eFuse 开着、本路 INA226 + PCA9555 连续 200 ms 都不应答
|
20: ("BLADE_MUTE", "刀片不应答关路"), # 0.1.4:eFuse 开着、本路 INA226 + PCA9555 连续 200 ms 都不应答
|
||||||
21: ("CHG_MODE", "刷机模式"), # 0.1.5:arg 1~120 = 开(分钟),0 = 手动关,255 = 到期;0.1.6:0x80|分钟 = 加钟;0.1.13:254 = 断电自动退出
|
21: ("CHG_MODE", "刷机模式"), # 0.1.5:arg 1~120 = 开(分钟),0 = 手动关,255 = 到期;0.1.6:0x80|分钟 = 加钟;0.1.13:254 = 断电自动退出
|
||||||
|
22: ("HUB", "Hub 启停"), # 0.1.14:arg 0 = 休眠、1 = 唤醒、0x10|模式 = 改模式;mV 列 = 唤醒理由(HUB_WHY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# LOG_EV_HUB 的 arg 与理由(firmware/main/hubctl.h)。⚠️ 冻结。
|
||||||
|
HUB_EV_OFF, HUB_EV_ON, HUB_EV_MODE = 0, 1, 0x10
|
||||||
|
HUB_MODES = ("auto", "on") # hub_mode_t 的下标
|
||||||
|
HUB_MODE_NAMES = {"auto": "按需", "on": "常开"}
|
||||||
|
HUB_WHY_NAMES = {0: "休眠", 1: "常开", 2: "有手机开着 VBUS", 3: "刷机模式", 4: "执行器在跑序列",
|
||||||
|
5: "手动保持", 6: "MQTT 断线", 7: "空闲倒计时"}
|
||||||
|
HUB_HOLD_MAX_MIN = 240
|
||||||
|
|
||||||
# LOG_EV_CHG_MODE 的 arg(firmware/main/chgmode.h)。⚠️ 冻结。
|
# LOG_EV_CHG_MODE 的 arg(firmware/main/chgmode.h)。⚠️ 冻结。
|
||||||
CHG_EV_OFF, CHG_EV_EXPIRED, CHG_EV_EXTEND = 0, 255, 0x80
|
CHG_EV_OFF, CHG_EV_EXPIRED, CHG_EV_EXTEND = 0, 255, 0x80
|
||||||
CHG_EV_DROPPED = 254 # 0.1.13:这一路断了电(刀片拔走 / 安全判据关路),自动退出。⚠️ 带 0x80 位,要先于「加钟」判
|
CHG_EV_DROPPED = 254 # 0.1.13:这一路断了电(刀片拔走 / 安全判据关路),自动退出。⚠️ 带 0x80 位,要先于「加钟」判
|
||||||
@@ -59,7 +68,7 @@ BOOT_CHECK_FAILED = 0x80 # LOG_EV_BOOT_CHECK 的 arg bit7
|
|||||||
# cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。
|
# cmd_id_t(firmware/main/cmd.h),CMD_REJECT 事件的 arg 就是它。
|
||||||
CMD_NAMES = {1: "status", 2: "set_voltage", 3: "power_cycle", 4: "key", 5: "edl", 6: "vbus",
|
CMD_NAMES = {1: "status", 2: "set_voltage", 3: "power_cycle", 4: "key", 5: "edl", 6: "vbus",
|
||||||
7: "fan", 8: "events", 9: "power", 10: "wifi", 11: "mqtt", 12: "net",
|
7: "fan", 8: "events", 9: "power", 10: "wifi", 11: "mqtt", 12: "net",
|
||||||
13: "ota", 14: "flash_mode"}
|
13: "ota", 14: "flash_mode", 15: "hub"}
|
||||||
|
|
||||||
KEYS = ("pwr", "vup", "vdn")
|
KEYS = ("pwr", "vup", "vdn")
|
||||||
KEY_MAX_MS = 15000
|
KEY_MAX_MS = 15000
|
||||||
@@ -128,6 +137,22 @@ def build_command(req: dict) -> dict:
|
|||||||
raise CmdError("刷机模式时长必须是 1~%d 分钟" % FLASH_MODE_MAX_MIN)
|
raise CmdError("刷机模式时长必须是 1~%d 分钟" % FLASH_MODE_MAX_MIN)
|
||||||
out["min"] = m
|
out["min"] = m
|
||||||
return out
|
return out
|
||||||
|
if cmd == "hub":
|
||||||
|
# 0.1.14 Hub 按需启停(整板命令):mode = auto / on(板子存 NVS);min = 保持 1~240 分钟,
|
||||||
|
# 0 = 取消保持并结束空闲倒计时(没别的理由就马上休眠)。两个都可以给,至少给一个。
|
||||||
|
out = {"cmd": cmd}
|
||||||
|
if req.get("mode") is not None:
|
||||||
|
if req["mode"] not in HUB_MODES:
|
||||||
|
raise CmdError("Hub 模式只能是 auto / on")
|
||||||
|
out["mode"] = req["mode"]
|
||||||
|
if req.get("min") is not None:
|
||||||
|
m = req["min"]
|
||||||
|
if not isinstance(m, int) or isinstance(m, bool) or not 0 <= m <= HUB_HOLD_MAX_MIN:
|
||||||
|
raise CmdError("Hub 保持时长必须是 0~%d 分钟" % HUB_HOLD_MAX_MIN)
|
||||||
|
out["min"] = m
|
||||||
|
if len(out) == 1:
|
||||||
|
raise CmdError("hub 至少要给 mode 或 min")
|
||||||
|
return out
|
||||||
if cmd == "fan":
|
if cmd == "fan":
|
||||||
duty = req.get("duty")
|
duty = req.get("duty")
|
||||||
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
|
if not isinstance(duty, int) or isinstance(duty, bool) or not 0 <= duty <= 1000:
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class SimBoard:
|
|||||||
self.ota = {"st": "idle", "got": 0, "size": 0, "err": None, "pend": False, "bad": None}
|
self.ota = {"st": "idle", "got": 0, "size": 0, "err": None, "pend": False, "bad": None}
|
||||||
self.ota_req: Optional[dict] = None
|
self.ota_req: Optional[dict] = None
|
||||||
self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s)
|
self.ota_rate = 400000 # 每次 tick 下载多少字节(真板 ≈125 KB/s)
|
||||||
|
self.hub = {"on": True, "mode": "auto", "why": "idle", "hold": 0, "idle": 600} # 0.1.14,只回显不计时
|
||||||
bus.on_message = self._on_message
|
bus.on_message = self._on_message
|
||||||
self._ev(0, 1, 0) # BOOT
|
self._ev(0, 1, 0) # BOOT
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ class SimBoard:
|
|||||||
"fan": {"duty": max(300, self.fan_floor), "floor": self.fan_floor, "en": True,
|
"fan": {"duty": max(300, self.fan_floor), "floor": self.fan_floor, "en": True,
|
||||||
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
|
"rpm": [3300, 3280], "stall": [False, False]}, "derate": False,
|
||||||
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
|
"safety": {"cycles": self.up_ms // 20, "skipped": 0, "i2c_err": 0, "events": 0},
|
||||||
|
"hub": dict(self.hub),
|
||||||
"ota": dict(self.ota), "ev_next": len(self.events), "ev_pending": 0,
|
"ota": dict(self.ota), "ev_next": len(self.events), "ev_pending": 0,
|
||||||
"cmd": {"lines": len(self.received), "dups": 0, "rej": 0}}
|
"cmd": {"lines": len(self.received), "dups": 0, "rej": 0}}
|
||||||
|
|
||||||
@@ -183,6 +185,29 @@ class SimBoard:
|
|||||||
return {"ok": True, "floor": self.fan_floor}
|
return {"ok": True, "floor": self.fan_floor}
|
||||||
if req.get("seq") is None:
|
if req.get("seq") is None:
|
||||||
return {"ok": False, "err": "no_seq"}
|
return {"ok": False, "err": "no_seq"}
|
||||||
|
if cmd == "hub": # 0.1.14:固件 hubctl.h 的命令形状;不模拟倒计时
|
||||||
|
h = self.hub
|
||||||
|
if "mode" in req:
|
||||||
|
if req["mode"] not in ("auto", "on"):
|
||||||
|
return {"ok": False, "err": "arg"}
|
||||||
|
if req["mode"] != h["mode"]:
|
||||||
|
self._ev(0, 22, 0x10 | ("auto", "on").index(req["mode"]))
|
||||||
|
h["mode"] = req["mode"]
|
||||||
|
if "min" in req:
|
||||||
|
if not 0 <= req["min"] <= 240:
|
||||||
|
return {"ok": False, "err": "range"}
|
||||||
|
h["hold"] = req["min"] * 60
|
||||||
|
if req["min"] == 0:
|
||||||
|
h["idle"] = 0
|
||||||
|
if "mode" not in req and "min" not in req:
|
||||||
|
return {"ok": False, "err": "arg"}
|
||||||
|
want = h["mode"] == "on" or h["hold"] > 0 or h["idle"] > 0 or any(r.vb for r in self.rails)
|
||||||
|
if want != h["on"]:
|
||||||
|
self._ev(0, 22, 1 if want else 0, 5 if want else 0)
|
||||||
|
h["on"] = want
|
||||||
|
h["why"] = ("mode" if h["mode"] == "on" else "vbus" if any(r.vb for r in self.rails) else
|
||||||
|
"hold" if h["hold"] else "idle" if h["idle"] else "off") if want else "off"
|
||||||
|
return {"ok": True, "hub": dict(h)}
|
||||||
if cmd == "ota":
|
if cmd == "ota":
|
||||||
if not (isinstance(req.get("url"), str) and req["url"].startswith("http://")
|
if not (isinstance(req.get("url"), str) and req["url"].startswith("http://")
|
||||||
and len(req["url"]) <= 64 and isinstance(req.get("sha256"), str)
|
and len(req["url"]) <= 64 and isinstance(req.get("sha256"), str)
|
||||||
|
|||||||
@@ -218,3 +218,23 @@ def test_flash_mode_extend_round_trip(env):
|
|||||||
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
|
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
|
||||||
assert "加钟 30 分钟" in ev[9101]["name"]
|
assert "加钟 30 分钟" in ev[9101]["name"]
|
||||||
assert "断电自动退出" in ev[9102]["name"] # 0.1.13:254 带 0x80 位,不能被当成「加钟 126 分钟」
|
assert "断电自动退出" in ev[9102]["name"] # 0.1.13:254 带 0x80 位,不能被当成「加钟 126 分钟」
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_round_trip_and_event_names(env):
|
||||||
|
# 固件 0.1.14:hub 命令经 /cmd 放行、回应带 hub 对象;事件 22 按 arg 命名、mV 列不当电压显示
|
||||||
|
c, hub, sim, db = env
|
||||||
|
post = lambda body: c.post("/api/boards/%s/cmd" % B, json=body).json()
|
||||||
|
r = post({"cmd": "hub", "mode": "on"})
|
||||||
|
assert r["ok"] is True and r["hub"]["mode"] == "on" and r["hub"]["on"] is True
|
||||||
|
r = post({"cmd": "hub", "mode": "auto", "min": 0})
|
||||||
|
assert r["hub"]["on"] is False and r["hub"]["why"] == "off"
|
||||||
|
assert post({"cmd": "status"})["hub"]["mode"] == "auto"
|
||||||
|
assert c.post("/api/boards/%s/cmd" % B, json={"cmd": "hub", "min": 999}).status_code == 400
|
||||||
|
now = 10 ** 13
|
||||||
|
db.many("INSERT INTO events (board_id, seq, t_ms, rail, type, arg, mv, ua, received) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
[(B, 9201, 1, 0, 22, 1, 2, 0, now), (B, 9202, 2, 0, 22, 0, 0, 0, now),
|
||||||
|
(B, 9203, 3, 0, 22, 0x11, 7, 0, now)])
|
||||||
|
ev = {e["seq"]: e for e in c.get("/api/events", params={"board": B}).json()}
|
||||||
|
assert "唤醒:有手机开着 VBUS" in ev[9201]["name"] and ev[9201]["mv"] is None
|
||||||
|
assert "休眠" in ev[9202]["name"] and ev[9202]["severe"] is False
|
||||||
|
assert "模式改为常开" in ev[9203]["name"]
|
||||||
|
|||||||
@@ -320,6 +320,19 @@ def test_flash_mode_command_and_event_numbers():
|
|||||||
protocol.build_command(bad)
|
protocol.build_command(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hub_command_and_event_numbers():
|
||||||
|
# 固件 0.1.14:命令 15 / 事件 22(cmd.h / evlog.h 冻结编号)
|
||||||
|
assert protocol.CMD_NAMES[15] == "hub" and protocol.EVENT_TYPES[22][0] == "HUB"
|
||||||
|
assert protocol.HUB_MODES == ("auto", "on") and protocol.HUB_WHY_NAMES[2] == "有手机开着 VBUS"
|
||||||
|
assert protocol.build_command({"cmd": "hub", "mode": "on"}) == {"cmd": "hub", "mode": "on"}
|
||||||
|
assert protocol.build_command({"cmd": "hub", "min": 0}) == {"cmd": "hub", "min": 0}
|
||||||
|
assert protocol.build_command({"cmd": "hub", "mode": "auto", "min": 30}) == {"cmd": "hub", "mode": "auto", "min": 30}
|
||||||
|
for bad in ({"cmd": "hub"}, {"cmd": "hub", "mode": "off"}, {"cmd": "hub", "min": 241},
|
||||||
|
{"cmd": "hub", "min": -1}, {"cmd": "hub", "min": True}):
|
||||||
|
with pytest.raises(protocol.CmdError):
|
||||||
|
protocol.build_command(bad)
|
||||||
|
|
||||||
|
|
||||||
def test_flash_mode_extend_command():
|
def test_flash_mode_extend_command():
|
||||||
assert protocol.build_command({"cmd": "flash_mode", "rail": 1, "add": 30}) == {"cmd": "flash_mode", "rail": 1, "add": 30}
|
assert protocol.build_command({"cmd": "flash_mode", "rail": 1, "add": 30}) == {"cmd": "flash_mode", "rail": 1, "add": 30}
|
||||||
for bad in ({"cmd": "flash_mode", "rail": 1, "add": 0},
|
for bad in ({"cmd": "flash_mode", "rail": 1, "add": 0},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="legend" aria-hidden="true">
|
<div class="legend" aria-hidden="true">
|
||||||
<span><i :style="{ background: c.s1 }" />电流 · 功率 · 电压</span>
|
<span><i :style="{ background: c.s1 }" />电流 · 功率 · 电压</span>
|
||||||
<span class="hint">点上面的卡片切换是哪一路</span>
|
<span class="hint">点上面拓扑图里的一路切换</span>
|
||||||
</div>
|
</div>
|
||||||
<div ref="el" class="chart" :style="{ height: h + 'px' }" role="img" :aria-label="`第 ${rail} 路电流、功率与电压曲线`" />
|
<div ref="el" class="chart" :style="{ height: h + 'px' }" role="img" :aria-label="`第 ${rail} 路电流、功率与电压曲线`" />
|
||||||
<p class="muted note">{{ note }}</p>
|
<p class="muted note">{{ note }}</p>
|
||||||
|
|||||||
@@ -1,34 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card rail" :class="[{ sel: selected }, h.level]" role="button" tabindex="0"
|
<div class="ops" @click.stop @keydown.stop>
|
||||||
:aria-pressed="selected" :aria-label="`第 ${rail} 路,${h.text},点击查看曲线`"
|
<div class="btns">
|
||||||
@click="$emit('select')" @keydown.enter.self="$emit('select')">
|
|
||||||
<div class="rh">
|
|
||||||
<span class="no readout">{{ rail }}</span>
|
|
||||||
<span :class="['led', h.level]" />
|
|
||||||
<span class="st" :style="{ color: loud ? LEVEL_COLOR[h.level] : 'var(--ink-2)' }">{{ h.text }}</span>
|
|
||||||
</div>
|
|
||||||
<el-input v-model="label" size="small" class="label" placeholder="手机编号" @change="saveLabel" @click.stop />
|
|
||||||
|
|
||||||
<div class="nums" v-if="r?.pr">
|
|
||||||
<div><b class="readout">{{ mA(r.ua) }}</b><small>mA</small><div class="muted set">功率 <span class="readout">{{ W(r.mv, r.ua) }}</span> W</div></div>
|
|
||||||
<div><b class="readout">{{ V(r.mv) }}</b><small>V</small><div class="muted set">设定 {{ V(r.set) }}</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="flags" v-if="r?.pr">
|
|
||||||
<span :class="{ on: r.en }">直供{{ r.en ? '开' : '关' }}</span>
|
|
||||||
<span :class="{ on: r.vb }">USB{{ r.vb ? '通' : '断' }}</span>
|
|
||||||
<span :class="{ on: r.pg, bad: r.en && r.pg === false }">PG</span>
|
|
||||||
<span v-if="r.flt && r.chg > 0" title="刷机模式下手机充电器把电池端顶高,eFuse 进入反向阻断,FLT 随之拉低:正常指示,不是故障">反向阻断</span>
|
|
||||||
<span :class="{ bad: r.flt }" v-else-if="r.flt">FLT</span>
|
|
||||||
<span class="chg" v-if="r.chg > 0" :title="'刷机模式:USB 通着时允许手机充电、不断 USB(固件 0.1.5)'">刷机 {{ Math.ceil(r.chg / 60) }} 分</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ar" @click.stop>
|
|
||||||
<el-switch v-model="auto" size="small" :disabled="!r?.pr" @change="saveAuto" />
|
|
||||||
<span class="muted">自动恢复</span>
|
|
||||||
<el-tag v-if="recState && recState !== 'idle'" type="warning" size="small">{{ RECOVERY_ZH[recState] }}</el-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="acts" @click.stop>
|
|
||||||
<el-button size="small" type="danger" plain :disabled="!can" @click="run('断电重启', { cmd: 'power_cycle' }, true)">断电重启</el-button>
|
<el-button size="small" type="danger" plain :disabled="!can" @click="run('断电重启', { cmd: 'power_cycle' }, true)">断电重启</el-button>
|
||||||
<el-button size="small" plain :disabled="!can" @click="run('强制重启(长按电源 10 秒)', { cmd: 'key', keys: 'pwr', ms: 10000 }, true)">强制重启</el-button>
|
<el-button size="small" plain :disabled="!can" @click="run('强制重启(长按电源 10 秒)', { cmd: 'key', keys: 'pwr', ms: 10000 }, true)">强制重启</el-button>
|
||||||
<el-dropdown trigger="click" :disabled="!r?.pr" @command="more">
|
<el-dropdown trigger="click" :disabled="!r?.pr" @command="more">
|
||||||
@@ -36,7 +8,7 @@
|
|||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="tap">短按电源(亮屏 / 息屏)</el-dropdown-item>
|
<el-dropdown-item command="tap">短按电源(亮屏 / 息屏)</el-dropdown-item>
|
||||||
<el-dropdown-item command="on">开机(长按电源 2 秒)</el-dropdown-item>
|
<el-dropdown-item command="on">开机(长按电源 5 秒)</el-dropdown-item>
|
||||||
<el-dropdown-item command="fastboot" divided>进 Fastboot(音量下 + 电源)</el-dropdown-item>
|
<el-dropdown-item command="fastboot" divided>进 Fastboot(音量下 + 电源)</el-dropdown-item>
|
||||||
<el-dropdown-item command="recovery">进 Recovery(音量上 + 电源)</el-dropdown-item>
|
<el-dropdown-item command="recovery">进 Recovery(音量上 + 电源)</el-dropdown-item>
|
||||||
<el-dropdown-item command="edl">进 EDL / 9008</el-dropdown-item>
|
<el-dropdown-item command="edl">进 EDL / 9008</el-dropdown-item>
|
||||||
@@ -49,9 +21,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ar">
|
||||||
|
<el-switch v-model="auto" size="small" :disabled="!r?.pr" @change="saveAuto" />
|
||||||
|
<span class="muted">自动恢复</span>
|
||||||
|
<el-tag v-if="recState && recState !== 'idle'" type="warning" size="small">{{ RECOVERY_ZH[recState] }}</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="voltOpen" title="调整输出电压" width="min(420px, 92vw)" append-to-body>
|
<el-dialog v-model="voltOpen" title="调整输出电压" width="min(420px, 92vw)" append-to-body>
|
||||||
<p class="muted">范围 3.524 ~ 4.010 V(硬件决定,固件再限一次)。只是微调,不是保护手段。</p>
|
<p class="muted">第 {{ rail }} 路。范围 3.524 ~ 4.010 V(硬件决定,固件再限一次)。只是微调,不是保护手段。</p>
|
||||||
<el-slider v-model="mv" :min="3524" :max="4010" :step="1" show-input />
|
<el-slider v-model="mv" :min="3524" :max="4010" :step="1" show-input />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="voltOpen = false">取消</el-button>
|
<el-button @click="voltOpen = false">取消</el-button>
|
||||||
@@ -62,27 +39,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
// 一路的全部操作(原 RailCard 的动作部分)。拓扑图里的元件点击也走这里:defineExpose 出 more()。
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowDown } from '@element-plus/icons-vue'
|
import { ArrowDown } from '@element-plus/icons-vue'
|
||||||
import { api, rspText } from '../api'
|
import { api, rspText } from '../api'
|
||||||
import { mA, V, W, railHealth, LEVEL_COLOR, RECOVERY_ZH } from '../util'
|
import { RECOVERY_ZH } from '../util'
|
||||||
|
|
||||||
const props = defineProps({ board: Object, rail: Number, selected: Boolean, recState: String })
|
const props = defineProps({ board: Object, rail: Number, recState: String })
|
||||||
defineEmits(['select'])
|
|
||||||
|
|
||||||
const r = computed(() => props.board?.status?.rails?.[props.rail - 1])
|
const r = computed(() => props.board?.status?.rails?.[props.rail - 1])
|
||||||
const phone = computed(() => props.board?.phones?.find(p => p.rail === props.rail) || {})
|
const phone = computed(() => props.board?.phones?.find(p => p.rail === props.rail) || {})
|
||||||
const h = computed(() => railHealth(props.board, r.value))
|
const online = computed(() => props.board?.online && !props.board?.stale)
|
||||||
const loud = computed(() => ['bad', 'warn', 'busy'].includes(h.value.level))
|
|
||||||
// 在位、板子在线、执行器空闲才能下动作(忙的时候板子也会回 busy,这里只是少一次无用的往返)
|
// 在位、板子在线、执行器空闲才能下动作(忙的时候板子也会回 busy,这里只是少一次无用的往返)
|
||||||
const can = computed(() => r.value?.pr && !props.board.stale && props.board.online && r.value.st === 'idle')
|
const can = computed(() => !!(r.value?.pr && online.value && r.value.st === 'idle'))
|
||||||
|
|
||||||
const label = ref('')
|
|
||||||
const auto = ref(false)
|
const auto = ref(false)
|
||||||
watch(phone, p => { label.value = p.label || ''; auto.value = !!p.auto_recover }, { immediate: true })
|
watch(phone, p => { auto.value = !!p.auto_recover }, { immediate: true })
|
||||||
|
|
||||||
const who = () => `${props.board.name || props.board.id} #${props.rail}${label.value ? `(${label.value})` : ''}`
|
const who = () => `${props.board.name || props.board.id} #${props.rail}${phone.value.label ? `(${phone.value.label})` : ''}`
|
||||||
|
|
||||||
async function run(title, body, confirm) {
|
async function run(title, body, confirm) {
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
@@ -100,9 +75,10 @@ async function run(title, body, confirm) {
|
|||||||
|
|
||||||
function more(c) {
|
function more(c) {
|
||||||
const on = r.value
|
const on = r.value
|
||||||
|
if (!on?.pr) return
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case 'tap': return run('短按电源', { cmd: 'key', keys: 'pwr', ms: 100 })
|
case 'tap': return run('短按电源', { cmd: 'key', keys: 'pwr', ms: 100 })
|
||||||
case 'on': return run('开机', { cmd: 'key', keys: 'pwr', ms: 2000 })
|
case 'on': return run('开机', { cmd: 'key', keys: 'pwr', ms: 5000 })
|
||||||
case 'fastboot': return run('进 Fastboot', { cmd: 'key', keys: 'vdn+pwr', ms: 10000 }, true)
|
case 'fastboot': return run('进 Fastboot', { cmd: 'key', keys: 'vdn+pwr', ms: 10000 }, true)
|
||||||
case 'recovery': return run('进 Recovery', { cmd: 'key', keys: 'vup+pwr', ms: 10000 }, true)
|
case 'recovery': return run('进 Recovery', { cmd: 'key', keys: 'vup+pwr', ms: 10000 }, true)
|
||||||
case 'edl': return run('进 EDL', { cmd: 'edl' }, true)
|
case 'edl': return run('进 EDL', { cmd: 'edl' }, true)
|
||||||
@@ -134,9 +110,6 @@ async function setVolt() {
|
|||||||
await run(`电压 ${(mv.value / 1000).toFixed(3)} V`, { cmd: 'set_voltage', mv: mv.value })
|
await run(`电压 ${(mv.value / 1000).toFixed(3)} V`, { cmd: 'set_voltage', mv: mv.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLabel() {
|
|
||||||
try { await api.patchPhone(props.board.id, props.rail, { label: label.value }) } catch (e) { ElMessage.error(e.message) }
|
|
||||||
}
|
|
||||||
async function saveAuto(v) {
|
async function saveAuto(v) {
|
||||||
if (v) {
|
if (v) {
|
||||||
try {
|
try {
|
||||||
@@ -147,27 +120,13 @@ async function saveAuto(v) {
|
|||||||
}
|
}
|
||||||
try { await api.patchPhone(props.board.id, props.rail, { auto_recover: v }) } catch (e) { ElMessage.error(e.message); auto.value = !v }
|
try { await api.patchPhone(props.board.id, props.rail, { auto_recover: v }) } catch (e) { ElMessage.error(e.message); auto.value = !v }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ more })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rail { cursor: pointer; display: flex; flex-direction: column; gap: 10px; padding: 14px; }
|
.ops { display: flex; flex-direction: column; gap: 6px; cursor: default; }
|
||||||
.rail:hover { border-color: var(--muted); }
|
.btns { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.rail.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
.btns .el-button + .el-button { margin-left: 0; }
|
||||||
.rail.empty { background: transparent; border-style: dashed; }
|
.ar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
.rh { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.no { font-weight: 700; font-size: 20px; color: var(--ink); min-width: 14px; }
|
|
||||||
.st { font-size: 13px; font-weight: 600; }
|
|
||||||
.label :deep(.el-input__wrapper) { box-shadow: none; background: var(--bay); }
|
|
||||||
.nums { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
|
||||||
.nums b { font-size: 26px; font-weight: 400; color: var(--ink); line-height: 1.1; }
|
|
||||||
.nums small { color: var(--muted); font-size: 11px; margin-left: 3px; }
|
|
||||||
.nums .set { font-size: 12px; }
|
|
||||||
.flags { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
||||||
.flags span { font-size: 12px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); }
|
|
||||||
.flags span.on { color: var(--ink); border-color: var(--ink-2); }
|
|
||||||
.flags span.bad { color: var(--st-bad); border-color: var(--st-bad); }
|
|
||||||
.flags span.chg { color: var(--st-warn); border-color: currentColor; }
|
|
||||||
.ar { display: flex; align-items: center; gap: 6px; }
|
|
||||||
.acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
|
||||||
.acts .el-button + .el-button { margin-left: 0; }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card topo" :class="{ frozen: !online }">
|
||||||
|
<header class="hh">
|
||||||
|
<h2 class="ttl">供电拓扑</h2>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="lg live" />能量流:线越粗电流越大,流得越快功率越大</span>
|
||||||
|
<span><i class="lg idle" />有电无流</span>
|
||||||
|
<span><i class="lg off" />断开</span>
|
||||||
|
<span><i class="lg data" />USB 数据</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 功率分配:底板输入 = 五路主板 + 辅助与损耗 -->
|
||||||
|
<div class="split" v-if="split" :aria-label="split.aria" role="img">
|
||||||
|
<div class="bar">
|
||||||
|
<span v-for="seg in split.segs" :key="seg.key" :class="['seg', seg.kind]" :style="{ flexGrow: seg.w }"
|
||||||
|
:title="seg.title"><em v-if="seg.w > 0.07">{{ seg.short }}</em></span>
|
||||||
|
</div>
|
||||||
|
<div class="sl muted">
|
||||||
|
<span>输入 <b class="readout">{{ fmtW(pin) }}</b> W</span>
|
||||||
|
<span>→ 主板 <b class="readout">{{ fmtW(prails) }}</b> W</span>
|
||||||
|
<span>辅助与损耗 <b class="readout">{{ fmtW(ploss) }}</b> W</span>
|
||||||
|
<span v-if="eff != null">效率 <b class="readout">{{ eff }}</b> %</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="net">
|
||||||
|
<!-- 输入 -->
|
||||||
|
<div class="src node" :class="{ ghost: !srcLive && !online }">
|
||||||
|
<div class="nt">12 V 输入</div>
|
||||||
|
<div class="ns" title="J1 12V 座 · F1 保险丝 · D1 TVS · Q1 反接保护 · Rs6 + U13 INA226 输入侧采样">J1 · F1 · TVS · 反接 · U13</div>
|
||||||
|
<div class="nv readout">{{ V(s?.in?.mv) }}<small>V</small></div>
|
||||||
|
<div class="nv2 readout">{{ A(s?.in?.ua) }}<small>A</small> {{ fmtW(pin) }}<small>W</small></div>
|
||||||
|
</div>
|
||||||
|
<i class="feed w" :class="srcLive ? 'live' : online ? 'idle' : 'off'" :style="flow(s?.in?.ua, pin, 12)" />
|
||||||
|
|
||||||
|
<div class="rows">
|
||||||
|
<div v-for="x in rails" :key="x.i" class="row" :class="{ sel: sel === x.i, empty: !x.pr }"
|
||||||
|
role="button" tabindex="0" :aria-label="`第 ${x.i} 路:${x.h.text}`"
|
||||||
|
@click="$emit('select', x.i)" @keydown.enter="$emit('select', x.i)">
|
||||||
|
<div class="tap v"><i class="vseg" :class="[busCls, x.i <= 3 ? 'up' : 'down']" :style="busStyle" /></div>
|
||||||
|
<div class="tap h">
|
||||||
|
<i v-if="x.i === 1" class="vseg head" :class="[busCls, 'up']" :style="busStyle" />
|
||||||
|
<i class="w" :class="x.w1" :style="x.f12" />
|
||||||
|
</div>
|
||||||
|
<div class="node buck" :class="{ ghost: !x.pr }">
|
||||||
|
<div class="nt"><span class="no readout">{{ x.i }}</span>降压</div>
|
||||||
|
<div class="ns">TPS54622</div>
|
||||||
|
<button type="button" class="act nv3 readout" v-if="x.pr" :disabled="!x.ctl" title="调输出电压"
|
||||||
|
@click.stop="op(x.i, 'volt')">设定 {{ V(x.r.set) }}<small>V</small></button>
|
||||||
|
<div class="nv3 muted" v-else>空槽</div>
|
||||||
|
</div>
|
||||||
|
<i class="w lab wa" :class="x.w2" :style="x.f"><span class="wl readout" v-if="x.pr && x.w2 === 'live'">{{ mA(x.r.ua) }} mA</span></i>
|
||||||
|
<div class="node efuse" :class="{ ghost: !x.pr, bad: x.fault, dim: x.pr && !x.r.en }">
|
||||||
|
<div class="nt">eFuse</div>
|
||||||
|
<div class="ns">Rs1 · INA226 · TPS25940A</div>
|
||||||
|
<div class="chips" v-if="x.pr">
|
||||||
|
<button type="button" class="act" :class="{ on: x.r.en }" :disabled="!x.ctl" :title="x.r.en ? '断电(保持关闭)' : '上电'"
|
||||||
|
@click.stop="op(x.i, 'power')">{{ x.r.en ? '通' : '断' }}</button>
|
||||||
|
<span :class="{ on: x.r.pg, bad: x.r.en && x.r.pg === false }">PG</span>
|
||||||
|
<span v-if="x.r.flt" :class="{ bad: x.fault, warn: !x.fault }">{{ x.fault ? 'FLT' : '反向阻断' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="w wb" :class="[x.w3, { rev: x.rev }]" :style="x.f" />
|
||||||
|
<div class="node phone" :class="{ ghost: !x.pr, [x.h.level]: true }">
|
||||||
|
<div class="nt">
|
||||||
|
<span :class="['led', x.h.level]" />
|
||||||
|
<el-input v-if="x.pr" v-model="labels[x.i]" size="small" class="pl" placeholder="小米8" :aria-label="`第 ${x.i} 路手机编号`"
|
||||||
|
@change="saveLabel(x.i)" @click.stop @keydown.stop />
|
||||||
|
<span v-else class="pl">小米8</span>
|
||||||
|
<button type="button" class="act usb" :class="{ on: x.r?.vb }" :disabled="!x.ctl"
|
||||||
|
:title="`TPS22918 VBUS 开关(Hub 口 1~5),点击${x.r?.vb ? '断开' : '接通'} USB`" @click.stop="op(x.i, 'vbus')">USB</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="x.pr">
|
||||||
|
<div class="pv"><b class="readout">{{ mA(x.r.ua) }}</b><small>mA</small>
|
||||||
|
<span class="readout pw">{{ W(x.r.mv, x.r.ua) }}<small>W</small></span></div>
|
||||||
|
<div class="ps" :style="{ color: loud(x.h.level) ? LEVEL_COLOR[x.h.level] : 'var(--muted)' }">
|
||||||
|
{{ x.h.text }}<span class="muted readout"> {{ V(x.r.mv) }} V</span></div>
|
||||||
|
</template>
|
||||||
|
<div class="ps muted" v-else>空槽</div>
|
||||||
|
</div>
|
||||||
|
<RailOps v-if="x.pr" :ref="el => (ops[x.i] = el)" class="opc" :board="board" :rail="x.i" :rec-state="recStates?.[x.i]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 辅助与损耗:输入 − 五路合计 -->
|
||||||
|
<div class="row aux">
|
||||||
|
<div class="tap v"><i class="vseg down" :class="busCls" :style="busStyle" /></div>
|
||||||
|
<div class="tap h">
|
||||||
|
<i class="w" :class="auxLive ? 'live' : online ? 'idle' : 'off'" :style="flow(ploss == null ? null : ploss * 1000 / 12, ploss, 12)" />
|
||||||
|
</div>
|
||||||
|
<div class="node auxn">
|
||||||
|
<div class="nt">辅助与损耗</div>
|
||||||
|
<div class="ns">U19 3V3 · U30 5V · ESP32 · Hub · 风扇 · 五路转换损耗</div>
|
||||||
|
<div class="nv3 readout">{{ fmtW(ploss) }}<small>W</small>
|
||||||
|
<span class="muted" v-if="s?.fan"> 风扇 {{ (s.fan.duty ?? 0) / 10 }}%</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USB:一根线连电脑(D13) -->
|
||||||
|
<div class="usbline">
|
||||||
|
<div class="node sm" :class="{ dim: host === false }">
|
||||||
|
<div class="nt">电脑</div><div class="ns">J23 上游 USB-C · {{ host == null ? '—' : host ? '已连' : '未连' }}</div>
|
||||||
|
</div>
|
||||||
|
<i class="w dw" :class="host && hubOn ? 'data' : 'off'" />
|
||||||
|
<div class="node sm" :class="{ dim: hub && !hub.on }">
|
||||||
|
<div class="nt">Hub</div><div class="ns">U32 USB2517I · {{ hub ? (hub.on ? '工作' : '休眠') : '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="fan2">
|
||||||
|
<div class="br"><i class="w dw" :class="hubOn ? 'data' : 'off'" />
|
||||||
|
<div class="node sm"><div class="nt">ESP32-S3</div><div class="ns">口 6 · 固件 {{ s?.fw || '—' }}</div></div></div>
|
||||||
|
<div class="br"><i class="w dw" :class="hubOn && vbN ? 'data' : 'off'" />
|
||||||
|
<div class="node sm" :class="{ dim: !vbN }"><div class="nt">手机 USB</div><div class="ns">口 1~5 · 接通 {{ vbN }} 路</div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 整块板的供电拓扑 + 能量流。结构照 R2(刀片式方案规划-R2.md §1):
|
||||||
|
// J1 12V → F1/TVS/反接 → Rs6+U13 → 12V 母线 → 每片刀片 TPS54622 → Rs1(INA226) → TPS25940A → 小米8 电池座
|
||||||
|
// 数都来自遥测,没有的就不画成「有」:
|
||||||
|
// · 每路的流动:en 且电流 > 5 mA 才流;负电流(反灌)反着流;
|
||||||
|
// · 母线到刀片那一段是 12 V 侧,电流按「这一路功率 ÷ 12 V ÷ 0.9」估,只影响线粗;
|
||||||
|
// · 辅助与损耗 = 输入 − 五路合计(和 Board 页仪表同一算法)。
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import RailOps from './RailOps.vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { mA, V, W, railHealth, LEVEL_COLOR } from '../util'
|
||||||
|
|
||||||
|
const props = defineProps({ board: Object, sel: Number, recStates: Object })
|
||||||
|
defineEmits(['select'])
|
||||||
|
|
||||||
|
const LIVE_UA = 5000
|
||||||
|
const s = computed(() => props.board?.status)
|
||||||
|
const online = computed(() => !!props.board?.online && !props.board?.stale && !!s.value)
|
||||||
|
const A = ua => (ua == null ? '—' : (ua / 1e6).toFixed(2))
|
||||||
|
const fmtW = mw => (mw == null ? '—' : (mw / 1000).toFixed(2))
|
||||||
|
const loud = lv => ['bad', 'warn', 'busy'].includes(lv)
|
||||||
|
const mw = (mv, ua) => (mv == null || ua == null ? null : mv * ua / 1e6)
|
||||||
|
|
||||||
|
// 线粗 ∝ √电流(4 A 封顶),速度 ∝ √功率:小电流也看得见在动,大电流不至于糊成一条
|
||||||
|
function flow(ua, pmw) {
|
||||||
|
const i = Math.min(Math.abs(ua ?? 0), 4e6) / 4e6
|
||||||
|
const p = Math.min(Math.abs(pmw ?? 0), 20000) / 20000
|
||||||
|
const speed = 18 + 110 * Math.sqrt(p) // px/s
|
||||||
|
return { '--w': `${(2 + 6 * Math.sqrt(i)).toFixed(1)}px`, '--dur': `${(16 / speed).toFixed(3)}s` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin = computed(() => mw(s.value?.in?.mv, s.value?.in?.ua))
|
||||||
|
const srcLive = computed(() => online.value && (s.value?.in?.ua ?? 0) > LIVE_UA)
|
||||||
|
|
||||||
|
const rails = computed(() => [1, 2, 3, 4, 5].map(i => {
|
||||||
|
const r = s.value?.rails?.[i - 1]
|
||||||
|
const h = railHealth(props.board, r)
|
||||||
|
const pr = !!r?.pr
|
||||||
|
const p = pr ? mw(r.mv, r.ua) : null
|
||||||
|
const live = online.value && pr && r.en && Math.abs(r.ua ?? 0) > LIVE_UA
|
||||||
|
const fault = pr && r.flt && !(r.chg > 0)
|
||||||
|
const phone = props.board?.phones?.find(x => x.rail === i)
|
||||||
|
const w1 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 母线 → 降压(12 V 侧)
|
||||||
|
const w2 = !online.value || !pr ? 'off' : live ? 'live' : 'idle' // 降压 → eFuse
|
||||||
|
const w3 = !online.value || !pr || !r.en ? 'off' : fault ? 'bad' : live ? 'live' : 'idle' // eFuse → 手机
|
||||||
|
return {
|
||||||
|
i, r, h, pr, fault, w1, w2, w3, label: phone?.label || '',
|
||||||
|
ctl: online.value && pr, // 图上元件可点(调压 / 上下电 / USB);序列类动作另看执行器空闲,在 RailOps 里
|
||||||
|
rev: live && (r.ua ?? 0) < -LIVE_UA,
|
||||||
|
f: flow(r?.ua, p),
|
||||||
|
f12: flow(p == null ? null : p * 1000 / 12 / 0.9, p),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const prails = computed(() => {
|
||||||
|
let t = null
|
||||||
|
for (const x of rails.value) { const p = x.pr ? mw(x.r.mv, x.r.ua) : null; if (p != null) t = (t ?? 0) + Math.max(p, 0) }
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
const ploss = computed(() => (pin.value == null || prails.value == null ? null : Math.max(pin.value - prails.value, 0)))
|
||||||
|
const auxLive = computed(() => online.value && (ploss.value ?? 0) > 50)
|
||||||
|
const eff = computed(() => (pin.value > 500 && prails.value != null ? Math.round(prails.value / pin.value * 100) : null))
|
||||||
|
|
||||||
|
const busCls = computed(() => (srcLive.value ? 'live' : online.value ? 'idle' : 'off'))
|
||||||
|
const busStyle = computed(() => flow(s.value?.in?.ua, pin.value))
|
||||||
|
|
||||||
|
const split = computed(() => {
|
||||||
|
if (!online.value || !(pin.value > 0)) return null
|
||||||
|
const segs = rails.value.filter(x => x.pr).map(x => {
|
||||||
|
const p = Math.max(mw(x.r.mv, x.r.ua) ?? 0, 0)
|
||||||
|
return { key: x.i, kind: 'r', w: p / pin.value, short: `${x.i}`, title: `第 ${x.i} 路 ${fmtW(p)} W`, p }
|
||||||
|
})
|
||||||
|
segs.push({ key: 'loss', kind: 'loss', w: (ploss.value ?? 0) / pin.value, short: '辅助与损耗', title: `辅助与损耗 ${fmtW(ploss.value)} W` })
|
||||||
|
return { segs, aria: `底板输入 ${fmtW(pin.value)} W:` + segs.map(x => x.title).join(',') }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 图上元件点击 → 这一路 RailOps 的同名动作(确认框、提示都在那边)
|
||||||
|
const ops = {}
|
||||||
|
const op = (i, c) => ops[i]?.more(c)
|
||||||
|
|
||||||
|
// 手机编号:就地编辑;正在输入的那一格不被推送覆盖
|
||||||
|
const labels = reactive({})
|
||||||
|
watch(() => props.board?.phones, ps => {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
if (document.activeElement?.closest?.(`[aria-label="第 ${i} 路手机编号"]`)) continue
|
||||||
|
labels[i] = ps?.find(p => p.rail === i)?.label || ''
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true })
|
||||||
|
async function saveLabel(i) {
|
||||||
|
try { await api.patchPhone(props.board.id, i, { label: labels[i] }) } catch (e) { ElMessage.error(e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = computed(() => s.value?.host)
|
||||||
|
const hub = computed(() => s.value?.hub ?? null)
|
||||||
|
const hubOn = computed(() => online.value && (hub.value ? hub.value.on : true))
|
||||||
|
const vbN = computed(() => rails.value.filter(x => x.pr && x.r.vb).length)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.topo { display: flex; flex-direction: column; gap: 14px; overflow: hidden; }
|
||||||
|
.hh { display: flex; justify-content: space-between; align-items: center; gap: 8px 16px; flex-wrap: wrap; }
|
||||||
|
.hh .ttl { margin: 0; }
|
||||||
|
.legend { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.lg { display: inline-block; width: 22px; height: 4px; border-radius: 2px; }
|
||||||
|
.lg.live { background: var(--flow); box-shadow: 0 0 6px var(--flow-glow); }
|
||||||
|
.lg.idle { background: var(--flow-dim); }
|
||||||
|
.lg.off { height: 2px; background: repeating-linear-gradient(90deg, var(--muted) 0 4px, transparent 4px 7px); }
|
||||||
|
.lg.data { height: 2px; background: repeating-linear-gradient(90deg, var(--data) 0 5px, transparent 5px 9px); }
|
||||||
|
|
||||||
|
/* ---- 功率分配条 ---- */
|
||||||
|
.split { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.bar { display: flex; height: 14px; gap: 2px; border-radius: 4px; overflow: hidden; background: var(--bay); }
|
||||||
|
.seg { min-width: 2px; flex-basis: 0; display: flex; align-items: center; justify-content: center; transition: flex-grow .6s ease; }
|
||||||
|
.seg.r { background: var(--flow); }
|
||||||
|
/* 各路同色、靠 2px 缝分开:交替深浅会和图例「有电无流」的淡橙撞色(2026-09-30 被误读成第 2 路无流) */
|
||||||
|
.seg.loss { background: repeating-linear-gradient(135deg, var(--line) 0 4px, var(--bay) 4px 8px); }
|
||||||
|
.seg em { font-style: normal; font-size: 10px; line-height: 1; color: #fff; font-family: var(--readout); white-space: nowrap; }
|
||||||
|
.seg.loss em { color: var(--ink-2); font-family: var(--ui); }
|
||||||
|
.sl { display: flex; flex-wrap: wrap; gap: 2px 18px; font-size: 12px; }
|
||||||
|
.sl b { color: var(--ink); font-weight: 500; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---- 节点:凹槽风格的小面板 ---- */
|
||||||
|
.node { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 7px 10px;
|
||||||
|
min-width: 0; display: flex; flex-direction: column; gap: 1px; position: relative; z-index: 1; transition: border-color .2s, opacity .2s; }
|
||||||
|
.node.ghost { background: transparent; border-style: dashed; opacity: .65; }
|
||||||
|
.node.dim { opacity: .7; }
|
||||||
|
.node.bad { border-color: var(--st-bad); box-shadow: 0 0 0 1px var(--st-bad) inset; }
|
||||||
|
.nt { font-size: 13px; font-weight: 600; color: var(--ink); display: flex; align-items: center; gap: 6px; min-width: 0; white-space: nowrap; }
|
||||||
|
.ns { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.nv { font-size: 24px; color: var(--ink); line-height: 1.15; margin-top: 4px; }
|
||||||
|
.nv2, .nv3 { font-size: 13px; color: var(--ink-2); white-space: nowrap; }
|
||||||
|
.nv3 { margin-top: 2px; }
|
||||||
|
.node small { font-size: 10px; color: var(--muted); margin-left: 2px; font-family: var(--ui); }
|
||||||
|
.no { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 4px; background: var(--ink);
|
||||||
|
color: var(--panel); font-size: 12px; font-weight: 700; }
|
||||||
|
.chips { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 3px; }
|
||||||
|
.chips > * { font-size: 11px; line-height: 16px; padding: 0 5px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); }
|
||||||
|
.chips > .on { color: var(--ink); border-color: var(--ink-2); }
|
||||||
|
.chips > .bad { color: var(--st-bad); border-color: var(--st-bad); }
|
||||||
|
.chips > .warn { color: var(--st-warn); border-color: var(--st-warn); }
|
||||||
|
.phone .pl { overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
|
||||||
|
.pl :deep(.el-input__wrapper) { box-shadow: none; background: transparent; padding: 0 4px; margin-left: -4px; }
|
||||||
|
.pl :deep(.el-input__wrapper:hover), .pl :deep(.el-input__wrapper.is-focus) { box-shadow: 0 0 0 1px var(--line) inset; background: var(--panel); }
|
||||||
|
.pl :deep(.el-input__inner) { font-weight: 600; color: var(--ink); height: 22px; }
|
||||||
|
.pl :deep(.el-input__inner::placeholder) { color: var(--ink); opacity: 1; }
|
||||||
|
/* 图上可点的元件:看着还是读数 / 小标签,悬停出下划虚线 */
|
||||||
|
.act { font: inherit; color: inherit; background: none; border: 0; padding: 0; text-align: left; cursor: pointer; }
|
||||||
|
.act:not(:disabled):hover { text-decoration: underline dotted; text-underline-offset: 3px; color: var(--accent); }
|
||||||
|
.chips > .act, .usb.act { border: 1px solid var(--line); border-radius: 4px; }
|
||||||
|
.chips > .act { padding: 0 5px; }
|
||||||
|
.usb.act { padding: 0 4px; }
|
||||||
|
.chips > .act.on, .usb.act.on { border-color: var(--ink-2); }
|
||||||
|
.usb.act.on { border-color: var(--data); }
|
||||||
|
.chips > .act:not(:disabled):hover, .usb.act:not(:disabled):hover { border-color: var(--accent); text-decoration: none; }
|
||||||
|
.act:disabled { cursor: default; }
|
||||||
|
.opc { justify-self: end; padding-left: 12px; }
|
||||||
|
.usb { font-size: 10px; font-weight: 500; line-height: 14px; padding: 0 4px; border-radius: 3px; border: 1px solid var(--line); color: var(--muted); }
|
||||||
|
.usb.on { color: var(--ink); border-color: var(--data); }
|
||||||
|
.pv { display: flex; align-items: baseline; gap: 2px; margin-top: 2px; }
|
||||||
|
.pv b { font-size: 22px; font-weight: 400; color: var(--ink); line-height: 1.1; }
|
||||||
|
.pv .pw { margin-left: auto; font-size: 14px; color: var(--ink-2); }
|
||||||
|
.ps { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.ps .muted { font-weight: 400; font-size: 11px; }
|
||||||
|
.phone.bad { border-color: var(--st-bad); }
|
||||||
|
.phone.busy { border-color: var(--st-busy); }
|
||||||
|
.phone.warn { border-color: var(--st-warn); }
|
||||||
|
|
||||||
|
/* ---- 连线 ---- */
|
||||||
|
.w { display: block; position: relative; height: var(--w, 2px); min-width: 12px; border-radius: 99px; align-self: center;
|
||||||
|
background: var(--flow-dim); transition: height .4s; }
|
||||||
|
.w.off { height: 2px; background: repeating-linear-gradient(90deg, var(--line) 0 4px, transparent 4px 8px); }
|
||||||
|
.w.idle { height: 3px; }
|
||||||
|
.w.bad { background: var(--st-bad); height: 3px; }
|
||||||
|
.w.live { background-color: var(--flow-dim);
|
||||||
|
background-image: linear-gradient(90deg, var(--flow) 0 8px, transparent 8px 16px);
|
||||||
|
background-size: 16px 100%; box-shadow: 0 0 8px var(--flow-glow); }
|
||||||
|
.w.data { height: 2px; background-color: transparent; background-image: linear-gradient(90deg, var(--data) 0 8px, transparent 8px 16px); background-size: 16px 100%; }
|
||||||
|
.wl { position: absolute; bottom: calc(100% + 3px); left: 50%; transform: translateX(-50%); font-size: 11px; color: var(--ink-2);
|
||||||
|
white-space: nowrap; pointer-events: none; }
|
||||||
|
|
||||||
|
/* ---- 网格:输入 | 馈线 | 母线 + 五路 + 辅助 ---- */
|
||||||
|
.net { display: grid; grid-template-columns: minmax(118px, 150px) minmax(18px, 48px) minmax(0, 1fr); align-items: center; }
|
||||||
|
.src { align-self: center; }
|
||||||
|
.feed { align-self: center; margin-right: -2px; }
|
||||||
|
.rows { display: grid; grid-auto-rows: 1fr; }
|
||||||
|
.row { display: grid; grid-template-columns: 30px minmax(92px, .9fr) minmax(32px, .6fr) minmax(118px, 1.1fr) minmax(18px, .4fr) minmax(170px, 1.5fr) auto;
|
||||||
|
align-items: center; padding: var(--rp, 5px) 0; border-radius: var(--r-bay); cursor: pointer; }
|
||||||
|
.row.aux { grid-template-columns: 30px minmax(0, 1fr); cursor: default; }
|
||||||
|
.row.aux .auxn { margin-right: 0; }
|
||||||
|
.row:not(.aux):hover .phone { border-color: var(--muted); }
|
||||||
|
.row.sel .phone { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.row:focus-visible { outline-offset: 0; }
|
||||||
|
/* 每一路:第 1 列是母线(竖,.tap.v 跨满整行)+ 支线(横,.tap.h 只占第一行);
|
||||||
|
窄屏时操作挪到第二行,竖线照样连续。 */
|
||||||
|
.row { --rp: 5px; grid-template-rows: auto; }
|
||||||
|
.tap { position: relative; align-self: stretch; display: flex; align-items: center; grid-column: 1; }
|
||||||
|
.tap.v { grid-row: 1 / -1; }
|
||||||
|
.tap.h { grid-row: 1; }
|
||||||
|
.row > .buck { grid-column: 2; grid-row: 1; }
|
||||||
|
.row > .wa { grid-column: 3; grid-row: 1; }
|
||||||
|
.row > .efuse { grid-column: 4; grid-row: 1; }
|
||||||
|
.row > .wb { grid-column: 5; grid-row: 1; }
|
||||||
|
.row > .phone { grid-column: 6; grid-row: 1; }
|
||||||
|
.row > .opc { grid-column: 7; grid-row: 1; }
|
||||||
|
.row > .auxn { grid-column: 2; grid-row: 1; }
|
||||||
|
.row:first-child > .tap.v { display: none; }
|
||||||
|
.tap .w { flex: 1; min-width: 0; border-radius: 0 99px 99px 0; }
|
||||||
|
/* 母线:每行一段竖线,拼成一根;第一行从中间往下、最后一行到中间为止 */
|
||||||
|
.vseg { position: absolute; left: 0; top: calc(-1 * var(--rp)); bottom: calc(-1 * var(--rp)); width: var(--w, 2px); border-radius: 0; background: var(--flow-dim); }
|
||||||
|
.vseg.head { top: 50%; }
|
||||||
|
.row.aux .vseg { bottom: 50%; }
|
||||||
|
.vseg.off { width: 2px; background: repeating-linear-gradient(180deg, var(--line) 0 4px, transparent 4px 8px); }
|
||||||
|
.vseg.idle { width: 3px; }
|
||||||
|
.vseg.live { background-image: linear-gradient(180deg, var(--flow) 0 8px, transparent 8px 16px); background-size: 100% 16px;
|
||||||
|
box-shadow: 0 0 8px var(--flow-glow); }
|
||||||
|
.row .w { margin: 0 -1px; }
|
||||||
|
.row .lab { margin: 0; }
|
||||||
|
|
||||||
|
/* ---- USB 行 ---- */
|
||||||
|
.usbline { display: grid; grid-template-columns: minmax(118px, 150px) minmax(18px, 48px) minmax(110px, 170px) minmax(0, 1fr);
|
||||||
|
align-items: center; padding-top: 12px; border-top: 1px solid var(--line-soft); }
|
||||||
|
.node.sm { padding: 5px 10px; }
|
||||||
|
.fan2 { display: flex; flex-direction: column; gap: 6px; position: relative; padding-left: 0; }
|
||||||
|
.br { display: grid; grid-template-columns: minmax(18px, 48px) minmax(0, 260px); align-items: center; }
|
||||||
|
|
||||||
|
/* ---- 动起来 ---- */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.w.live { animation: fx var(--dur, 1s) linear infinite; }
|
||||||
|
.w.live.rev { animation-direction: reverse; }
|
||||||
|
.w.data { animation: fx 1.2s linear infinite; }
|
||||||
|
.vseg.live.down { animation: fdown var(--dur, 1s) linear infinite; }
|
||||||
|
.vseg.live.up { animation: fup var(--dur, 1s) linear infinite; }
|
||||||
|
.led.busy { animation: led-blink 1s steps(2, jump-none) infinite; }
|
||||||
|
}
|
||||||
|
@keyframes fx { from { background-position-x: 0; } to { background-position-x: 16px; } }
|
||||||
|
@keyframes fdown { from { background-position-y: 0; } to { background-position-y: 16px; } }
|
||||||
|
@keyframes fup { from { background-position-y: 0; } to { background-position-y: -16px; } }
|
||||||
|
|
||||||
|
/* ---- 窄屏:输入挪到上面,母线从顶上一路往下 ---- */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.rows { grid-auto-rows: auto; }
|
||||||
|
.row { grid-template-rows: auto auto; grid-template-columns: 30px minmax(92px, .9fr) minmax(32px, .6fr) minmax(118px, 1.1fr) minmax(18px, .4fr) minmax(170px, 1.5fr); }
|
||||||
|
.row.aux { grid-template-rows: auto; grid-template-columns: 30px minmax(0, 1fr); }
|
||||||
|
.row:first-child > .tap.v { display: block; grid-row: 2; }
|
||||||
|
.row > .opc { grid-row: 2; grid-column: 2 / -1; padding-left: 0; margin: 6px 0 4px; justify-self: stretch;
|
||||||
|
flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px 14px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.row { grid-template-columns: 24px minmax(78px, .8fr) minmax(22px, .45fr) minmax(96px, 1fr) minmax(14px, .3fr) minmax(128px, 1.4fr); }
|
||||||
|
.efuse .ns { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.net { grid-template-columns: 1fr; }
|
||||||
|
.src { justify-self: start; min-width: 200px; flex-direction: row; flex-wrap: wrap; align-items: baseline; column-gap: 12px; }
|
||||||
|
.src .ns { flex-basis: 100%; order: 1; }
|
||||||
|
.src .nv { font-size: 20px; margin-top: 0; }
|
||||||
|
.feed { width: var(--w, 2px); min-width: 0; height: 14px !important; margin: 0; justify-self: start;
|
||||||
|
background-image: linear-gradient(180deg, var(--flow) 0 8px, transparent 8px 16px) !important; background-size: 100% 16px !important; }
|
||||||
|
.feed.live { animation-name: fdown !important; }
|
||||||
|
.feed.off { background: repeating-linear-gradient(180deg, var(--line) 0 4px, transparent 4px 8px) !important; width: 2px; }
|
||||||
|
.feed.idle { background: var(--flow-dim) !important; width: 3px; }
|
||||||
|
.vseg.head { top: 0; }
|
||||||
|
.row > .opc { justify-content: flex-start; }
|
||||||
|
.vseg.live.up { animation-name: fdown; }
|
||||||
|
/* 手机宽度:降压 + eFuse 收成一列窄节点,只保留状态 */
|
||||||
|
.row { grid-template-columns: 18px minmax(0, .8fr) 10px minmax(0, 1fr) 10px minmax(0, 1.5fr); }
|
||||||
|
.buck .ns, .efuse .ns, .wl { display: none; }
|
||||||
|
.node { padding: 6px 7px; }
|
||||||
|
.nt { font-size: 12px; gap: 4px; }
|
||||||
|
.buck .nt { font-size: 0; }
|
||||||
|
.buck .nt .no { font-size: 12px; }
|
||||||
|
.nv3 { font-size: 11px; }
|
||||||
|
.pv b { font-size: 18px; }
|
||||||
|
.pv .pw { font-size: 12px; }
|
||||||
|
.usbline { grid-template-columns: 1fr; gap: 0; }
|
||||||
|
.usbline > .dw { width: 2px; height: 12px; min-width: 0; margin-left: 20px; justify-self: start;
|
||||||
|
background-image: linear-gradient(180deg, var(--data) 0 8px, transparent 8px 16px); background-size: 100% 16px; animation-name: fdown; }
|
||||||
|
.usbline .node.sm { justify-self: start; }
|
||||||
|
.fan2 { padding-left: 20px; margin-top: 6px; border-left: 2px dotted var(--line); margin-left: 20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,17 @@
|
|||||||
<div><dt>固件</dt><dd><span class="readout">{{ b.status?.fw || b.fw }}</span> <OtaState :status="b.status" quiet /> <router-link to="/firmware">推固件</router-link></dd></div>
|
<div><dt>固件</dt><dd><span class="readout">{{ b.status?.fw || b.fw }}</span> <OtaState :status="b.status" quiet /> <router-link to="/firmware">推固件</router-link></dd></div>
|
||||||
<div><dt>IP</dt><dd class="readout">{{ b.ip || '—' }}</dd></div>
|
<div><dt>IP</dt><dd class="readout">{{ b.ip || '—' }}</dd></div>
|
||||||
<div><dt>上游</dt><dd :title="'上游 USB-C 的 VBUS(UP_VBUS_DET)。Hub 连着电脑工作时比挂起多耗约 1 W'">{{ HOST[b.status?.host] ?? '—' }}</dd></div>
|
<div><dt>上游</dt><dd :title="'上游 USB-C 的 VBUS(UP_VBUS_DET)。Hub 连着电脑工作时比挂起多耗约 1 W'">{{ HOST[b.status?.host] ?? '—' }}</dd></div>
|
||||||
|
<div class="hubrow"><dt>Hub</dt><dd :title="HUB_TIP">
|
||||||
|
<span :class="{ quiet: hub && !hub.on }">{{ hubText }}</span>
|
||||||
|
<template v-if="hub">
|
||||||
|
<el-select v-model="hubMode" size="small" class="hubmode" aria-label="Hub 模式" @change="setHub({ mode: $event }, `Hub 改为${HUB_MODE[$event]}`)">
|
||||||
|
<el-option value="auto" label="按需" />
|
||||||
|
<el-option value="on" label="常开" />
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" link type="primary" @click="setHub({ min: 30 }, '唤醒 Hub 30 分钟')">唤醒 30 分</el-button>
|
||||||
|
<el-button v-if="hub.on && hub.mode === 'auto'" size="small" link @click="setHub({ min: 0 }, 'Hub 马上休眠')">马上休眠</el-button>
|
||||||
|
</template>
|
||||||
|
</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="gauges" v-if="b.status">
|
<div class="gauges" v-if="b.status">
|
||||||
@@ -40,10 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="rails">
|
<Topology :board="b" :sel="sel" :rec-states="recStates" @select="sel = $event" />
|
||||||
<RailCard v-for="i in 5" :key="i" :board="b" :rail="i" :selected="sel === i"
|
|
||||||
:rec-state="recStates[i]" @select="sel = i" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="charts">
|
<div class="charts">
|
||||||
<HistoryChart :board="b.id" :rail="sel" />
|
<HistoryChart :board="b.id" :rail="sel" />
|
||||||
@@ -76,10 +84,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import RailCard from '../components/RailCard.vue'
|
|
||||||
import HistoryChart from '../components/HistoryChart.vue'
|
import HistoryChart from '../components/HistoryChart.vue'
|
||||||
import BoardChart from '../components/BoardChart.vue'
|
import BoardChart from '../components/BoardChart.vue'
|
||||||
import OtaState from '../components/OtaState.vue'
|
import OtaState from '../components/OtaState.vue'
|
||||||
|
import Topology from '../components/Topology.vue'
|
||||||
import { api, rspText } from '../api'
|
import { api, rspText } from '../api'
|
||||||
import { state, onMessage } from '../store'
|
import { state, onMessage } from '../store'
|
||||||
import { mA, V, W, C, ago, fmtTime, mW, railsMw } from '../util'
|
import { mA, V, W, C, ago, fmtTime, mW, railsMw } from '../util'
|
||||||
@@ -96,6 +104,22 @@ const pw = computed(() => {
|
|||||||
// 固件 0.1.3 起才有 host 字段;老固件没有 ⇒ 显示「—」
|
// 固件 0.1.3 起才有 host 字段;老固件没有 ⇒ 显示「—」
|
||||||
const HOST = { true: '已连电脑', false: '未连电脑' }
|
const HOST = { true: '已连电脑', false: '未连电脑' }
|
||||||
const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2))
|
const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2))
|
||||||
|
// 固件 0.1.14 起的 Hub 按需启停(hubctl.h)。老固件没有 hub 字段 ⇒ 只显示「—」、不给控件
|
||||||
|
const HUB_TIP = 'USB Hub(U32)按需启停:有手机开着 VBUS / 刷机模式 / EDL / 手动保持 / MQTT 断线时工作,' +
|
||||||
|
'否则空闲 10 分钟后按住复位省约 1 W。休眠时电脑上看不到 ESP32 的 COM 口,要 USB 烧录先「唤醒 30 分」'
|
||||||
|
const HUB_MODE = { auto: '按需', on: '常开' }
|
||||||
|
const HUB_WHY = { mode: '常开', vbus: '有手机开着 VBUS', flash: '刷机模式', act: '执行器在跑序列',
|
||||||
|
hold: '手动保持', link: 'MQTT 断线', idle: '空闲倒计时' }
|
||||||
|
const hub = computed(() => b.value?.status?.hub ?? null)
|
||||||
|
const hubText = computed(() => {
|
||||||
|
const h = hub.value
|
||||||
|
if (!h) return '—'
|
||||||
|
if (!h.on) return '休眠'
|
||||||
|
if (h.why === 'hold') return `工作 · 手动保持 ${Math.ceil(h.hold / 60)} 分`
|
||||||
|
if (h.why === 'idle') return `工作 · ${Math.ceil(h.idle / 60)} 分后休眠`
|
||||||
|
return `工作 · ${HUB_WHY[h.why] ?? h.why}`
|
||||||
|
})
|
||||||
|
const hubMode = ref('auto')
|
||||||
const sel = ref(1)
|
const sel = ref(1)
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const location = ref('')
|
const location = ref('')
|
||||||
@@ -108,6 +132,7 @@ watch(b, v => {
|
|||||||
if (!v) return
|
if (!v) return
|
||||||
if (document.activeElement?.tagName !== 'INPUT') { name.value = v.name; location.value = v.location }
|
if (document.activeElement?.tagName !== 'INPUT') { name.value = v.name; location.value = v.location }
|
||||||
fanFloor.value = v.status?.fan?.floor ?? 0
|
fanFloor.value = v.status?.fan?.floor ?? 0
|
||||||
|
hubMode.value = v.status?.hub?.mode ?? 'auto'
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
async function saveMeta() {
|
async function saveMeta() {
|
||||||
@@ -120,6 +145,13 @@ async function setFan(d) {
|
|||||||
} catch (e) { ElMessage.error(e.message) }
|
} catch (e) { ElMessage.error(e.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setHub(body, title) {
|
||||||
|
try {
|
||||||
|
const rsp = await api.cmd(props.id, { cmd: 'hub', ...body })
|
||||||
|
ElMessage({ type: rsp.ok ? 'success' : 'error', message: `${title}:${rspText(rsp)}` })
|
||||||
|
} catch (e) { ElMessage.error(e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const [e, a, r] = await Promise.all([api.events({ board: props.id, limit: 50 }), api.audit({ board: props.id, limit: 50 }), api.recovery()])
|
const [e, a, r] = await Promise.all([api.events({ board: props.id, limit: 50 }), api.audit({ board: props.id, limit: 50 }), api.recovery()])
|
||||||
@@ -161,23 +193,28 @@ watch(() => props.id, refresh)
|
|||||||
.g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px;
|
.g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px;
|
||||||
display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
.gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; }
|
.gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; }
|
||||||
.gk { color: var(--muted); font-size: 13px; }
|
.gk { color: var(--muted); font-size: 13px; white-space: nowrap; }
|
||||||
.gs { color: var(--muted); font-size: 11px; }
|
.gs { color: var(--muted); font-size: 11px; text-align: right; min-width: 0; }
|
||||||
.gvs { display: flex; gap: 6px 18px; align-items: baseline; flex-wrap: wrap; }
|
.gvs { display: flex; gap: 6px 18px; align-items: baseline; flex-wrap: wrap; }
|
||||||
.gv { font-size: 24px; color: var(--ink); line-height: 1.1; white-space: nowrap; }
|
.gv { font-size: 24px; color: var(--ink); line-height: 1.1; white-space: nowrap; }
|
||||||
.gv.sm { font-size: 17px; color: var(--ink-2); }
|
.gv.sm { font-size: 17px; color: var(--ink-2); }
|
||||||
.gv.bad { color: var(--st-bad); }
|
.gv.bad { color: var(--st-bad); }
|
||||||
.gv small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); }
|
.gv small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); }
|
||||||
.floor { width: 120px; }
|
.floor { width: 120px; }
|
||||||
.rails { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
/* Hub 那一行控件多,独占整行,窄屏折行而不是把页面撑宽 */
|
||||||
|
.meta .hubrow { grid-column: 1 / -1; }
|
||||||
|
.hubrow dd { min-width: 0; }
|
||||||
|
/* `.meta div` 那条会连 el-select 的根一起变成 flex,选区随之塌成只剩箭头 —— 这里压回去 */
|
||||||
|
.meta .hubmode { display: inline-block; width: 84px; flex: none; }
|
||||||
|
.hubrow .el-button + .el-button { margin-left: 0; }
|
||||||
|
.quiet { color: var(--muted); }
|
||||||
.charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
.charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||||
.two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
|
.two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
|
||||||
@media (max-width: 1500px) { .gauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
@media (max-width: 1500px) { .gauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||||
@media (max-width: 1200px) { .rails { grid-template-columns: repeat(3, minmax(0, 1fr)); } .charts { grid-template-columns: 1fr; }
|
@media (max-width: 1200px) { .charts { grid-template-columns: 1fr; }
|
||||||
.gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
.gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
@media (max-width: 800px) { .rails { grid-template-columns: 1fr 1fr; } .two { grid-template-columns: 1fr; } }
|
@media (max-width: 800px) { .two { grid-template-columns: 1fr; } }
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.rails { grid-template-columns: 1fr; }
|
|
||||||
.gauges { gap: 8px; }
|
.gauges { gap: 8px; }
|
||||||
.g { padding: 8px 10px 10px; }
|
.g { padding: 8px 10px 10px; }
|
||||||
.gv { font-size: 20px; }
|
.gv { font-size: 20px; }
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
--led-ok: #16b416; /* 灯本身:亮一点,但只当圆点用,不当文字 */
|
--led-ok: #16b416; /* 灯本身:亮一点,但只当圆点用,不当文字 */
|
||||||
--led-warn: #fab219;
|
--led-warn: #fab219;
|
||||||
--led-bad: #e04343;
|
--led-bad: #e04343;
|
||||||
|
/* 能量流(拓扑图):只画线和条,不当文字用。橙 = 与 BoardChart「主板合计功率」同一色相 */
|
||||||
|
--flow: #e0621f;
|
||||||
|
--flow-dim: #f5d6c4;
|
||||||
|
--flow-glow: rgba(224, 98, 31, .28);
|
||||||
|
--data: #6b7785; /* USB 数据链路 */
|
||||||
|
|
||||||
/* 两套字体都随前端打包(main.js 引入),系统字体只在字体文件还没下完时顶一下 */
|
/* 两套字体都随前端打包(main.js 引入),系统字体只在字体文件还没下完时顶一下 */
|
||||||
--readout: "Barlow", "Noto Sans SC Variable", sans-serif;
|
--readout: "Barlow", "Noto Sans SC Variable", sans-serif;
|
||||||
@@ -76,6 +81,10 @@ html.dark {
|
|||||||
--led-ok: #2fd12f;
|
--led-ok: #2fd12f;
|
||||||
--led-warn: #fab219;
|
--led-warn: #fab219;
|
||||||
--led-bad: #ef5353;
|
--led-bad: #ef5353;
|
||||||
|
--flow: #f07a3a;
|
||||||
|
--flow-dim: #4a2c1c;
|
||||||
|
--flow-glow: rgba(240, 122, 58, .35);
|
||||||
|
--data: #8d98a5;
|
||||||
|
|
||||||
--el-color-primary: #5b95f2;
|
--el-color-primary: #5b95f2;
|
||||||
--el-color-primary-light-3: #3f6fb8;
|
--el-color-primary-light-3: #3f6fb8;
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户