mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
feat(frontend): Phase 5c-v — custom-geo section in VersionModal
Adds the third collapse panel ("Custom geo") that lets users register
external geosite/geoip files referenced by routing rules via
ext:<filename>:tag. Backend endpoints are unchanged.
- CustomGeoSection.vue: bordered table over /panel/api/custom-geo/list
with per-row edit, download (refetch), and delete actions, plus an
Add button and Update-all. Lazy-loads the list when the parent
collapse opens this panel — closed panels don't fetch.
- CustomGeoFormModal.vue: shared add/edit form with the same alias
regex (^[a-z0-9_-]+$) and URL validation as legacy. Type and alias
are immutable when editing — backend rejects changes anyway.
- ext:<filename>:tag value is click-to-copy via ClipboardManager.
- Relative time is computed inline (no moment dep); tooltip shows the
absolute timestamp.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c44f25ec1f
commit
732b3f51aa
3 changed files with 426 additions and 0 deletions
112
frontend/src/pages/index/CustomGeoFormModal.vue
Normal file
112
frontend/src/pages/index/CustomGeoFormModal.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
// Populate with the record when editing; null/undefined when adding.
|
||||
record: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const form = reactive({ type: 'geosite', alias: '', url: '' });
|
||||
const saving = ref(false);
|
||||
|
||||
const editing = ref(false);
|
||||
const editId = ref(null);
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (props.record) {
|
||||
editing.value = true;
|
||||
editId.value = props.record.id;
|
||||
form.type = props.record.type;
|
||||
form.alias = props.record.alias;
|
||||
form.url = props.record.url;
|
||||
} else {
|
||||
editing.value = false;
|
||||
editId.value = null;
|
||||
form.type = 'geosite';
|
||||
form.alias = '';
|
||||
form.url = '';
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function validate() {
|
||||
// Backend expects a filesystem-safe alias; legacy enforces the same regex.
|
||||
if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
|
||||
message.error('Alias must contain only lowercase letters, digits, dashes or underscores.');
|
||||
return false;
|
||||
}
|
||||
const u = (form.url || '').trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
message.error('URL must start with http:// or https://');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
message.error('URL must start with http:// or https://');
|
||||
return false;
|
||||
}
|
||||
} catch (_e) {
|
||||
message.error('URL must start with http:// or https://');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const url = editing.value
|
||||
? `/panel/api/custom-geo/update/${editId.value}`
|
||||
: '/panel/api/custom-geo/add';
|
||||
const msg = await HttpUtil.post(url, form);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="editing ? 'Edit custom geo entry' : 'Add custom geo entry'"
|
||||
:confirm-loading="saving"
|
||||
ok-text="Save"
|
||||
cancel-text="Close"
|
||||
@ok="submit"
|
||||
@cancel="close"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="Type">
|
||||
<a-select v-model:value="form.type" :disabled="editing">
|
||||
<a-select-option value="geosite">geosite</a-select-option>
|
||||
<a-select-option value="geoip">geoip</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Alias">
|
||||
<a-input
|
||||
v-model:value="form.alias"
|
||||
:disabled="editing"
|
||||
placeholder="lowercase letters, digits, dashes, underscores"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="URL">
|
||||
<a-input v-model:value="form.url" placeholder="https://" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
309
frontend/src/pages/index/CustomGeoSection.vue
Normal file
309
frontend/src/pages/index/CustomGeoSection.vue
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, ClipboardManager } from '@/utils';
|
||||
import CustomGeoFormModal from './CustomGeoFormModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
// Re-fetch the list when the parent collapse expands this section.
|
||||
active: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const list = ref([]);
|
||||
const loading = ref(false);
|
||||
const updatingAll = ref(false);
|
||||
const actionId = ref(null);
|
||||
|
||||
const formOpen = ref(false);
|
||||
const editingRecord = ref(null);
|
||||
|
||||
const columns = [
|
||||
{ title: 'Alias', key: 'alias', width: 200 },
|
||||
{ title: 'URL', key: 'url', ellipsis: true },
|
||||
{ title: 'Ext', key: 'extDat', width: 220 },
|
||||
{ title: 'Last updated', key: 'lastUpdatedAt', width: 140 },
|
||||
{ title: 'Actions', key: 'action', width: 120 },
|
||||
];
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||
if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingRecord.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
editingRecord.value = record;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function extDisplay(record) {
|
||||
const fn = record.type === 'geoip'
|
||||
? `geoip_${record.alias}.dat`
|
||||
: `geosite_${record.alias}.dat`;
|
||||
return `ext:${fn}:tag`;
|
||||
}
|
||||
|
||||
async function copyExt(record) {
|
||||
const text = extDisplay(record);
|
||||
const ok = await ClipboardManager.copyText(text);
|
||||
if (ok) message.success(`Copied: ${text}`);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// Tiny inline relative-time formatter so we don't pull in moment.
|
||||
function relativeTime(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
||||
return formatTime(ts);
|
||||
}
|
||||
|
||||
function confirmDelete(record) {
|
||||
Modal.confirm({
|
||||
title: 'Delete custom geo entry',
|
||||
content: `Delete "${record.alias}"? This cannot be undone.`,
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||
if (msg?.success) await loadList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadOne(id) {
|
||||
actionId.value = id;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||
if (msg?.success) await loadList();
|
||||
} finally {
|
||||
actionId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
updatingAll.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||
const ok = msg?.obj?.succeeded?.length || 0;
|
||||
const failed = msg?.obj?.failed?.length || 0;
|
||||
if (msg?.success || ok > 0) {
|
||||
await loadList();
|
||||
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
|
||||
}
|
||||
} finally {
|
||||
updatingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load: only fetch when the parent collapse opens this panel.
|
||||
watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="custom-geo-section">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
class="mb-10"
|
||||
message="Reference custom files in routing rules with ext:<filename>:tag"
|
||||
/>
|
||||
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" :loading="loading" @click="openAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
Add
|
||||
</a-button>
|
||||
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
Update all
|
||||
</a-button>
|
||||
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:pagination="false"
|
||||
:row-key="(r) => r.id"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
:scroll="{ x: 760 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'alias'">
|
||||
<div class="custom-geo-alias-cell">
|
||||
<a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
|
||||
{{ record.type }}
|
||||
</a-tag>
|
||||
<span class="custom-geo-alias">{{ record.alias }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'url'">
|
||||
<a-tooltip placement="topLeft" :title="record.url">
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
|
||||
{{ record.url }}
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'extDat'">
|
||||
<a-tooltip title="Copy">
|
||||
<code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
|
||||
{{ extDisplay(record) }}
|
||||
</code>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lastUpdatedAt'">
|
||||
<a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
|
||||
<span>{{ relativeTime(record.lastUpdatedAt) }}</span>
|
||||
</a-tooltip>
|
||||
<span v-else class="custom-geo-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space size="small">
|
||||
<a-tooltip title="Edit">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="Download">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:loading="actionId === record.id"
|
||||
@click="downloadOne(record.id)"
|
||||
>
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="Delete">
|
||||
<a-button type="link" size="small" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<div class="custom-geo-empty">
|
||||
<InboxOutlined class="custom-geo-empty-icon" />
|
||||
<div>No custom geo entries yet</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<CustomGeoFormModal
|
||||
v-model:open="formOpen"
|
||||
:record="editingRecord"
|
||||
@saved="loadList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-10 { margin-bottom: 10px; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.custom-geo-count {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
:global(body.dark) .custom-geo-count {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.custom-geo-alias-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.custom-geo-alias {
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
.custom-geo-type-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-geo-url {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-ext-code {
|
||||
cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
user-select: all;
|
||||
}
|
||||
.custom-geo-copyable:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
:global(body.dark) .custom-geo-ext-code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
:global(body.dark) .custom-geo-copyable:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.custom-geo-muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.custom-geo-empty {
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.custom-geo-empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,6 +3,7 @@ import { ref, watch } from 'vue';
|
|||
import { Modal } from 'ant-design-vue';
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import CustomGeoSection from './CustomGeoSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
|
|
@ -113,6 +114,10 @@ watch(() => props.open, (next) => { if (next) fetchVersions(); });
|
|||
<a-button @click="updateGeofile('')">Update all</a-button>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" header="Custom geo">
|
||||
<CustomGeoSection :active="activeKey === '3'" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
|
|
|
|||
Loading…
Reference in a new issue