CustomGeoSection.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. <script setup>
  2. import { computed, ref, watch } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { Modal, message } from 'ant-design-vue';
  5. import {
  6. PlusOutlined,
  7. ReloadOutlined,
  8. EditOutlined,
  9. DeleteOutlined,
  10. InboxOutlined,
  11. } from '@ant-design/icons-vue';
  12. import { HttpUtil, ClipboardManager } from '@/utils';
  13. import CustomGeoFormModal from './CustomGeoFormModal.vue';
  14. const { t } = useI18n();
  15. const props = defineProps({
  16. // Re-fetch the list when the parent collapse expands this section.
  17. active: { type: Boolean, default: false },
  18. });
  19. const list = ref([]);
  20. const loading = ref(false);
  21. const updatingAll = ref(false);
  22. const actionId = ref(null);
  23. const formOpen = ref(false);
  24. const editingRecord = ref(null);
  25. // Computed so column titles re-render after a locale swap.
  26. const columns = computed(() => [
  27. { title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
  28. { title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
  29. { title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
  30. { title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
  31. { title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
  32. ]);
  33. async function loadList() {
  34. loading.value = true;
  35. try {
  36. const msg = await HttpUtil.get('/panel/api/custom-geo/list');
  37. if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
  38. } finally {
  39. loading.value = false;
  40. }
  41. }
  42. function openAdd() {
  43. editingRecord.value = null;
  44. formOpen.value = true;
  45. }
  46. function openEdit(record) {
  47. editingRecord.value = record;
  48. formOpen.value = true;
  49. }
  50. function extDisplay(record) {
  51. const fn = record.type === 'geoip'
  52. ? `geoip_${record.alias}.dat`
  53. : `geosite_${record.alias}.dat`;
  54. return `ext:${fn}:tag`;
  55. }
  56. async function copyExt(record) {
  57. const text = extDisplay(record);
  58. const ok = await ClipboardManager.copyText(text);
  59. if (ok) message.success(`${t('copied')}: ${text}`);
  60. }
  61. function formatTime(ts) {
  62. if (!ts) return '';
  63. const d = new Date(ts * 1000);
  64. if (isNaN(d.getTime())) return String(ts);
  65. const pad = (n) => String(n).padStart(2, '0');
  66. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
  67. }
  68. // Tiny inline relative-time formatter so we don't pull in moment.
  69. function relativeTime(ts) {
  70. if (!ts) return '';
  71. const diff = Math.floor(Date.now() / 1000) - ts;
  72. if (diff < 60) return 'just now';
  73. if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
  74. if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
  75. if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
  76. return formatTime(ts);
  77. }
  78. function confirmDelete(record) {
  79. Modal.confirm({
  80. title: t('pages.index.customGeoDelete'),
  81. content: t('pages.index.customGeoDeleteConfirm'),
  82. okText: t('delete'),
  83. okType: 'danger',
  84. cancelText: t('cancel'),
  85. onOk: async () => {
  86. const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
  87. if (msg?.success) await loadList();
  88. },
  89. });
  90. }
  91. async function downloadOne(id) {
  92. actionId.value = id;
  93. try {
  94. const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
  95. if (msg?.success) await loadList();
  96. } finally {
  97. actionId.value = null;
  98. }
  99. }
  100. async function updateAll() {
  101. updatingAll.value = true;
  102. try {
  103. const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
  104. const ok = msg?.obj?.succeeded?.length || 0;
  105. const failed = msg?.obj?.failed?.length || 0;
  106. if (msg?.success || ok > 0) {
  107. await loadList();
  108. if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
  109. }
  110. } finally {
  111. updatingAll.value = false;
  112. }
  113. }
  114. // Lazy-load: only fetch when the parent collapse opens this panel.
  115. watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
  116. </script>
  117. <template>
  118. <div class="custom-geo-section">
  119. <a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
  120. <div class="toolbar">
  121. <a-button type="primary" :loading="loading" @click="openAdd">
  122. <template #icon>
  123. <PlusOutlined />
  124. </template>
  125. {{ t('pages.index.customGeoAdd') }}
  126. </a-button>
  127. <a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
  128. <template #icon>
  129. <ReloadOutlined />
  130. </template>
  131. {{ t('pages.index.geofilesUpdateAll') }}
  132. </a-button>
  133. <span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
  134. </div>
  135. <a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
  136. size="small" :scroll="{ x: 760 }">
  137. <template #bodyCell="{ column, record }">
  138. <template v-if="column.key === 'alias'">
  139. <div class="custom-geo-alias-cell">
  140. <a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
  141. {{ record.type }}
  142. </a-tag>
  143. <span class="custom-geo-alias">{{ record.alias }}</span>
  144. </div>
  145. </template>
  146. <template v-else-if="column.key === 'url'">
  147. <a-tooltip placement="topLeft" :title="record.url">
  148. <a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
  149. {{ record.url }}
  150. </a>
  151. </a-tooltip>
  152. </template>
  153. <template v-else-if="column.key === 'extDat'">
  154. <a-tooltip :title="t('copy')">
  155. <code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
  156. {{ extDisplay(record) }}
  157. </code>
  158. </a-tooltip>
  159. </template>
  160. <template v-else-if="column.key === 'lastUpdatedAt'">
  161. <a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
  162. <span>{{ relativeTime(record.lastUpdatedAt) }}</span>
  163. </a-tooltip>
  164. <span v-else class="custom-geo-muted">—</span>
  165. </template>
  166. <template v-else-if="column.key === 'action'">
  167. <a-space size="small">
  168. <a-tooltip :title="t('pages.index.customGeoEdit')">
  169. <a-button type="link" size="small" @click="openEdit(record)">
  170. <template #icon>
  171. <EditOutlined />
  172. </template>
  173. </a-button>
  174. </a-tooltip>
  175. <a-tooltip :title="t('pages.index.customGeoDownload')">
  176. <a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
  177. <template #icon>
  178. <ReloadOutlined />
  179. </template>
  180. </a-button>
  181. </a-tooltip>
  182. <a-tooltip :title="t('pages.index.customGeoDelete')">
  183. <a-button type="link" size="small" danger @click="confirmDelete(record)">
  184. <template #icon>
  185. <DeleteOutlined />
  186. </template>
  187. </a-button>
  188. </a-tooltip>
  189. </a-space>
  190. </template>
  191. </template>
  192. <template #emptyText>
  193. <div class="custom-geo-empty">
  194. <InboxOutlined class="custom-geo-empty-icon" />
  195. <div>{{ t('pages.index.customGeoEmpty') }}</div>
  196. </div>
  197. </template>
  198. </a-table>
  199. <CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
  200. </div>
  201. </template>
  202. <style scoped>
  203. .mb-10 {
  204. margin-bottom: 10px;
  205. }
  206. .toolbar {
  207. display: flex;
  208. align-items: center;
  209. flex-wrap: wrap;
  210. gap: 8px;
  211. margin-bottom: 10px;
  212. }
  213. .custom-geo-count {
  214. margin-left: 4px;
  215. padding: 2px 8px;
  216. border-radius: 10px;
  217. background: rgba(0, 0, 0, 0.05);
  218. font-size: 12px;
  219. opacity: 0.75;
  220. }
  221. :global(body.dark) .custom-geo-count {
  222. background: rgba(255, 255, 255, 0.08);
  223. }
  224. .custom-geo-alias-cell {
  225. display: flex;
  226. align-items: center;
  227. gap: 6px;
  228. }
  229. .custom-geo-alias {
  230. font-weight: 500;
  231. word-break: break-all;
  232. }
  233. .custom-geo-type-tag {
  234. margin: 0;
  235. }
  236. .custom-geo-url {
  237. word-break: break-all;
  238. }
  239. .custom-geo-ext-code {
  240. cursor: pointer;
  241. font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  242. font-size: 12px;
  243. padding: 2px 6px;
  244. border-radius: 4px;
  245. background: rgba(0, 0, 0, 0.05);
  246. user-select: all;
  247. }
  248. .custom-geo-copyable:hover {
  249. background: rgba(0, 0, 0, 0.1);
  250. }
  251. :global(body.dark) .custom-geo-ext-code {
  252. background: rgba(255, 255, 255, 0.08);
  253. }
  254. :global(body.dark) .custom-geo-copyable:hover {
  255. background: rgba(255, 255, 255, 0.14);
  256. }
  257. .custom-geo-muted {
  258. opacity: 0.5;
  259. }
  260. .custom-geo-empty {
  261. text-align: center;
  262. padding: 18px 0;
  263. opacity: 0.6;
  264. }
  265. .custom-geo-empty-icon {
  266. font-size: 32px;
  267. margin-bottom: 6px;
  268. display: block;
  269. }
  270. </style>