This commit is contained in:
admin8800
2026-05-10 06:41:44 +00:00
parent d104b6e40d
commit 3eb70ee9ed
252 changed files with 42215 additions and 2 deletions
+259
View File
@@ -0,0 +1,259 @@
<template>
<v-dialog transition="dialog-top-transition" width="800">
<v-card class="rounded-lg">
<v-card-title>
<v-row>
<v-col>
{{ $t('rule.import.rulesTitle') }}
</v-col>
<v-col cols="auto">
<v-chip v-if="parsed" size="small" color="primary" variant="tonal">
{{ parsed.rules?.length ?? 0 }} {{ $t('pages.rules') }} · {{ parsed.rule_set?.length ?? 0 }} {{ $t('rule.ruleset') }}
</v-chip>
</v-col>
</v-row>
</v-card-title>
<v-divider></v-divider>
<v-card-text style="padding: 0 16px; overflow-y: scroll;">
<v-tabs v-model="tab" @update:modelValue="tabChanged">
<v-tab value="json">JSON</v-tab>
<v-tab value="file">{{ $t('rule.import.uploadFile') }}</v-tab>
<v-tab value="url">{{ $t('rule.import.fromUrl') }}</v-tab>
</v-tabs>
<v-window v-model="tab">
<v-window-item value="json">
<v-alert variant="text" type="info">{{ $t('rule.import.jsonHint') }}</v-alert>
<v-textarea
v-model="rawJson"
label="JSON"
variant="outlined"
rows="12"
hide-details
spellcheck="false"
class="mb-4"
:error="!!error"></v-textarea>
</v-window-item>
<v-window-item value="file">
<v-alert variant="text" type="info">{{ $t('rule.import.fileJsonHint') }}</v-alert>
<v-file-input
:label="$t('rule.import.selectJson')"
accept=".json"
variant="outlined"
hide-details
prepend-icon="mdi-file-code"
clearable
@click:clear="tabChanged"
@update:modelValue="onFileUpload($event)" />
</v-window-item>
<v-window-item value="url">
<v-alert variant="text" type="info">{{ $t('rule.import.urlHint') }}</v-alert>
<v-text-field
v-model="fetchUrl"
label="URL"
variant="outlined"
hide-details
spellcheck="false"
placeholder="https://raw.githubusercontent.com/.../rules.json"
append-icon="mdi-download"
@keydown.enter="fetchFromUrl"
@click:append="fetchFromUrl" />
</v-window-item>
</v-window>
<v-alert v-if="error" type="error" variant="text" v-html="error"></v-alert>
<template v-if="parsed">
<v-divider class="my-4" />
<v-alert v-if="hasConflicts" type="warning" variant="tonal" :title="$t('rule.import.conflict')" class="mb-4">
{{ $t('rule.import.conflictDesc', { rules: existingRulesCount, rulesets: existingRulesetsCount }) }}
<v-radio-group v-model="mode" hide-details class="mt-2">
<v-radio value="merge" :label="$t('rule.import.merge')" />
<v-radio value="replace" :label="$t('rule.import.replace')" />
</v-radio-group>
</v-alert>
<v-alert v-if="parsed.final" type="info" variant="tonal" class="mb-4">
{{ $t('rule.import.finalOutbound') }}:
<v-chip size="small" color="secondary" variant="tonal">{{ parsed.final }}</v-chip>
<v-checkbox v-model="applyFinal" :label="$t('rule.import.applyFinal')" hide-details density="compact" />
</v-alert>
<span class="v-card-subtitle">
{{ $t('pages.rules') }}
<v-badge v-if="parsed.rules?.length > 0" color="success" :content="parsed.rules?.length" inline />
</span>
<v-table v-if="parsed.rules?.length" density="compact" class="mb-4" striped="even">
<thead>
<tr><th>#</th><th>{{ $t('type') }}</th><th>{{ $t('admin.action') }}</th><th>{{ $t('objects.outbound') }}</th></tr>
</thead>
<tbody>
<tr v-for="(r, i) in parsed.rules" :key="i">
<td>{{ (i as number) + 1 }}</td>
<td>{{ r.type ?? 'simple' }}</td>
<td>{{ r.action }}</td>
<td>{{ r.outbound ?? '-' }}</td>
</tr>
</tbody>
</v-table>
<span class="v-card-subtitle">
{{ $t('rule.ruleset') }}
<v-badge v-if="parsed.rule_set?.length > 0" color="success" :content="parsed.rule_set?.length" inline />
<span v-if="skippedRulesets > 0">
<v-badge color="warning" :content="skippedRulesets" inline v-tooltip:top="$t('rule.import.skipped')" />
</span>
</span>
<v-table v-if="parsed.rule_set?.length" density="compact" striped="even">
<thead>
<tr><th>{{ $t('objects.tag') }}</th><th>{{ $t('ruleset.format') }}</th><th>{{ $t('type') }}</th><th>{{ $t('ruleset.interval') }}</th></tr>
</thead>
<tbody>
<tr v-for="(rs, i) in parsed.rule_set" :key="i"
:style="mode === 'merge' && existingRulesetTags.includes(rs.tag) ? 'opacity:0.4' : ''">
<td style="font-family: monospace;">{{ rs.tag }}</td>
<td>{{ rs.format }}</td>
<td>{{ rs.type }}</td>
<td>{{ rs.update_interval ?? '-' }}</td>
</tr>
</tbody>
</v-table>
</template>
</v-card-text>
<v-card-actions>
<v-btn
v-if="tab === 'json'"
@click="parseJson"
variant="tonal"
color="success"
:disabled="rawJson.trim().length === 0"
>
{{ $t('rule.import.parse') }}
<v-icon icon="mdi-magnify" />
</v-btn>
<v-spacer />
<v-btn @click="close" variant="text">{{ $t('actions.close') }}</v-btn>
<v-btn @click="save" color="primary" variant="flat" :disabled="!parsed">
{{ $t('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
export default {
props: ["visible", "existingRulesCount", "existingRulesetsCount", "existingRulesetTags"],
emits: ['save', 'close'],
data() {
return {
tab: 'json',
rawJson: '',
fetchUrl: '',
fetching: false,
error: '',
parsed: null as any,
mode: 'merge' as 'merge' | 'replace',
applyFinal: false,
}
},
computed: {
hasConflicts(): boolean {
return this.existingRulesCount > 0 || this.existingRulesetsCount > 0
},
skippedRulesets(): number {
if (!this.parsed?.rule_set) return 0
const existing = new Set(this.existingRulesetTags)
return this.parsed.rule_set.filter((rs: any) => existing.has(rs.tag)).length
},
},
methods: {
tabChanged() {
this.rawJson = ''
this.fetchUrl = ''
this.error = ''
this.parsed = null
this.mode = this.hasConflicts ? 'merge' : 'replace'
this.applyFinal = false
},
extractRouteBlock(obj: any): any {
if (obj?.route && (obj.route.rules || obj.route.rule_set)) return obj.route
if (obj?.rules || obj?.rule_set) return obj
return null
},
setParsed(block: any) {
this.parsed = block
this.mode = this.hasConflicts ? 'merge' : 'replace'
this.applyFinal = false
},
reset() {
this.tab = 'json'
this.tabChanged()
},
parseJson() {
this.error = ''
this.parsed = null
try {
const block = this.extractRouteBlock(JSON.parse(this.rawJson))
if (!block) {
this.error = this.$t('rule.import.errNoArrays')
return
}
this.setParsed(block)
} catch (e: any) {
this.error = this.$t('rule.import.errJsonParse', { message: e.message })
}
},
async fetchFromUrl() {
this.error = ''
this.parsed = null
this.fetching = true
try {
const resp = await fetch(this.fetchUrl)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const block = this.extractRouteBlock(await resp.json())
if (!block) this.error = this.$t('rule.import.errNoArraysFetched')
else this.setParsed(block)
} catch (e: any) {
this.error = this.$t('rule.import.errFetch', { message: e.message })
} finally {
this.fetching = false
}
},
async onFileUpload(files: File | File[] | null) {
this.error = ''
this.parsed = null
const file = Array.isArray(files) ? files[0] : files
if (!file) {
this.error = this.$t('rule.import.errNoFile')
return
}
try {
const block = this.extractRouteBlock(JSON.parse(await file.text()))
if (!block) {
this.error = this.$t('rule.import.errNoArraysInFile')
return
}
this.setParsed(block)
} catch (e: any) {
this.error = this.$t('rule.import.errJsonParse', { message: e.message })
return
}
},
save() {
if (!this.parsed) return
this.$emit('save', this.parsed, this.mode, this.applyFinal)
},
close() {
this.$emit('close')
},
},
watch: {
visible(v: boolean) {
if (v) this.reset()
},
},
}
</script>