Files
mihomo-patchbay/web/views/ConnectionsPage.vue
T
im0o 275f536429 追加 Hosts、路径撞名拦截、连接页表头随行滚动、GEO 借内核通路
设置页内核参数加 hosts 行,铅笔进弹窗编辑(顶层 hosts: 键,
config.go:454,值单个或数组 parseHosts config.go:1131)。弹窗里随便改、
保存才一次性提交——写配置有几秒的校验重载,逐行失焦提交会把等待塞进
每次输入。没动过的行原样写回,数组不被重整形。

订阅和规则组新增时查缓存路径撞名:路径由名字净化而来,「订阅 A」和
「订阅/A」是两个名字、同一个文件,名字查重挡不住,会互相覆盖缓存。
纯函数在 shared 带测试,页面提交前拦。

连接页表头挪进滚动容器加 sticky:.scroller 声明 overflow-y 后按规范
连带拿到横向滚动上下文,表头留在外面时窄屏横向滚动两者脱节。表头
40px 的窗口偏移由 overscan 8 行吸收,不配 scrollMargin。

GEO 单文件下载先借内核的混合端口出去再退直连——内核自己更新 GEO 走
的是它的分流,Patchbay 直连 GitHub 不一定通。ECharts 拆独立 chunk,
业务代码改动不再让这 500KB 跟着换 hash。
2026-08-12 02:47:17 +08:00

