Files
im0o d6942c5702 订阅健康检查默认开启,编辑支持改名
内核没有默认的健康检查地址,url 为空时检查根本不会运行
(adapter/provider/healthcheck.go:196-199),所以默认值必须写全;
开关只翻 enable,手写的 url/interval 留着下次开启时还用。
改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存
链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型
的数据本体和副本认亲都靠它。
2026-08-12 17:30:07 +08:00

432 lines
11 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 新增或编辑一个订阅。
*
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
* 让用户填只会填出和链式代理对不上的路径。编辑时名字也能改,引用同步和
* 副本重建由保存链路负责(renameSubscription + syncGenerated)。
*
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
*/
import { computed, ref, watch } from 'vue'
import { DEFAULT_INTERVAL } from '@shared/providers.ts'
import type { Subscription } from '@shared/providers.ts'
import { GENERATED_PREFIX } from '@shared/inbound-groups.ts'
import DropFile from '../components/DropFile.vue'
import IconClose from '~icons/ri/close-line'
import IconFile from '~icons/ri/file-text-line'
const props = defineProps<{
open: boolean
/** 传了就是编辑,没传就是新增 */
editing: Subscription | null
/** proxy-providers 里的所有名字(含生成的副本),用来挡重名 */
takenNames: string[]
saving: boolean
}>()
const emit = defineEmits<{
close: []
/** 编辑时 name 和原名不同就是改名。file 非 null 表示从文件导入 */
submit: [
draft: {
name: string
url: string
/** null = 不自动更新(配置里不写 interval) */
interval: number | null
file: { name: string; content: string } | null
},
]
}>()
const dialog = ref<HTMLDialogElement | null>(null)
const name = ref('')
const url = ref('')
const autoUpdate = ref(true)
const interval = ref(String(DEFAULT_INTERVAL))
const file = ref<{ name: string; content: string } | null>(null)
watch(
() => props.open,
(open) => {
if (open) {
name.value = props.editing?.name ?? ''
url.value = props.editing?.url ?? ''
autoUpdate.value = props.editing ? props.editing.interval !== null : true
interval.value = String(props.editing?.interval ?? DEFAULT_INTERVAL)
file.value = null
dialog.value?.showModal()
} else {
dialog.value?.close()
}
},
)
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 '名称已存在'
return ''
})
const intervalInvalid = computed(() => {
if (!autoUpdate.value) return false
const raw = interval.value.trim()
return raw === '' || !/^\d+$/.test(raw) || Number(raw) < 1
})
const canSubmit = computed(() => {
if (props.saving) 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
})
function onFile(fileName: string, content: string) {
file.value = { name: fileName, content }
}
function submit() {
if (!canSubmit.value) return
emit('submit', {
name: name.value.trim(),
url: url.value.trim(),
interval: autoUpdate.value ? Number(interval.value.trim()) : null,
file: file.value,
})
}
/**
* Esc 关弹窗走这里。文件选择框被取消时 input 的 cancel 事件会冒泡到 dialog,
* 不是发给 dialog 自己的一律不理——否则取消选文件会把整个弹窗带关。
*/
function onCancel(event: Event) {
if (event.target !== dialog.value) return
event.preventDefault()
emit('close')
}
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 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="onCancel"
>
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>{{ editing ? `编辑订阅 ${editing.name}` : '新增订阅' }}</h2>
<button type="button" class="x" @click="emit('close')"><IconClose /></button>
</header>
<div class="body">
<label class="row">
<span class="k">名称</span>
<span class="v">
<input v-model="name" type="text" placeholder="subs" autocomplete="off" />
<span v-if="nameError" class="bad">{{ nameError }}</span>
</span>
</label>
<!-- file 类型没有地址和自动更新,编辑它只有名称可改 -->
<template v-if="file === null && editing?.type !== 'file'">
<label class="row">
<span class="k">地址</span>
<span class="v">
<input v-model="url" class="mono" type="text" placeholder="https://…" autocomplete="off" />
</span>
</label>
<div class="row">
<span class="k">自动更新</span>
<span class="v">
<button
type="button"
role="switch"
:aria-checked="autoUpdate"
:class="['sw', autoUpdate && 'is-on']"
@click="autoUpdate = !autoUpdate"
>
<span class="knob" aria-hidden="true"></span>
</button>
</span>
</div>
<label v-if="autoUpdate" class="row">
<span class="k">自动更新间隔</span>
<span class="v">
<span class="with-unit">
<input v-model="interval" class="mono" type="text" inputmode="numeric" autocomplete="off" />
<span class="unit">秒</span>
</span>
<span v-if="intervalInvalid" class="bad">正整数,单位秒</span>
</span>
</label>
</template>
<template v-if="!editing">
<div class="divide" role="presentation"><span>或从文件导入</span></div>
<DropFile v-if="file === null" accept=".yaml,.yml,.txt" @load="onFile" />
<div v-else class="picked">
<IconFile class="picked-ico" aria-hidden="true" />
<span class="picked-name">{{ file.name }}</span>
<button type="button" class="x" title="移除文件" @click="file = null"><IconClose /></button>
</div>
<p v-if="file !== null" class="note">
导入后是 file 类型的订阅:内容就是这份文件,内核监视它的变化,没有自动更新。
</p>
</template>
</div>
<footer class="dlg-foot">
<button type="button" class="btn" @click="emit('close')">取消</button>
<button type="submit" class="btn btn-go" :disabled="!canSubmit">
{{ saving ? '保存中' : '保存' }}
</button>
</footer>
</form>
</dialog>
</template>
<style scoped>
.dlg {
width: min(520px, 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);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-4) var(--s-5);
border-bottom: 1px solid var(--c-border);
}
.dlg-head h2 {
font-size: var(--t-lg);
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.x {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
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);
}
.body {
display: flex;
flex-direction: column;
gap: var(--s-4);
padding: var(--s-5);
}
.row {
display: grid;
grid-template-columns: 6.5rem 1fr;
gap: var(--s-4);
align-items: baseline;
}
.k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.v {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
input {
width: 100%;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-bg);
}
/* 单位画在输入框内侧右缘:框和其它行齐宽,数字和量纲还在一起 */
.with-unit {
position: relative;
}
.with-unit input {
width: 100%;
padding-right: 2.4em;
}
.unit {
position: absolute;
right: var(--s-3);
top: 50%;
transform: translateY(-50%);
color: var(--c-text-faint);
font-size: var(--t-sm);
pointer-events: none;
}
/* 开关。align-self 抵消 row 的 baseline 对齐,胶囊不该被拉到文字基线上 */
.sw {
/* 靠右:和输入框的右缘同一条线,也是设置页「标签左、控件右」的行模式 */
align-self: flex-end;
position: relative;
width: 36px;
height: 20px;
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: 999px;
background: var(--c-surface-sunken);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.sw .knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--c-text-faint);
transition:
transform 0.15s ease,
background-color 0.15s ease;
}
.sw.is-on {
background: var(--c-accent);
border-color: transparent;
}
.sw.is-on .knob {
transform: translateX(16px);
background: #fff;
}
.divide {
display: flex;
align-items: center;
gap: var(--s-3);
color: var(--c-text-faint);
font-size: var(--t-xs);
user-select: none;
}
.divide::before,
.divide::after {
content: '';
flex: 1;
height: 1px;
background: var(--c-border);
}
.picked {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
font-size: var(--t-sm);
}
.picked-ico {
flex: none;
width: 16px;
height: 16px;
color: var(--c-text-soft);
}
.picked-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note {
font-size: var(--t-xs);
color: var(--c-text-faint);
line-height: 1.5;
}
.bad {
font-size: var(--t-xs);
color: var(--c-danger);
}
.dlg-foot {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
padding: var(--s-4) var(--s-5);
border-top: 1px solid var(--c-border);
}
.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-go {
background: var(--c-accent);
border-color: transparent;
color: #fff;
font-weight: 500;
}
.btn-go:disabled {
background: var(--c-surface-sunken);
border-color: var(--c-border);
color: var(--c-text-faint);
cursor: not-allowed;
}
</style>