搭出前端骨架和入站列表

设计基调定为:层次靠底色和一像素描边而不是投影,一屏最多两层容器,间距给足。
tokens.css 顶部写了这三条规则,后面的组件都是它们的推论。

入站列表把两套写法合并成一张表:老式顶层端口字段(mixed-port 这些)和 listeners 数组。
只画 listeners 的话,一台只用 mixed-port 的机器打开会是空板,而那个端口正在跑——
这等于对用户撒谎。合并逻辑在 shared/inbounds.ts,带 10 个测试。

后端顺带能提供打包后的静态文件了,一个进程就能跑起来。
This commit is contained in:
im0o
2026-08-10 04:10:17 +08:00
parent ada547adf7
commit 7a764f5fe1
14 changed files with 1054 additions and 5 deletions
+197
View File
@@ -0,0 +1,197 @@
<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>