日志改常驻收集,加清空按钮;原生 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
+50 -125
View File
@@ -2,33 +2,21 @@
/**
* 请求页:内核的日志流,一条一条「这次请求匹配了哪条规则、走了哪个出站」。
*
* 编排只有三件事:
* 1. 流的生命周期——进页面开始读,离开页面中止;切换级别断开重连,
* 因为内核的级别是建立连接时定的,中途改不了
* 2. 渲染节奏——收到的行先进缓冲,定时一批批刷进列表;暂停就是不刷,
* 缓冲照收(也设上限),恢复时补上
* 3. 滚动——默认跟着最新条目,用户往上滚就停,滚回底部恢复
* 收集在 web/log-store.ts 的常驻单例里——日志跨页面留存,切走再回来不清空。
* 这个组件只管呈现:筛选、暂停、清空、跟随滚动。
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { kernel } from '../runtime'
import { LEVELS, LOG_CAP, pushCapped, streamLogs } from '../logs'
import type { LogLevel, LogLine } from '../logs'
import { LEVELS } from '../logs'
import { clearLogs, ended, failure, level, paused, revision, rows } from '../log-store'
import SelectBox from '../components/SelectBox.vue'
import IconPause from '~icons/ri/pause-line'
import IconPlay from '~icons/ri/play-line'
import IconClear from '~icons/ri/delete-bin-line'
interface Row extends LogLine {
id: number
/** 收到那一刻的时间。日志行本身不带时间戳 */
time: string
}
const level = ref<LogLevel>('info')
const query = ref('')
const paused = ref(false)
const rows = ref<Row[]>([])
const failure = ref('')
/** 内核那边把流断了(比如重启)。和主动中止区分开,这个要告诉用户 */
const ended = ref(false)
const levelOptions = LEVELS.map((l) => ({ value: l }))
/** 级别标签的着色。只染级别那一小块,整行染色在低密度的设计里很吵 */
const LEVEL_CLASS: Record<string, string> = {
@@ -38,16 +26,6 @@ const LEVEL_CLASS: Record<string, string> = {
error: 'lv-error',
}
let pending: Row[] = []
let nextId = 0
let aborter: AbortController | null = null
let timer = 0
const clock = (d: Date): string =>
[d.getHours(), d.getMinutes(), d.getSeconds()]
.map((n) => String(n).padStart(2, '0'))
.join(':')
const listEl = ref<HTMLElement | null>(null)
/** 是否跟随最新条目。用户手动上滚时关掉,滚回底部再开 */
let follow = true
@@ -58,84 +36,18 @@ function onScroll() {
follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 24
}
async function scrollToBottom() {
if (!follow) return
async function scrollToBottom(force = false) {
if (!follow && !force) return
await nextTick()
const el = listEl.value
if (el) el.scrollTop = el.scrollHeight
}
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 = []
void scrollToBottom()
}
// 收集在后台持续进行,新一批刷进来时跟着滚
watch(revision, () => void scrollToBottom())
/**
* 断流不等于内核没了:配置重载会掐断流,而内核几秒内就回来、online 从未翻转。
* 断了 5 秒后自动重连,失败会再次走到这里形成重试节奏;真离线时交给下面等
* online 翻转的 watch。
*/
let retryTimer = 0
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()
}
}
onMounted(() => {
timer = 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()
})
onBeforeUnmount(() => {
aborter?.abort()
window.clearInterval(timer)
window.clearTimeout(retryTimer)
})
// 切回这一页时接上离开时的进度,直接落到最新一条
onMounted(() => void scrollToBottom(true))
const visible = computed(() => {
const q = query.value.trim().toLowerCase()
@@ -147,17 +59,20 @@ const visible = computed(() => {
<template>
<div class="page">
<header class="head">
<select v-model="level" class="ctl" aria-label="日志级别">
<option v-for="l in LEVELS" :key="l" :value="l">{{ l }}</option>
</select>
<SelectBox v-model="level" class="lvl" :options="levelOptions" aria-label="日志级别" />
<input v-model="query" class="ctl search" type="search" placeholder="筛选" />
<button class="ctl pause" type="button" @click="paused = !paused">
<button class="ctl act" type="button" @click="paused = !paused">
<IconPlay v-if="paused" />
<IconPause v-else />
<span>{{ paused ? '继续' : '暂停' }}</span>
</button>
<button class="ctl act" type="button" @click="clearLogs">
<IconClear />
<span>清空日志</span>
</button>
</header>
<div v-if="failure" class="failure">
@@ -165,22 +80,21 @@ const visible = computed(() => {
</div>
<div ref="listEl" class="log" @scroll="onScroll">
<p v-if="!kernel.online" class="state">内核未连接,无法读取日志。</p>
<div v-for="row in visible" :key="row.id" class="row">
<span class="time mono">{{ row.time }}</span>
<span :class="['lv', LEVEL_CLASS[row.type] ?? 'lv-info']">{{ row.type }}</span>
<span class="msg mono">{{ row.payload }}</span>
</div>
<template v-else>
<div v-for="row in visible" :key="row.id" class="row">
<span class="time mono">{{ row.time }}</span>
<span :class="['lv', LEVEL_CLASS[row.type] ?? 'lv-info']">{{ row.type }}</span>
<span class="msg mono">{{ row.payload }}</span>
</div>
<p v-if="rows.length === 0 && !failure && !ended" class="state">
暂无日志。没有流量经过内核时不会产生请求记录。
</p>
<p v-else-if="visible.length === 0 && rows.length > 0" class="state">
没有包含「{{ query.trim() }}」的日志。
</p>
</template>
<p v-if="!kernel.online" class="state">
{{ rows.length > 0 ? '内核未连接,日志不再更新。' : '内核未连接,无法读取日志。' }}
</p>
<p v-else-if="rows.length === 0 && !failure && !ended" class="state">
暂无日志。没有流量经过内核时不会产生请求记录。
</p>
<p v-else-if="visible.length === 0 && rows.length > 0" class="state">
没有包含「{{ query.trim() }}」的日志。
</p>
</div>
<p v-if="ended && !failure" class="notice">日志流已中断,将自动重连。</p>
@@ -202,6 +116,13 @@ const visible = computed(() => {
margin-bottom: var(--s-4);
}
/* 页面底色上的控件要比底色浮一档 */
.lvl {
width: 130px;
flex: none;
--select-bg: var(--c-surface);
}
.ctl {
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
@@ -214,18 +135,22 @@ const visible = computed(() => {
max-width: 320px;
}
.pause {
.act {
display: flex;
align-items: center;
gap: var(--s-2);
margin-left: auto;
cursor: pointer;
}
.pause:hover {
.act:hover {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
/* 两个动作靠右,和左边的筛选拉开 */
.act:first-of-type {
margin-left: auto;
}
.failure {
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md);