Files
mihomo-patchbay/web/views/IniImportForm.vue
T
im0o 9e87517a4e 规则页:全局分流规则的编辑与规则组管理
新增 RulesPage.vue 和规则、规则组、ini 导入三个对话框,纯逻辑放在
shared/rules.ts。拆分规则字符串复用 vendor 的 splitRuleFields——引号和
嵌套括号里的逗号不会被当成字段分隔;目标之后的选项段原样拼回,改一条
规则不会丢掉它的 no-resolve 尾巴。规则组的缓存路径显式生成:不写 path
时内核按 url 哈希自己算一个,那个路径这里算不出来。
2026-08-11 18:53:21 +08:00

327 lines
8.2 KiB
Vue

<script setup lang="ts">
/**
* 从 subconverter 的 ini 导入规则组。
*
* 粘贴文本或选本地文件(浏览器 File API 读内容)。只解析 ruleset= 行:
* 清单来源(URL 或路径)每行生成一个规则组声明加一条 RULE-SET 规则骨架,
* []GEOIP,CN 这类内联写法直接生成规则,[]FINAL 生成兜底规则。
* custom_proxy_group= 是 subconverter 自己的建组指令,和本项目的专属组体系
* 冲突,不导入。
*
* ini 里的目标组名(「🎯 全球直连」这类)在这份配置里不一定存在,逐个让用户
* 映射到实际出站;没映射的行不导入。
*/
import { computed, ref, watch } from 'vue'
import { parseIniRulesets, planIniImport } from '@shared/rules.ts'
import type { IniImportPlan } from '@shared/rules.ts'
import IconClose from '~icons/ri/close-line'
const props = defineProps<{
open: boolean
/** 映射候选:内置出站、策略组、自有线路 */
outboundNames: string[]
/** 已声明的规则组名,生成新名字时避开 */
existingProviderNames: string[]
saving: boolean
}>()
const emit = defineEmits<{ close: []; submit: [plan: IniImportPlan] }>()
const KIND_LABELS: Record<string, string> = {
url: '规则组',
file: '规则组',
geoip: 'GEOIP 规则',
geosite: 'GEOSITE 规则',
final: '兜底规则',
}
const dialog = ref<HTMLDialogElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const text = ref('')
const mapping = ref<Record<string, string>>({})
const entries = computed(() => parseIniRulesets(text.value))
/** ini 里出现过的目标组名,按出现顺序去重。每个都要映射到一个实际出站。 */
const targets = computed(() => [...new Set(entries.value.map((e) => e.target))])
const plan = computed(() =>
planIniImport(entries.value, mapping.value, new Set(props.existingProviderNames)),
)
const unmapped = computed(() => targets.value.filter((t) => !mapping.value[t]))
const canSubmit = computed(
() =>
entries.value.length > 0 &&
unmapped.value.length === 0 &&
Object.keys(plan.value.providers).length +
plan.value.rules.length +
plan.value.finals.length >
0 &&
!props.saving,
)
watch(
() => props.open,
(open) => {
if (open) {
text.value = ''
mapping.value = {}
dialog.value?.showModal()
} else {
dialog.value?.close()
}
},
)
// 新解析出来的目标组名补进映射表,消失的清掉
watch(targets, (list) => {
const next: Record<string, string> = {}
for (const target of list) next[target] = mapping.value[target] ?? ''
mapping.value = next
})
async function pickFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) text.value = await file.text()
// 同一个文件选第二次也要触发 change
if (fileInput.value) fileInput.value.value = ''
}
function submit() {
if (!canSubmit.value) return
emit('submit', plan.value)
}
</script>
<template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<form method="dialog" @submit.prevent="submit">
<header class="dlg-head">
<h2>导入 ini</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">
<textarea
v-model="text"
class="mono"
rows="8"
placeholder="粘贴 ini 文本,或从文件读取"
spellcheck="false"
></textarea>
<input
ref="fileInput"
type="file"
accept=".ini,.conf,.txt"
hidden
@change="pickFile"
/>
<button type="button" class="btn file" @click="fileInput?.click()">从文件读取</button>
</span>
</label>
<template v-if="entries.length > 0">
<div class="row">
<span class="k">条目</span>
<ol class="found">
<li v-for="(entry, i) in entries" :key="i">
<span class="mono src" :title="entry.source || entry.target">
{{ entry.kind === 'final' ? '[]FINAL' : entry.source }}
</span>
<span class="kind">{{ KIND_LABELS[entry.kind] }}</span>
</li>
</ol>
</div>
<label v-for="target in targets" :key="target" class="row">
<span class="k map-name" :title="target">{{ target }}</span>
<span class="v">
<select v-model="mapping[target]">
<option value="" disabled>映射到…</option>
<option v-for="name in outboundNames" :key="name" :value="name">{{ name }}</option>
</select>
</span>
</label>
</template>
</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(560px, 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);
max-height: min(64vh, 560px);
overflow-y: auto;
}
.row {
display: grid;
grid-template-columns: 7rem 1fr;
gap: var(--s-4);
align-items: baseline;
}
.k {
color: var(--c-text-soft);
font-size: var(--t-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v {
display: flex;
flex-direction: column;
gap: var(--s-2);
min-width: 0;
}
textarea {
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);
resize: vertical;
font-size: var(--t-sm);
line-height: 1.5;
}
select {
width: 100%;
appearance: none;
padding: var(--s-2) var(--s-3);
padding-right: var(--s-6);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-bg);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16'%3E%3Cpath fill='%23898f98' d='M12 15.6 5.7 9.3l1.4-1.4 4.9 4.9 4.9-4.9 1.4 1.4z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--s-3) center;
}
.found {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s-1);
max-height: 10rem;
overflow-y: auto;
}
.found li {
display: flex;
align-items: baseline;
gap: var(--s-3);
font-size: var(--t-sm);
}
.src {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kind {
flex: none;
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.file {
align-self: flex-start;
}
.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;
}
.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-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>