[feat] GeoData object
Some checks failed
Release X-UI / build (386) (push) Has been cancelled
Release X-UI / build (amd64) (push) Has been cancelled
Release X-UI / build (arm64) (push) Has been cancelled
Release X-UI / build (armv5) (push) Has been cancelled
Release X-UI / build (armv6) (push) Has been cancelled
Release X-UI / build (armv7) (push) Has been cancelled
Release X-UI / build (s390x) (push) Has been cancelled
Release X-UI / Build for Windows (push) Has been cancelled

This commit is contained in:
Alireza Ahmadi 2026-05-19 00:50:03 +02:00
parent 682077f5c8
commit 75858aadd6
8 changed files with 314 additions and 3 deletions

View file

@ -692,6 +692,52 @@
</a-table>
</template>
</a-tab-pane>
<a-tab-pane key="tpl-geodata" tab='{{ i18n "pages.xray.geodata.title"}}' style="padding-top: 20px;" force-render="true">
<setting-list-item type="switch" title='{{ i18n "pages.xray.geodata.enable" }}' desc='{{ i18n "pages.xray.geodata.enableDesc" }}' v-model="enableGeoData"></setting-list-item>
<template v-if="enableGeoData">
<setting-list-item type="text" title='{{ i18n "pages.xray.geodata.cron" }}' desc='{{ i18n "pages.xray.geodata.cronDesc" }}' v-model="geodataCron"></setting-list-item>
<a-list-item>
<a-row style="padding: 20px">
<a-col :lg="24" :xl="12">
<a-list-item-meta title='{{ i18n "pages.xray.geodata.outbound" }}' description='{{ i18n "pages.xray.geodata.outboundDesc" }}' />
</a-col>
<a-col :lg="24" :xl="12">
<a-select v-model="geodataOutbound" allow-clear style="width: 100%"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="">Routing</a-select-option>
<a-select-option v-for="tag in geodataOutboundTags" :value="tag">[[ tag ]]</a-select-option>
</a-select>
</a-col>
</a-row>
</a-list-item>
<a-button type="primary" icon="plus" @click="addGeoDataAsset()" style="margin-bottom: 10px;">{{ i18n "pages.xray.geodata.addAsset" }}</a-button>
<a-table :columns="geodataAssetColumns" bordered v-if="geodataAssets.length>0"
:row-key="(r, index) => index"
:data-source="geodataAssets"
:scroll="isMobile ? {} : { x: 200 }"
:pagination="false"
:indent-size="0"
:style="isMobile ? 'padding: 5px 0' : 'margin-left: 1px;'">
<template slot="action" slot-scope="text, asset, index">
[[ index+1 ]]
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more" style="font-size: 16px; text-decoration: bold;"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item @click="editGeoDataAsset(index)">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item @click="deleteGeoDataAsset(index)">
<span style="color: #FF4D4F">
<a-icon type="delete"></a-icon> {{ i18n "delete"}}
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
</a-table>
</template>
</a-tab-pane>
<a-tab-pane key="tpl-advanced" tab='{{ i18n "pages.xray.advancedTemplate"}}' style="padding-top: 20px;" force-render="true">
<a-list-item-meta title='{{ i18n "pages.xray.Template"}}' description='{{ i18n "pages.xray.TemplateDesc"}}'></a-list-item-meta>
<a-radio-group v-model="advSettings" @change="changeCode" button-style="solid" style="margin: 10px 0;" :size="isMobile ? 'small' : ''">
@ -716,6 +762,7 @@
{{template "balancerModal"}}
{{template "dnsModal"}}
{{template "fakednsModal"}}
{{template "geodataAssetModal"}}
{{template "warpModal"}}
<script>
const rulesColumns = [
@ -772,6 +819,12 @@
{ title: '{{ i18n "pages.xray.fakedns.poolSize"}}', dataIndex: 'poolSize', align: 'center', width: 50 },
];
const geodataAssetColumns = [
{ title: "#", align: 'center', width: 20, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.geodata.url"}}', dataIndex: 'url', align: 'center', width: 80, ellipsis: true },
{ title: '{{ i18n "pages.xray.geodata.file"}}', dataIndex: 'file', align: 'center', width: 40 },
];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
@ -1314,6 +1367,36 @@
fakeDns.splice(index,1);
this.fakeDns = fakeDns;
},
addGeoDataAsset() {
geodataAssetModal.show({
title: '{{ i18n "pages.xray.geodata.addAsset" }}',
confirm: (asset) => {
assets = this.geodataAssets;
assets.push(asset);
this.geodataAssets = assets;
geodataAssetModal.close();
},
isEdit: false
});
},
editGeoDataAsset(index) {
geodataAssetModal.show({
title: '{{ i18n "pages.xray.geodata.editAsset" }} #' + (index + 1),
asset: this.geodataAssets[index],
confirm: (asset) => {
assets = this.geodataAssets;
assets[index] = asset;
this.geodataAssets = assets;
geodataAssetModal.close();
},
isEdit: true
});
},
deleteGeoDataAsset(index) {
assets = this.geodataAssets;
assets.splice(index, 1);
this.geodataAssets = assets;
},
addRule(){
ruleModal.show({
title: '{{ i18n "pages.xray.rules.add"}}',
@ -1707,6 +1790,83 @@
newTemplateSettings.fakedns = newValue.length >0 ? newValue : null;
this.templateSettings = newTemplateSettings;
}
},
geodataSettings: function () {
if (!this.templateSettings) return null;
return this.templateSettings.geodata ?? this.templateSettings.geoData ?? null;
},
enableGeoData: {
get: function () { return this.geodataSettings != null; },
set: function (newValue) {
newTemplateSettings = this.templateSettings;
if (newValue) {
if (!newTemplateSettings.geodata) {
newTemplateSettings.geodata = newTemplateSettings.geoData ?? { assets: [] };
delete newTemplateSettings.geoData;
}
} else {
delete newTemplateSettings.geodata;
delete newTemplateSettings.geoData;
}
this.templateSettings = newTemplateSettings;
}
},
geodataCron: {
get: function () {
const g = this.geodataSettings;
return g && g.cron ? g.cron : '';
},
set: function (newValue) {
newTemplateSettings = this.templateSettings;
if (!newTemplateSettings.geodata) {
newTemplateSettings.geodata = newTemplateSettings.geoData ?? { assets: [] };
delete newTemplateSettings.geoData;
}
if (newValue) {
newTemplateSettings.geodata.cron = newValue;
} else {
delete newTemplateSettings.geodata.cron;
}
this.templateSettings = newTemplateSettings;
}
},
geodataOutbound: {
get: function () {
const g = this.geodataSettings;
return g && g.outbound ? g.outbound : '';
},
set: function (newValue) {
newTemplateSettings = this.templateSettings;
if (!newTemplateSettings.geodata) {
newTemplateSettings.geodata = newTemplateSettings.geoData ?? { assets: [] };
delete newTemplateSettings.geoData;
}
if (newValue) {
newTemplateSettings.geodata.outbound = newValue;
} else {
delete newTemplateSettings.geodata.outbound;
}
this.templateSettings = newTemplateSettings;
}
},
geodataOutboundTags: function () {
if (!this.templateSettings) return [];
return this.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map((o) => o.tag);
},
geodataAssets: {
get: function () {
const g = this.geodataSettings;
return g && g.assets ? g.assets : [];
},
set: function (newValue) {
newTemplateSettings = this.templateSettings;
if (!newTemplateSettings.geodata) {
newTemplateSettings.geodata = newTemplateSettings.geoData ?? { assets: [] };
delete newTemplateSettings.geoData;
}
newTemplateSettings.geodata.assets = newValue;
this.templateSettings = newTemplateSettings;
}
}
},
});

