设计基调定为:层次靠底色和一像素描边而不是投影,一屏最多两层容器,间距给足。 tokens.css 顶部写了这三条规则,后面的组件都是它们的推论。 入站列表把两套写法合并成一张表:老式顶层端口字段(mixed-port 这些)和 listeners 数组。 只画 listeners 的话,一台只用 mixed-port 的机器打开会是空板,而那个端口正在跑—— 这等于对用户撒谎。合并逻辑在 shared/inbounds.ts,带 10 个测试。 后端顺带能提供打包后的静态文件了,一个进程就能跑起来。
198 lines
4.1 KiB
Vue
198 lines
4.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { ApiError, api, getToken, setToken } from './api'
|
|
import type { ConfigSnapshot } from './api'
|
|
import InboundList from './views/InboundList.vue'
|
|
import TokenGate from './views/TokenGate.vue'
|
|
|
|
const snapshot = ref<ConfigSnapshot | null>(null)
|
|
const kernel = ref<{ online: boolean; version?: string }>({ online: false })
|
|
const needsToken = ref(!getToken())
|
|
const loadError = ref('')
|
|
const loading = ref(false)
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
loadError.value = ''
|
|
try {
|
|
const [config, status] = await Promise.all([api.readConfig(), api.kernel()])
|
|
snapshot.value = config
|
|
kernel.value = status
|
|
needsToken.value = false
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.unauthorized) {
|
|
needsToken.value = true
|
|
} else {
|
|
loadError.value = error instanceof Error ? error.message : String(error)
|
|
}
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function submitToken(value: string) {
|
|
setToken(value)
|
|
void load()
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (!needsToken.value) void load()
|
|
})
|
|
|
|
const problems = computed(
|
|
() => snapshot.value?.diagnostics.filter((d) => d.severity === 'error') ?? [],
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<TokenGate v-if="needsToken" @submit="submitToken" />
|
|
|
|
<div v-else class="shell">
|
|
<header class="bar">
|
|
<div class="brand">
|
|
<span class="mark" aria-hidden="true"></span>
|
|
<span class="name">Patchbay</span>
|
|
</div>
|
|
<div class="status">
|
|
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
|
|
<span>{{ kernel.online ? `内核 ${kernel.version ?? ''}` : '内核未连接' }}</span>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="page">
|
|
<p v-if="loading" class="hint">读取配置中…</p>
|
|
|
|
<div v-else-if="loadError" class="panel panel-bad">
|
|
<h2>读不到配置</h2>
|
|
<p>{{ loadError }}</p>
|
|
</div>
|
|
|
|
<template v-else-if="snapshot">
|
|
<div v-if="problems.length" class="panel panel-bad">
|
|
<h2>配置里有 {{ problems.length }} 处问题</h2>
|
|
<ul>
|
|
<li v-for="(p, i) in problems" :key="i">
|
|
<code>{{ p.path.join('.') }}</code> {{ p.message }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<InboundList :data="snapshot.data" :kernel-online="kernel.online" />
|
|
</template>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100%;
|
|
}
|
|
|
|
.bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--s-4);
|
|
padding: var(--s-3) var(--s-6);
|
|
border-bottom: 1px solid var(--c-border);
|
|
background: var(--c-surface);
|
|
}
|
|
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s-3);
|
|
}
|
|
|
|
/* 插线板的意象:两个接口加一条线 */
|
|
.mark {
|
|
width: 22px;
|
|
height: 10px;
|
|
border: 1.5px solid var(--c-accent);
|
|
border-radius: 999px;
|
|
position: relative;
|
|
}
|
|
.mark::before,
|
|
.mark::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 4px;
|
|
height: 4px;
|
|
margin-top: -2px;
|
|
border-radius: 50%;
|
|
background: var(--c-accent);
|
|
}
|
|
.mark::before {
|
|
left: 2px;
|
|
}
|
|
.mark::after {
|
|
right: 2px;
|
|
}
|
|
|
|
.name {
|
|
font-size: var(--t-lg);
|
|
font-weight: 600;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
.status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s-2);
|
|
color: var(--c-text-soft);
|
|
font-size: var(--t-sm);
|
|
}
|
|
|
|
.dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
}
|
|
.dot-ok {
|
|
background: var(--c-ok);
|
|
}
|
|
.dot-off {
|
|
background: var(--c-text-faint);
|
|
}
|
|
|
|
.page {
|
|
flex: 1;
|
|
width: 100%;
|
|
max-width: 880px;
|
|
margin: 0 auto;
|
|
padding: var(--s-7) var(--s-6);
|
|
}
|
|
|
|
.hint {
|
|
color: var(--c-text-soft);
|
|
}
|
|
|
|
.panel {
|
|
border: 1px solid var(--c-border);
|
|
border-radius: var(--r-md);
|
|
padding: var(--s-4) var(--s-5);
|
|
margin-bottom: var(--s-5);
|
|
}
|
|
|
|
.panel-bad {
|
|
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
|
background: var(--c-danger-soft);
|
|
}
|
|
|
|
.panel h2 {
|
|
font-size: var(--t-base);
|
|
font-weight: 600;
|
|
margin-bottom: var(--s-2);
|
|
}
|
|
|
|
.panel ul {
|
|
margin: 0;
|
|
padding-left: 1.2em;
|
|
color: var(--c-text-soft);
|
|
font-size: var(--t-sm);
|
|
}
|
|
</style>
|