文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作 目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状), 成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。 file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示, 编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。 订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取 (component/resource/fetcher.go:226-228),关掉开关就是不写这个键。 配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。 规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条 代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网 直连」,只在有缺口时出现。 所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
257 lines
6.3 KiB
Vue
257 lines
6.3 KiB
Vue
<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'
|
||
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
|
||
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 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">
|
||
暂无日志。没有流量经过内核时不会产生请求记录。
|
||
</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>
|