日志改常驻收集,加清空按钮;原生 select 统一换成 SelectBox

日志收集从请求页组件搬进模块级单例(web/log-store.ts,模式同 telemetry),
切走页面不再清空;离线时保留已收的日志,只提示不再更新。清空只清界面。

五处原生 <select> 在三个文件里各自重复同一套自绘箭头样式,而且原生控件
没有「展开中」状态可读,箭头做不了开合动画——提成 SelectBox 组件统一替换:
箭头随开合旋转 180 度,弹层带出入场动画,下方视口不够时向上弹。背景色
留 --select-bg 口子,对话框里沉一档、页面上浮一档,一个值满足不了两边。
This commit is contained in:
2026-08-11 23:47:28 +08:00
parent 60fb015e36
commit f927f9fff7
7 changed files with 473 additions and 187 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* 内核日志的常驻收集。
*
* 住在模块级单例里而不是请求页组件里,是因为日志要跨页面留存——收集跟着组件
* 走的话,切走一次列表就清空一次。外壳启动时点火,之后跟着 kernel.online 起落。
*
* 节奏和缓冲:收到的行先进 pending,每 200ms 一批刷进 rows——日志密集时逐条
* 触发响应式更新会把界面拖死。暂停就是不刷,缓冲照收(也设上限),恢复时补上。
*/
import { ref, watch } from 'vue'
import { kernel } from './runtime'
import { LOG_CAP, pushCapped, streamLogs } from './logs'
import type { LogLevel, LogLine } from './logs'
export interface LogRow extends LogLine {
id: number
/** 收到那一刻的时间。日志行本身不带时间戳 */
time: string
}
/** 日志级别。改它会断开重连:内核的级别是建立连接时定的,中途改不了。 */
export const level = ref<LogLevel>('info')
export const paused = ref(false)
export const rows = ref<LogRow[]>([])
export const failure = ref('')
/** 内核那边把流断了(比如重启)。和主动中止区分开,这个要告诉用户 */
export const ended = ref(false)
/** 每刷进一批加一。rows 是原地增改,组件要跟随滚动只能看这个信号 */
export const revision = ref(0)
let pending: LogRow[] = []
let nextId = 0
let aborter: AbortController | null = null
let retryTimer = 0
let started = false
const clock = (d: Date): string =>
[d.getHours(), d.getMinutes(), d.getSeconds()]
.map((n) => String(n).padStart(2, '0'))
.join(':')
function flush() {
if (paused.value) {
// 暂停时继续接收但不渲染。缓冲也设上限,暂停再久内存也不涨
if (pending.length > LOG_CAP) pending.splice(0, pending.length - LOG_CAP)
return
}
if (pending.length === 0) return
pushCapped(rows.value, pending, LOG_CAP)
pending = []
revision.value += 1
}
/** 只清界面上攒下的,内核那边的日志不受影响。 */
export function clearLogs() {
rows.value = []
pending = []
revision.value += 1
}
/**
* 断流不等于内核没了:配置重载会掐断流,而内核几秒内就回来、online 从未翻转。
* 断了 5 秒后自动重连,失败会再次走到这里形成重试节奏;真离线时交给下面等
* online 翻转的 watch。
*/
function scheduleRetry() {
window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(() => {
if (kernel.value.online) void pump()
}, 5000)
}
async function pump() {
// 切换级别或内核恢复在线都从这里重连。旧流先停,不然两条流一起往缓冲里倒
aborter?.abort()
window.clearTimeout(retryTimer)
const controller = new AbortController()
aborter = controller
failure.value = ''
ended.value = false
try {
for await (const line of streamLogs(level.value, controller.signal)) {
pending.push({ ...line, id: nextId++, time: clock(new Date()) })
}
if (!controller.signal.aborted) {
ended.value = true
scheduleRetry()
}
} catch (error) {
if (controller.signal.aborted) return
failure.value = error instanceof Error ? error.message : String(error)
scheduleRetry()
}
}
/** 外壳启动时调一次。之后跟着内核在线状态和级别自己重连,应用关掉前不停。 */
export function startLogStream(): void {
if (started) return
started = true
// 刷新节拍常驻,缓冲空转一次是零开销;应用关掉前不需要清
window.setInterval(flush, 200)
if (kernel.value.online) void pump()
// 挂载时内核不在线就不连;等它上线再开始。反方向(流到一半内核掉了)
// 由流自己报错或结束来呈现
watch(
() => kernel.value.online,
(online) => {
if (online) void pump()
},
)
watch(level, () => {
if (kernel.value.online) void pump()
})
}