View file

@ -0,0 +1,79 @@
{{define "geodataAssetModal"}}
<a-modal id="geodata-asset-modal" v-model="geodataAssetModal.visible" :title="geodataAssetModal.title" @ok="geodataAssetModal.ok"
:confirm-loading="geodataAssetModal.confirmLoading" :closable="true" :mask-closable="false"
:ok-button-props="{ props: { disabled: !geodataAssetModal.isValid } }"
:ok-text="geodataAssetModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "pages.xray.geodata.url" }}' has-feedback
:validate-status="geodataAssetModal.invalidUrl ? 'warning' : 'success'">
<a-input v-model.trim="geodataAssetModal.asset.url" @change="geodataAssetModal.check()"
placeholder="https://example.com/geoip.dat"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.geodata.file" }}' has-feedback
:validate-status="geodataAssetModal.emptyFile ? 'warning' : 'success'">
<a-input v-model.trim="geodataAssetModal.asset.file" @change="geodataAssetModal.check()"
placeholder="geoip.dat"></a-input>
</a-form-item>
</a-form>
</a-modal>
<script>
const geodataAssetModal = {
title: '',
visible: false,
confirmLoading: false,
okText: '{{ i18n "confirm" }}',
isEdit: false,
confirm: null,
invalidUrl: false,
emptyFile: false,
isValid: false,
asset: {
url: '',
file: '',
},
ok() {
if (!geodataAssetModal.check()) return;
ObjectUtil.execute(geodataAssetModal.confirm, {
url: geodataAssetModal.asset.url,
file: geodataAssetModal.asset.file,
});
},
show({ title = '', okText = '{{ i18n "confirm" }}', asset, confirm = (asset) => { }, isEdit = false }) {
geodataAssetModal.title = title;
geodataAssetModal.okText = okText;
geodataAssetModal.confirm = confirm;
geodataAssetModal.visible = true;
geodataAssetModal.isEdit = isEdit;
if (isEdit && asset) {
geodataAssetModal.asset = { url: asset.url ?? '', file: asset.file ?? '' };
} else {
geodataAssetModal.asset = { url: '', file: '' };
}
geodataAssetModal.check();
},
close() {
geodataAssetModal.visible = false;
geodataAssetModal.loading(false);
},
loading(loading = true) {
geodataAssetModal.confirmLoading = loading;
},
check() {
const url = geodataAssetModal.asset.url.trim();
const file = geodataAssetModal.asset.file.trim();
geodataAssetModal.emptyFile = file.length === 0;
geodataAssetModal.invalidUrl = url.length === 0 || !url.toLowerCase().startsWith('https://');
geodataAssetModal.isValid = !geodataAssetModal.emptyFile && !geodataAssetModal.invalidUrl;
return geodataAssetModal.isValid;
},
};
new Vue({
delimiters: ['[[', ']]'],
el: '#geodata-asset-modal',
data: {
geodataAssetModal: geodataAssetModal,
},
});
</script>
{{end}}

