规则页:全局分流规则的编辑与规则组管理
新增 RulesPage.vue 和规则、规则组、ini 导入三个对话框,纯逻辑放在 shared/rules.ts。拆分规则字符串复用 vendor 的 splitRuleFields——引号和 嵌套括号里的逗号不会被当成字段分隔;目标之后的选项段原样拼回,改一条 规则不会丢掉它的 no-resolve 尾巴。规则组的缓存路径显式生成:不写 path 时内核按 url 哈希自己算一个,那个路径这里算不出来。
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user