搭出前端骨架和入站列表
设计基调定为:层次靠底色和一像素描边而不是投影,一屏最多两层容器,间距给足。 tokens.css 顶部写了这三条规则,后面的组件都是它们的推论。 入站列表把两套写法合并成一张表:老式顶层端口字段(mixed-port 这些)和 listeners 数组。 只画 listeners 的话,一台只用 mixed-port 的机器打开会是空板,而那个端口正在跑—— 这等于对用户撒谎。合并逻辑在 shared/inbounds.ts,带 10 个测试。 后端顺带能提供打包后的静态文件了,一个进程就能跑起来。
This commit is contained in:
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 后端接口的客户端。
|
||||
*
|
||||
* 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1
|
||||
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
|
||||
*/
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
const TOKEN_KEY = 'patchbay.token'
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setToken(value: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, value.trim())
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** 接口报错时抛这个,界面据此决定是提示重填令牌还是显示错误内容。 */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly payload: Record<string, unknown>,
|
||||
) {
|
||||
super(typeof payload.message === 'string' ? payload.message : `请求失败(${String(status)})`)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
|
||||
get unauthorized(): boolean {
|
||||
return this.status === 401
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await res.text()
|
||||
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||
if (!res.ok) throw new ApiError(res.status, payload)
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
data: ConfigObject
|
||||
yaml: string
|
||||
revision: string
|
||||
diagnostics: ConfigDiagnostic[]
|
||||
}
|
||||
|
||||
export interface ApplySuccess {
|
||||
ok: true
|
||||
snapshot: ConfigSnapshot
|
||||
reloaded: boolean
|
||||
reloadMessage: string
|
||||
}
|
||||
|
||||
export interface ApplyFailure {
|
||||
ok: false
|
||||
stage: 'stale' | 'self-check' | 'kernel-check' | 'write' | 'reload'
|
||||
message: string
|
||||
hint: string
|
||||
restored: boolean
|
||||
}
|
||||
|
||||
export const api = {
|
||||
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
|
||||
|
||||
applyConfig: (data: ConfigObject, baseRevision: string) =>
|
||||
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
|
||||
|
||||
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
||||
|
||||
restoreBackup: (name: string) =>
|
||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||||
|
||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||
|
||||
/** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */
|
||||
providers: () =>
|
||||
request<{ providers: Record<string, { vehicleType: string; proxies: { name: string }[] }> }>(
|
||||
'GET',
|
||||
'/api/kernel/providers/proxies',
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user