设置页内核参数加 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。
290 lines
6.6 KiB
Vue
290 lines
6.6 KiB
Vue
<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>
|