搭出前端骨架和入站列表
设计基调定为:层次靠底色和一像素描边而不是投影,一屏最多两层容器,间距给足。 tokens.css 顶部写了这三条规则,后面的组件都是它们的推论。 入站列表把两套写法合并成一张表:老式顶层端口字段(mixed-port 这些)和 listeners 数组。 只画 listeners 的话,一台只用 mixed-port 的机器打开会是空板,而那个端口正在跑—— 这等于对用户撒谎。合并逻辑在 shared/inbounds.ts,带 10 个测试。 后端顺带能提供打包后的静态文件了,一个进程就能跑起来。
This commit is contained in:
+197
@@ -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>
|
||||
Reference in New Issue
Block a user