Files
mihomo-patchbay/web/views/RuleGroupForm.vue
T
im0o 91243354de 取消文件选择不再把弹窗一起关掉
input[type=file] 在用户取消选择时发出的 cancel 事件会冒泡,带 DropFile
的三个弹窗把它当成了 dialog 自己的 Esc 取消。cancel 处理器现在只认
target 是 dialog 本身的事件。
2026-08-12 03:22:53 +08:00

379 lines
9.9 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">
/**
* 新增一个规则组(rule-providers 里的一条声明)。
*
* 内容的拉取、缓存、定时更新全是内核的活,这里只写声明:URL 生成 http 类型,
* 本地路径生成 file 类型,缓存路径自动补一个确定的——不写 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 || source.value.trim() !== '') &&
!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,
}
: 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,
})
}
/**
* 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://… 或本地文件路径"
autocomplete="off"
spellcheck="false"
/>
<span v-if="source.trim() !== ''" class="note">
{{ isUrl ? 'http 类型,由内核定时拉取' : 'file 类型,读本机文件' }}
</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>