订阅和规则组支持从文件导入,配置页与表单一批打磨

文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作
目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状),
成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。
file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示,
编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。

订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取
(component/resource/fetcher.go:226-228),关掉开关就是不写这个键。
配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。

规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条
代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网
直连」,只在有缺口时出现。

所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
This commit is contained in:
2026-08-12 00:46:17 +08:00
parent 7d08f37248
commit 013f537eaa
19 changed files with 871 additions and 101 deletions
+193 -25
View File
@@ -5,12 +5,17 @@
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:`
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。
*
* 新增时地址之外还能从文件导入:选了文件就是 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
@@ -23,14 +28,24 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
/** 编辑时 name 是被编辑那个订阅的原名 */
submit: [draft: { name: string; url: string; interval: number }]
/** 编辑时 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,
@@ -38,7 +53,9 @@ watch(
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()
@@ -56,30 +73,46 @@ const nameError = computed(() => {
})
const intervalInvalid = computed(() => {
if (!autoUpdate.value) return false
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,
)
const canSubmit = computed(() => {
if (props.saving) return false
if (props.editing === null && (name.value.trim() === '' || nameError.value !== '')) return false
if (file.value !== null) 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: props.editing?.name ?? name.value.trim(),
url: url.value.trim(),
interval: Number(interval.value.trim()),
interval: autoUpdate.value ? Number(interval.value.trim()) : null,
file: file.value,
})
}
/** 点在遮罩上就关闭。内容区的点击 target 是 form 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>{{ editing ? `编辑订阅 ${editing.name}` : '新增订阅' }}</h2>
@@ -95,20 +128,54 @@ function submit() {
</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>
<template v-if="file === null">
<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 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">
@@ -179,7 +246,7 @@ function submit() {
.row {
display: grid;
grid-template-columns: 5rem 1fr;
grid-template-columns: 6.5rem 1fr;
gap: var(--s-4);
align-items: baseline;
}
@@ -204,6 +271,107 @@ input {
background: var(--c-bg);
}
/* 输入框加单位后缀:数字和它的量纲摆在一起 */
.with-unit {
display: flex;
align-items: center;
gap: var(--s-2);
}
.unit {
flex: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
}
/* 开关。align-self 抵消 row 的 baseline 对齐,胶囊不该被拉到文字基线上 */
.sw {
align-self: center;
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);