rack:单板页 Hub 行与拓扑图、单路操作拆成 RailOps;开机按键改 5 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-10-04 19:24:32 +08:00
共同撰写人 Claude Opus 5.5
父节点 c41d1ec931
当前提交 1d842cf45f
共修改 10 个文件,包含 580 行新增和 80 行删除
+10
查看文件
@@ -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 "开起来了")
+26 -1
查看文件
@@ -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:
+25
查看文件
@@ -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)
+20
查看文件
@@ -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"]
+13
查看文件
@@ -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>
+402
查看文件
@@ -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>
+50 -13
查看文件
@@ -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'
@@ -93,9 +101,25 @@ const pw = computed(() => {
const rails = railsMw(s?.rails) const rails = railsMw(s?.rails)
return { rails, loss: input == null || rails == null ? null : input - rails } return { rails, loss: input == null || rails == null ? null : input - rails }
}) })
// 固件 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; }
+9
查看文件
@@ -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;