View file

@ -660,6 +660,19 @@
"ipPool" = "IP Pool Subnet"
"poolSize" = "Pool Size"
[pages.xray.geodata]
"title" = "GeoData"
"enable" = "Enable GeoData"
"enableDesc" = "Reload geodata files on a schedule and download new .dat files before reloading."
"cron" = "Cron Schedule"
"cronDesc" = "5-field cron expression in local time, e.g. 0 4 * * * for daily at 04:00. Leave empty to disable the schedule."
"outbound" = "Download Outbound"
"outboundDesc" = "Outbound tag used when downloading geodata. Empty means routing decides the path."
"addAsset" = "Add Asset"
"editAsset" = "Edit Asset"
"url" = "URL"
"file" = "File"
[tgbot]
"noResult" = "❗ No result!"
"wentWrong" = "❌ Something went wrong!"

View file

@ -660,6 +660,19 @@
"ipPool" = "زیرشبکه استخر آی‌پی"
"poolSize" = "اندازه استخر"
[pages.xray.geodata]
"title" = "GeoData"
"enable" = "فعال‌سازی GeoData"
"enableDesc" = "بارگذاری مجدد فایل‌های geodata طبق زمان‌بندی و دانلود فایل‌های .dat جدید قبل از reload."
"cron" = "زمان‌بندی Cron"
"cronDesc" = "عبارت cron پنج‌فیلدی بر اساس زمان محلی، مثلاً 0 4 * * * برای هر روز ساعت ۴. خالی = بدون زمان‌بندی."
"outbound" = "خروجی دانلود"
"outboundDesc" = "برچسب outbound برای دانلود geodata. خالی یعنی مسیریابی مسیر را تعیین می‌کند."
"addAsset" = "افزودن فایل"
"editAsset" = "ویرایش فایل"
"url" = "آدرس"
"file" = "نام فایل"
[tgbot]
"noResult" = "❗نتیجه‌ای یافت نشد"
"wentWrong" = "❌ مشکلی رخ داده‌است"

