Files
mihomo-patchbay/web/views/RequestsPage.vue
T
im0o f927f9fff7 日志改常驻收集,加清空按钮;原生 select 统一换成 SelectBox
日志收集从请求页组件搬进模块级单例(web/log-store.ts,模式同 telemetry),
切走页面不再清空;离线时保留已收的日志,只提示不再更新。清空只清界面。

五处原生 <select> 在三个文件里各自重复同一套自绘箭头样式,而且原生控件
没有「展开中」状态可读,箭头做不了开合动画——提成 SelectBox 组件统一替换:
箭头随开合旋转 180 度,弹层带出入场动画,下方视口不够时向上弹。背景色
留 --select-bg 口子,对话框里沉一档、页面上浮一档,一个值满足不了两边。
2026-08-11 23:47:28 +08:00

228 lines
5.7 KiB
Vue
Raw 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, 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'
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
follow = el.scrollTop + el.clientHeight >= el.scrollHeight - 24
}
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))
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>{{ failure }}</strong>
</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">
暂无日志。没有流量经过内核时不会产生请求记录。
</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 {
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);
}
.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>