追加 Hosts、路径撞名拦截、连接页表头随行滚动、GEO 借内核通路

设置页内核参数加 hosts 行,铅笔进弹窗编辑(顶层 hosts: 键,
config.go:454,值单个或数组 parseHosts config.go:1131)。弹窗里随便改、
保存才一次性提交——写配置有几秒的校验重载,逐行失焦提交会把等待塞进
每次输入。没动过的行原样写回,数组不被重整形。

订阅和规则组新增时查缓存路径撞名:路径由名字净化而来,「订阅 A」和
「订阅/A」是两个名字、同一个文件,名字查重挡不住,会互相覆盖缓存。
纯函数在 shared 带测试,页面提交前拦。

连接页表头挪进滚动容器加 sticky:.scroller 声明 overflow-y 后按规范
连带拿到横向滚动上下文,表头留在外面时窄屏横向滚动两者脱节。表头
40px 的窗口偏移由 overscan 8 行吸收,不配 scrollMargin。

GEO 单文件下载先借内核的混合端口出去再退直连——内核自己更新 GEO 走
的是它的分流,Patchbay 直连 GitHub 不一定通。ECharts 拆独立 chunk,
业务代码改动不再让这 500KB 跟着换 hash。
This commit is contained in:
2026-08-12 02:47:17 +08:00
parent b276c4a8fe
commit 275f536429
12 changed files with 513 additions and 36 deletions
+14
View File
@@ -15,6 +15,7 @@ import {
listSubscriptions,
providerNames,
removeSubscription,
subscriptionPathConflict,
subscriptionReferences,
updateSubscription,
} from '@shared/providers.ts'
@@ -148,6 +149,19 @@ async function submitForm(draft: {
}) {
const editing = formEditing.value
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦(编辑不改名,只查新增)
if (!editing) {
const path = providerCachePath(draft.name)
const clash = subscriptionPathConflict(props.data, draft.name, path)
if (clash) {
toast(`「${draft.name}」和「${clash}」的缓存文件会是同一个`, {
kind: 'error',
hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。',
})
return
}
}
// 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。
// 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件
if (draft.file) {
+36 -26
View File
@@ -175,30 +175,32 @@ async function closeAllConfirmed() {
<p v-if="rows.length === 0" class="note">没有主机匹配「{{ query.trim() }}」。</p>
<div v-else class="table">
<div class="rowgrid thead">
<span>主机</span>
<span>规则</span>
<span>出站链</span>
<span class="num">上传</span>
<button
type="button"
:class="['th num', sort === 'download' && 'is-on']"
@click="sort = 'download'"
>
下载{{ sort === 'download' ? ' ↓' : '' }}
</button>
<button
type="button"
:class="['th num', sort === 'duration' && 'is-on']"
@click="sort = 'duration'"
>
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
</button>
<span>入站</span>
<span></span>
</div>
<!-- 表头在滚动容器里而不是外面:.scroller 声明 overflow-y 后按规范会连带
拿到自己的横向滚动上下文,表头留在外面的话窄屏横向滚动时两者会脱节。
竖直方向靠 sticky 钉住。 -->
<div ref="scrollEl" class="scroller">
<div class="rowgrid thead">
<span>主机</span>
<span>规则</span>
<span>出站链</span>
<span class="num">上传</span>
<button
type="button"
:class="['th num', sort === 'download' && 'is-on']"
@click="sort = 'download'"
>
下载{{ sort === 'download' ? ' ↓' : '' }}
</button>
<button
type="button"
:class="['th num', sort === 'duration' && 'is-on']"
@click="sort = 'duration'"
>
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
</button>
<span>入站</span>
<span></span>
</div>
<div class="spacer" :style="{ height: `${totalSize}px` }">
<div
v-for="vRow in virtualRows"
@@ -367,12 +369,12 @@ async function closeAllConfirmed() {
font-variant-numeric: tabular-nums;
}
/* 窄屏不挤变形,横向滚动 */
.table {
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
overflow-x: auto;
/* 只负责圆角裁切;横竖两个方向的滚动都归 .scroller 一个容器管 */
overflow: hidden;
}
.rowgrid {
@@ -386,9 +388,15 @@ async function closeAllConfirmed() {
padding: 0 var(--s-4);
}
/* 表头随内容横向滚动(同一个容器),竖直方向 sticky 钉在顶上。
行是绝对定位的,要 z-index 才能压住;背景不透明才不会透出滚过的行 */
.thead {
position: sticky;
top: 0;
z-index: 1;
height: 40px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-faint);
font-size: var(--t-xs);
}
@@ -412,9 +420,11 @@ async function closeAllConfirmed() {
}
.scroller {
overflow-y: auto;
overflow: auto;
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
max-height: calc(100vh - 20rem);
/* 表头占 40px,虚拟滚动的可见窗口按纯行高算会偏这 40px——overscan 8 行
(384px)足以盖住,不必给虚拟器配 scrollMargin */
}
.spacer {
+289
View File
@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* 追加 Hosts 的编辑弹窗:配置顶层的 `hosts:` 映射(config.go:454,值可以是
* 单个 IP/域名或数组,parseHosts config.go:1131)。
*
* 在弹窗里随便改,点保存才一次性提交——写配置要走校验和重载,每行失焦
* 就提交会把几秒的等待塞进每一次输入。
*
* 值的往返规则:没动过的行原样保留(数组仍是数组);动过的行按显示文本
* 重建,含逗号拆成数组,否则存字符串。
*/
import { ref, watch } from 'vue'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import IconAdd from '~icons/ri/add-line'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{ open: boolean; hosts: ConfigObject }>()
const emit = defineEmits<{ close: []; save: [hosts: ConfigObject] }>()
interface Row {
key: string
value: string
/** 值被编辑过。没动过的保存时用 original 原样写回 */
dirty: boolean
original: unknown
}
const dialog = ref<HTMLDialogElement | null>(null)
const rows = ref<Row[]>([])
/** 保存被拦下的行号(键空着或和别的行重复) */
const badRows = ref<Set<number>>(new Set())
function display(value: unknown): string {
if (typeof value === 'string') return value
if (Array.isArray(value)) return value.map(String).join(', ')
return JSON.stringify(value)
}
watch(
() => props.open,
(open) => {
if (open) {
rows.value = Object.entries(props.hosts).map(([key, value]) => ({
key,
value: display(value),
dirty: false,
original: value,
}))
badRows.value = new Set()
dialog.value?.showModal()
} else {
dialog.value?.close()
}
},
)
function addRow() {
rows.value = [...rows.value, { key: '', value: '', dirty: true, original: undefined }]
}
function removeRow(index: number) {
rows.value = rows.value.filter((_, i) => i !== index)
badRows.value = new Set()
}
function submit() {
const bad = new Set<number>()
const seen = new Set<string>()
const next: ConfigObject = {}
for (const [index, row] of rows.value.entries()) {
const key = row.key.trim()
const raw = row.value.trim()
if (key === '' && raw === '') continue
if (key === '' || seen.has(key)) {
bad.add(index)
continue
}
seen.add(key)
if (!row.dirty && row.original !== undefined) {
next[key] = row.original as ConfigObject[string]
} else {
next[key] = raw.includes(',')
? raw.split(',').map((part) => part.trim()).filter(Boolean)
: raw
}
}
if (bad.size > 0) {
badRows.value = bad
return
}
emit('save', next)
}
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<div class="body">
<header class="head">
<h2>追加 Hosts</h2>
<button type="button" class="add" @click="addRow"><IconAdd /> 添加</button>
</header>
<p v-if="rows.length === 0" class="faint">
没有条目。hosts 把域名固定解析到指定的 IP 或别名,值里用逗号分隔可以写多个。
</p>
<div v-else class="list">
<div v-for="(row, i) in rows" :key="i" :class="['hrow', badRows.has(i) && 'is-bad']">
<input
v-model="row.key"
class="input mono"
type="text"
placeholder="域名,如 router.lan"
autocomplete="off"
spellcheck="false"
/>
<input
v-model="row.value"
class="input mono grow"
type="text"
placeholder="IP 或别名,逗号分隔多个"
autocomplete="off"
spellcheck="false"
@input="row.dirty = true"
/>
<button type="button" class="x" title="删除这一行" @click="removeRow(i)">
<IconClose />
</button>
</div>
</div>
<footer class="foot">
<button type="button" class="btn" @click="emit('close')">取消</button>
<button type="button" class="btn go" @click="submit">保存</button>
</footer>
</div>
</dialog>
</template>
<style scoped>
.dlg {
width: min(560px, calc(100vw - 2 * var(--s-5)));
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
background: var(--c-surface);
color: var(--c-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.45);
}
.body {
padding: var(--s-5);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--s-4);
}
.head h2 {
font-size: var(--t-lg);
font-weight: 600;
}
.add {
display: inline-flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1) var(--s-2);
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
cursor: pointer;
}
.add:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.add svg {
width: 15px;
height: 15px;
}
.faint {
color: var(--c-text-faint);
font-size: var(--t-sm);
line-height: var(--line);
}
.list {
display: flex;
flex-direction: column;
gap: var(--s-2);
max-height: 50vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
}
.hrow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.input {
width: 180px;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-bg);
font-size: var(--t-sm);
}
.grow {
flex: 1;
min-width: 0;
}
.is-bad .input {
border-color: var(--c-danger);
}
.x {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.x:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.foot {
display: flex;
justify-content: flex-end;
gap: var(--s-3);
margin-top: var(--s-5);
}
.btn {
padding: var(--s-2) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
}
.btn:hover {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.go {
border-color: transparent;
background: var(--c-accent);
color: var(--c-bg);
}
.go:hover {
border-color: transparent;
color: var(--c-bg);
opacity: 0.9;
}
</style>
+13
View File
@@ -17,6 +17,7 @@ import {
missingLanRules,
moveRule,
parseRule,
ruleProviderPathConflict,
ruleProvidersOf,
ruleSetReferences,
rulesOf,
@@ -219,6 +220,18 @@ async function submitGroup(entry: {
declaration: ConfigObject
file: { content: string } | null
}) {
// 名字查重放行了、但净化后的清单路径和别人相同的,在这里拦
if (typeof entry.declaration.path === 'string') {
const clash = ruleProviderPathConflict(props.data, entry.name, entry.declaration.path)
if (clash) {
toast(`「${entry.name}」和「${clash}」的清单文件会是同一个`, {
kind: 'error',
hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。',
})
return
}
}
// 上传的清单先落盘再写声明。顺序不能反——声明先生效的话,内核重载会去读
// 一个还不存在的文件
if (entry.file && typeof entry.declaration.path === 'string') {
+51 -3
View File
@@ -24,6 +24,7 @@ import { DISCLAIMER, DISCLAIMER_TITLE } from '../legal'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import InlineEdit from '../components/InlineEdit.vue'
import SelectBox from '../components/SelectBox.vue'
import HostsForm from './HostsForm.vue'
import IconRight from '~icons/ri/arrow-right-s-line'
import IconBack from '~icons/ri/arrow-left-s-line'
import IconDownload from '~icons/ri/download-2-line'
@@ -60,17 +61,19 @@ function saveTestUrl(value: string) {
const saving = ref(false)
/** 所有改配置文件的动作都从这里走。 */
async function commitConfig(next: ConfigObject, success: string) {
if (saving.value) return
/** 所有改配置文件的动作都从这里走。反馈走轻提示,返回值告知成败。 */
async function commitConfig(next: ConfigObject, success: string): Promise<boolean> {
if (saving.value) return false
saving.value = true
try {
const result = await api.applyConfig(next, props.revision)
// 内核不在线时只写了文件没重载,如实说,不能只报成功
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
emit('saved')
return true
} catch (error) {
toastError(error)
return false
} finally {
saving.value = false
}
@@ -101,6 +104,25 @@ function saveNumberKey(key: string, value: string, success: string) {
void commitConfig(next, success)
}
/* ── 追加 Hosts ───────────────────────────────── */
const hostsOpen = ref(false)
const hostsOf = (): ConfigObject => {
const value = props.data.hosts
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as ConfigObject)
: {}
}
async function submitHosts(hosts: ConfigObject) {
const next = clone()
if (Object.keys(hosts).length === 0) delete next.hosts
else next.hosts = hosts
const ok = await commitConfig(next, 'hosts 已保存')
if (ok) hostsOpen.value = false
}
/* ── 资源(GEO 数据库) ───────────────────────── */
const geoFiles = ref<GeoFileStatus[] | null>(null)
@@ -451,6 +473,25 @@ const backend = location.origin
@submit="(v) => saveNumberKey('keep-alive-interval', v, 'TCP 保持活动间隔已保存')"
/>
</div>
<div class="row">
<span class="key mono">hosts</span>
<span class="g-right">
<span :class="['g-meta', Object.keys(hostsOf()).length === 0 && 'faint']">
{{
Object.keys(hostsOf()).length > 0 ? `${Object.keys(hostsOf()).length} 条` : '未设置'
}}
</span>
<button
type="button"
class="ico"
title="编辑"
:disabled="saving"
@click="hostsOpen = true"
>
<IconEdit />
</button>
</span>
</div>
</section>
<section class="group">
@@ -601,6 +642,13 @@ const backend = location.origin
</button>
</section>
<HostsForm
:open="hostsOpen"
:hosts="hostsOf()"
@close="hostsOpen = false"
@save="submitHosts"
/>
<ConfirmDialog
:open="pendingUpload !== null"
:message="uploadMessage()"