判定按状态码白名单而不是只认 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:
@@ -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>
|
||||
Reference in New Issue
Block a user