订阅健康检查默认开启,编辑支持改名

内核没有默认的健康检查地址,url 为空时检查根本不会运行
(adapter/provider/healthcheck.go:196-199),所以默认值必须写全;
开关只翻 enable,手写的 url/interval 留着下次开启时还用。
改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存
链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型
的数据本体和副本认亲都靠它。
This commit is contained in:
2026-08-12 17:30:07 +08:00
parent b28fc572f7
commit d6942c5702
6 changed files with 237 additions and 49 deletions
+28 -4
View File
@@ -15,6 +15,8 @@ import {
listSubscriptions,
providerNames,
removeSubscription,
renameSubscription,
setSubscriptionHealthCheck,
subscriptionPathConflict,
subscriptionReferences,
updateSubscription,
@@ -103,7 +105,6 @@ const rows = computed(() =>
name: s.name,
lines: info ? info.proxies.length : null,
updated: parseUpdated(info?.updatedAt),
healthCheck: s.healthCheck !== null,
}
}),
)
@@ -149,7 +150,8 @@ async function submitForm(draft: {
}) {
const editing = formEditing.value
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦(编辑不改名,只查新增)
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦
// (只查新增:改名不动缓存路径,不会制造新的撞车)
if (!editing) {
const path = providerCachePath(draft.name)
const clash = subscriptionPathConflict(props.data, draft.name, path)
@@ -173,8 +175,17 @@ async function submitForm(draft: {
}
}
// 编辑时先改名再改字段。名字没动时 renameSubscription 原样返回;
// file 类型只有名称可改,不能走 updateSubscription——它会写进 url
const next = editing
? updateSubscription(clone(), editing.name, draft.url, draft.interval)
? editing.type === 'file'
? renameSubscription(clone(), editing.name, draft.name)
: updateSubscription(
renameSubscription(clone(), editing.name, draft.name),
draft.name,
draft.url,
draft.interval,
)
: draft.file
? addFileSubscription(clone(), draft.name)
: addSubscription(clone(), draft.name, draft.url, draft.interval)
@@ -182,7 +193,8 @@ async function submitForm(draft: {
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
if (!ok) return
formOpen.value = false
if (!editing) selectedName.value = draft.name
// 新增和改名都要把选中跟过去,否则选中名失效会被重置到第一行
selectedName.value = draft.name
}
function askRemove() {
@@ -205,6 +217,17 @@ async function removeConfirmed() {
await commit(removeSubscription(clone(), target.name), `已删除 ${target.name}`)
}
/** 开关选中订阅的健康检查。写配置文件,走 commit()。 */
async function toggleHealthCheck() {
const target = selected.value
if (!target || saving.value) return
const enable = target.healthCheck === null
await commit(
setSubscriptionHealthCheck(clone(), target.name, enable),
enable ? `已开启 ${target.name} 的健康检查` : `已关闭 ${target.name} 的健康检查`,
)
}
/** 让内核立刻重新拉取。运行时动作,不写配置文件,所以不走 commit()。 */
async function refresh() {
const target = selected.value
@@ -290,6 +313,7 @@ async function refreshAll() {
@edit="openEdit"
@remove="askRemove"
@refresh="refresh"
@toggle-health-check="toggleHealthCheck"
/>
</div>
+17 -15
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
/**
* 选中那个订阅的详情:地址、更新间隔、健康检查,和它带来的线路。
* 选中那个订阅的详情:地址、更新间隔,和它带来的线路。
*
* 健康检查不在字段区展示,只是右上角一个开关动作:默认就是开着的,
* 值得占一行的只有「被谁关掉了」这件事,而按钮文案本身已经说明了状态。
*
* 线路列表只读。这些线路不在内核的全局名字表里,不能被入站直接引用,
* 只能经策略组的 `use:` 整批引入,所以这里没有「换线」可点。
@@ -13,6 +16,7 @@ import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconRefresh from '~icons/ri/refresh-line'
import IconOffline from '~icons/ri/cloud-off-line'
import IconPulse from '~icons/ri/pulse-line'
const props = defineProps<{
subscription: Subscription
@@ -30,6 +34,8 @@ const emit = defineEmits<{
remove: []
/** 让内核立刻重新拉取。运行时动作,不写配置文件 */
refresh: []
/** 开关健康检查。写配置文件,走保存链路 */
toggleHealthCheck: []
}>()
const lines = computed(() => props.info?.proxies ?? [])
@@ -48,15 +54,13 @@ function delayClass(ms: number | null): string {
<div class="title">
<span class="name">{{ subscription.name }}</span>
<div class="acts">
<button type="button" :disabled="saving" @click="emit('toggleHealthCheck')">
<IconPulse /> {{ subscription.healthCheck ? '关闭健康检查' : '开启健康检查' }}
</button>
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')">
<IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }}
</button>
<button
type="button"
:disabled="saving || subscription.type === 'file'"
:title="subscription.type === 'file' ? '文件导入的订阅没有可编辑的字段' : undefined"
@click="emit('edit')"
>
<button type="button" :disabled="saving" @click="emit('edit')">
<IconEdit /> 编辑
</button>
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
@@ -78,14 +82,6 @@ function delayClass(ms: number | null): string {
{{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
</dd>
</template>
<dt>健康检查</dt>
<dd class="mono">
<template v-if="subscription.healthCheck">
{{ subscription.healthCheck.url
}}{{ subscription.healthCheck.interval !== null ? `,每 ${subscription.healthCheck.interval} 秒` : '' }}
</template>
<template v-else>未开启</template>
</dd>
</dl>
</header>
@@ -221,6 +217,12 @@ h3 {
padding: 0;
max-height: 420px;
overflow-y: auto;
/* 细杆加透明轨道,别让系统滚动条压在延迟列上;gutter 常驻,
线路数跨过滚动阈值时右缘不横跳 */
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
scrollbar-gutter: stable;
padding-right: var(--s-2);
}
.row {
+11 -7
View File
@@ -3,8 +3,8 @@
* 新增或编辑一个订阅。
*
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:`
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。
* 让用户填只会填出和链式代理对不上的路径。编辑时名字也能改,引用同步和
* 副本重建由保存链路负责(renameSubscription + syncGenerated)。
*
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
@@ -28,7 +28,7 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
/** 编辑时 name 是被编辑那个订阅的原名。file 非 null 表示从文件导入 */
/** 编辑时 name 和原名不同就是改名。file 非 null 表示从文件导入 */
submit: [
draft: {
name: string
@@ -66,6 +66,8 @@ watch(
const nameError = computed(() => {
const n = name.value.trim()
if (n === '') return ''
// 编辑时名字没动不算重名,takenNames 里有它自己
if (n === props.editing?.name) return ''
// 带这个前缀的会被当成 Patchbay 生成物:列表里不显示,pruneGenerated 还会把它清掉
if (n.startsWith(GENERATED_PREFIX)) return `名称不能以 ${GENERATED_PREFIX} 开头`
if (props.takenNames.includes(n)) return '名称已存在'
@@ -80,8 +82,9 @@ const intervalInvalid = computed(() => {
const canSubmit = computed(() => {
if (props.saving) return false
if (props.editing === null && (name.value.trim() === '' || nameError.value !== '')) return false
if (name.value.trim() === '' || nameError.value !== '') return false
if (file.value !== null) return true
if (props.editing?.type === 'file') return true
return url.value.trim() !== '' && !intervalInvalid.value
})
@@ -92,7 +95,7 @@ function onFile(fileName: string, content: string) {
function submit() {
if (!canSubmit.value) return
emit('submit', {
name: props.editing?.name ?? name.value.trim(),
name: name.value.trim(),
url: url.value.trim(),
interval: autoUpdate.value ? Number(interval.value.trim()) : null,
file: file.value,
@@ -130,7 +133,7 @@ function onBackdrop(event: MouseEvent) {
</header>
<div class="body">
<label v-if="!editing" class="row">
<label class="row">
<span class="k">名称</span>
<span class="v">
<input v-model="name" type="text" placeholder="subs" autocomplete="off" />
@@ -138,7 +141,8 @@ function onBackdrop(event: MouseEvent) {
</span>
</label>
<template v-if="file === null">
<!-- file 类型没有地址和自动更新,编辑它只有名称可改 -->
<template v-if="file === null && editing?.type !== 'file'">
<label class="row">
<span class="k">地址</span>
<span class="v">