Files
mihomo-patchbay/web/views/SubscriptionForm.vue
T
im0o 0eef680fba 配置页:订阅的增删改和立刻更新
写配置的纯逻辑在 shared/providers.ts。删除前 subscriptionReferences 同时查
直接 use 和经订阅副本(共用缓存文件 path)的间接引用,有被引用的就拦下并列出
策略组。订阅不允许改名:名字被 use: 和副本引用,同步所有引用不如删了重建。

页面还没挂进 App.vue 的导航,由集成者统一加。
2026-08-11 18:50:13 +08:00

242 lines
6.0 KiB
Vue
Raw 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。缓存路径不写进表单:它由名字自动生成,
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:`
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。
*/
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 IconClose from '~icons/ri/close-line'
const props = defineProps<{
open: boolean
/** 传了就是编辑,没传就是新增 */
editing: Subscription | null
/** proxy-providers 里的所有名字(含生成的副本),用来挡重名 */
takenNames: string[]
saving: boolean
}>()
const emit = defineEmits<{
close: []
/** 编辑时 name 是被编辑那个订阅的原名 */
submit: [draft: { name: string; url: string; interval: number }]
}>()
const dialog = ref<HTMLDialogElement | null>(null)
const name = ref('')
const url = ref('')
const interval = ref(String(DEFAULT_INTERVAL))
watch(
() => props.open,
(open) => {
if (open) {
name.value = props.editing?.name ?? ''
url.value = props.editing?.url ?? ''
interval.value = String(props.editing?.interval ?? DEFAULT_INTERVAL)
dialog.value?.showModal()
} else {
dialog.value?.close()
}
},
)
const nameError = computed(() => {
const n = name.value.trim()
if (n === '') return ''
// 带这个前缀的会被当成 Patchbay 生成物:列表里不显示,pruneGenerated 还会把它清掉
if (n.startsWith(GENERATED_PREFIX)) return `名称不能以 ${GENERATED_PREFIX} 开头`
if (props.takenNames.includes(n)) return '名称已存在'
return ''
})
const intervalInvalid = computed(() => {
const raw = interval.value.trim()
return raw === '' || !/^\d+$/.test(raw) || Number(raw) < 1
})
const canSubmit = computed(
() =>
url.value.trim() !== '' &&
!intervalInvalid.value &&
(props.editing !== null || (name.value.trim() !== '' && nameError.value === '')) &&
!props.saving,
)
function submit() {
if (!canSubmit.value) return
emit('submit', {
name: props.editing?.name ?? name.value.trim(),
url: url.value.trim(),
interval: Number(interval.value.trim()),
})
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<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 v-if="!editing" 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>
<label class="row">
<span class="k">地址</span>
<span class="v">
<input v-model="url" class="mono" type="text" placeholder="https://…" autocomplete="off" />
</span>
</label>
<label class="row">
<span class="k">更新间隔</span>
<span class="v">
<input v-model="interval" class="mono" type="text" inputmode="numeric" autocomplete="off" />
<span v-if="intervalInvalid" class="bad">正整数,单位秒</span>
</span>
</label>
</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: 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);
}
.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>