Files
mihomo-patchbay/web/views/ConnectionsPage.vue
T
im0o b276c4a8fe 轻提示铺开到全部页面;内核状态悬停变重启;GEO 支持单文件下载
侧栏动作排撤掉重启图标,内核状态(圆点加版本号)本身变成按钮:悬停
换层显示「重启内核」,不在线时只当状态看。行高压成 1 修掉圆点和等宽
字体不居中的问题。

四个页面的一次性反馈(保存、删除、更新订阅、断开连接、测延迟结论)
全部改走 toast,commit() 改返回成败给「失败不关对话框」用;持续状态
(连接页轮询失败、请求页日志流失败)留在页内,会自动消失的提示装不下
需要一直可见的信息。toast 加 info 类别给中性结论用。

GEO 每行的更新改成单独下载那一个文件:内核只有全量更新的口子,单文件
只能服务端自己下(POST /api/geo/sync),代价是只有形状校验(MaxMind
末尾标记、geodata 首字节,shared/geo.ts 带测试)且内核重启后才换用新
文件;「更新全部」文字按钮保留内核的全量口子,有完整校验和热加载。
上传两行的按钮改「选择或拖放文件」,投放区拖入高亮,恢复策略挪到
恢复行下方。
2026-08-12 02:20:46 +08:00

468 lines
12 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">
<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 ref="scrollEl" class="scroller">
<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);
overflow-x: auto;
}
.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);
}
.thead {
height: 40px;
border-bottom: 1px solid var(--c-border);
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-y: auto;
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
max-height: calc(100vh - 20rem);
}
.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>