订阅和规则组支持从文件导入,配置页与表单一批打磨
文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作 目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状), 成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。 file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示, 编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。 订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取 (component/resource/fetcher.go:226-228),关掉开关就是不写这个键。 配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。 规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条 代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网 直连」,只在有缺口时出现。 所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
This commit is contained in:
+193
-25
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user