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,两个页面都要用它了。
314 lines
7.6 KiB
Vue
314 lines
7.6 KiB
Vue
<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>
|