Files
im0o 3d7304dd22
release / release (push) Successful in 29s
入站详情可以测常用 AI 服务的可访问性
判定按状态码白名单而不是只认 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,两个页面都要用它了。
2026-08-31 03:59:08 +08:00

314 lines
7.6 KiB
Vue
Raw Permalink 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">
/**
* 可访问性测试:这一路入站到几个常用 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>