478 lines
13 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">
/**
* 连接页:内核当前有哪些连接,从哪一路入站进来,能断开它们。
*
* /connections 没有流式版本,只能每秒全量轮询一次。表格数据来自内核快照,
* 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 shared/connections.ts
* 里回答,这里只管取数、状态和断开动作。
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import {
chainLabel,
filterConnections,
formatDuration,
groupByInbound,
hostLabel,
ruleLabel,
sortConnections,
} from '@shared/connections.ts'
import type { ConnectionSnapshot, SortKey } from '@shared/connections.ts'
import { formatBytes } from '@shared/format.ts'
import { ApiError, api } from '../api'
import { toast, toastError } from '../toast'
import { kernel } from '../runtime'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import IconClose from '~icons/ri/close-line'
const snapshot = ref<ConnectionSnapshot | null>(null)
/**
* 轮询取数的失败,成功时自己清掉。这是持续状态所以留在页内;
* 断开动作的失败是一次性事件,走右上角轻提示。
*/
const pollFailure = ref('')
/** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */
const now = ref(Date.now())
const query = ref('')
const sort = ref<SortKey>('download')
/** 正在断开中的连接 id,防止连点 */
const closing = ref<string[]>([])
const closingAll = ref(false)
const askAll = ref(false)
/** 有请求在飞就不再发,内核卡住时每秒叠一个请求只会让局面更糟 */
let fetching = false
let timer: number | undefined
/**
* 轮询的取数失败和断开动作的失败分开放。共用一条通道的话,断开失败的提示
* 会被下一秒轮询成功时的清空抹掉,用户只看到那条连接还在、不知道为什么。
*/
async function refresh() {
// 内核不在线时不打这个接口:得不到数据,界面直接说原因
if (!kernel.value.online || fetching) return
fetching = true
try {
snapshot.value = await api.connections()
now.value = Date.now()
pollFailure.value = ''
} catch (error) {
pollFailure.value = error instanceof ApiError ? error.message : String(error)
} finally {
fetching = false
}
}
onMounted(() => {
void refresh()
timer = window.setInterval(() => void refresh(), 1000)
})
onUnmounted(() => {
window.clearInterval(timer)
})
const all = computed(() => snapshot.value?.connections ?? [])
const rows = computed(() =>
sortConnections(filterConnections(all.value, query.value), sort.value, now.value),
)
const uses = computed(() => groupByInbound(all.value))
/* 行高写死,estimateSize 必须和它一致,否则滚动位置算不准 */
const ROW_HEIGHT = 48
const scrollEl = ref<HTMLElement | null>(null)
const virtualizer = useVirtualizer(
computed(() => ({
count: rows.value.length,
getScrollElement: () => scrollEl.value,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
})),
)
const virtualRows = computed(() => virtualizer.value.getVirtualItems())
const totalSize = computed(() => virtualizer.value.getTotalSize())
async function closeOne(id: string) {
if (closing.value.includes(id) || closingAll.value) return
closing.value = [...closing.value, id]
try {
await api.closeConnection(id)
await refresh()
} catch (error) {
toastError(error)
} finally {
closing.value = closing.value.filter((item) => item !== id)
}
}
async function closeAllConfirmed() {
askAll.value = false
if (closingAll.value) return
closingAll.value = true
try {
await api.closeAllConnections()
await refresh()
toast('已断开所有连接')
} catch (error) {
toastError(error)
} finally {
closingAll.value = false
}
}
</script>
<template>
<div class="page">
<header class="head">
<div class="sums">
<div class="sum">
<span class="sum-n">{{ all.length }}</span>
<span class="sum-k">连接</span>
</div>
<div class="sum">
<span class="sum-n">{{ formatBytes(snapshot?.uploadTotal ?? 0) }}</span>
<span class="sum-k">上传总量</span>
</div>
<div class="sum">
<span class="sum-n">{{ formatBytes(snapshot?.downloadTotal ?? 0) }}</span>
<span class="sum-k">下载总量</span>
</div>
</div>
<div class="tools">
<input v-model="query" type="search" class="search" placeholder="按主机筛选" />
<button
type="button"
class="close-all"
:disabled="all.length === 0 || closingAll"
@click="askAll = true"
>
全部断开
</button>
</div>
</header>
<div v-if="pollFailure" class="failure">
<strong class="failure-text">{{ pollFailure }}</strong>
<button type="button" class="failure-x" title="关闭提示" @click="pollFailure = ''">
<IconClose />
</button>
</div>
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
<p v-else-if="!snapshot" class="note">读取中</p>
<p v-else-if="all.length === 0" class="note">当前没有连接。</p>
<template v-else>
<!-- 这个项目特有的视角:每一路入站(每个容器)现在有多少条连接 -->
<div class="uses">
<span v-for="use in uses" :key="use.label" class="use">
{{ use.label }}
<strong>{{ use.count }}</strong> 条
</span>
</div>
<p v-if="rows.length === 0" class="note">没有主机匹配「{{ query.trim() }}」。</p>
<div v-else class="table">
<!-- 表头在滚动容器里而不是外面:.scroller 声明 overflow-y 后按规范会连带
拿到自己的横向滚动上下文,表头留在外面的话窄屏横向滚动时两者会脱节。
竖直方向靠 sticky 钉住。 -->
<div ref="scrollEl" class="scroller">
<div class="rowgrid thead">
<span>主机</span>
<span>规则</span>
<span>出站链</span>
<span class="num">上传</span>
<button
type="button"
:class="['th num', sort === 'download' && 'is-on']"
@click="sort = 'download'"
>
下载{{ sort === 'download' ? ' ↓' : '' }}
</button>
<button
type="button"
:class="['th num', sort === 'duration' && 'is-on']"
@click="sort = 'duration'"
>
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
</button>
<span>入站</span>
<span></span>
</div>
<div class="spacer" :style="{ height: `${totalSize}px` }">
<div
v-for="vRow in virtualRows"
:key="rows[vRow.index]?.id ?? vRow.index"
class="rowgrid row"
:style="{ transform: `translateY(${vRow.start}px)` }"
>
<template v-if="rows[vRow.index]">
<span class="cell mono" :title="hostLabel(rows[vRow.index]!.metadata)">
{{ hostLabel(rows[vRow.index]!.metadata) }}
</span>
<span class="cell mono">{{ ruleLabel(rows[vRow.index]!.rule, rows[vRow.index]!.rulePayload) }}</span>
<span class="cell mono" :title="chainLabel(rows[vRow.index]!.chains)">
{{ chainLabel(rows[vRow.index]!.chains) }}
</span>
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.upload) }}</span>
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.download) }}</span>
<span class="cell num mono">{{ formatDuration(rows[vRow.index]!.start, now) }}</span>
<span class="cell mono">{{ rows[vRow.index]!.metadata.inboundName }}</span>
<button
type="button"
class="drop"
title="断开这条连接"
:disabled="closing.includes(rows[vRow.index]!.id) || closingAll"
@click="closeOne(rows[vRow.index]!.id)"
>
<IconClose />
</button>
</template>
</div>
</div>
</div>
</div>
</template>
<ConfirmDialog
:open="askAll"
:message="`断开全部 ${String(all.length)} 条连接?正在进行的传输会中断。`"
action="全部断开"
@close="askAll = false"
@confirm="closeAllConfirmed"
/>
</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
margin-bottom: var(--s-5);
}
.sums {
display: flex;
gap: var(--s-6);
}
.sum {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.sum-n {
font-size: var(--t-xl);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.sum-k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.tools {
display: flex;
align-items: center;
gap: var(--s-2);
}
.search {
width: 200px;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
}
.close-all {
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
white-space: nowrap;
}
.close-all:hover:not(:disabled) {
border-color: var(--c-danger);
color: var(--c-danger);
}
.close-all:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.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);
}
.note {
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.uses {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
margin-bottom: var(--s-4);
}
.use {
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border);
border-radius: 999px;
background: var(--c-surface);
font-size: var(--t-sm);
color: var(--c-text-soft);
}
.use strong {
color: var(--c-text);
font-variant-numeric: tabular-nums;
}
.table {
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
/* 只负责圆角裁切;横竖两个方向的滚动都归 .scroller 一个容器管 */
overflow: hidden;
}
.rowgrid {
display: grid;
grid-template-columns:
minmax(160px, 1.5fr) minmax(120px, 1fr) minmax(160px, 1.5fr)
5rem 5rem 6.5rem minmax(90px, 0.8fr) 2rem;
align-items: center;
gap: var(--s-3);
min-width: 880px;
padding: 0 var(--s-4);
}
/* 表头随内容横向滚动(同一个容器),竖直方向 sticky 钉在顶上。
行是绝对定位的,要 z-index 才能压住;背景不透明才不会透出滚过的行 */
.thead {
position: sticky;
top: 0;
z-index: 1;
height: 40px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-faint);
font-size: var(--t-xs);
}
/* 可排序的列表头。用按钮但视觉上仍是表头文字,排序中的那列才显色 */
.th {
padding: 0;
border: none;
background: none;
color: inherit;
font-size: inherit;
cursor: pointer;
text-align: right;
}
.th:hover {
color: var(--c-text);
}
.th.is-on {
color: var(--c-text);
font-weight: 600;
}
.scroller {
overflow: auto;
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
max-height: calc(100vh - 20rem);
/* 表头占 40px,虚拟滚动的可见窗口按纯行高算会偏这 40px——overscan 8 行
(384px)足以盖住,不必给虚拟器配 scrollMargin */
}
.spacer {
position: relative;
}
.row {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 48px;
border-bottom: 1px solid var(--c-border);
font-size: var(--t-sm);
}
.cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.drop {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.drop:hover:not(:disabled) {
background: var(--c-danger-soft);
color: var(--c-danger);
}
.drop:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>