Files
im0o 158dc9fca7 消化一批 P3:GEOIP 附加参数、坏 JSON 跳行、兜底丢弃;请求连接两页保活
ini 里 []GEOIP,CN,no-resolve 的附加参数原来被并进类别名,生成字段错位
的规则;现在拆出来放在目标之后。表里已有 MATCH 时配方的 []FINAL 不再
追加成死行,改为丢弃并在成功提示里注明——已有兜底是用户自己安排的
分流终点。

遥测流里偶发的坏 JSON 行改为跳过,原来一行坏数据会杀掉整个流进 5 秒
重试循环。请求页:跟随底部的阈值从 24px 降到 8px(原值比行高还大,
向上滚不足一行就被新日志拽回);silent 级别下的空状态不再说「没有流量
经过」。logs.test 的 localStorage 桩用完删掉,bun test 单进程跑,全局
残留会影响之后的测试文件。api.ts 的 format 注释照 provider.go:37 纠正。

请求页和连接页包进 KeepAlive:切走不销毁,筛选词、排序、滚动位置留住。
KeepAlive 常驻、子组件自己 v-if 进出;连接页的轮询改挂 activated——
挂载时它也触发,继续用 onMounted 会叠两个定时器,切走时停掉轮询。
2026-08-12 03:07:56 +08:00

266 lines
6.8 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 请求页:内核的日志流,一条一条「这次请求匹配了哪条规则、走了哪个出站」。
*
* 收集在 web/log-store.ts 的常驻单例里——日志跨页面留存,切走再回来不清空。
* 这个组件只管呈现:筛选、暂停、清空、跟随滚动。
*/
import { computed, nextTick, onActivated, onMounted, ref, watch } from 'vue'
import { kernel } from '../runtime'
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'
import IconClose from '~icons/ri/close-line'
const query = ref('')
const levelOptions = LEVELS.map((l) => ({ value: l }))
/** 级别标签的着色。只染级别那一小块,整行染色在低密度的设计里很吵 */
const LEVEL_CLASS: Record<string, string> = {
debug: 'lv-debug',
info: 'lv-info',
warning: 'lv-warn',
error: 'lv-error',
}
const listEl = ref<HTMLElement | null>(null)
/** 是否跟随最新条目。用户手动上滚时关掉,滚回底部再开 */
let follow = true
function onScroll() {
const el = listEl.value
if (!el) return
// 阈值必须小于一行的高度(约 25px),否则向上滚不足一行仍算「在底部」,
// 新日志会把人拽回去。8px 只容浏览器的取整误差
follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 8
}
async function scrollToBottom(force = false) {
if (!follow && !force) return
await nextTick()
const el = listEl.value
if (el) el.scrollTop = el.scrollHeight
}
// 收集在后台持续进行,新一批刷进来时跟着滚
watch(revision, () => void scrollToBottom())
// 切回这一页时接上离开时的进度,直接落到最新一条
onMounted(() => void scrollToBottom(true))
// 组件包在 KeepAlive 里,切走期间元素脱离文档、scrollHeight 不可靠,
// 跟随模式的自动滚动可能把位置写歪;切回来重新贴底(手动上滚过的不动)
onActivated(() => void scrollToBottom())
const visible = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter((row) => row.payload.toLowerCase().includes(q))
})
</script>
<template>
<div class="page">
<header class="head">
<SelectBox v-model="level" class="lvl" :options="levelOptions" aria-label="日志级别" />
<input v-model="query" class="ctl search" type="search" placeholder="筛选" />
<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">
<strong class="failure-text">{{ failure }}</strong>
<button type="button" class="failure-x" title="关闭提示" @click="failure = ''">
<IconClose />
</button>
</div>
<div ref="listEl" class="log" @scroll="onScroll">
<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="!kernel.online" class="state">
{{ rows.length > 0 ? '内核未连接,日志不再更新。' : '内核未连接,无法读取日志。' }}
</p>
<p v-else-if="rows.length === 0 && !failure && !ended" class="state">
{{
level === 'silent'
? '级别为 silent 时内核不输出日志。'
: '暂无日志。没有流量经过内核时不会产生请求记录。'
}}
</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>
</div>
</template>
<style scoped>
/* 日志区自己滚动、撑满视口剩余高度:自动跟随需要它有自己的滚动容器 */
.page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--s-6) - var(--s-7));
}
.head {
display: flex;
align-items: center;
gap: var(--s-3);
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);
border-radius: var(--r-sm);
background: var(--c-surface);
}
.search {
flex: 1;
max-width: 320px;
}
.act {
display: flex;
align-items: center;
gap: var(--s-2);
cursor: pointer;
}
.act:hover {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
/* 两个动作靠右,和左边的筛选拉开 */
.act:first-of-type {
margin-left: auto;
}
.failure {
display: flex;
align-items: flex-start;
gap: var(--s-3);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md);
background: var(--c-danger-soft);
padding: var(--s-3) var(--s-4);
margin-bottom: var(--s-4);
font-size: var(--t-sm);
}
.failure-text {
flex: 1;
min-width: 0;
}
.failure-x {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-danger);
cursor: pointer;
}
.failure-x:hover {
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
}
.log {
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
padding: var(--s-3) var(--s-4);
}
.row {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
gap: var(--s-3);
align-items: baseline;
padding: 2px 0;
font-size: var(--t-sm);
line-height: 1.6;
}
.time {
color: var(--c-text-faint);
}
.lv {
padding: 0 var(--s-2);
border-radius: var(--r-sm);
font-size: var(--t-xs);
text-align: center;
}
.lv-debug {
background: var(--c-surface-sunken);
color: var(--c-text-faint);
}
.lv-info {
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
.lv-warn {
background: var(--c-warn-soft);
color: var(--c-warn);
}
.lv-error {
background: var(--c-danger-soft);
color: var(--c-danger);
}
.msg {
word-break: break-all;
white-space: pre-wrap;
}
.state {
color: var(--c-text-faint);
font-size: var(--t-sm);
padding: var(--s-3) 0;
}
.notice {
color: var(--c-text-soft);
font-size: var(--t-sm);
margin-top: var(--s-3);
}
</style>