rack:界面改版(仪器前面板风格 + 暗色模式)+ 底板温度曲线
- 总览:每块板一块面板、五个刀片槽(状态灯 + DIN 读数 + 最近两分钟电流小曲线,平线 = 疑似卡死);
只有异常 / 故障 / 执行中上色;顶上一句话概况,有问题直接列出是哪几路
- 单板页:新增「底板温度」(两路 NTC + 风扇占空比,上下两块共用时间轴,10 分钟 ~ 7 天);
每路电流 / 电压去掉双 Y 轴,改上下两块,电压固定 3.5~4.1 V;名称 / 位置就地编辑
- 主题:theme.css 亮 / 暗两套变量(跟随系统,Element Plus 暗色变量同挂 html.dark),图表切换时重画;
读数用系统自带 DIN 系字体(不加载网络字体);温度两色按本页亮暗底色跑过 dataviz validate_palette.js 全通过
- 后端:board_tele_min 分钟聚合(温度带 min/max,留 90 天)+ GET /api/boards/{id}/history(Db.board_history)
- 修一个偶发失败的测试(test_ota_events_are_named_by_arg:只推一次遥测,赶上 Hub 正在同步时会漏触发);
后端 42 例本机 + 服务器全绿,已部署;线上已取到板 B 温度 24.4 / 26.0 °C
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
+14
-2
@@ -12,8 +12,8 @@
|
||||
| 目录 | 内容 |
|
||||
|---|---|
|
||||
| `backend/prrack/` | `protocol.py` 板侧协议镜像(命令白名单 / 事件表 / seq)· `bus.py` MQTT 抽象(paho / 进程内假 broker)· `hub.py` 核心 · `recovery.py` 自动恢复 · `db.py` 存储 · `firmware.py` 固件库 + OTA 命令 · `api.py` HTTP+WS · `simboard.py` 模拟板子 · `main.py` 入口 |
|
||||
| `backend/tests/` | 41 例(pytest),本机与服务器(Python 3.8)都跑 |
|
||||
| `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入) |
|
||||
| `backend/tests/` | 42 例(pytest),本机与服务器(Python 3.8)都跑 |
|
||||
| `frontend/` | Vue3 + Vite + Element Plus + ECharts(按需引入)。主题在 `src/theme.css`(亮 / 暗两套变量,跟随系统),图表公共部分在 `src/chart.js` |
|
||||
| `deploy/` | `deploy.sh` 一键部署 · systemd unit · 每日备份 |
|
||||
|
||||
## 规矩(都来自板侧协议 §10.8,改代码前先看)
|
||||
@@ -24,6 +24,18 @@
|
||||
4. **只绑 127.0.0.1**:`X-Auth-User`(门户覆盖式注入)按 L2 信任,只用于审计「谁按的」;`main.py` 拒绝非回环绑定。
|
||||
5. **命令白名单在 `protocol.build_command`**:`wifi` / `mqtt`(只收 USB)与 `events`(服务器自己同步)不给界面。板子的校验仍是唯一权威。
|
||||
|
||||
## 界面(2026-09-27 改版)
|
||||
|
||||
- 仪器前面板风格:每块板一块面板,五个「刀片槽」各有状态灯、电流读数和**最近两分钟电流小曲线**(平线 = 疑似卡死)。
|
||||
只有要人看的状态(异常 / 故障 / 正在执行)上色,「运行」「断电」「空槽」保持安静;总览顶上一句话列出需要看的路。
|
||||
- 读数用 DIN 系等宽数字(系统自带:macOS/iOS `DIN Alternate`、Windows `Bahnschrift`),不加载网络字体(国内拉不到 Google Fonts)。
|
||||
- 暗色模式跟随系统(`html.dark`,Element Plus 的暗色变量也认它),图表在切换时重画。
|
||||
- 曲线**不用双 Y 轴**:一路的电流 / 电压、底板的温度 / 风扇,都是上下两块共用时间轴(dataviz 规矩)。
|
||||
两路温度用 dataviz 参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 `validate_palette.js`,全部通过。
|
||||
- **底板温度曲线**:`GET /api/boards/{id}/history`(`Db.board_history`)。6 小时以内回每秒原始数据,更长回分钟聚合
|
||||
`board_tele_min`(温度带每分钟 min/max、风扇、输入电压电流;与每路的 `tele_min` 一样留 90 天)。
|
||||
NTC1 贴 Hub 稳压 U31,NTC2 贴输入降压 U37(R2 规划 §32)。
|
||||
|
||||
## 推固件(OTA,板侧 C2)
|
||||
|
||||
界面「固件」页:上传 `firmware/build/phonerail.bin` → 选板子推送。进度、待验证、回滚都直接显示遥测里的 `ota` 一块。
|
||||
|
||||
@@ -108,6 +108,12 @@ def create_app(hub: Hub, rec: RecoveryManager, static_dir: Optional[str] = None,
|
||||
except CmdTimeout as e:
|
||||
raise HTTPException(504, str(e))
|
||||
|
||||
@app.get("/api/boards/{board}/history")
|
||||
def board_history(board: str, start: int, end: Optional[int] = None):
|
||||
"""整板曲线(温度 / 风扇 / 输入),见 Db.board_history。"""
|
||||
_board_or_404(hub, board)
|
||||
return hub.db.board_history(board, start, end or now_ms())
|
||||
|
||||
@app.get("/api/boards/{board}/rails/{rail}/history")
|
||||
def history(board: str, rail: int, start: int, end: Optional[int] = None):
|
||||
_board_or_404(hub, board)
|
||||
|
||||
+46
-1
@@ -52,6 +52,14 @@ CREATE TABLE IF NOT EXISTS board_tele (
|
||||
in_mv INTEGER, in_ua INTEGER, temp0 INTEGER, temp1 INTEGER, fan_duty INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS board_tele_idx ON board_tele (board_id, ts);
|
||||
-- 整板遥测的分钟聚合(温度 / 风扇 / 输入),和 tele_min 一样留 90 天:「前天夜里底板多热」要看得到
|
||||
CREATE TABLE IF NOT EXISTS board_tele_min (
|
||||
ts INTEGER NOT NULL, board_id TEXT NOT NULL,
|
||||
t0_avg INTEGER, t0_min INTEGER, t0_max INTEGER,
|
||||
t1_avg INTEGER, t1_min INTEGER, t1_max INTEGER,
|
||||
fan_avg INTEGER, in_mv_avg INTEGER, in_ua_avg INTEGER, n INTEGER,
|
||||
PRIMARY KEY (board_id, ts)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
board_id TEXT NOT NULL, seq INTEGER NOT NULL,
|
||||
t_ms INTEGER, rail INTEGER, type INTEGER, arg INTEGER, mv INTEGER, ua INTEGER,
|
||||
@@ -162,7 +170,17 @@ class Db:
|
||||
"CAST(AVG(ua) AS INTEGER), MIN(ua), MAX(ua), COUNT(*) FROM tele "
|
||||
"WHERE ts >= ? AND ts < ? AND ua IS NOT NULL GROUP BY m, board_id, rail",
|
||||
(start, upto))
|
||||
return cur.rowcount
|
||||
n = cur.rowcount
|
||||
row = self.conn.execute("SELECT MAX(ts) FROM board_tele_min").fetchone()
|
||||
bstart = (row[0] + 60000) if row[0] is not None else 0
|
||||
cur = self.conn.execute(
|
||||
"INSERT OR IGNORE INTO board_tele_min (ts, board_id, t0_avg, t0_min, t0_max, t1_avg, t1_min, "
|
||||
"t1_max, fan_avg, in_mv_avg, in_ua_avg, n) "
|
||||
"SELECT ts/60000*60000 AS m, board_id, CAST(AVG(temp0) AS INTEGER), MIN(temp0), MAX(temp0), "
|
||||
"CAST(AVG(temp1) AS INTEGER), MIN(temp1), MAX(temp1), CAST(AVG(fan_duty) AS INTEGER), "
|
||||
"CAST(AVG(in_mv) AS INTEGER), CAST(AVG(in_ua) AS INTEGER), COUNT(*) FROM board_tele "
|
||||
"WHERE ts >= ? AND ts < ? GROUP BY m, board_id", (bstart, upto))
|
||||
return n + cur.rowcount
|
||||
|
||||
def prune(self, now: Optional[int] = None):
|
||||
t = now if now is not None else now_ms()
|
||||
@@ -170,6 +188,7 @@ class Db:
|
||||
self.conn.execute("DELETE FROM tele WHERE ts < ?", (t - RAW_KEEP_S * 1000,))
|
||||
self.conn.execute("DELETE FROM board_tele WHERE ts < ?", (t - RAW_KEEP_S * 1000,))
|
||||
self.conn.execute("DELETE FROM tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||||
self.conn.execute("DELETE FROM board_tele_min WHERE ts < ?", (t - MIN_KEEP_S * 1000,))
|
||||
|
||||
def history(self, board: str, rail: int, t0: int, t1: int, max_points: int = 1500) -> dict:
|
||||
"""[t0, t1) 的电压电流曲线。跨度 ≤ 原始数据保留期且点数不多时回原始 1 s 数据,
|
||||
@@ -192,3 +211,29 @@ class Db:
|
||||
res = "minute"
|
||||
return {"resolution": res, "bucket_ms": bucket,
|
||||
"points": [[r["t"], r["mv"], r["ua"], r["ua_min"], r["ua_max"]] for r in rows]}
|
||||
|
||||
def board_history(self, board: str, t0: int, t1: int, max_points: int = 1500) -> dict:
|
||||
"""[t0, t1) 的整板曲线:两路 NTC 温度(m°C,带每桶 min/max)、风扇占空比(‰)、输入电压电流。
|
||||
取数规则同 `history()`:近期短跨度回原始 1 s,否则回分钟聚合。
|
||||
points:[t, t0, t0_min, t0_max, t1, t1_min, t1_max, fan, in_mv, in_ua];读不到的是 None。"""
|
||||
span = max(1, t1 - t0)
|
||||
raw_ok = t0 >= now_ms() - RAW_KEEP_S * 1000 and span <= 6 * 3600 * 1000
|
||||
if raw_ok:
|
||||
bucket = max(1000, span // max_points)
|
||||
rows = self.q(
|
||||
"SELECT ts/?*? AS t, CAST(AVG(temp0) AS INTEGER) a0, MIN(temp0) n0, MAX(temp0) x0, "
|
||||
"CAST(AVG(temp1) AS INTEGER) a1, MIN(temp1) n1, MAX(temp1) x1, CAST(AVG(fan_duty) AS INTEGER) f, "
|
||||
"CAST(AVG(in_mv) AS INTEGER) mv, CAST(AVG(in_ua) AS INTEGER) ua FROM board_tele "
|
||||
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
|
||||
res = "raw"
|
||||
else:
|
||||
bucket = max(60000, span // max_points // 60000 * 60000)
|
||||
rows = self.q(
|
||||
"SELECT ts/?*? AS t, CAST(AVG(t0_avg) AS INTEGER) a0, MIN(t0_min) n0, MAX(t0_max) x0, "
|
||||
"CAST(AVG(t1_avg) AS INTEGER) a1, MIN(t1_min) n1, MAX(t1_max) x1, CAST(AVG(fan_avg) AS INTEGER) f, "
|
||||
"CAST(AVG(in_mv_avg) AS INTEGER) mv, CAST(AVG(in_ua_avg) AS INTEGER) ua FROM board_tele_min "
|
||||
"WHERE board_id=? AND ts>=? AND ts<? GROUP BY t ORDER BY t", (bucket, bucket, board, t0, t1))
|
||||
res = "minute"
|
||||
return {"resolution": res, "bucket_ms": bucket,
|
||||
"points": [[r["t"], r["a0"], r["n0"], r["x0"], r["a1"], r["n1"], r["x1"], r["f"], r["mv"], r["ua"]]
|
||||
for r in rows]}
|
||||
|
||||
@@ -129,3 +129,27 @@ def test_a_real_websocket_library_is_installed():
|
||||
deploy.sh 在服务器上跑这套测试,所以缺库会在换上去之前被拦住。"""
|
||||
from prrack.main import ws_library_ok
|
||||
assert ws_library_ok()
|
||||
|
||||
|
||||
def test_board_history_raw_and_minute(env):
|
||||
c, hub, sim, db = env
|
||||
base = now_ms() // 60000 * 60000 - 3 * 60000
|
||||
rows = [(base + i * 1000, B, 12000, 1500000, 35000 + i, 40000 - i, 300) for i in range(120)]
|
||||
db.many("INSERT INTO board_tele VALUES (?,?,?,?,?,?,?)", rows)
|
||||
h = c.get("/api/boards/%s/history" % B, params={"start": base, "end": base + 120000}).json()
|
||||
assert h["resolution"] == "raw" and len(h["points"]) >= 100
|
||||
p = h["points"][0]
|
||||
assert p[1] == 35000 and p[4] == 40000 and p[7] == 300 and p[8] == 12000
|
||||
# 分钟聚合:两个整分钟,每分钟 min/max 就是那一分钟里的首尾
|
||||
assert db.downsample(base + 180000) > 0
|
||||
m = db.board_history(B, base, base + 120000 + 1) # 跨度小也能强制走聚合:t0 早于原始保留期时
|
||||
rows_min = db.q("SELECT * FROM board_tele_min WHERE board_id=? ORDER BY ts", (B,))
|
||||
assert [r["t0_min"] for r in rows_min][:2] == [35000, 35060]
|
||||
assert [r["t0_max"] for r in rows_min][:2] == [35059, 35119]
|
||||
assert rows_min[0]["t1_min"] == 40000 - 59 and rows_min[0]["fan_avg"] == 300
|
||||
assert m["resolution"] == "raw"
|
||||
old = db.board_history(B, base - 10 * 86400000, base + 120000)
|
||||
# 10 天跨度 ⇒ 桶约 9 分钟,两分钟落进同一桶:min 取两分钟的最小、max 取最大
|
||||
assert old["resolution"] == "minute" and old["points"][0][2] == 35000
|
||||
assert max(p[3] for p in old["points"]) == 35119
|
||||
assert c.get("/api/boards/nope/history", params={"start": 0}).status_code == 404
|
||||
|
||||
@@ -199,8 +199,13 @@ def test_ota_events_are_named_by_arg(env):
|
||||
c, hub, sim = env
|
||||
sim._ev(0, 18, 3, 3, 996064) # 失败:哈希不对
|
||||
sim._ev(0, 18, 5) # 回滚
|
||||
|
||||
# 每次检查前都推一条遥测:Hub 正在同步上一批事件(启动时的 BOOT)时会跳过这一条遥测带来的
|
||||
# 「有新事件」,只推一次的话偶尔再也没人触发同步(服务器上 3 次里红过 1 次)。真板 1 s 一条,没有这个问题。
|
||||
def synced():
|
||||
sim.tick(now_ms())
|
||||
assert wait_for(lambda: len([e for e in c.get("/api/events").json() if e["type"] == 18]) == 2)
|
||||
return len([e for e in c.get("/api/events").json() if e["type"] == 18]) == 2
|
||||
assert wait_for(synced)
|
||||
ev = {e["arg"]: e for e in c.get("/api/events").json() if e["type"] == 18}
|
||||
assert ev[3]["name"] == "固件升级(失败):哈希不对" and ev[3]["severe"] is True
|
||||
assert ev[5]["name"] == "固件升级(回滚)" and ev[5]["severe"] is True
|
||||
|
||||
+29
-27
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="top">
|
||||
<router-link to="/" class="brand">PhoneRail 机架</router-link>
|
||||
<router-link to="/" class="brand"><span class="mark" aria-hidden="true" />PhoneRail 机架</router-link>
|
||||
<nav>
|
||||
<router-link to="/">总览</router-link>
|
||||
<router-link to="/events">事件</router-link>
|
||||
@@ -9,10 +9,9 @@
|
||||
<router-link to="/recovery">自动恢复</router-link>
|
||||
<router-link to="/firmware">固件</router-link>
|
||||
</nav>
|
||||
<div class="conn">
|
||||
<span :class="['dot', state.ws ? 'up' : 'down']" :title="state.ws ? '已连上服务器' : '与服务器断开,重连中'" />
|
||||
<span v-if="!state.ws" class="warn">断开</span>
|
||||
<span v-else-if="!state.mqtt" class="warn" title="后端连不上 MQTT broker:所有板子都会显示通信中断">broker 断开</span>
|
||||
<div class="conn" :title="connTitle">
|
||||
<span :class="['led', connLevel]" />
|
||||
<span class="ct">{{ connText }}</span>
|
||||
<span class="user">{{ state.user }}</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -21,33 +20,36 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { state } from './store'
|
||||
|
||||
const connLevel = computed(() => (!state.ws ? 'bad' : !state.mqtt ? 'warn' : 'ok'))
|
||||
const connText = computed(() => (!state.ws ? '与服务器断开' : !state.mqtt ? 'broker 断开' : '已连接'))
|
||||
const connTitle = computed(() => (!state.ws ? '浏览器与后端断开,正在重连'
|
||||
: !state.mqtt ? '后端连不上 MQTT broker:所有板子都会显示通信中断' : '实时数据正常'))
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root { --bg: #f1f5f9; --card: #fff; --line: #e2e8f0; --muted: #64748b; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; color: #0f172a; }
|
||||
.shell { min-height: 100vh; }
|
||||
.top { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 16px; height: 52px; background: #0f172a; color: #fff; }
|
||||
.top a { color: #cbd5e1; text-decoration: none; }
|
||||
.top .brand { color: #fff; font-weight: 600; white-space: nowrap; }
|
||||
.top nav { display: flex; gap: 14px; overflow-x: auto; flex: 1; min-width: 0; }
|
||||
.top nav a { white-space: nowrap; padding: 4px 0; }
|
||||
.top nav a.router-link-exact-active { color: #fff; border-bottom: 2px solid #38bdf8; }
|
||||
.conn { display: flex; align-items: center; gap: 8px; font-size: 13px; white-space: nowrap; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
|
||||
.dot.up { background: #22c55e; } .dot.down { background: #ef4444; }
|
||||
.conn .warn { color: #fca5a5; }
|
||||
.conn .user { color: #94a3b8; }
|
||||
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.top { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 20px;
|
||||
padding: 0 20px; height: 54px; background: var(--bar); border-bottom: 1px solid var(--line); }
|
||||
.top a { color: var(--ink-2); text-decoration: none; }
|
||||
.top .brand { color: var(--ink); font-weight: 700; white-space: nowrap; display: flex; align-items: center; gap: 8px;
|
||||
letter-spacing: .01em; }
|
||||
/* 标志:一块板上五个槽 */
|
||||
.top .mark { width: 22px; height: 14px; border-radius: 3px; border: 2px solid var(--ink);
|
||||
background: repeating-linear-gradient(90deg, transparent 0 2px, var(--ink) 2px 3px, transparent 3px 3.6px); }
|
||||
.top nav { display: flex; gap: 4px; overflow-x: auto; flex: 1; min-width: 0; scrollbar-width: none; }
|
||||
.top nav a { white-space: nowrap; padding: 6px 10px; border-radius: 6px; }
|
||||
.top nav a:hover { color: var(--ink); background: var(--bay); }
|
||||
.top nav a.router-link-exact-active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
|
||||
.conn { display: flex; align-items: center; gap: 8px; font-size: 13px; white-space: nowrap; color: var(--ink-2); }
|
||||
.conn .user { color: var(--muted); padding-left: 8px; border-left: 1px solid var(--line); }
|
||||
main { padding: 20px; max-width: 1440px; margin: 0 auto; }
|
||||
@media (max-width: 640px) {
|
||||
.top { gap: 10px; padding: 0 10px; }
|
||||
.top .brand { display: none; }
|
||||
.conn .user { display: none; }
|
||||
main { padding: 10px; }
|
||||
.top { gap: 8px; padding: 0 10px; height: 50px; }
|
||||
.top .brand { font-size: 0; gap: 0; }
|
||||
.conn .ct, .conn .user { display: none; }
|
||||
main { padding: 12px 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,8 @@ export const api = {
|
||||
patchPhone: (board, rail, body) => req('PATCH', `/api/boards/${board}/rails/${rail}`, body),
|
||||
history: (board, rail, start, end) =>
|
||||
req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`),
|
||||
boardHistory: (board, start, end) =>
|
||||
req('GET', `/api/boards/${board}/history?start=${start}${end ? `&end=${end}` : ''}`),
|
||||
events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)),
|
||||
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
|
||||
recovery: () => req('GET', '/api/recovery'),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// ECharts 的公共部分:按需引入 + 取色 + 两张上下叠放的图共用一条时间轴。
|
||||
//
|
||||
// 规矩(dataviz skill):
|
||||
// · 不用双 Y 轴 —— 电流和电压、温度和风扇,一律上下两个 grid,共用 x(dataZoom 联动);
|
||||
// · 系列色取参考调色板第 1/2 槽,按本页亮暗两种面板底色跑过 validate_palette.js(全部通过);
|
||||
// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
|
||||
// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { cssVar, dark } from './theme'
|
||||
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer])
|
||||
export { echarts }
|
||||
|
||||
const SERIES = { light: ['#2a78d6', '#eb6834'], dark: ['#3987e5', '#d95926'] }
|
||||
|
||||
export function palette() {
|
||||
const s = SERIES[dark.value ? 'dark' : 'light']
|
||||
return {
|
||||
s1: s[0], s2: s[1],
|
||||
ink: cssVar('--ink'), ink2: cssVar('--ink-2'), muted: cssVar('--muted'),
|
||||
grid: cssVar('--line-soft'), axis: cssVar('--line'), panel: cssVar('--panel'), bay: cssVar('--bay'),
|
||||
}
|
||||
}
|
||||
|
||||
export function band(hex, a = 0.15) {
|
||||
const n = parseInt(hex.slice(1), 16)
|
||||
return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`
|
||||
}
|
||||
|
||||
// 上下两块:上面主图(高),下面副图(矮)。返回 grid / xAxis / dataZoom 的公共配置。
|
||||
export function stacked(p, { topName, bottomName, topFmt, bottomFmt, bottomMin, bottomMax, topMin, topMax, bottomInterval }) {
|
||||
const axisLabel = { color: p.muted, fontSize: 11 }
|
||||
const x = i => ({
|
||||
type: 'time', gridIndex: i, axisLine: { lineStyle: { color: p.axis } }, axisTick: { show: false },
|
||||
axisLabel: { ...axisLabel, show: i === 1, hideOverlap: true }, splitLine: { show: false },
|
||||
})
|
||||
const y = (i, name, fmt, min, max, interval) => ({
|
||||
type: 'value', gridIndex: i, name, nameTextStyle: { color: p.muted, fontSize: 11, align: 'left', padding: [0, 0, 0, -30] },
|
||||
scale: true, min, max, interval, splitNumber: i === 0 ? 4 : 2, axisLabel: { ...axisLabel, formatter: fmt },
|
||||
splitLine: { lineStyle: { color: p.grid } }, axisLine: { show: false }, axisTick: { show: false },
|
||||
})
|
||||
return {
|
||||
animation: false,
|
||||
textStyle: { fontFamily: getComputedStyle(document.body).fontFamily },
|
||||
grid: [{ left: 48, right: 16, top: 30, height: '48%' }, { left: 48, right: 16, top: '75%', bottom: 44 }],
|
||||
xAxis: [x(0), x(1)],
|
||||
yAxis: [y(0, topName, topFmt, topMin, topMax), y(1, bottomName, bottomFmt, bottomMin, bottomMax, bottomInterval)],
|
||||
axisPointer: { link: [{ xAxisIndex: 'all' }], lineStyle: { color: p.muted } },
|
||||
dataZoom: [{ type: 'inside', xAxisIndex: [0, 1] },
|
||||
{ type: 'slider', xAxisIndex: [0, 1], height: 16, bottom: 8, borderColor: p.axis, fillerColor: band(p.s1, 0.12),
|
||||
dataBackground: { lineStyle: { color: p.axis }, areaStyle: { color: p.bay } }, textStyle: { color: p.muted } }],
|
||||
tooltip: { trigger: 'axis', backgroundColor: p.panel, borderColor: p.axis, textStyle: { color: p.ink, fontSize: 12 } },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<section class="card bc">
|
||||
<div class="hh">
|
||||
<h2 class="ttl">底板温度</h2>
|
||||
<RangePicker v-model="range" />
|
||||
</div>
|
||||
<div class="legend" aria-hidden="true">
|
||||
<span><i :style="{ background: c.s1 }" />NTC1 Hub 稳压旁(U31)</span>
|
||||
<span><i :style="{ background: c.s2 }" />NTC2 输入降压旁(U37)</span>
|
||||
</div>
|
||||
<div ref="el" class="chart" role="img" aria-label="底板两路温度与风扇占空比曲线" />
|
||||
<p class="muted note">{{ note }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 底板两路 NTC 温度(上)+ 风扇占空比(下)。温度两条线用 dataviz 参考调色板第 1/2 槽,
|
||||
// 亮暗两种面板底色都跑过 validate_palette.js(CVD ΔE ≥ 24,对比 ≥ 3:1)。
|
||||
// 图例用自绘的一行(带位置说明),曲线末端不再直接标名:两条线常常贴得很近,标签会打架。
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import RangePicker from './RangePicker.vue'
|
||||
import { api } from '../api'
|
||||
import { onMessage } from '../store'
|
||||
import { dark } from '../theme'
|
||||
import { echarts, palette, band, stacked } from '../chart'
|
||||
|
||||
const props = defineProps({ board: String })
|
||||
const range = ref(3600e3)
|
||||
const el = ref(null)
|
||||
const note = ref('')
|
||||
const c = ref(palette())
|
||||
let chart = null
|
||||
let pts = [] // [t, t0, t0min, t0max, t1, t1min, t1max, fan, in_mv, in_ua]
|
||||
let live = false
|
||||
|
||||
const degC = v => (v == null ? null : v / 1000)
|
||||
|
||||
function render() {
|
||||
const p = palette()
|
||||
c.value = p
|
||||
const hasBand = pts.some(x => x[2] != null && x[2] !== x[3])
|
||||
const bandOf = (lo, hi, color) => [
|
||||
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[lo])]), stack: `b${lo}`,
|
||||
symbol: 'none', lineStyle: { opacity: 0 }, silent: true, tooltip: { show: false } },
|
||||
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], x[hi] == null || x[lo] == null ? null : (x[hi] - x[lo]) / 1000]),
|
||||
stack: `b${lo}`, symbol: 'none', lineStyle: { opacity: 0 }, areaStyle: { color: band(color, 0.12) }, silent: true, tooltip: { show: false } },
|
||||
]
|
||||
const fmtC = v => (v == null ? '—' : `${v.toFixed(1)} °C`)
|
||||
chart.setOption({
|
||||
...stacked(p, {
|
||||
topName: '温度 °C', bottomName: '风扇 %',
|
||||
topFmt: v => v.toFixed(0), bottomFmt: v => v.toFixed(0), bottomMin: 0, bottomMax: 100, bottomInterval: 50,
|
||||
// 温度按数据撑满,但至少 10 °C 的跨度:别把 0.2 °C 的抖动放大成山峰
|
||||
topMin: v => Math.floor(Math.min(v.min, (v.min + v.max) / 2 - 5)), topMax: v => Math.ceil(Math.max(v.max, (v.min + v.max) / 2 + 5)),
|
||||
}),
|
||||
series: [
|
||||
...(hasBand ? [...bandOf(2, 3, p.s1), ...bandOf(5, 6, p.s2)] : []),
|
||||
{ name: 'NTC1', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[1])]), symbol: 'none',
|
||||
lineStyle: { width: 2 }, color: p.s1, tooltip: { valueFormatter: fmtC } },
|
||||
{ name: 'NTC2', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], degC(x[4])]), symbol: 'none',
|
||||
lineStyle: { width: 2 }, color: p.s2, tooltip: { valueFormatter: fmtC } },
|
||||
{ name: '风扇', type: 'line', xAxisIndex: 1, yAxisIndex: 1, step: 'end', data: pts.map(x => [x[0], x[7] == null ? null : x[7] / 10]),
|
||||
symbol: 'none', lineStyle: { width: 2 }, color: p.ink2, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(0)} %`) } },
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const end = Date.now()
|
||||
try {
|
||||
const h = await api.boardHistory(props.board, end - range.value, end)
|
||||
pts = h.points
|
||||
live = h.resolution === 'raw' && range.value <= 3600e3
|
||||
note.value = pts.length === 0 ? '这段时间没有温度数据(板子离线,或者刚上线)'
|
||||
: h.resolution === 'raw' ? `每秒一个点${live ? ',实时追加' : ''}`
|
||||
: '每分钟一个点;浅色带是这一分钟里的最低到最高温度'
|
||||
render()
|
||||
} catch (e) {
|
||||
note.value = '曲线没取到:' + e.message
|
||||
}
|
||||
}
|
||||
|
||||
const off = onMessage(m => {
|
||||
if (!live || m.t !== 'tele' || m.board !== props.board) return
|
||||
const s = m.status
|
||||
const t = s.temp || []
|
||||
pts.push([m.ts, t[0], t[0], t[0], t[1], t[1], t[1], s.fan?.duty, s.in?.mv, s.in?.ua])
|
||||
const cut = Date.now() - range.value
|
||||
while (pts.length && pts[0][0] < cut) pts.shift()
|
||||
render()
|
||||
})
|
||||
|
||||
const onResize = () => chart && chart.resize()
|
||||
onMounted(() => { chart = echarts.init(el.value); load(); window.addEventListener('resize', onResize) })
|
||||
onBeforeUnmount(() => { off(); window.removeEventListener('resize', onResize); chart && chart.dispose() })
|
||||
watch(() => [props.board, range.value], load)
|
||||
watch(dark, () => chart && render())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hh { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.hh .ttl { margin: 0; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 13px; color: var(--ink-2); margin-top: 8px; }
|
||||
.legend i { display: inline-block; width: 14px; height: 3px; border-radius: 2px; margin-right: 6px; vertical-align: middle; }
|
||||
.chart { height: 320px; }
|
||||
.note { margin: 4px 0 0; }
|
||||
@media (max-width: 520px) { .chart { height: 260px; } }
|
||||
</style>
|
||||
@@ -1,32 +1,25 @@
|
||||
<template>
|
||||
<div class="card hc">
|
||||
<section class="card hc">
|
||||
<div class="hh">
|
||||
<b>第 {{ rail }} 路 · 电流 / 电压</b>
|
||||
<el-radio-group v-model="range" size="small">
|
||||
<el-radio-button v-for="o in RANGES" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div ref="el" class="chart" />
|
||||
<div class="muted">{{ note }}</div>
|
||||
<h2 class="ttl">第 {{ rail }} 路</h2>
|
||||
<RangePicker v-model="range" />
|
||||
</div>
|
||||
<div ref="el" class="chart" role="img" :aria-label="`第 ${rail} 路电流与电压曲线`" />
|
||||
<p class="muted note">{{ note }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 一路的电流(上)与电压(下)。**不用双 Y 轴**:3.9 V 上的几十毫伏起伏和几百 mA 的电流
|
||||
// 放在一张图的两个刻度上,谁也看不清(dataviz 头号反模式)。上下两块共用时间轴,缩放联动。
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
// 按需引入:整包 echarts ≈340 KB(gzip),手机上打开板子页要等好几秒
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer])
|
||||
import RangePicker from './RangePicker.vue'
|
||||
import { api } from '../api'
|
||||
import { onMessage } from '../store'
|
||||
import { dark } from '../theme'
|
||||
import { echarts, palette, band, stacked } from '../chart'
|
||||
|
||||
const props = defineProps({ board: String, rail: Number })
|
||||
const RANGES = [
|
||||
{ v: 600e3, t: '10 分钟' }, { v: 3600e3, t: '1 小时' }, { v: 6 * 3600e3, t: '6 小时' },
|
||||
{ v: 86400e3, t: '1 天' }, { v: 7 * 86400e3, t: '7 天' },
|
||||
]
|
||||
const range = ref(600e3)
|
||||
const el = ref(null)
|
||||
const note = ref('')
|
||||
@@ -35,24 +28,28 @@ let pts = [] // [t, mv, ua, ua_min, ua_max]
|
||||
let live = false
|
||||
|
||||
function render() {
|
||||
const band = pts.some(p => p[3] != null && p[3] !== p[4])
|
||||
const p = palette()
|
||||
const hasBand = pts.some(x => x[3] != null && x[3] !== x[4])
|
||||
const mA = v => (v == null ? null : v / 1000)
|
||||
chart.setOption({
|
||||
animation: false,
|
||||
grid: { left: 50, right: 50, top: 30, bottom: 50 },
|
||||
tooltip: { trigger: 'axis', valueFormatter: v => (v == null ? '—' : v.toFixed(1)) },
|
||||
legend: { data: ['电流 mA', '电压 V'], top: 0 },
|
||||
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 8 }],
|
||||
xAxis: { type: 'time' },
|
||||
yAxis: [{ type: 'value', name: 'mA', scale: true }, // 电压几乎恒定:不固定范围的话 echarts 会把 3.9 V 画在 1~6 V 的正中间,一点起伏都看不出
|
||||
{ type: 'value', name: 'V', min: v => Math.min(3.4, Math.floor(v.min * 10) / 10), max: v => Math.max(4.2, Math.ceil(v.max * 10) / 10), splitLine: { show: false } }],
|
||||
...stacked(p, {
|
||||
topName: '电流 mA', bottomName: '电压 V',
|
||||
topFmt: v => v.toFixed(0), bottomFmt: v => v.toFixed(2),
|
||||
// 电压固定 3.5~4.1 V(硬件可调范围 3.524~4.010 V 全在里面),每 0.3 V 一格:
|
||||
// 不固定的话 echarts 会把 3.9 V 画在 1~6 V 正中间;自动取整的刻度在这么矮的图上会挤成一团
|
||||
bottomMin: 3.5, bottomMax: 4.1, bottomInterval: 0.3,
|
||||
}),
|
||||
series: [
|
||||
// 分钟聚合时把 min/max 画成带状:「这一分钟里电流动没动」比均值更能看出卡死
|
||||
...(band ? [
|
||||
{ name: 'min', type: 'line', data: pts.map(p => [p[0], p[3] / 1000]), lineStyle: { opacity: 0 }, stack: 'b', symbol: 'none', silent: true, tooltip: { show: false } },
|
||||
{ name: 'max', type: 'line', data: pts.map(p => [p[0], (p[4] - p[3]) / 1000]), lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(37,99,235,.15)' }, stack: 'b', symbol: 'none', silent: true, tooltip: { show: false } },
|
||||
...(hasBand ? [
|
||||
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], mA(x[3])]), stack: 'b', symbol: 'none',
|
||||
lineStyle: { opacity: 0 }, silent: true, tooltip: { show: false } },
|
||||
{ type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], x[4] == null ? null : (x[4] - x[3]) / 1000]),
|
||||
stack: 'b', symbol: 'none', lineStyle: { opacity: 0 }, areaStyle: { color: band(p.s1) }, silent: true, tooltip: { show: false } },
|
||||
] : []),
|
||||
{ name: '电流 mA', type: 'line', data: pts.map(p => [p[0], p[2] == null ? null : p[2] / 1000]), symbol: 'none', color: '#2563eb', connectNulls: false },
|
||||
{ name: '电压 V', type: 'line', yAxisIndex: 1, data: pts.map(p => [p[0], p[1] == null ? null : p[1] / 1000]), symbol: 'none', color: '#16a34a' },
|
||||
{ name: '电流', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map(x => [x[0], mA(x[2])]), symbol: 'none',
|
||||
lineStyle: { width: 2 }, color: p.s1, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(0)} mA`) } },
|
||||
{ name: '电压', type: 'line', xAxisIndex: 1, yAxisIndex: 1, data: pts.map(x => [x[0], x[1] == null ? null : x[1] / 1000]),
|
||||
symbol: 'none', lineStyle: { width: 2 }, color: p.s1, tooltip: { valueFormatter: v => (v == null ? '—' : `${v.toFixed(3)} V`) } },
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
@@ -63,11 +60,12 @@ async function load() {
|
||||
const h = await api.history(props.board, props.rail, end - range.value, end)
|
||||
pts = h.points
|
||||
live = h.resolution === 'raw' && range.value <= 3600e3
|
||||
note.value = `${h.resolution === 'raw' ? '原始数据' : '分钟聚合(带状 = 每分钟最小~最大)'} · ${pts.length} 点` +
|
||||
(live ? ' · 实时追加' : '')
|
||||
note.value = h.resolution === 'raw'
|
||||
? `每秒一个点${live ? ',实时追加' : ''}`
|
||||
: '每分钟一个点;浅色带是这一分钟里电流的最小到最大,带子塌成一条线说明电流不动了'
|
||||
render()
|
||||
} catch (e) {
|
||||
note.value = '加载失败:' + e.message
|
||||
note.value = '曲线没取到:' + e.message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +83,13 @@ const onResize = () => chart && chart.resize()
|
||||
onMounted(() => { chart = echarts.init(el.value); load(); window.addEventListener('resize', onResize) })
|
||||
onBeforeUnmount(() => { off(); window.removeEventListener('resize', onResize); chart && chart.dispose() })
|
||||
watch(() => [props.board, props.rail, range.value], load)
|
||||
watch(dark, () => chart && render())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hh { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.chart { height: 320px; margin-top: 8px; }
|
||||
@media (max-width: 520px) { .chart { height: 240px; } }
|
||||
.hh .ttl { margin: 0; }
|
||||
.chart { height: 340px; margin-top: 6px; }
|
||||
.note { margin: 4px 0 0; }
|
||||
@media (max-width: 520px) { .chart { height: 280px; } }
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<el-tag v-if="o.pend" type="warning" size="small" title="新固件还没连上 broker;120 s 内连不上会自动回滚">待验证</el-tag>
|
||||
<el-tag v-if="o.err && o.st !== 'dl'" type="danger" size="small">上次失败:{{ OTA_ERR_ZH[o.err] || o.err }}</el-tag>
|
||||
<el-tag v-if="o.bad" type="danger" size="small" effect="plain" title="这份固件在板子上没通过自证健康,被回滚掉了">回滚过:{{ o.bad }}</el-tag>
|
||||
<span v-if="!busy && !o.pend && !o.err && !o.bad && o.st !== 'ready'" class="muted">—</span>
|
||||
<span v-if="!quiet && !busy && !o.pend && !o.err && !o.bad && o.st !== 'ready'" class="muted">—</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { OTA_ERR_ZH } from '../api'
|
||||
|
||||
const props = defineProps({ status: Object })
|
||||
const props = defineProps({ status: Object, quiet: Boolean }) // quiet:没事时什么都不显示(不画「—」)
|
||||
const o = computed(() => props.status?.ota || {})
|
||||
const busy = computed(() => o.value.st === 'dl')
|
||||
const pct = computed(() => (o.value.size ? Math.floor((o.value.got / o.value.size) * 100) : 0))
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<div class="card rail" :class="{ sel: selected }" :style="{ borderTopColor: LEVEL_COLOR[h.level] }" @click="$emit('select')">
|
||||
<div class="card rail" :class="[{ sel: selected }, h.level]" role="button" tabindex="0"
|
||||
:aria-pressed="selected" :aria-label="`第 ${rail} 路,${h.text},点击查看曲线`"
|
||||
@click="$emit('select')" @keydown.enter.self="$emit('select')">
|
||||
<div class="rh">
|
||||
<span class="no">{{ rail }}</span>
|
||||
<el-input v-model="label" size="small" class="label" placeholder="手机编号" @change="saveLabel" @click.stop />
|
||||
<el-tag :color="LEVEL_COLOR[h.level]" effect="dark" size="small" class="tag">{{ h.text }}</el-tag>
|
||||
<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>{{ V(r.mv) }}</b><small> V</small><div class="muted">设定 {{ V(r.set) }}</div></div>
|
||||
<div><b>{{ mA(r.ua) }}</b><small> mA</small><div class="muted"> </div></div>
|
||||
<div><b class="readout">{{ mA(r.ua) }}</b><small>mA</small></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>
|
||||
@@ -67,6 +70,7 @@ defineEmits(['select'])
|
||||
const r = computed(() => props.board?.status?.rails?.[props.rail - 1])
|
||||
const phone = computed(() => props.board?.phones?.find(p => p.rail === props.rail) || {})
|
||||
const h = computed(() => railHealth(props.board, r.value))
|
||||
const loud = computed(() => ['bad', 'warn', 'busy'].includes(h.value.level))
|
||||
// 在位、板子在线、执行器空闲才能下动作(忙的时候板子也会回 busy,这里只是少一次无用的往返)
|
||||
const can = computed(() => r.value?.pr && !props.board.stale && props.board.online && r.value.st === 'idle')
|
||||
|
||||
@@ -127,20 +131,23 @@ async function saveAuto(v) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rail { border-top: 4px solid; cursor: pointer; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rail.sel { box-shadow: 0 0 0 2px #38bdf8; }
|
||||
.rh { display: flex; align-items: center; gap: 6px; }
|
||||
.no { font-weight: 700; font-size: 18px; width: 18px; }
|
||||
.label { flex: 1; min-width: 0; }
|
||||
.tag { border: none; flex-shrink: 0; }
|
||||
.nums { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.nums b { font-size: 22px; font-variant-numeric: tabular-nums; }
|
||||
.nums small { color: var(--muted); }
|
||||
.rail { cursor: pointer; display: flex; flex-direction: column; gap: 10px; padding: 14px; }
|
||||
.rail:hover { border-color: var(--muted); }
|
||||
.rail.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.rail.empty { background: transparent; border-style: dashed; }
|
||||
.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: 1px 6px; border-radius: 4px; background: #f1f5f9; color: #94a3b8; }
|
||||
.flags span.on { background: #dcfce7; color: #166534; }
|
||||
.flags span.bad { background: #fee2e2; color: #b91c1c; }
|
||||
.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); }
|
||||
.ar { display: flex; align-items: center; gap: 6px; }
|
||||
.acts { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
.acts .el-button + .el-button { margin-left: 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<el-radio-group :model-value="modelValue" size="small" @update:model-value="v => $emit('update:modelValue', v)">
|
||||
<el-radio-button v-for="o in RANGES" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 曲线的时间范围。与后端的取数规则对应:6 小时以内回每秒原始数据,更长回分钟聚合(db.py)。
|
||||
defineProps({ modelValue: Number })
|
||||
defineEmits(['update:modelValue'])
|
||||
const RANGES = [
|
||||
{ v: 600e3, t: '10 分钟' }, { v: 3600e3, t: '1 小时' }, { v: 6 * 3600e3, t: '6 小时' },
|
||||
{ v: 86400e3, t: '1 天' }, { v: 7 * 86400e3, t: '7 天' },
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<svg class="spark" :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none" role="img"
|
||||
:aria-label="aria">
|
||||
<polyline v-if="path" :points="path" fill="none" :stroke="color" stroke-width="1.5"
|
||||
vector-effect="non-scaling-stroke" stroke-linejoin="round" />
|
||||
<line v-else :x1="0" :x2="W" :y1="H - 1" :y2="H - 1" stroke="var(--line)" stroke-width="1"
|
||||
vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 一路最近两分钟的电流。**平线 = 疑似卡死**(自动恢复看的就是这个),所以纵轴不从 0 起,
|
||||
// 按这段数据自己的 min~max 撑满 —— 但给一个最小跨度(20 mA),否则噪声也会被放大成锯齿。
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { spark, sparkSeed, SPARK_MS } from '../store'
|
||||
|
||||
const props = defineProps({ board: String, rail: Number, color: { type: String, default: 'var(--ink-2)' } })
|
||||
const W = 100
|
||||
const H = 28
|
||||
const key = computed(() => `${props.board}/${props.rail}`)
|
||||
const pts = computed(() => (spark[key.value] || []).filter(p => p[1] != null))
|
||||
|
||||
const path = computed(() => {
|
||||
const p = pts.value
|
||||
if (p.length < 2) return ''
|
||||
const t1 = p[p.length - 1][0]
|
||||
const t0 = t1 - SPARK_MS
|
||||
let lo = Infinity, hi = -Infinity
|
||||
for (const [, v] of p) { lo = Math.min(lo, v); hi = Math.max(hi, v) }
|
||||
const span = Math.max(hi - lo, 20000)
|
||||
const mid = (hi + lo) / 2
|
||||
return p.map(([t, v]) => {
|
||||
const x = ((t - t0) / SPARK_MS) * W
|
||||
const y = H - 2 - ((v - (mid - span / 2)) / span) * (H - 4)
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
})
|
||||
|
||||
const aria = computed(() => {
|
||||
const p = pts.value
|
||||
if (p.length < 2) return '最近两分钟电流:暂无数据'
|
||||
const vs = p.map(x => x[1])
|
||||
return `最近两分钟电流 ${Math.round(Math.min(...vs) / 1000)}~${Math.round(Math.max(...vs) / 1000)} mA`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if ((spark[key.value] || []).length > 10) return
|
||||
try {
|
||||
const end = Date.now()
|
||||
const h = await api.history(props.board, props.rail, end - SPARK_MS, end)
|
||||
sparkSeed(key.value, h.points.map(p => [p[0], p[2]]))
|
||||
} catch { /* 拉不到就等遥测慢慢攒 */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spark { width: 100%; height: 28px; display: block; }
|
||||
</style>
|
||||
@@ -3,6 +3,9 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import './theme.css'
|
||||
import './theme'
|
||||
|
||||
import App from './App.vue'
|
||||
import Overview from './pages/Overview.vue'
|
||||
|
||||
@@ -33,6 +33,5 @@ onBeforeUnmount(off)
|
||||
|
||||
<style scoped>
|
||||
.bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.bad { color: #dc2626; }
|
||||
code { font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,67 @@
|
||||
<template>
|
||||
<div v-if="!b" class="card"><el-empty description="没有这块板子(或者还没收到它的数据)" /></div>
|
||||
<div v-if="!b" class="card"><el-empty description="没有这块板子,或者还没收到它的数据" /></div>
|
||||
<div v-else class="bd">
|
||||
<div class="card head">
|
||||
<section class="card head">
|
||||
<div class="names">
|
||||
<el-input v-model="name" placeholder="板子名称" @change="saveMeta" />
|
||||
<el-input v-model="location" placeholder="位置(如 机架 A 第 3 层)" @change="saveMeta" />
|
||||
<span :class="['led', !b.online || b.stale ? 'lost' : 'ok']" />
|
||||
<el-input v-model="name" class="ed name" placeholder="给这块板起个名字" @change="saveMeta" />
|
||||
<el-input v-model="location" class="ed" placeholder="位置,比如 机架 A 第 3 层" @change="saveMeta" />
|
||||
</div>
|
||||
<div class="info">
|
||||
<el-tag v-if="!b.online || b.stale" type="info">通信中断 · 最后数据 {{ ago(b.last_tele) }}</el-tag>
|
||||
<el-tag v-else type="success">在线</el-tag>
|
||||
<span class="muted">ID {{ b.id }} · 固件 {{ b.status?.fw || b.fw }} · IP {{ b.ip }}</span>
|
||||
<OtaState :status="b.status" />
|
||||
<router-link to="/firmware" class="muted">推固件 →</router-link>
|
||||
</div>
|
||||
<div class="stats" v-if="b.status">
|
||||
<div><span class="muted">输入</span><b>{{ V(b.status.in?.mv) }} V</b><b>{{ mA(b.status.in?.ua) }} mA</b></div>
|
||||
<div><span class="muted">温度</span><b v-for="(t, i) in b.status.temp || []" :key="i">{{ t == null ? '—' : C(t) + ' °C' }}</b></div>
|
||||
<div>
|
||||
<span class="muted">风扇</span><b>{{ (b.status.fan?.duty ?? 0) / 10 }}%</b>
|
||||
<b v-for="(r, i) in b.status.fan?.rpm || []" :key="i" :class="{ bad: b.status.fan?.stall?.[i] }">{{ r ?? '—' }} rpm</b>
|
||||
<el-select v-model="fanFloor" size="small" style="width: 130px" @change="setFan">
|
||||
<dl class="meta">
|
||||
<div><dt>状态</dt><dd :class="{ bad: !b.online || b.stale }">{{ !b.online || b.stale ? `通信中断,最后数据 ${ago(b.last_tele)}` : '在线' }}</dd></div>
|
||||
<div><dt>ID</dt><dd class="readout">{{ b.id }}</dd></div>
|
||||
<div><dt>固件</dt><dd>{{ b.status?.fw || b.fw }} <OtaState :status="b.status" quiet /> <router-link to="/firmware">推固件</router-link></dd></div>
|
||||
<div><dt>IP</dt><dd class="readout">{{ b.ip || '—' }}</dd></div>
|
||||
</dl>
|
||||
<div class="gauges" v-if="b.status">
|
||||
<div class="g"><span class="gk">输入</span>
|
||||
<span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span>
|
||||
<span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div>
|
||||
<div class="g"><span class="gk">温度</span>
|
||||
<span v-for="(t, i) in b.status.temp || []" :key="i" class="gv readout">{{ t == null ? '—' : C(t) }}<small>°C</small></span></div>
|
||||
<div class="g"><span class="gk">风扇</span>
|
||||
<span class="gv readout">{{ (b.status.fan?.duty ?? 0) / 10 }}<small>%</small></span>
|
||||
<span v-for="(r, i) in b.status.fan?.rpm || []" :key="i" class="gv readout" :class="{ bad: b.status.fan?.stall?.[i] }">{{ r ?? '—' }}<small>rpm</small></span>
|
||||
<el-select v-model="fanFloor" size="small" class="floor" aria-label="风扇占空比下限" @change="setFan">
|
||||
<el-option :value="0" label="自动" />
|
||||
<el-option v-for="d in [500, 700, 850, 1000]" :key="d" :value="d" :label="`不低于 ${d / 10}%`" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div><span class="muted">巡检</span><b>{{ b.status.safety?.cycles }} 圈</b>
|
||||
<b :class="{ bad: b.status.safety?.skipped }">跳过 {{ b.status.safety?.skipped }}</b>
|
||||
<b :class="{ bad: b.status.safety?.i2c_err }">I²C 错 {{ b.status.safety?.i2c_err }}</b></div>
|
||||
</div>
|
||||
</el-select></div>
|
||||
<div class="g"><span class="gk">巡检</span>
|
||||
<span class="gv readout">{{ b.status.safety?.cycles }}<small>圈</small></span>
|
||||
<span class="gv readout" :class="{ bad: b.status.safety?.skipped }">{{ b.status.safety?.skipped }}<small>跳过</small></span>
|
||||
<span class="gv readout" :class="{ bad: b.status.safety?.i2c_err }">{{ b.status.safety?.i2c_err }}<small>I²C 错</small></span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rails">
|
||||
<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">
|
||||
<HistoryChart :board="b.id" :rail="sel" />
|
||||
<BoardChart :board="b.id" />
|
||||
</div>
|
||||
|
||||
<div class="two">
|
||||
<div class="card">
|
||||
<div class="ttl">最近事件</div>
|
||||
<el-table :data="events" size="small" max-height="360" empty-text="暂无">
|
||||
<el-table-column label="收到" width="130"><template #default="{ row }">{{ fmtTime(row.received) }}</template></el-table-column>
|
||||
<el-table-column label="路" prop="rail" width="44" />
|
||||
<section class="card">
|
||||
<h2 class="ttl">最近事件</h2>
|
||||
<el-table :data="events" size="small" max-height="360" empty-text="这块板还没有事件">
|
||||
<el-table-column label="收到" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.received) }}</span></template></el-table-column>
|
||||
<el-table-column label="路" width="50"><template #default="{ row }">{{ row.rail || '整板' }}</template></el-table-column>
|
||||
<el-table-column label="事件"><template #default="{ row }"><span :class="{ bad: row.severe }">{{ row.name }}</span></template></el-table-column>
|
||||
<el-table-column label="当时" width="140"><template #default="{ row }">{{ V(row.mv) }} V / {{ mA(row.ua) }} mA</template></el-table-column>
|
||||
<el-table-column label="当时" width="150"><template #default="{ row }"><span class="readout">{{ V(row.mv) }} V {{ mA(row.ua) }} mA</span></template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="ttl">操作记录</div>
|
||||
<el-table :data="audit" size="small" max-height="360" empty-text="暂无">
|
||||
<el-table-column label="时间" width="130"><template #default="{ row }">{{ fmtTime(row.ts) }}</template></el-table-column>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 class="ttl">操作记录</h2>
|
||||
<el-table :data="audit" size="small" max-height="360" empty-text="还没有人操作过这块板">
|
||||
<el-table-column label="时间" width="130"><template #default="{ row }"><span class="readout">{{ fmtTime(row.ts) }}</span></template></el-table-column>
|
||||
<el-table-column label="谁" prop="user" width="110" />
|
||||
<el-table-column label="操作"><template #default="{ row }">#{{ row.rail ?? '' }} {{ row.request.cmd }}{{ row.request.keys ? ' ' + row.request.keys : '' }}</template></el-table-column>
|
||||
<el-table-column label="操作"><template #default="{ row }">{{ row.rail ? `#${row.rail} ` : '' }}{{ row.request.cmd }}{{ row.request.keys ? ' ' + row.request.keys : '' }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="120"><template #default="{ row }"><span :class="{ bad: !row.ok }">{{ rspText(row.response) }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,6 +71,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import RailCard from '../components/RailCard.vue'
|
||||
import HistoryChart from '../components/HistoryChart.vue'
|
||||
import BoardChart from '../components/BoardChart.vue'
|
||||
import OtaState from '../components/OtaState.vue'
|
||||
import { api, rspText } from '../api'
|
||||
import { state, onMessage } from '../store'
|
||||
@@ -119,17 +126,31 @@ watch(() => props.id, refresh)
|
||||
|
||||
<style scoped>
|
||||
.bd { display: flex; flex-direction: column; gap: 16px; }
|
||||
.head { display: flex; flex-direction: column; gap: 10px; }
|
||||
.names { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.info { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; }
|
||||
.stats > div { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.stats b { font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.rails { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.ttl { font-weight: 600; margin-bottom: 8px; }
|
||||
.bad { color: #dc2626; }
|
||||
@media (max-width: 1200px) { .rails { grid-template-columns: repeat(3, 1fr); } }
|
||||
.head { display: flex; flex-direction: column; gap: 12px; }
|
||||
.names { display: grid; grid-template-columns: auto minmax(0, 1.2fr) minmax(0, 1fr); gap: 10px; align-items: center; }
|
||||
/* 就地编辑:平时像标题,点进去才像输入框 */
|
||||
.ed :deep(.el-input__wrapper) { box-shadow: none; background: transparent; padding-left: 4px; }
|
||||
.ed :deep(.el-input__wrapper:hover), .ed :deep(.el-input__wrapper.is-focus) { box-shadow: 0 0 0 1px var(--line) inset; background: var(--bay); }
|
||||
.name :deep(.el-input__inner) { font-size: 18px; font-weight: 700; color: var(--ink); }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 0; font-size: 13px; }
|
||||
.meta div { display: flex; gap: 8px; align-items: center; }
|
||||
.meta dt { color: var(--muted); }
|
||||
.meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.gauges { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 8px;
|
||||
border-top: 1px solid var(--line-soft); padding-top: 12px; }
|
||||
.g { display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
||||
.gk { color: var(--muted); font-size: 13px; width: 32px; flex-shrink: 0; }
|
||||
.gv { font-size: 20px; color: var(--ink); }
|
||||
.gv small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); }
|
||||
.floor { width: 130px; align-self: center; }
|
||||
.rails { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
||||
.charts { 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: 1200px) { .rails { grid-template-columns: repeat(3, minmax(0, 1fr)); } .charts { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 800px) { .rails { grid-template-columns: 1fr 1fr; } .two { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 520px) { .rails { grid-template-columns: 1fr; } .names { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 520px) {
|
||||
.rails { grid-template-columns: 1fr; }
|
||||
.names { grid-template-columns: auto 1fr; }
|
||||
.names .ed:not(.name) { grid-column: 1 / -1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,5 +50,5 @@ onBeforeUnmount(off)
|
||||
<style scoped>
|
||||
.bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.more { margin-top: 10px; text-align: center; }
|
||||
:deep(.sev) { color: #b91c1c; }
|
||||
:deep(.sev) { color: var(--st-bad); }
|
||||
</style>
|
||||
|
||||
@@ -195,7 +195,6 @@ onBeforeUnmount(off)
|
||||
.fw .card { min-width: 0; overflow: hidden; }
|
||||
.ttl { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; }
|
||||
.hint { margin-bottom: 8px; }
|
||||
code { font-size: 12px; background: #f1f5f9; padding: 1px 4px; border-radius: 4px; word-break: break-all; }
|
||||
.boards { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; margin-bottom: 12px; }
|
||||
.boards .muted { margin-left: 6px; }
|
||||
.reboot { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||
|
||||
+131
-62
@@ -1,53 +1,77 @@
|
||||
<template>
|
||||
<div class="ov">
|
||||
<div class="main">
|
||||
<p class="summary" v-if="boards.length">
|
||||
<template v-if="attention.length">
|
||||
<span class="bad">{{ attention.length }} 路需要看:</span>
|
||||
<router-link v-for="a in attention" :key="a.key" :to="`/board/${a.board}`" class="att">
|
||||
{{ a.name }} #{{ a.rail }} {{ a.text }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>{{ tally }}</template>
|
||||
</p>
|
||||
|
||||
<el-empty v-if="!boards.length" description="还没有板子上线。新板子经 USB 配好 WiFi 和 MQTT 之后会自动出现在这里。" />
|
||||
|
||||
<section class="boards">
|
||||
<el-empty v-if="!boards.length" description="还没有板子上线。新板子经 USB 配好 wifi / mqtt 之后会自动出现在这里。" />
|
||||
<router-link v-for="b in boards" :key="b.id" :to="`/board/${b.id}`" class="card board">
|
||||
<div class="bh">
|
||||
<div>
|
||||
<div class="bname">{{ b.name || b.id }}</div>
|
||||
<div class="muted">{{ b.location || '未填位置' }} · {{ b.fw }}</div>
|
||||
<router-link v-for="b in boards" :key="b.id" :to="`/board/${b.id}`" class="card panel">
|
||||
<header class="ph">
|
||||
<div class="pn">
|
||||
<span :class="['led', !b.online || b.stale ? 'lost' : 'ok']" />
|
||||
<b>{{ b.name || b.id }}</b>
|
||||
<span class="muted loc">{{ b.location || '没填位置' }}</span>
|
||||
</div>
|
||||
<el-tag v-if="!b.online || b.stale" type="info" size="small">通信中断 · {{ ago(b.last_tele) }}</el-tag>
|
||||
<el-tag v-else type="success" size="small">在线</el-tag>
|
||||
<span v-if="!b.online || b.stale" class="muted">通信中断,最后数据 {{ ago(b.last_tele) }}</span>
|
||||
<span v-else class="muted fw">{{ b.status?.fw || b.fw }}</span>
|
||||
</header>
|
||||
|
||||
<div class="bays">
|
||||
<div v-for="i in 5" :key="i" :class="['bay', health(b, i).level]">
|
||||
<div class="bt">
|
||||
<span class="bn readout">{{ i }}</span>
|
||||
<span :class="['led', health(b, i).level]" />
|
||||
<el-icon v-if="phone(b, i).auto_recover" class="ar" title="已开自动恢复"><Refresh /></el-icon>
|
||||
</div>
|
||||
<div class="slots">
|
||||
<div v-for="i in 5" :key="i" class="slot" :style="{ borderColor: color(b, i) }">
|
||||
<div class="sh">
|
||||
<span class="sn">{{ i }}</span>
|
||||
<span class="lbl">{{ phone(b, i).label }}</span>
|
||||
<el-icon v-if="phone(b, i).auto_recover" title="已开自动恢复"><Refresh /></el-icon>
|
||||
</div>
|
||||
<div class="val" :style="{ color: color(b, i) }">{{ health(b, i).text }}</div>
|
||||
<div class="num" v-if="rail(b, i)?.pr">{{ mA(rail(b, i)?.ua) }}<small> mA</small></div>
|
||||
<div class="bl" :title="phone(b, i).label">{{ phone(b, i).label || ' ' }}</div>
|
||||
<template v-if="rail(b, i)?.pr">
|
||||
<div class="bv readout">{{ mA(rail(b, i)?.ua) }}<small>mA</small></div>
|
||||
<Spark :board="b.id" :rail="i" :color="loud(b, i) ? LEVEL_COLOR[health(b, i).level] : 'var(--ink-2)'" />
|
||||
</template>
|
||||
<div class="bs" :style="{ color: loud(b, i) ? LEVEL_COLOR[health(b, i).level] : 'var(--muted)' }">{{ health(b, i).text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bf muted" v-if="b.status">
|
||||
输入 {{ V(b.status.in?.mv) }} V / {{ mA(b.status.in?.ua) }} mA ·
|
||||
温度 {{ temps(b) }} ·
|
||||
风扇 {{ b.status.fan?.duty / 10 }}%
|
||||
<span v-if="b.status.fan?.stall?.some(x => x)" class="bad"> · 风扇停转</span>
|
||||
<span v-if="b.status.derate" class="bad"> · 过温</span>
|
||||
</div>
|
||||
|
||||
<footer class="pf" v-if="b.status">
|
||||
<div><span class="k">输入</span><span class="readout">{{ V(b.status.in?.mv) }}</span> V
|
||||
<span class="readout">{{ A(b.status.in?.ua) }}</span> A</div>
|
||||
<div><span class="k">温度</span>
|
||||
<span v-for="(t, k) in b.status.temp || []" :key="k" class="readout t">{{ t == null ? '—' : C(t) }}</span> °C</div>
|
||||
<div><span class="k">风扇</span><span class="readout">{{ (b.status.fan?.duty ?? 0) / 10 }}</span> %
|
||||
<span v-if="b.status.fan?.stall?.some(x => x)" class="bad">停转</span></div>
|
||||
<div v-if="b.status.derate" class="bad">过温降载</div>
|
||||
</footer>
|
||||
</router-link>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="card feed">
|
||||
<div class="fh">实时动态</div>
|
||||
<div v-if="!state.feed.length" class="muted">暂无。事件、操作、自动恢复都会出现在这里。</div>
|
||||
<div v-for="(f, k) in state.feed" :key="k" class="fi">
|
||||
<span class="muted">{{ fmtTime(f.ts) }}</span>
|
||||
<h2 class="ttl">实时动态</h2>
|
||||
<div v-if="!state.feed.length" class="muted">事件、操作和自动恢复会实时出现在这里。</div>
|
||||
<ol class="fl">
|
||||
<li v-for="(f, k) in state.feed" :key="k">
|
||||
<time class="muted readout">{{ fmtTime(f.ts) }}</time>
|
||||
<span class="who">{{ boardName(f.board) }}</span>
|
||||
<template v-if="f.kind === 'event'">
|
||||
<span :class="{ bad: SEVERE.has(f.ev[3]) }">{{ f.ev[2] ? `#${f.ev[2]} ` : '' }}{{ evName(f.ev[3]) }}</span>
|
||||
</template>
|
||||
<template v-else-if="f.kind === 'audit'">
|
||||
<span>{{ f.row.user }}:#{{ f.row.rail ?? '' }} {{ f.row.request.cmd }} → {{ rspText(f.row.response) }}</span>
|
||||
<span>{{ f.row.user }} 对 #{{ f.row.rail ?? '' }} 执行 {{ f.row.request.cmd }}:{{ rspText(f.row.response) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="warn">自动恢复 #{{ f.row.rail }}:{{ RECOVERY_ZH[f.row.state] }}({{ REASON_ZH[f.row.reason] || f.row.reason }})</span>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,57 +79,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import Spark from '../components/Spark.vue'
|
||||
import { state } from '../store'
|
||||
import { rspText } from '../api'
|
||||
import { mA, V, C, ago, fmtTime, railHealth, LEVEL_COLOR, RECOVERY_ZH, REASON_ZH } from '../util'
|
||||
|
||||
// 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h)
|
||||
const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障',
|
||||
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率']
|
||||
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14])
|
||||
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率',
|
||||
'上电等不到 PG', '固件升级']
|
||||
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17])
|
||||
const evName = t => EV[t] || `事件 ${t}`
|
||||
|
||||
const A = ua => (ua == null ? '—' : (ua / 1e6).toFixed(2))
|
||||
const boards = computed(() => Object.values(state.boards).sort((a, b) =>
|
||||
(a.location + a.name + a.id).localeCompare(b.location + b.name + b.id)))
|
||||
const rail = (b, i) => b.status?.rails?.[i - 1]
|
||||
const phone = (b, i) => b.phones?.find(p => p.rail === i) || {}
|
||||
const health = (b, i) => railHealth(b, rail(b, i))
|
||||
const color = (b, i) => LEVEL_COLOR[health(b, i).level]
|
||||
const temps = b => (b.status.temp || []).map(t => (t == null ? '—' : C(t) + '°')).join(' / ')
|
||||
const boardName = id => state.boards[id]?.name || id
|
||||
// 只有要人看的状态(异常 / 故障 / 正在执行)才上色;「运行」「断电」「空槽」安静 —— 灯已经说了
|
||||
const loud = (b, i) => ['bad', 'warn', 'busy'].includes(health(b, i).level)
|
||||
|
||||
// 需要人看的:异常 / 故障 / 读不到。断电、空槽、通信中断是「状态」,不是「告警」,只进计数。
|
||||
const attention = computed(() => {
|
||||
const out = []
|
||||
for (const b of boards.value) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const h = health(b, i)
|
||||
if (h.level === 'bad' || h.level === 'warn') {
|
||||
out.push({ key: `${b.id}/${i}`, board: b.id, name: b.name || b.id, rail: i, text: h.text })
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
const tally = computed(() => {
|
||||
const n = { ok: 0, busy: 0, off: 0, empty: 0, lost: 0 }
|
||||
for (const b of boards.value) for (let i = 1; i <= 5; i++) n[health(b, i).level] = (n[health(b, i).level] || 0) + 1
|
||||
const parts = [`${n.ok} 台在跑`]
|
||||
if (n.busy) parts.push(`${n.busy} 台正在执行动作`)
|
||||
if (n.off) parts.push(`${n.off} 台断电`)
|
||||
if (n.empty) parts.push(`${n.empty} 个空槽`)
|
||||
if (n.lost) parts.push(`${n.lost} 路通信中断`)
|
||||
return parts.join(',') + '。'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ov { display: grid; grid-template-columns: 1fr 320px; gap: 16px; align-items: start; }
|
||||
.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); gap: 16px; }
|
||||
.board { display: block; color: inherit; text-decoration: none; transition: box-shadow .15s; }
|
||||
.board:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .08); }
|
||||
.bh { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
.bname { font-weight: 600; font-size: 16px; }
|
||||
.slots { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
|
||||
.slot { border: 2px solid; border-radius: 8px; padding: 8px 6px; min-height: 86px; }
|
||||
.sh { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.sn { font-weight: 700; color: #0f172a; }
|
||||
.lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.val { font-size: 13px; font-weight: 600; margin-top: 6px; }
|
||||
.num { font-size: 18px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||
.num small { font-size: 11px; color: var(--muted); font-weight: 400; }
|
||||
.bf { margin-top: 10px; }
|
||||
.bad { color: #dc2626; } .warn { color: #d97706; }
|
||||
.feed { position: sticky; top: 68px; max-height: calc(100vh - 90px); overflow: auto; }
|
||||
.fh { font-weight: 600; margin-bottom: 8px; }
|
||||
.fi { font-size: 13px; padding: 6px 0; border-bottom: 1px dashed var(--line); display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.who { color: #334155; font-weight: 500; }
|
||||
@media (max-width: 1000px) {
|
||||
.ov { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
|
||||
.summary { margin: 0 0 14px; color: var(--ink-2); font-size: 15px; display: flex; flex-wrap: wrap; gap: 4px 10px; }
|
||||
.att { color: var(--st-bad); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(460px, 100%), 1fr)); gap: 16px; }
|
||||
|
||||
.panel { display: flex; flex-direction: column; gap: 12px; color: inherit; text-decoration: none; padding: 14px; }
|
||||
.panel:hover { border-color: var(--muted); }
|
||||
.ph { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||
.pn { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.pn b { font-size: 16px; }
|
||||
.loc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fw { white-space: nowrap; }
|
||||
|
||||
/* 刀片槽:凹进去的一格,顶上一条状态色的「灯条」 */
|
||||
.bays { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
|
||||
.bay { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay);
|
||||
padding: 8px 8px 6px; display: flex; flex-direction: column; gap: 2px; min-height: 118px;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, .06); }
|
||||
.bay.empty { background: transparent; border-style: dashed; box-shadow: none; }
|
||||
.bt { display: flex; align-items: center; gap: 6px; }
|
||||
.bn { font-size: 15px; font-weight: 700; color: var(--ink-2); }
|
||||
.ar { margin-left: auto; color: var(--muted); font-size: 13px; }
|
||||
.bl { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bv { font-size: 24px; line-height: 1.1; color: var(--ink); margin-top: 2px; }
|
||||
.bv small { font-size: 11px; color: var(--muted); margin-left: 2px; font-family: var(--ui); }
|
||||
.bs { font-size: 12px; font-weight: 600; margin-top: auto; }
|
||||
.bay.off .bv, .bay.lost .bv { color: var(--muted); }
|
||||
|
||||
.pf { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 13px; color: var(--ink-2);
|
||||
border-top: 1px solid var(--line-soft); padding-top: 10px; }
|
||||
.pf .k { color: var(--muted); margin-right: 6px; }
|
||||
.pf .readout { font-size: 15px; color: var(--ink); }
|
||||
.pf .t + .t::before { content: '/'; margin: 0 4px; color: var(--muted); }
|
||||
|
||||
.feed { position: sticky; top: 74px; max-height: calc(100vh - 94px); overflow: auto; }
|
||||
.fl { list-style: none; margin: 0; padding: 0; }
|
||||
.fl li { font-size: 13px; padding: 7px 0; border-bottom: 1px solid var(--line-soft); display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.fl time { font-size: 12px; }
|
||||
.who { color: var(--ink); font-weight: 500; }
|
||||
@media (max-width: 1100px) {
|
||||
.ov { grid-template-columns: 1fr; }
|
||||
.feed { position: static; max-height: 360px; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.boards { grid-template-columns: 1fr; }
|
||||
.slots { gap: 4px; }
|
||||
.slot { padding: 5px 3px; min-height: 72px; }
|
||||
.val { font-size: 12px; }
|
||||
.num { font-size: 14px; }
|
||||
.num small { display: none; }
|
||||
@media (max-width: 560px) {
|
||||
.bays { gap: 4px; }
|
||||
.bay { padding: 6px 5px 5px; min-height: 104px; }
|
||||
.bv { font-size: 18px; }
|
||||
.bv small { display: none; }
|
||||
.bl { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -80,5 +80,4 @@ onBeforeUnmount(off)
|
||||
.rc { display: flex; flex-direction: column; gap: 16px; }
|
||||
.ttl { font-weight: 600; margin-bottom: 10px; }
|
||||
.form { max-width: 560px; }
|
||||
.bad { color: #dc2626; }
|
||||
</style>
|
||||
|
||||
+20
-1
@@ -1,6 +1,6 @@
|
||||
// 全站唯一的实时状态:WebSocket 推什么,这里就改什么;页面只读它。
|
||||
// 断线自动重连(指数退避到 15 s);重连后服务器会先发一条 hello 带全量,不用补拉。
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
export const state = reactive({
|
||||
boards: {}, // id → 快照(含 status / phones)
|
||||
@@ -11,6 +11,24 @@ export const state = reactive({
|
||||
feed: [], // 最近的事件 / 审计 / 恢复,给总览页右侧的动态流
|
||||
})
|
||||
|
||||
// 每路最近 SPARK_MS 的电流(总览的小曲线):key = `${board}/${rail}` → [[ts, ua], ...]。
|
||||
// shallowReactive:换整个数组才触发重画,push 不触发 —— 25 条 × 120 点,每秒不该有 3000 次响应式通知。
|
||||
export const SPARK_MS = 120000
|
||||
export const spark = shallowReactive({})
|
||||
export function sparkSeed(key, pts) {
|
||||
if (!spark[key] || spark[key].length < pts.length) spark[key] = pts
|
||||
}
|
||||
function sparkPush(board, ts, rails) {
|
||||
for (const r of rails || []) {
|
||||
const key = `${board}/${r.r}`
|
||||
const old = spark[key] || []
|
||||
const cut = ts - SPARK_MS
|
||||
let i = 0
|
||||
while (i < old.length && old[i][0] < cut) i++
|
||||
spark[key] = old.slice(i).concat([[ts, r.ua]])
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = new Set()
|
||||
export function onMessage(fn) { listeners.add(fn); return () => listeners.delete(fn) }
|
||||
|
||||
@@ -36,6 +54,7 @@ function handle(m) {
|
||||
case 'tele': {
|
||||
const b = state.boards[m.board]
|
||||
if (b) { b.status = m.status; b.last_tele = m.ts; b.stale = false; b.online = true }
|
||||
sparkPush(m.board, m.ts, m.status.rails)
|
||||
break
|
||||
}
|
||||
case 'events':
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/* PhoneRail 机架 —— 仪器前面板风格。
|
||||
一处用力:每块板是一块「面板」,五个槽是凹进去的「刀片槽」,槽里是状态灯 + 读数 + 电流小曲线。
|
||||
其余一律安静:冷灰阳极铝底、细线分隔、读数用 DIN 系等宽数字。
|
||||
颜色只有两类职责:
|
||||
· 状态(运行 / 忙 / 异常 / 故障 / 断电 / 空槽 / 失联)—— 只用来表示状态,旁边一定有文字;
|
||||
· 交互(选中、链接、主按钮)—— 阳极蓝。
|
||||
图表的系列色在 chart.js 里(dataviz 参考调色板第 1/2 槽,按本页底色校验过)。 */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--plane: #eceff2; /* 页面底 */
|
||||
--panel: #fbfcfd; /* 面板 */
|
||||
--bay: #f2f4f7; /* 凹槽(比面板深一档) */
|
||||
--bay-edge: #dde2e8;
|
||||
--ink: #16202b;
|
||||
--ink-2: #566270;
|
||||
--muted: #8a94a0;
|
||||
--line: #d9dee4;
|
||||
--line-soft: #e8ecf0;
|
||||
--accent: #1d5fd1;
|
||||
--accent-soft: #e3ecfb;
|
||||
--bar: #fbfcfd;
|
||||
|
||||
--st-ok: #0c8a0c; /* 运行(比 dataviz 的 good 深一档:它要当文字用) */
|
||||
--st-busy: #1d5fd1;
|
||||
--st-warn: #b07a00;
|
||||
--st-bad: #c62f2f;
|
||||
--st-off: #5d6875;
|
||||
--st-empty: #b9c1ca;
|
||||
--st-lost: #98a1ab;
|
||||
--led-ok: #16b416; /* 灯本身:亮一点,但只当圆点用,不当文字 */
|
||||
--led-warn: #fab219;
|
||||
--led-bad: #e04343;
|
||||
|
||||
--readout: "DIN Alternate", "DIN 2014", Bahnschrift, "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
|
||||
--ui: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei UI", "Microsoft YaHei",
|
||||
"Noto Sans CJK SC", system-ui, sans-serif;
|
||||
|
||||
--r-panel: 10px;
|
||||
--r-bay: 6px;
|
||||
|
||||
/* Element Plus:主色换成阳极蓝,其余跟着面板走 */
|
||||
--el-color-primary: #1d5fd1;
|
||||
--el-color-primary-light-3: #6190e0;
|
||||
--el-color-primary-light-5: #8eafe8;
|
||||
--el-color-primary-light-7: #bbcff1;
|
||||
--el-color-primary-light-8: #d2dff6;
|
||||
--el-color-primary-light-9: #e8effb;
|
||||
--el-color-primary-dark-2: #184ca7;
|
||||
--el-font-family: var(--ui);
|
||||
--el-border-radius-base: 6px;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
--plane: #0f1216;
|
||||
--panel: #171b21;
|
||||
--bay: #12161b;
|
||||
--bay-edge: #262c34;
|
||||
--ink: #e8ecf1;
|
||||
--ink-2: #a7b0bb;
|
||||
--muted: #76808b;
|
||||
--line: #2a3038;
|
||||
--line-soft: #20252c;
|
||||
--accent: #5b95f2;
|
||||
--accent-soft: #1b2a44;
|
||||
--bar: #13171c;
|
||||
|
||||
--st-ok: #3cc43c;
|
||||
--st-busy: #5b95f2;
|
||||
--st-warn: #f0b23a;
|
||||
--st-bad: #ef6464;
|
||||
--st-off: #8a95a1;
|
||||
--st-empty: #3a414a;
|
||||
--st-lost: #5d6671;
|
||||
--led-ok: #2fd12f;
|
||||
--led-warn: #fab219;
|
||||
--led-bad: #ef5353;
|
||||
|
||||
--el-color-primary: #5b95f2;
|
||||
--el-color-primary-light-3: #3f6fb8;
|
||||
--el-color-primary-light-5: #2f548b;
|
||||
--el-color-primary-light-7: #203a5f;
|
||||
--el-color-primary-light-8: #1a2f4c;
|
||||
--el-color-primary-light-9: #142338;
|
||||
--el-color-primary-dark-2: #7eabf5;
|
||||
--el-bg-color: #171b21;
|
||||
--el-bg-color-overlay: #1c2128;
|
||||
--el-fill-color-blank: #171b21;
|
||||
--el-border-color: #2f363f;
|
||||
--el-border-color-light: #2a3038;
|
||||
--el-border-color-lighter: #242a31;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { background: var(--plane); }
|
||||
body { margin: 0; font-family: var(--ui); color: var(--ink); font-size: 14px; line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased; }
|
||||
a { color: var(--accent); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* 面板:页面上的一级容器 */
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-panel); padding: 16px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.readout { font-family: var(--readout); font-variant-numeric: tabular-nums; letter-spacing: .01em; }
|
||||
.bad { color: var(--st-bad); }
|
||||
.warn { color: var(--st-warn); }
|
||||
|
||||
/* 面板标题:粗一点的正文字,不加眉标、不全大写 */
|
||||
.ttl { font-weight: 600; font-size: 15px; margin: 0 0 10px; color: var(--ink); }
|
||||
|
||||
/* 状态灯 */
|
||||
.led { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0;
|
||||
background: var(--st-empty); box-shadow: 0 0 0 2px var(--panel); }
|
||||
.led.ok { background: var(--led-ok); }
|
||||
.led.busy { background: var(--st-busy); }
|
||||
.led.warn { background: var(--led-warn); }
|
||||
.led.bad { background: var(--led-bad); }
|
||||
.led.off { background: var(--st-off); }
|
||||
.led.lost { background: var(--st-lost); }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.led.busy { animation: led-blink 1s steps(2, jump-none) infinite; }
|
||||
}
|
||||
@keyframes led-blink { 50% { opacity: .35; } }
|
||||
|
||||
/* 表格:去掉 Element Plus 的斑马感,行线用细线 */
|
||||
.el-table { --el-table-border-color: var(--line-soft); --el-table-header-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent; --el-table-bg-color: transparent; --el-table-row-hover-bg-color: var(--bay);
|
||||
--el-table-text-color: var(--ink); --el-table-header-text-color: var(--muted); }
|
||||
.el-table th.el-table__cell { font-weight: 500; }
|
||||
code { font-size: 12px; background: var(--bay); border: 1px solid var(--line-soft); padding: 0 4px; border-radius: 4px;
|
||||
word-break: break-all; }
|
||||
@@ -0,0 +1,18 @@
|
||||
// 亮 / 暗:跟随系统(运维夜里看手机多)。切换时在 <html> 上挂 `dark`(Element Plus 暗色变量也认这个类),
|
||||
// 并通知图表重画 —— ECharts 画在 canvas 上,CSS 变量变了它不知道。
|
||||
import { ref } from 'vue'
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
export const dark = ref(mq.matches)
|
||||
|
||||
function apply() {
|
||||
dark.value = mq.matches
|
||||
document.documentElement.classList.toggle('dark', dark.value)
|
||||
}
|
||||
apply()
|
||||
mq.addEventListener('change', apply)
|
||||
|
||||
// 读一个 CSS 变量的当前值(图表取色用)
|
||||
export function cssVar(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
@@ -40,9 +40,10 @@ export function railHealth(board, r) {
|
||||
return { level: 'ok', text: '运行' }
|
||||
}
|
||||
|
||||
// 状态色引用 theme.css 的变量:亮 / 暗两套,切换时自动跟着变。只用来表示状态,旁边一定有文字。
|
||||
export const LEVEL_COLOR = {
|
||||
ok: '#16a34a', busy: '#2563eb', warn: '#d97706', bad: '#dc2626',
|
||||
off: '#475569', empty: '#cbd5e1', lost: '#94a3b8',
|
||||
ok: 'var(--st-ok)', busy: 'var(--st-busy)', warn: 'var(--st-warn)', bad: 'var(--st-bad)',
|
||||
off: 'var(--st-off)', empty: 'var(--st-empty)', lost: 'var(--st-lost)',
|
||||
}
|
||||
|
||||
export const RECOVERY_ZH = {
|
||||
|
||||
在新工单中引用
屏蔽一个用户