订阅和规则组支持从文件导入,配置页与表单一批打磨
文件导入走两步:内容先经新端点 POST /api/provider-files 写进内核工作 目录的 providers/ 下(路径白名单校验,只认两个路径助手生成的形状), 成了再写 file 类型的声明——顺序反了内核重载会去读不存在的文件。 file 类型没有地址和自动更新(内核靠文件监视跟进),详情页照此展示, 编辑按钮禁用。ini 导入的取文件按钮换成同一个拖放模块。 订阅的更新间隔改成开关语义:内核只在 interval > 0 时才起定时拉取 (component/resource/fetcher.go:226-228),关掉开关就是不写这个键。 配置页加「更新所有订阅」,详情的「立刻更新」改叫「更新订阅」。 规则表从零开始写入时自动垫上内网直连——保留地址不直连的话第一条 代理规则一生效,访问局域网的流量就全进了代理;按钮改成「补全内网 直连」,只在有缺口时出现。 所有表单对话框点遮罩关闭;失败横幅右侧加关闭按钮。
This commit is contained in:
+96
-10
@@ -10,7 +10,9 @@ import { computed, ref, watch } from 'vue'
|
||||
import { inferRuleFormat, ruleProviderPath } from '@shared/rules.ts'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import SelectBox from '../components/SelectBox.vue'
|
||||
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
|
||||
@@ -21,7 +23,8 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [entry: { name: string; declaration: ConfigObject }]
|
||||
/** file 非 null 表示清单内容来自上传,要先落盘再写声明 */
|
||||
submit: [entry: { name: string; declaration: ConfigObject; file: { content: string } | null }]
|
||||
}>()
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
@@ -36,12 +39,20 @@ const formatTouched = ref(false)
|
||||
const BEHAVIOR_OPTIONS = [{ value: 'classical' }, { value: 'domain' }, { value: 'ipcidr' }]
|
||||
const FORMAT_OPTIONS = [{ value: 'yaml' }, { value: 'text' }, { value: 'mrs' }]
|
||||
|
||||
/** 上传的清单。选了文件就不再需要来源:内容就是这份文件 */
|
||||
const file = ref<{ name: string; content: string } | null>(null)
|
||||
|
||||
const isUrl = computed(() => /^https?:\/\//i.test(source.value.trim()))
|
||||
|
||||
watch(source, (value) => {
|
||||
if (!formatTouched.value) format.value = inferRuleFormat(value.trim())
|
||||
})
|
||||
|
||||
function onFile(fileName: string, content: string) {
|
||||
file.value = { name: fileName, content }
|
||||
if (!formatTouched.value) format.value = inferRuleFormat(fileName)
|
||||
}
|
||||
|
||||
const nameTaken = computed(() => props.takenNames.includes(name.value.trim()))
|
||||
|
||||
/** 内核没有实现 classical 行为的 mrs 格式(rules/provider/ 下只有 domain 和 ipcidr 两种策略) */
|
||||
@@ -50,7 +61,7 @@ const mrsConflict = computed(() => format.value === 'mrs' && behavior.value ===
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
name.value.trim() !== '' &&
|
||||
source.value.trim() !== '' &&
|
||||
(file.value !== null || source.value.trim() !== '') &&
|
||||
!nameTaken.value &&
|
||||
!mrsConflict.value &&
|
||||
!props.saving,
|
||||
@@ -65,6 +76,7 @@ watch(
|
||||
behavior.value = 'classical'
|
||||
format.value = 'yaml'
|
||||
formatTouched.value = false
|
||||
file.value = null
|
||||
dialog.value?.showModal()
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
@@ -76,22 +88,44 @@ function submit() {
|
||||
if (!canSubmit.value) return
|
||||
const trimmedName = name.value.trim()
|
||||
const trimmedSource = source.value.trim()
|
||||
const declaration: ConfigObject = isUrl.value
|
||||
const declaration: ConfigObject = file.value
|
||||
? {
|
||||
type: 'http',
|
||||
type: 'file',
|
||||
behavior: behavior.value,
|
||||
url: trimmedSource,
|
||||
path: ruleProviderPath(trimmedName, format.value),
|
||||
format: format.value,
|
||||
interval: 86400,
|
||||
}
|
||||
: { type: 'file', behavior: behavior.value, path: trimmedSource, format: format.value }
|
||||
emit('submit', { name: trimmedName, declaration })
|
||||
: isUrl.value
|
||||
? {
|
||||
type: 'http',
|
||||
behavior: behavior.value,
|
||||
url: trimmedSource,
|
||||
path: ruleProviderPath(trimmedName, format.value),
|
||||
format: format.value,
|
||||
interval: 86400,
|
||||
}
|
||||
: { type: 'file', behavior: behavior.value, path: trimmedSource, format: format.value }
|
||||
emit('submit', {
|
||||
name: trimmedName,
|
||||
declaration,
|
||||
file: file.value ? { content: file.value.content } : null,
|
||||
})
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 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>新增规则组</h2>
|
||||
@@ -107,7 +141,7 @@ function submit() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
<label v-if="file === null" class="row">
|
||||
<span class="k">来源</span>
|
||||
<span class="v">
|
||||
<input
|
||||
@@ -146,6 +180,15 @@ function submit() {
|
||||
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="divide" role="presentation"><span>或从文件导入</span></div>
|
||||
|
||||
<DropFile v-if="file === null" accept=".yaml,.yml,.txt,.list" @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>
|
||||
</div>
|
||||
|
||||
<footer class="dlg-foot">
|
||||
@@ -236,6 +279,49 @@ input {
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user