内核没有默认的健康检查地址,url 为空时检查根本不会运行 (adapter/provider/healthcheck.go:196-199),所以默认值必须写全; 开关只翻 enable,手写的 url/interval 留着下次开启时还用。 改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存 链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型 的数据本体和副本认亲都靠它。
432 lines
11 KiB
Vue
432 lines
11 KiB
Vue
<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>
|