机架管理前后端(rack/):FastAPI + SQLite + Vue3,经统一门户上线

- 后端 prrack:MQTT(phonerail-backend @ 127.0.0.1:1884)↔ 遥测入库 / 事件增量同步 / WebSocket 推送;
  命令白名单 + 重传同 seq;审计记门户 X-Auth-User(只绑 127.0.0.1,main.py 拒绝非回环)。
- 自动恢复:只看电流(平线 / 过低,服务器够不到手机 adb),先长按电源再断电重启,
  每小时限次后停手;默认逐台关闭。adb 探活接口 recovery.Prober 预留。
- 前端:总览 / 板子页(五路操作、电流电压曲线)/ 事件 / 操作记录 / 自动恢复;手机宽度可用。
- 部署 deploy/deploy.sh:服务器 Python 3.8 上先跑测试再替换;systemd phonerail-rack,每日备份。
- 门户应用 id 16、key rack(phonerail 已被需求文档站占用),授权 admin + staff。

测试 26 例(本机 3.14 / 服务器 3.8 均过)。线上板 b81f3fc9c8bc 在线,WS 经门户 101 + 1 条/s 遥测。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 12:33:28 +08:00
共同撰写人 Claude Opus 5.5
父节点 a32fa06c43
当前提交 26c04ce878
共修改 34 个文件,包含 4031 行新增和 0 行删除
+12
查看文件
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PhoneRail 机架</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+22
查看文件
@@ -0,0 +1,22 @@
{
"name": "phonerail-rack-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"echarts": "^5.6.0",
"element-plus": "^2.9.0",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"vite": "^6.0.0"
}
}
+1044
查看文件
文件差异内容过多而无法显示 加载差异
+52
查看文件
@@ -0,0 +1,52 @@
<template>
<div class="shell">
<header class="top">
<router-link to="/" class="brand">PhoneRail 机架</router-link>
<nav>
<router-link to="/">总览</router-link>
<router-link to="/events">事件</router-link>
<router-link to="/audit">操作记录</router-link>
<router-link to="/recovery">自动恢复</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>
<span class="user">{{ state.user }}</span>
</div>
</header>
<main><router-view /></main>
</div>
</template>
<script setup>
import { state } from './store'
</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; }
@media (max-width: 640px) {
.top { gap: 10px; padding: 0 10px; }
.top .brand { display: none; }
.conn .user { display: none; }
main { padding: 10px; }
}
</style>
+47
查看文件
@@ -0,0 +1,47 @@
// REST 调用。错误统一变成带中文说明的 Error(后端的 HTTPException detail)。
async function req(method, url, body) {
const r = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
const text = await r.text()
let data = null
try { data = text ? JSON.parse(text) : null } catch { data = text }
if (!r.ok) {
const msg = (data && data.detail) || `${r.status} ${r.statusText}`
// 401/403 是门户挡的(会话过期):整页刷新让门户带去登录
if (r.status === 401 || r.status === 403) location.reload()
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))
}
return data
}
export const api = {
boards: () => req('GET', '/api/boards'),
cmd: (board, body) => req('POST', `/api/boards/${board}/cmd`, body),
patchBoard: (board, body) => req('PATCH', `/api/boards/${board}`, body),
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}` : ''}`),
events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)),
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
recovery: () => req('GET', '/api/recovery'),
setRecoveryParams: p => req('PUT', '/api/recovery/params', p),
resetRecovery: (board, rail) => req('POST', `/api/recovery/${board}/${rail}/reset`),
}
// 板子回的错误码 → 人话(firmware/main/cmd.h + ulink/mlink 的链路层错误)
export const ERR_ZH = {
parse: '板子没看懂这条命令', unknown_cmd: '板子不认识这条命令', no_seq: '缺序号',
arg: '参数不对', range: '参数越界', empty: '这个槽没插刀片', busy: '这一路正在执行别的动作',
state: '当前状态下不允许(比如没上电时开 VBUS)', io: '下发到器件失败(I²C)',
overflow: '回应太长', link: '这条命令只能经 USB 下发', too_long: '命令太长',
bad_crc: '校验错', bad_frame: '坏数据', queue_full: '板子命令队列满,稍后再试', timeout: '板子没有回应',
}
export function rspText(rsp) {
if (!rsp) return '无回应'
if (rsp.ok) return rsp.dup ? '成功(重传,板子已判重)' : '成功'
return ERR_ZH[rsp.err] || rsp.err
}
@@ -0,0 +1,94 @@
<template>
<div 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>
</div>
</template>
<script setup>
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 { api } from '../api'
import { onMessage } from '../store'
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('')
let chart = null
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])
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 } }],
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 } },
] : []),
{ 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' },
],
}, true)
}
async function load() {
const end = Date.now()
try {
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 ? ' · 实时追加' : '')
render()
} catch (e) {
note.value = '加载失败:' + e.message
}
}
const off = onMessage(m => {
if (!live || m.t !== 'tele' || m.board !== props.board) return
const r = m.status.rails?.[props.rail - 1]
if (!r) return
pts.push([m.ts, r.mv, r.ua, r.ua, r.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, props.rail, range.value], load)
</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; } }
</style>
+146
查看文件
@@ -0,0 +1,146 @@
<template>
<div class="card rail" :class="{ sel: selected }" :style="{ borderTopColor: LEVEL_COLOR[h.level] }" @click="$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>
</div>
<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">&nbsp;</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 :class="{ bad: r.flt }" v-if="r.flt">FLT</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" 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-button size="small" plain>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="tap">短按电源(亮屏 / 息屏)</el-dropdown-item>
<el-dropdown-item command="on">开机(长按电源 2 秒)</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="edl">进 EDL / 9008</el-dropdown-item>
<el-dropdown-item command="vbus" divided>{{ r?.vb ? '断开 USB' : '接通 USB' }}</el-dropdown-item>
<el-dropdown-item command="power">{{ r?.en ? '断电(保持关闭)' : '上电' }}</el-dropdown-item>
<el-dropdown-item command="volt">调电压…</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-dialog v-model="voltOpen" title="调整输出电压" width="min(420px, 92vw)" append-to-body>
<p class="muted">范围 3.524 ~ 4.010 V(硬件决定,固件再限一次)。只是微调,不是保护手段。</p>
<el-slider v-model="mv" :min="3524" :max="4010" :step="1" show-input />
<template #footer>
<el-button @click="voltOpen = false">取消</el-button>
<el-button type="primary" @click="setVolt">下发 {{ (mv / 1000).toFixed(3) }} V</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import { api, rspText } from '../api'
import { mA, V, railHealth, LEVEL_COLOR, RECOVERY_ZH } from '../util'
const props = defineProps({ board: Object, rail: Number, selected: Boolean, recState: String })
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))
// 在位、板子在线、执行器空闲才能下动作(忙的时候板子也会回 busy,这里只是少一次无用的往返)
const can = computed(() => r.value?.pr && !props.board.stale && props.board.online && r.value.st === 'idle')
const label = ref('')
const auto = ref(false)
watch(phone, p => { label.value = p.label || ''; auto.value = !!p.auto_recover }, { immediate: true })
const who = () => `${props.board.name || props.board.id} #${props.rail}${label.value ? `(${label.value})` : ''}`
async function run(title, body, confirm) {
if (confirm) {
try {
await ElMessageBox.confirm(`对 ${who()} 执行「${title}」?`, '确认', { type: 'warning', confirmButtonText: '执行' })
} catch { return }
}
try {
const rsp = await api.cmd(props.board.id, { rail: props.rail, ...body })
ElMessage({ type: rsp.ok ? 'success' : 'error', message: `${title}:${rspText(rsp)}` })
} catch (e) {
ElMessage.error(`${title}:${e.message}`)
}
}
function more(c) {
const on = r.value
switch (c) {
case 'tap': return run('短按电源', { cmd: 'key', keys: 'pwr', ms: 100 })
case 'on': return run('开机', { cmd: 'key', keys: 'pwr', ms: 2000 })
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 'edl': return run('进 EDL', { cmd: 'edl' }, true)
case 'vbus': return run(on.vb ? '断开 USB' : '接通 USB', { cmd: 'vbus', on: !on.vb }, on.vb)
case 'power': return run(on.en ? '断电' : '上电', { cmd: 'power', on: !on.en }, on.en)
case 'volt': mv.value = on.set || 3900; voltOpen.value = true
}
}
const voltOpen = ref(false)
const mv = ref(3900)
async function setVolt() {
voltOpen.value = false
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) {
if (v) {
try {
await ElMessageBox.confirm(
`打开 ${who()} 的自动恢复?电流平直或过低持续 5 分钟会自动长按电源,仍不好再断电重启;` +
'判据阈值还没有实测依据,建议先在一两台上观察。', '自动恢复', { type: 'warning' })
} catch { auto.value = false; return }
}
try { await api.patchPhone(props.board.id, props.rail, { auto_recover: v }) } catch (e) { ElMessage.error(e.message); auto.value = !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); }
.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; }
.ar { display: flex; align-items: center; gap: 6px; }
.acts { display: flex; gap: 6px; flex-wrap: wrap; }
.acts .el-button + .el-button { margin-left: 0; }
</style>
+27
查看文件
@@ -0,0 +1,27 @@
import { createApp } from 'vue'
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 App from './App.vue'
import Overview from './pages/Overview.vue'
import Board from './pages/Board.vue'
import Events from './pages/Events.vue'
import Audit from './pages/Audit.vue'
import Recovery from './pages/Recovery.vue'
import { connect } from './store'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Overview },
{ path: '/board/:id', component: Board, props: true },
{ path: '/events', component: Events },
{ path: '/audit', component: Audit },
{ path: '/recovery', component: Recovery },
],
})
connect()
createApp(App).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
+38
查看文件
@@ -0,0 +1,38 @@
<template>
<div class="card">
<div class="bar">
<b>操作记录</b>
<span class="muted">每一条会动东西的命令:谁(门户账号 / auto-recovery)、对哪块板哪一路、板子怎么回的。</span>
</div>
<el-table :data="rows" size="small" empty-text="暂无">
<el-table-column label="时间" width="140"><template #default="{ row }">{{ fmtTime(row.ts) }}</template></el-table-column>
<el-table-column label="谁" prop="user" width="130" />
<el-table-column label="板子" width="150"><template #default="{ row }">{{ state.boards[row.board_id]?.name || row.board_id }}</template></el-table-column>
<el-table-column label="路" prop="rail" width="50" />
<el-table-column label="命令" min-width="200"><template #default="{ row }"><code>{{ show(row.request) }}</code></template></el-table-column>
<el-table-column label="结果" width="200">
<template #default="{ row }"><span :class="{ bad: !row.ok }">{{ rspText(row.response) }}</span></template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { api, rspText } from '../api'
import { state, onMessage } from '../store'
import { fmtTime } from '../util'
const rows = ref([])
const show = r => { const { seq, rail, ...rest } = r; return JSON.stringify(rest) }
const load = async () => { rows.value = await api.audit({ limit: 300 }) }
const off = onMessage(m => { if (m.t === 'audit') rows.value.unshift(m.row) })
onMounted(load)
onBeforeUnmount(off)
</script>
<style scoped>
.bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.bad { color: #dc2626; }
code { font-size: 12px; }
</style>
+132
查看文件
@@ -0,0 +1,132 @@
<template>
<div v-if="!b" class="card"><el-empty description="没有这块板子(或者还没收到它的数据)" /></div>
<div v-else class="bd">
<div class="card head">
<div class="names">
<el-input v-model="name" placeholder="板子名称" @change="saveMeta" />
<el-input v-model="location" 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.fw }} · IP {{ b.ip }}</span>
</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">
<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>
</div>
<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>
<HistoryChart :board="b.id" :rail="sel" />
<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" />
<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>
</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>
<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="结果" width="120"><template #default="{ row }"><span :class="{ bad: !row.ok }">{{ rspText(row.response) }}</span></template></el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup>
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 { api, rspText } from '../api'
import { state, onMessage } from '../store'
import { mA, V, C, ago, fmtTime } from '../util'
const props = defineProps({ id: String })
const b = computed(() => state.boards[props.id])
const sel = ref(1)
const name = ref('')
const location = ref('')
const fanFloor = ref(0)
const events = ref([])
const audit = ref([])
const recStates = ref({})
watch(b, v => {
if (!v) return
if (document.activeElement?.tagName !== 'INPUT') { name.value = v.name; location.value = v.location }
fanFloor.value = v.status?.fan?.floor ?? 0
}, { immediate: true })
async function saveMeta() {
try { await api.patchBoard(props.id, { name: name.value, location: location.value }) } catch (e) { ElMessage.error(e.message) }
}
async function setFan(d) {
try {
const rsp = await api.cmd(props.id, { cmd: 'fan', duty: d })
ElMessage({ type: rsp.ok ? 'success' : 'error', message: `风扇:${rspText(rsp)}` })
} catch (e) { ElMessage.error(e.message) }
}
async function refresh() {
try {
const [e, a, r] = await Promise.all([api.events({ board: props.id, limit: 50 }), api.audit({ board: props.id, limit: 50 }), api.recovery()])
events.value = e
audit.value = a
recStates.value = Object.fromEntries(r.states.filter(s => s.board === props.id).map(s => [s.rail, s.state]))
} catch (e) { /* 列表刷新失败不打扰:下一条推送会再试 */ }
}
let timer = null
const off = onMessage(m => {
if (['events', 'audit', 'recovery'].includes(m.t) && (m.board === props.id || m.row?.board_id === props.id)) {
clearTimeout(timer)
timer = setTimeout(refresh, 300)
}
})
onMounted(refresh)
onBeforeUnmount(off)
watch(() => props.id, refresh)
</script>
<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); } }
@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; } }
</style>
+54
查看文件
@@ -0,0 +1,54 @@
<template>
<div class="card">
<div class="bar">
<b>事件</b>
<el-select v-model="board" clearable placeholder="全部板子" size="small" style="width: 200px" @change="load">
<el-option v-for="b in boards" :key="b.id" :value="b.id" :label="b.name || b.id" />
</el-select>
<el-checkbox v-model="severeOnly" size="small">只看严重</el-checkbox>
<span class="muted">板子的事件日志(断电、反灌、故障、重启、命令被拒……),按序号增量同步,永久保留。</span>
</div>
<el-table :data="rows" size="small" empty-text="暂无事件" :row-class-name="r => r.row.severe ? 'sev' : ''">
<el-table-column label="服务器收到" width="140"><template #default="{ row }">{{ fmtTime(row.received) }}</template></el-table-column>
<el-table-column label="板子" width="150"><template #default="{ row }">{{ state.boards[row.board_id]?.name || row.board_id }}</template></el-table-column>
<el-table-column label="序号" prop="seq" width="70" />
<el-table-column label="路" width="50"><template #default="{ row }">{{ row.rail || '整板' }}</template></el-table-column>
<el-table-column label="事件" min-width="180"><template #default="{ row }">{{ row.name }}</template></el-table-column>
<el-table-column label="当时 V / I" width="150"><template #default="{ row }">{{ V(row.mv) }} V / {{ mA(row.ua) }} mA</template></el-table-column>
<el-table-column label="板子时刻" width="120"><template #default="{ row }">上电后 {{ (row.t_ms / 1000).toFixed(1) }} s</template></el-table-column>
</el-table>
<div class="more"><el-button size="small" :disabled="!more" @click="load(true)">更早的</el-button></div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { api } from '../api'
import { state, onMessage } from '../store'
import { mA, V, fmtTime } from '../util'
const boards = computed(() => Object.values(state.boards))
const board = ref('')
const severeOnly = ref(false)
const all = ref([])
const more = ref(true)
const rows = computed(() => (severeOnly.value ? all.value.filter(r => r.severe) : all.value))
async function load(older = false) {
const q = { limit: 200 }
if (board.value) q.board = board.value
if (older === true && all.value.length) q.before = all.value[all.value.length - 1].received
const r = await api.events(q)
all.value = older === true ? all.value.concat(r) : r
more.value = r.length === 200
}
const off = onMessage(m => { if (m.t === 'events') load() })
onMounted(load)
onBeforeUnmount(off)
</script>
<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; }
</style>
+111
查看文件
@@ -0,0 +1,111 @@
<template>
<div class="ov">
<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>
</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>
</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>
</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>
</router-link>
</section>
<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>
<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>
</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>
</aside>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Refresh } from '@element-plus/icons-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])
const evName = t => EV[t] || `事件 ${t}`
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
</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 { 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; }
}
</style>
+84
查看文件
@@ -0,0 +1,84 @@
<template>
<div class="rc">
<el-alert type="warning" :closable="false" show-icon
title="判据只看电流(服务器够不着手机的 adb);阈值还没有实测依据。自动恢复默认关,在板子页逐台打开。" />
<div class="card">
<div class="ttl">判据参数(全局)</div>
<el-form :model="p" label-width="170px" size="small" class="form">
<el-form-item label="判定窗口"><el-input-number v-model="p.window_s" :min="30" :max="3600" /> 秒</el-form-item>
<el-form-item label="平线:电流摆幅小于"><el-input-number v-model="flatMa" :min="0.1" :max="200" :step="0.5" /> mA</el-form-item>
<el-form-item label="过低:电流一直低于"><el-input-number v-model="lowMa" :min="1" :max="500" /> mA</el-form-item>
<el-form-item label="动作后先等"><el-input-number v-model="p.settle_s" :min="10" :max="1800" /> 秒(不看这段电流)</el-form-item>
<el-form-item label="每台每小时最多"><el-input-number v-model="p.max_per_hour" :min="1" :max="10" /> 次(超了就停手等人)</el-form-item>
<el-form-item label="一级:长按电源"><el-input-number v-model="p.soft_ms" :min="3000" :max="15000" :step="500" /> ms</el-form-item>
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
</el-form>
<div class="muted">流程:判定 → 长按电源 → 等待 → 再观察一个窗口;仍然判定 → 断电重启;再判定 → 停手(需人工复位)。</div>
</div>
<div class="card">
<div class="ttl">各台状态(只列开过自动恢复的)</div>
<el-table :data="states" size="small" empty-text="还没有手机开自动恢复">
<el-table-column label="板子" width="160"><template #default="{ row }">{{ bn(row.board) }}</template></el-table-column>
<el-table-column label="路" prop="rail" width="50" />
<el-table-column label="开关" width="70"><template #default="{ row }">{{ row.enabled ? '开' : '关' }}</template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><span :class="{ bad: row.state === 'gave_up' }">{{ RECOVERY_ZH[row.state] }}</span></template></el-table-column>
<el-table-column label="原因"><template #default="{ row }">{{ REASON_ZH[row.reason] || row.reason }}</template></el-table-column>
<el-table-column label="近 1 小时动作" prop="attempts_1h" width="110" />
<el-table-column width="90"><template #default="{ row }">
<el-button size="small" :disabled="row.state === 'idle'" @click="reset(row)">复位</el-button></template></el-table-column>
</el-table>
</div>
<div class="card">
<div class="ttl">恢复记录</div>
<el-table :data="log" size="small" empty-text="暂无">
<el-table-column label="时间" width="140"><template #default="{ row }">{{ fmtTime(row.ts) }}</template></el-table-column>
<el-table-column label="板子" width="160"><template #default="{ row }">{{ bn(row.board_id) }}</template></el-table-column>
<el-table-column label="路" prop="rail" width="50" />
<el-table-column label="进入状态" width="150"><template #default="{ row }">{{ RECOVERY_ZH[row.state] }}</template></el-table-column>
<el-table-column label="原因"><template #default="{ row }">{{ REASON_ZH[row.reason] || row.reason }}</template></el-table-column>
</el-table>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { state, onMessage } from '../store'
import { fmtTime, RECOVERY_ZH, REASON_ZH } from '../util'
const p = reactive({ window_s: 300, flat_ua: 5000, low_ua: 30000, settle_s: 120, max_per_hour: 3, soft_ms: 10000 })
const flatMa = computed({ get: () => p.flat_ua / 1000, set: v => { p.flat_ua = Math.round(v * 1000) } })
const lowMa = computed({ get: () => p.low_ua / 1000, set: v => { p.low_ua = Math.round(v * 1000) } })
const states = ref([])
const log = ref([])
const bn = id => state.boards[id]?.name || id
async function load() {
const r = await api.recovery()
Object.assign(p, r.params)
states.value = r.states
log.value = r.log
}
async function save() {
try { Object.assign(p, await api.setRecoveryParams({ ...p })); ElMessage.success('已保存') } catch (e) { ElMessage.error(e.message) }
}
async function reset(row) {
await api.resetRecovery(row.board, row.rail)
load()
}
const off = onMessage(m => { if (m.t === 'recovery') load() })
onMounted(load)
onBeforeUnmount(off)
</script>
<style scoped>
.rc { display: flex; flex-direction: column; gap: 16px; }
.ttl { font-weight: 600; margin-bottom: 10px; }
.form { max-width: 560px; }
.bad { color: #dc2626; }
</style>
+73
查看文件
@@ -0,0 +1,73 @@
// 全站唯一的实时状态:WebSocket 推什么,这里就改什么;页面只读它。
// 断线自动重连(指数退避到 15 s);重连后服务器会先发一条 hello 带全量,不用补拉。
import { reactive } from 'vue'
export const state = reactive({
boards: {}, // id → 快照(含 status / phones)
user: '',
mqtt: false, // 后端 ↔ broker
ws: false, // 浏览器 ↔ 后端
lastMsg: 0,
feed: [], // 最近的事件 / 审计 / 恢复,给总览页右侧的动态流
})
const listeners = new Set()
export function onMessage(fn) { listeners.add(fn); return () => listeners.delete(fn) }
function pushFeed(item) {
state.feed.unshift(item)
if (state.feed.length > 100) state.feed.length = 100
}
function handle(m) {
state.lastMsg = Date.now()
switch (m.t) {
case 'hello':
state.user = m.user
state.mqtt = m.mqtt
state.boards = Object.fromEntries(m.boards.map(b => [b.id, b]))
break
case 'mqtt':
state.mqtt = m.up
break
case 'board':
state.boards[m.board.id] = m.board
break
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 }
break
}
case 'events':
for (const e of m.ev) pushFeed({ kind: 'event', board: m.board, ts: m.received, ev: e })
break
case 'audit':
pushFeed({ kind: 'audit', board: m.row.board_id, ts: m.row.ts, row: m.row })
break
case 'recovery':
pushFeed({ kind: 'recovery', board: m.row.board_id, ts: m.row.ts, row: m.row })
break
}
for (const fn of listeners) fn(m)
}
let delay = 1000
export function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${location.host}/api/ws`)
ws.onopen = () => { state.ws = true; delay = 1000 }
ws.onmessage = ev => handle(JSON.parse(ev.data))
ws.onclose = () => {
state.ws = false
setTimeout(connect, delay)
delay = Math.min(delay * 2, 15000)
}
}
// 遥测超过 10 s 没来 = 通信中断(与后端 STALE_MS 一致)。每秒重算一次。
setInterval(() => {
const now = Date.now()
for (const b of Object.values(state.boards)) {
if (b.last_tele && now - b.last_tele > 10000) b.stale = true
}
}, 1000)
+54
查看文件
@@ -0,0 +1,54 @@
// 显示用的小工具:单位换算、每一路的健康状态。
export const mA = ua => (ua == null ? '—' : (ua / 1000).toFixed(ua >= 100000 || ua <= -100000 ? 0 : 1))
export const V = mv => (mv == null ? '—' : (mv / 1000).toFixed(3))
export const C = mc => (mc == null ? '—' : (mc / 1000).toFixed(1))
export function ago(ts) {
if (!ts) return '从未'
const s = Math.round((Date.now() - ts) / 1000)
if (s < 60) return `${s} 秒前`
if (s < 3600) return `${Math.round(s / 60)} 分钟前`
if (s < 86400) return `${Math.round(s / 3600)} 小时前`
return `${Math.round(s / 86400)} 天前`
}
export function fmtTime(ts) {
if (!ts) return ''
const d = new Date(ts)
const p = n => String(n).padStart(2, '0')
return `${d.getMonth() + 1}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// 执行器状态(firmware t_actuator_state_str)
export const ACT_ZH = {
idle: '空闲', 'hr-off': '断电中', 'hr-ramp': '上电中', 'hr-pg': '等 PG',
'key-hold': '按键中', 'edl-short': '进 EDL', 'edl-enum': '等枚举', cleanup: '收尾',
}
// 一路的健康状态 → { level, text }。level 决定颜色:
// ok 绿 / busy 蓝 / warn 橙 / bad 红 / off 深灰 / empty 浅灰 / lost 灰(通信中断)
export function railHealth(board, r) {
if (!board || board.stale || !board.online) return { level: 'lost', text: '通信中断' }
if (!r) return { level: 'lost', text: '无数据' }
if (!r.pr) return { level: 'empty', text: '空槽' }
if (r.st && r.st !== 'idle') return { level: 'busy', text: ACT_ZH[r.st] || r.st }
if (!r.en) return { level: 'off', text: '已断电' }
if (r.flt) return { level: 'bad', text: 'eFuse 故障' }
if (r.pg === false) return { level: 'bad', text: 'PG 丢失' }
if (r.mv == null || r.ua == null) return { level: 'warn', text: '读不到' }
return { level: 'ok', text: '运行' }
}
export const LEVEL_COLOR = {
ok: '#16a34a', busy: '#2563eb', warn: '#d97706', bad: '#dc2626',
off: '#475569', empty: '#cbd5e1', lost: '#94a3b8',
}
export const RECOVERY_ZH = {
idle: '正常', soft: '已长按电源', hard: '已断电重启', gave_up: '已放弃(需人工)',
}
export const REASON_ZH = {
flat: '电流平直(疑似卡死)', low: '电流过低(疑似关机)', recovered: '已恢复',
rate_limit: '一小时内动作过多', manual_reset: '人工复位',
}
+22
查看文件
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 开发:`python -m prrack.main --sim 2` 起后端(127.0.0.1:7764),这里把 /api 与 WS 反代过去。
// 生产:dist 由后端自己托管,同源,不需要代理。
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api/ws': { target: 'ws://127.0.0.1:7764', ws: true },
'/api': 'http://127.0.0.1:7764',
},
},
build: {
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks: { element: ['element-plus'] },
},
},
},
})