入站详情可以测常用 AI 服务的可访问性
release / release (push) Successful in 29s

判定按状态码白名单而不是只认 200:探测不带鉴权,OpenAI 回 401、Anthropic 回 405
才是正常应答,说明 TLS 握手、路由、目标服务全都正常。清单和白名单照 sub2api 的
代理质量检查抄来。

失败文案不照抄 Bun:本机端口没人监听时它给的是「Unable to connect. Is the
computer able to access the url?」,把矛头指向目标网址,而真正的原因在本机。

对话框留在 InboundDetail 而不是提到 RouterPage——它不写配置文件也不改内核状态,
不需要走那条 commit 链路。firstPort 挪进 shared/inbounds.ts,两个页面都要用它了。
This commit is contained in:
2026-08-31 03:59:08 +08:00
parent 7d2a3afff0
commit 3d7304dd22
12 changed files with 716 additions and 34 deletions
+313
View File
@@ -0,0 +1,313 @@
<script setup lang="ts">
/**
* 可访问性测试:这一路入站到几个常用 AI 服务通不通。
*
* 每个目标一个请求、并发发起,所以每一行各自落定,不用等最慢的那个才整表出现。
* 判定不在这里——「什么状态码算通」在 shared/reachability.ts,这里只管显示。
*
* 这是纯读的运行时动作:不写配置文件、不改内核状态,所以打开就直接开测,不设确认。
*/
import { ref, watch } from 'vue'
import { REACH_TARGETS } from '@shared/reachability.ts'
import type { ReachResult, ReachVerdict } from '@shared/reachability.ts'
import { api } from '../api'
import IconClose from '~icons/ri/close-line'
import IconRefresh from '~icons/ri/refresh-line'
const props = defineProps<{
open: boolean
/** 经由哪一路入站测。名字只用来显示,端口才是真正走的那个口 */
name: string
port: number
}>()
const emit = defineEmits<{ close: [] }>()
type Row = { state: 'testing' } | { state: 'done'; result: ReachResult }
const rows = ref<Record<string, Row>>({})
const running = ref(false)
/**
* 第几轮测试。重测和关掉重开都会让它加一,上一轮迟到的响应据此丢弃——
* 不这么做的话,换了一路入站再打开,上一路的结果会落到新的表里。
*/
let round = 0
async function runAll() {
if (props.port <= 0) return
round += 1
const mine = round
const port = props.port
running.value = true
rows.value = Object.fromEntries(REACH_TARGETS.map((t) => [t.id, { state: 'testing' } as Row]))
await Promise.all(
REACH_TARGETS.map(async (target) => {
const result = await api.probeReach(port, target.id).catch(
(error: unknown): ReachResult => ({
target: target.id,
verdict: 'unreachable',
status: null,
ms: 0,
message: error instanceof Error ? error.message : String(error),
}),
)
if (round !== mine) return
rows.value = { ...rows.value, [target.id]: { state: 'done', result } }
}),
)
if (round === mine) running.value = false
}
const dialog = ref<HTMLDialogElement | null>(null)
watch(
() => props.open,
(open) => {
if (open) {
dialog.value?.showModal()
void runAll()
} else {
dialog.value?.close()
}
},
)
/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
const VERDICT_LABEL: Record<ReachVerdict, string> = {
reachable: '通过',
throttled: '限流',
challenge: '人机验证',
unreachable: '失败',
}
/** 某个目标测完了的结果,还在测就是 null。模板里的分支只认这一个函数。 */
function resultOf(id: string): ReachResult | null {
const row = rows.value[id]
return row?.state === 'done' ? row.result : null
}
/** 目标地址显示成主机名加路径,完整 URL 太长会把这一行撑开。 */
function shortUrl(url: string): string {
const parsed = new URL(url)
return parsed.host + parsed.pathname
}
</script>
<template>
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<div class="body">
<header class="head">
<h2>
可访问性测试
<span class="via mono">{{ name }} : {{ port }}</span>
</h2>
<button type="button" class="icon" title="关闭" @click="emit('close')">
<IconClose />
</button>
</header>
<ul class="rows">
<li v-for="target in REACH_TARGETS" :key="target.id" class="row">
<div class="who">
<span class="label">{{ target.label }}</span>
<span class="url mono">{{ shortUrl(target.url) }}</span>
</div>
<template v-if="resultOf(target.id)">
<span :class="['verdict', resultOf(target.id)!.verdict]">
{{ VERDICT_LABEL[resultOf(target.id)!.verdict] }}
</span>
<span class="ms mono">{{ resultOf(target.id)!.ms }} ms</span>
<!-- 排在毫秒之后:它跨整行,写在前面会把毫秒挤到下一行去 -->
<span class="msg">{{ resultOf(target.id)!.message }}</span>
</template>
<span v-else class="waiting">测试中</span>
</li>
</ul>
<footer class="acts">
<button type="button" class="btn" :disabled="running" @click="void runAll()">
<IconRefresh /> 重新测试
</button>
</footer>
</div>
</dialog>
</template>
<style scoped>
.dlg {
width: min(560px, calc(100vw - 2 * var(--s-5)));
/* 内边距放在 .body 上:dialog 自己的内边距区域点了会被当成遮罩点击 */
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
background: var(--c-surface);
color: var(--c-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.45);
}
.body {
padding: var(--s-5);
}
.head {
display: flex;
align-items: center;
gap: var(--s-3);
margin-bottom: var(--s-4);
}
.head h2 {
display: flex;
align-items: baseline;
gap: var(--s-3);
font-size: var(--t-lg);
font-weight: 600;
}
.via {
color: var(--c-text-faint);
font-size: var(--t-sm);
font-weight: 400;
}
.icon {
display: flex;
margin-left: auto;
padding: var(--s-1);
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
cursor: pointer;
}
.icon:hover {
color: var(--c-text);
}
.rows {
display: flex;
flex-direction: column;
list-style: none;
}
.row {
display: grid;
/* 服务名那列吃掉剩余宽度,结论和毫秒各自贴右:四行对齐才看得出哪个不一样 */
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--s-1) var(--s-3);
padding: var(--s-3) 0;
border-bottom: 1px solid var(--c-border);
}
.row:last-child {
border-bottom: none;
}
.who {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.label {
font-weight: 500;
}
.url {
overflow: hidden;
color: var(--c-text-faint);
font-size: var(--t-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
/* 每一行是各自的 grid,列宽不共享,所以结论和毫秒都定死一个下限,
否则「通过」和「人机验证」不一样长,四行的徽章就对不齐 */
.verdict {
min-width: 4.5em;
padding: 2px var(--s-2);
border-radius: var(--r-sm);
font-size: var(--t-xs);
text-align: center;
white-space: nowrap;
}
.verdict.reachable {
background: var(--c-ok-soft);
color: var(--c-ok);
}
.verdict.throttled,
.verdict.challenge {
background: var(--c-warn-soft);
color: var(--c-warn);
}
.verdict.unreachable {
background: var(--c-danger-soft);
color: var(--c-danger);
}
.ms {
min-width: 4.5em;
color: var(--c-text-soft);
font-size: var(--t-sm);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
/* 结论那一句独占第二行,长了也不会把状态和毫秒挤出去 */
.msg {
grid-column: 1 / -1;
color: var(--c-text-soft);
font-size: var(--t-xs);
}
.waiting {
grid-column: 2 / -1;
color: var(--c-text-faint);
font-size: var(--t-sm);
text-align: right;
}
.acts {
display: flex;
justify-content: flex-end;
margin-top: var(--s-5);
}
.btn {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
color: inherit;
cursor: pointer;
}
.btn:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.btn:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
</style>