View file

@ -660,6 +660,19 @@
"ipPool" = "Подсеть пула IP"
"poolSize" = "Размер пула"
[pages.xray.geodata]
"title" = "GeoData"
"enable" = "Включить GeoData"
"enableDesc" = "Перезагружать geodata по расписанию и скачивать новые .dat файлы перед перезагрузкой."
"cron" = "Расписание Cron"
"cronDesc" = "5-полевое cron-выражение в локальном времени, например 0 4 * * * для ежедневного запуска в 04:00. Пусто — без расписания."
"outbound" = "Исходящий для загрузки"
"outboundDesc" = "Тег исходящего для загрузки geodata. Пусто — маршрутизация выбирает путь."
"addAsset" = "Добавить ресурс"
"editAsset" = "Редактировать ресурс"
"url" = "URL"
"file" = "Файл"
[tgbot]
"noResult" = "❗ Нет результатов!"
"wentWrong" = "❌ Что-то пошло не так!"

View file

@ -660,6 +660,19 @@
"ipPool" = "Mạng con nhóm IP"
"poolSize" = "Kích thước bể bơi"
[pages.xray.geodata]
"title" = "GeoData"
"enable" = "Bật GeoData"
"enableDesc" = "Tải lại tệp geodata theo lịch và tải xuống tệp .dat mới trước khi reload."
"cron" = "Lịch Cron"
"cronDesc" = "Biểu thức cron 5 trường theo giờ địa phương, ví dụ 0 4 * * * chạy hàng ngày lúc 04:00. Để trống để tắt lịch."
"outbound" = "Outbound tải xuống"
"outboundDesc" = "Thẻ outbound dùng khi tải geodata. Để trống thì routing quyết định đường đi."
"addAsset" = "Thêm tài nguyên"
"editAsset" = "Sửa tài nguyên"
"url" = "URL"
"file" = "Tệp"
[tgbot]
"noResult" = "❗ Không có kết quả!"
"wentWrong" = "❌ Đã xảy ra lỗi!"

View file

@ -660,6 +660,19 @@
"ipPool" = "IP 池子网"
"poolSize" = "池大小"
[pages.xray.geodata]
"title" = "GeoData"
"enable" = "启用 GeoData"
"enableDesc" = "按计划重新加载 geodata 文件,并在重新加载前下载新的 .dat 文件。"
"cron" = "Cron 计划"
"cronDesc" = "本地时间的 5 字段 cron 表达式,例如 0 4 * * * 表示每天 04:00。留空则禁用计划任务。"
"outbound" = "下载出站"
"outboundDesc" = "下载 geodata 时使用的出站标签。留空则由路由决定路径。"
"addAsset" = "添加资源"
"editAsset" = "编辑资源"
"url" = "URL"
"file" = "文件"
[tgbot]
"noResult" = "❗ 没有结果!"
"wentWrong" = "❌ 出了点问题!"

View file

@ -19,7 +19,8 @@ type Config struct {
FakeDNS json_util.RawMessage `json:"fakedns"`
Observatory json_util.RawMessage `json:"observatory"`
BurstObservatory json_util.RawMessage `json:"burstObservatory"`
Metrics json_util.RawMessage `json:"metrics"`
Metrics json_util.RawMessage `json:"metrics,omitEmpty"`
GeoData json_util.RawMessage `json:"geodata,omitempty"`
}
func (c *Config) Equals(other *Config) bool {
@ -55,14 +56,20 @@ func (c *Config) Equals(other *Config) bool {
if !bytes.Equal(c.Stats, other.Stats) {
return false
}
if !bytes.Equal(c.Reverse, other.Reverse) {
if !bytes.Equal(c.FakeDNS, other.FakeDNS) {
return false
}
if !bytes.Equal(c.FakeDNS, other.FakeDNS) {
if !bytes.Equal(c.Observatory, other.Observatory) {
return false
}
if !bytes.Equal(c.BurstObservatory, other.BurstObservatory) {
return false
}
if !bytes.Equal(c.Metrics, other.Metrics) {
return false
}
if !bytes.Equal(c.GeoData, other.GeoData) {
return false
}
return true
}