搭出前端骨架和入站列表

设计基调定为:层次靠底色和一像素描边而不是投影,一屏最多两层容器,间距给足。
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
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'bun:test'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { openDocument } from '../vendor/config-editor/index.ts'
import { collectInbounds, collectOutboundNames, outboundToProxyField } from './inbounds.ts'
const fixture = openDocument(
readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'),
).data
describe('合并两套写法的入站', () => {
it('只有旧写法时也能列出来,不会是空表', () => {
const list = collectInbounds(fixture)
expect(list).toHaveLength(1)
expect(list[0]!.port).toBe('7777')
expect(list[0]!.type).toBe('mixed')
expect(list[0]!.isDefault).toBe(true)
expect(list[0]!.legacyKey).toBe('mixed-port')
})
it('端口写 0 表示没启用,不该出现在表里', () => {
const list = collectInbounds({ 'mixed-port': 7777, 'socks-port': 0, port: 0 })
expect(list.map((i) => i.legacyKey)).toEqual(['mixed-port'])
})
it('新旧两套同时存在时都要列出来', () => {
const list = collectInbounds({
'mixed-port': 7777,
listeners: [{ name: '容器A', type: 'mixed', port: 7778, proxy: 'AUTO' }],
})
expect(list.map((i) => i.name)).toEqual(['HTTP 和 SOCKS5 共用', '容器A'])
expect(list[0]!.isDefault).toBe(true)
expect(list[1]!.isDefault).toBe(false)
expect(list[1]!.listenerIndex).toBe(0)
})
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
})
})
describe('出站去向的四种形态', () => {
const of = (proxy?: string) => {
// 用扩展而不是直接写 proxy: undefined —— 配置里「没有这个键」和「这个键是空」
// 是两回事,测试要还原真实的文件形状。
const listener = { name: 'x', type: 'mixed', port: 1, ...(proxy === undefined ? {} : { proxy }) }
return collectInbounds({ listeners: [listener] })[0]!.outbound
}
it('没写 proxy 就是跟着规则走', () => {
expect(of()).toEqual({ kind: 'rules' })
expect(of('')).toEqual({ kind: 'rules' })
})
it('DIRECT 和 GLOBAL 是内核自带的,单独成一类', () => {
expect(of('DIRECT')).toEqual({ kind: 'direct' })
expect(of('GLOBAL')).toEqual({ kind: 'global' })
})
it('其余都是指名道姓的出站', () => {
expect(of('AUTO')).toEqual({ kind: 'named', name: 'AUTO' })
})
it('写回时能还原成原来的字段值', () => {
expect(outboundToProxyField({ kind: 'rules' })).toBeUndefined()
expect(outboundToProxyField({ kind: 'direct' })).toBe('DIRECT')
expect(outboundToProxyField({ kind: 'global' })).toBe('GLOBAL')
expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO')
})
})
describe('能被入站引用的出站名字', () => {
it('列出内置出站、策略组和自有线路', () => {
const names = collectOutboundNames(fixture)
expect(names.groups).toEqual(['PROXY', 'AUTO'])
expect(names.builtins).toContain('DIRECT')
expect(names.builtins).toContain('GLOBAL')
})
it('订阅带来的线路不在其中——它们不在内核的名字表上', () => {
const names = collectOutboundNames(fixture)
// 样本里唯一的订阅是 subs,它带来的线路无法被按名字引用
expect(names.ownProxies).toEqual([])
expect([...names.groups, ...names.ownProxies, ...names.builtins]).not.toContain('subs')
})
})
+143
View File
@@ -0,0 +1,143 @@
/**
* 把配置文件里两套写法的入站合并成界面上的一张表。
*
* mihomo 有新旧两套声明入站的方式:
* 旧:顶层的 mixed-port / port / socks-port / redir-port / tproxy-port,
* 每种只能有一个,而且没法给它单独指定出站
* 新:listeners 数组,数量不限,每个都能有自己的出站和附加规则
*
* 界面上必须两边都画。只画 listeners 的话,一台只用 mixed-port 的机器打开是空板——
* 而实际上那个端口正在跑,这等于对用户撒谎。
*/
import type { ConfigObject } from '../vendor/config-editor/index.ts'
/** 内核自带、不需要配置就存在的出站。 */
export const BUILTIN_OUTBOUNDS = [
'DIRECT',
'REJECT',
'REJECT-DROP',
'PASS',
'PASS-RULE',
'COMPATIBLE',
'GLOBAL',
] as const
/** 旧写法的顶层端口字段,以及它们各自等价于哪种入站类型。 */
const LEGACY_PORTS: Array<{ key: string; type: string; label: string }> = [
{ key: 'mixed-port', type: 'mixed', label: 'HTTP 和 SOCKS5 共用' },
{ key: 'port', type: 'http', label: 'HTTP' },
{ key: 'socks-port', type: 'socks', label: 'SOCKS5' },
{ key: 'redir-port', type: 'redir', label: '透明代理(REDIRECT)' },
{ key: 'tproxy-port', type: 'tproxy', label: '透明代理(TPROXY)' },
]
/** 一路入站的出站去向。四选一,对应界面上那个下拉框。 */
export type OutboundMode =
| { kind: 'rules' } // 没写 proxy,流量进分流规则
| { kind: 'direct' } // proxy: DIRECT
| { kind: 'global' } // proxy: GLOBAL
| { kind: 'named'; name: string } // proxy: 某个策略组或自有线路
export interface Inbound {
/** 界面上用来定位这一行 */
id: string
name: string
type: string
/** 端口。旧写法一定是单个数字,新写法可以是 "200,302" 或 "401-429" 这类写法 */
port: string
listen: string | null
outbound: OutboundMode
/** 挂在这路入站上的附加规则组名,没有就是 null */
ruleSet: string | null
/** 旧写法声明的入站。界面上不可删除,也不能单独指定出站 */
isDefault: boolean
/** 旧写法对应的顶层字段名,用于写回。新写法为 null */
legacyKey: string | null
/** 在 listeners 数组里的下标,用于写回。旧写法为 null */
listenerIndex: number | null
/** 给人看的一句话说明,主要是给默认入站解释它为什么不能改出站 */
note: string | null
}
function outboundOf(proxy: unknown): OutboundMode {
if (typeof proxy !== 'string' || proxy === '') return { kind: 'rules' }
if (proxy === 'DIRECT') return { kind: 'direct' }
if (proxy === 'GLOBAL') return { kind: 'global' }
return { kind: 'named', name: proxy }
}
/** 把出站去向写回成配置里 proxy 字段该有的值。返回 undefined 表示不写这个字段。 */
export function outboundToProxyField(mode: OutboundMode): string | undefined {
if (mode.kind === 'rules') return undefined
if (mode.kind === 'direct') return 'DIRECT'
if (mode.kind === 'global') return 'GLOBAL'
return mode.name
}
export function collectInbounds(data: ConfigObject): Inbound[] {
const result: Inbound[] = []
for (const { key, type, label } of LEGACY_PORTS) {
const raw = data[key]
const port = typeof raw === 'number' ? raw : Number(raw)
// 0 和缺省都表示没启用这个端口
if (!Number.isFinite(port) || port <= 0) continue
result.push({
id: `legacy:${key}`,
name: label,
type,
port: String(port),
listen: null,
outbound: { kind: 'rules' },
ruleSet: null,
isDefault: true,
legacyKey: key,
listenerIndex: null,
note: `由配置文件顶层的 ${key} 声明。这种写法没法单独指定出站,流量一律走分流规则。想让这个端口固定走某个出站,要把它改成命名入站。`,
})
}
const listeners = Array.isArray(data.listeners) ? data.listeners : []
listeners.forEach((entry, index) => {
if (typeof entry !== 'object' || entry === null) return
const item = entry as Record<string, unknown>
result.push({
id: `listener:${String(index)}`,
name: typeof item.name === 'string' ? item.name : `第 ${String(index + 1)} 个入站`,
type: typeof item.type === 'string' ? item.type : '',
port: item.port === undefined || item.port === null ? '' : String(item.port),
listen: typeof item.listen === 'string' ? item.listen : null,
outbound: outboundOf(item.proxy),
ruleSet: typeof item.rule === 'string' && item.rule ? item.rule : null,
isDefault: false,
legacyKey: null,
listenerIndex: index,
note: null,
})
})
return result
}
/**
* 所有可以被入站的出站字段引用的名字。
*
* 订阅带来的线路**不在这里**——内核按名字找出站时查的是一张由内置出站、顶层 proxies
* 和 proxy-groups 构成的表,订阅线路住在订阅对象里,不在表上。想让入站固定走订阅里的
* 某条线路,得先建一个只装它的策略组。
*/
export function collectOutboundNames(data: ConfigObject): {
builtins: string[]
groups: string[]
ownProxies: string[]
} {
const groups = (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : [])
.map((g) => (typeof g === 'object' && g !== null ? (g as Record<string, unknown>).name : null))
.filter((n): n is string => typeof n === 'string')
const ownProxies = (Array.isArray(data.proxies) ? data.proxies : [])
.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : null))
.filter((n): n is string => typeof n === 'string')
return { builtins: [...BUILTIN_OUTBOUNDS], groups, ownProxies }
}