feat: GeoIP-based geo detection + configurable rename template

- Add MaxMind GeoLite2-City mmdb auto-download and IP-based geo lookup
  (covers all 249 countries globally, replacing limited 8-country regex)
- Add RenameOptions with 5 toggleable fields (flag/alias/country/city/index)
  stored as rename_options_json on collections
- Collection rename pipeline uses DetectGeoWithServer(name, server) which
  tries GeoIP lookup first, falls back to name-based regex matching
- Chinese country/city names + emoji flag prefix
- Country-city dedup: skip city when it equals country (e.g. 香港/香港)
- URI fragment encoding: url.QueryEscape -> url.PathEscape (%20 not +)
- Egress cache key excludes name field so renamed nodes still match cache
- Frontend: 5 toggle buttons in collection edit form, default all on
- Migrations: 0004 (source alias + collection rename_enabled),
  0005 (collection rename_options_json)
This commit is contained in:
2026-07-28 14:44:54 +08:00
parent 389c0d172a
commit de9ac92ede
29 changed files with 1256 additions and 225 deletions
+37 -14
View File
@@ -20,7 +20,7 @@
<span>原始节点: {{ nodeCounts[col.id]?.original ?? '-' }}</span>
<span>处理后: {{ nodeCounts[col.id]?.processed ?? '-' }}</span>
<span>订阅源: {{ col.sourceIds?.length || 0 }}</span>
<span>过滤器: {{ col.filters?.length || 0 }}</span>
<span v-if="col.renameEnabled" class="text-purple-500">重命名已开启</span>
<span>模板: {{ col.templateId || 'default' }}</span>
<span v-if="col.ignoreFailed">忽略失败</span>
</div>
@@ -114,18 +114,23 @@
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.ignoreFailed" class="rounded" /> 忽略失败源
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.renameEnabled" class="rounded" /> 节点重命名
</label>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-sm font-medium">过滤器</label>
<button class="text-xs text-primary-600 hover:underline" @click="form.filters.push({ type: 'include', pattern: '' })">+ 添加</button>
</div>
<div v-for="(f, i) in form.filters" :key="i" class="flex gap-1 mb-1.5">
<select v-model="f.type" class="px-2 py-1 border rounded text-xs w-28">
<option v-for="t in filterTypes" :key="t" :value="t">{{ t }}</option>
</select>
<input v-model="f.pattern" placeholder="pattern" class="flex-1 px-2 py-1 border rounded text-xs" />
<button class="px-2 py-1 text-xs text-red-500 hover:bg-red-50 rounded" @click="form.filters.splice(i,1)">×</button>
<div v-if="form.renameEnabled" class="rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-600">
开启后,所有节点将按以下格式重命名:<br>
<code class="text-blue-800">[国旗] [别名] [国家] [城市] [序号]</code><br>
点击下方按钮可切换各字段的显示(默认全开):
<div class="flex flex-wrap gap-1.5 mt-2">
<button type="button" v-for="opt in renameOptionList" :key="opt.key"
class="px-2.5 py-1 rounded-full text-xs font-medium border transition-colors"
:class="form.renameOptions[opt.key]
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-gray-400'"
@click="form.renameOptions[opt.key] = !form.renameOptions[opt.key]">
{{ opt.label }}
</button>
</div>
</div>
</div>
@@ -169,8 +174,18 @@ const confirmDialog = ref({ show: false, title: '', message: '', danger: false,
const filterTypes = ['include', 'exclude', 'rename', 'dedupe', 'sort', 'delete-field', 'flag', 'quick', 'resolve', 'custom']
const renameOptionList = [
{ key: 'flag', label: '国旗' },
{ key: 'alias', label: '别名' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' },
{ key: 'index', label: '序号' },
]
const defaultRenameOptions = () => ({ flag: true, alias: true, country: true, city: true, index: true })
const emptyForm = () => ({
name: '', sourceIds: [], filters: [], templateId: '', ignoreFailed: true, enabled: true
name: '', sourceIds: [], renameEnabled: false, renameOptions: defaultRenameOptions(), templateId: '', ignoreFailed: true, enabled: true
})
const form = reactive(emptyForm())
@@ -217,10 +232,18 @@ function openCreate() {
}
function editCol(col) {
const ro = col.renameOptions || {}
Object.assign(form, {
name: col.id,
sourceIds: [...(col.sourceIds || [])],
filters: JSON.parse(JSON.stringify(col.filters || [])),
renameEnabled: col.renameEnabled === true,
renameOptions: {
flag: ro.flag !== false,
alias: ro.alias !== false,
country: ro.country !== false,
city: ro.city !== false,
index: ro.index !== false,
},
templateId: col.templateId || '',
ignoreFailed: col.ignoreFailed !== false,
enabled: col.enabled !== false,
+19 -6
View File
@@ -14,6 +14,7 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ src.name }}</span>
<span v-if="src.alias" class="px-1.5 py-0.5 rounded text-xs bg-purple-100 text-purple-700">{{ src.alias }}</span>
<span class="px-1.5 py-0.5 rounded text-xs"
:class="src.type === 'remote' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'">{{ src.type }}</span>
<span v-if="!src.enabled" class="px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-700">已禁用</span>
@@ -22,6 +23,7 @@
<div class="flex gap-2 mt-1 text-xs text-gray-400">
<span>原始节点: {{ nodeCounts[src.id]?.original ?? '-' }}</span>
<span>处理后: {{ nodeCounts[src.id]?.processed ?? '-' }}</span>
<span v-if="src.alias">别名: {{ src.alias }}</span>
<span>过滤器: {{ src.filters?.length || 0 }}</span>
<span v-if="src.createdAt">创建: {{ formatTime(src.createdAt) }}</span>
</div>
@@ -87,11 +89,19 @@
<!-- Create/Edit Modal -->
<Modal v-model="showModal" :title="editing ? '编辑订阅源' : '新建订阅源'">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">名称 / ID</label>
<input v-model="form.name" :disabled="editing" placeholder="my-sub"
class="w-full px-3 py-2 border rounded-lg text-sm disabled:bg-gray-100" />
<p class="text-xs text-gray-400 mt-0.5">仅支持小写字母、数字、下划线、连字符</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1">名称 / ID</label>
<input v-model="form.name" :disabled="editing" placeholder="my-sub"
class="w-full px-3 py-2 border rounded-lg text-sm disabled:bg-gray-100" />
<p class="text-xs text-gray-400 mt-0.5">小写字母、数字、下划线、连字符</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">别名 (Alias)</label>
<input v-model="form.alias" placeholder="如:机场A"
class="w-full px-3 py-2 border rounded-lg text-sm" />
<p class="text-xs text-gray-400 mt-0.5">合集重命名时作为节点标识</p>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">类型</label>
@@ -225,7 +235,7 @@ const filterGuides = [
]
const emptyForm = () => ({
name: '', type: 'remote', url: '', content: '', enabled: true, filters: []
name: '', alias: '', type: 'remote', url: '', content: '', enabled: true, filters: []
})
const form = reactive(emptyForm())
@@ -305,6 +315,7 @@ function sourcePreviewPayload(src) {
return {
id: src.id,
name: src.name,
alias: src.alias,
type: src.type,
url: src.url,
content: src.content,
@@ -321,6 +332,7 @@ function openCreate() {
function editSource(src) {
Object.assign(form, {
name: src.id,
alias: src.alias || '',
type: src.type || 'remote',
url: src.url || '',
content: src.content || '',
@@ -339,6 +351,7 @@ async function save() {
try {
const payload = {
name: form.name,
alias: form.alias,
type: form.type,
url: form.url,
content: form.content,