379 lines
10 KiB
Vue
379 lines
10 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 新增一个规则组(rule-providers 里的一条声明)。
|
||
*
|
||
* 内容的拉取、缓存、定时更新全是内核的活,这里只写声明:URL 生成 http 类型,
|
||
* 上传的清单生成 file 类型。来源不收本地路径——浏览器里填服务器上的路径既没法
|
||
* 验证也容易写错,真有本地清单走文件上传,落盘位置由 Patchbay 统一管。
|
||
* 缓存路径自动补一个确定的——不写 path 时内核按 url 哈希自己生成,那个路径
|
||
* 这里算不出来。
|
||
*/
|
||
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
|
||
/** 已被占用的规则组名 */
|
||
takenNames: string[]
|
||
saving: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
/** file 非 null 表示清单内容来自上传,要先落盘再写声明 */
|
||
submit: [entry: { name: string; declaration: ConfigObject; file: { content: string } | null }]
|
||
}>()
|
||
|
||
const dialog = ref<HTMLDialogElement | null>(null)
|
||
const name = ref('')
|
||
const source = ref('')
|
||
const behavior = ref('classical')
|
||
// 值域就是选项里那三个,SelectBox 的 v-model 是宽的 string,这里不再收窄
|
||
const format = ref<string>('yaml')
|
||
/** 用户手选过格式就不再跟着来源推断 */
|
||
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 两种策略) */
|
||
const mrsConflict = computed(() => format.value === 'mrs' && behavior.value === 'classical')
|
||
|
||
const canSubmit = computed(
|
||
() =>
|
||
name.value.trim() !== '' &&
|
||
(file.value !== null || isUrl.value) &&
|
||
!nameTaken.value &&
|
||
!mrsConflict.value &&
|
||
!props.saving,
|
||
)
|
||
|
||
watch(
|
||
() => props.open,
|
||
(open) => {
|
||
if (open) {
|
||
name.value = ''
|
||
source.value = ''
|
||
behavior.value = 'classical'
|
||
format.value = 'yaml'
|
||
formatTouched.value = false
|
||
file.value = null
|
||
dialog.value?.showModal()
|
||
} else {
|
||
dialog.value?.close()
|
||
}
|
||
},
|
||
)
|
||
|
||
function submit() {
|
||
if (!canSubmit.value) return
|
||
const trimmedName = name.value.trim()
|
||
const trimmedSource = source.value.trim()
|
||
const declaration: ConfigObject = file.value
|
||
? {
|
||
type: 'file',
|
||
behavior: behavior.value,
|
||
path: ruleProviderPath(trimmedName, format.value),
|
||
format: format.value,
|
||
}
|
||
: {
|
||
type: 'http',
|
||
behavior: behavior.value,
|
||
url: trimmedSource,
|
||
path: ruleProviderPath(trimmedName, format.value),
|
||
format: format.value,
|
||
interval: 86400,
|
||
}
|
||
emit('submit', {
|
||
name: trimmedName,
|
||
declaration,
|
||
file: file.value ? { content: file.value.content } : null,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 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>新增规则组</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="ads" autocomplete="off" />
|
||
<span v-if="nameTaken" class="bad">名称已存在</span>
|
||
</span>
|
||
</label>
|
||
|
||
<label v-if="file === null" class="row">
|
||
<span class="k">来源</span>
|
||
<span class="v">
|
||
<input
|
||
v-model="source"
|
||
class="mono"
|
||
type="text"
|
||
placeholder="https://example.com/rules.list"
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
/>
|
||
<span v-if="source.trim() !== '' && !isUrl" class="bad">
|
||
来源要是 http(s) 地址,本地清单走下面的文件导入
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
<!-- SelectBox 的行不用 label 包:没有 for 的 label 绑定第一个可关联控件,
|
||
正好是它的开关按钮,悬停行内任何位置都会点亮它、点空白处会展开面板 -->
|
||
<div class="row">
|
||
<span class="k">行为</span>
|
||
<span class="v">
|
||
<SelectBox v-model="behavior" :options="BEHAVIOR_OPTIONS" aria-label="行为" />
|
||
<span class="note">
|
||
清单每行带 DOMAIN,、IP-CIDR, 这类前缀的是 classical;纯域名列表是 domain;
|
||
纯 IP 网段列表是 ipcidr
|
||
</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<span class="k">格式</span>
|
||
<span class="v">
|
||
<SelectBox
|
||
v-model="format"
|
||
:options="FORMAT_OPTIONS"
|
||
aria-label="格式"
|
||
@update:model-value="formatTouched = true"
|
||
/>
|
||
<span v-if="mrsConflict" class="bad">mrs 格式只支持 domain 和 ipcidr 两种行为</span>
|
||
</span>
|
||
</div>
|
||
|
||
<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">
|
||
<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;
|
||
}
|
||
|
||
.x {
|
||
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: 4rem 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);
|
||
}
|
||
|
||
.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>
|