Files
MyIP/frontend/components/widgets/QueryIP.vue
T
jason5ng32andClaude Fable 5 30da86042f Refactor(i18n): central locale registry with glob discovery and exact-first matching
One registry (common/locale-registry.js) now owns the locale list; the
lazy-load maps, picker options, <html lang>, zh->zh-CN mappings and test
locale lists all derive from it, so adding a language becomes one JSON
pack plus one registry line. Browser-language detection matches exact
tags before base languages, clearing the path for regional packs like
zh-TW. Pure refactor: behavior for the four shipped locales is unchanged
except the picker now renders in registry order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:50:35 +08:00

181 lines
7.6 KiB
Vue

<template>
<!-- Floating query button (bottom right fixed) -->
<JnTooltip :text="t('Tooltips.QueryIP')" side="left">
<Button size="icon" variant="action" type="button" aria-label="IP Check"
class="rounded-full shadow-lg cursor-pointer" @click="openQueryIP">
<Search class="size-4" />
</Button>
</JnTooltip>
<!-- Query Dialog -->
<Dialog :open="isOpen" @update:open="onOpenChange">
<DialogContent :title="t('ipcheck.Title')" class="max-w-2xl min-h-50">
<DialogHeader :icon="Search" :title="t('ipcheck.Title')" />
<div class="space-y-4">
<!-- Input Group -->
<div class="flex items-center gap-2">
<Input type="text" id="inputIP" name="inputIP" :placeholder="t('ipcheck.Placeholder')"
v-model="inputIP" @keyup.enter="submitQuery" :aria-invalid="modalQueryError !== ''"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore
data-lpignore="true" class="font-mono" />
<Button id="sumitQueryButton" type="button" variant="action"
:disabled="!isValidIP(inputIP) || isChecking === 'running'" @click="submitQuery"
class="cursor-pointer">
<Spinner v-if="isChecking === 'running'" />
<template v-else>
<Search class="size-4 shrink-0" />
</template>
</Button>
</div>
<!-- Error message -->
<p v-if="modalQueryError" class="text-sm text-destructive">{{ modalQueryError }}</p>
<!-- Query result: Hero IP (no Copy / Map) + shared IpDetailPanel -->
<div v-if="modalQueryResult" class="rounded-lg border bg-card overflow-hidden">
<div class="px-4 py-3 flex items-start gap-2 min-w-0 border-b mb-4">
<FitText :text="inputIP" :tiers="HERO_TIERS" :title="inputIP" :max-lines="2"
class="font-mono font-semibold min-w-0">
<template #prefix>
<Monitor class="inline size-5 align-middle text-muted-foreground mr-2 mb-1" />
</template>
</FitText>
</div>
<IpDetailPanel :data="modalQueryResult" :ip-geo-source="ipGeoSource" :asn-infos="asnInfos"
:asn-history-infos="asnHistoryInfos" :asn-connectivity-infos="asnConnectivityInfos"
:configs="configs" :is-dark-mode="isDarkMode" :enable-map="false"
@view-usage="onOpenChange(false)" />
</div>
</div>
</DialogContent>
</Dialog>
</template>
<script setup>
// QueryIP — manual IP lookup. Shares IpDetailPanel with IPCard so the info display stays in sync.
// Differences from IPCard:
// - No Copy button (the IP was typed by the user — copying it is pointless).
// - No Map button (Dialog-in-Dialog stacking is avoided; enableMap=false).
// - The panel's "view usage" link closes this dialog first, for the same reason.
// - Own asnInfos / asnHistoryInfos caches (local to this component; not shared with IPCard).
import { ref, computed, watch, nextTick } from 'vue';
import { useMainStore } from '@/store';
import { isValidIP, isUsablePublicIP } from '@/utils/valid-ip.js';
import { toApiTag } from '@/utils/locale-registry.js';
import FitText from '@/components/widgets/FitText.vue';
import { HERO_TIERS } from '@/composables/use-fit-text.js';
import { transformDataFromIPapi } from '@/utils/transform-ip-data.js';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { authenticatedFetch, fetchErrorLabel } from '@/utils/authenticated-fetch';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import IpDetailPanel from '../ip-infos/IpDetailPanel.vue';
import { Monitor, Search } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
const userPreferences = computed(() => store.userPreferences);
const configs = computed(() => store.configs);
const isDarkMode = computed(() => store.isDarkMode);
const lang = computed(() => store.lang);
const inputIP = ref('');
const modalQueryResult = ref(null);
const modalQueryError = ref('');
const isChecking = ref('idle');
const ipGeoSource = ref(userPreferences.value.ipGeoSource);
const asnInfos = ref({});
const asnHistoryInfos = ref({});
const asnConnectivityInfos = ref({});
watch(() => userPreferences.value.ipGeoSource, (newVal) => {
ipGeoSource.value = newVal;
}, { deep: true });
// Emptying the box retracts whatever the last attempt complained about —
// the message no longer describes anything on screen.
watch(inputIP, (value) => {
if (value.trim() === '') modalQueryError.value = '';
});
const rejectQuery = (message) => {
modalQueryError.value = message;
modalQueryResult.value = null;
isChecking.value = 'idle';
};
const submitQuery = async () => {
if (!isValidIP(inputIP.value)) return rejectQuery(t('ipcheck.Error'));
// Only publicly routable space has geolocation, so answer here rather
// than walking every geo source to collect the same "no data" from each.
if (!isUsablePublicIP(inputIP.value)) return rejectQuery(t('ipcheck.reservedIP'));
modalQueryError.value = '';
modalQueryResult.value = null;
isChecking.value = 'running';
await fetchIPForModal(inputIP.value);
};
const isOpen = ref(false);
const onOpenChange = (val) => {
isOpen.value = val;
if (val) {
nextTick(() => {
const inputElement = document.getElementById('inputIP');
if (inputElement) inputElement.focus();
});
}
};
const openQueryIP = () => {
trackEvent('SideButtons', 'ToggleClick', 'QueryIP');
openModal();
};
const openModal = () => onOpenChange(true);
const fetchIPForModal = async (ip) => {
const selectedLang = toApiTag(lang.value);
const sources = store.ipDBs.filter(s => s.enabled);
// Cyclic walk from the user's preferred source
// Fallback fires only on real failures (network error, 4xx/5xx,
// transform throwing). A 200 response that contains "N/A" fields
// means the upstream is healthy but doesn't know the IP — display
// those as-is rather than walking to the next source.
const startIdx = Math.max(0, sources.findIndex(s => s.id === ipGeoSource.value));
let currentIdx = startIdx;
let attempts = 0;
while (attempts < sources.length) {
const source = sources[currentIdx];
try {
const url = store.getDbUrl(source.id, ip, selectedLang);
const response = await authenticatedFetch(url);
modalQueryResult.value = { ...transformDataFromIPapi(response, source.id, t, lang.value), ip };
isChecking.value = 'idle';
return;
} catch (error) {
console.error(`Error fetching IP details from source ${source.id} (${fetchErrorLabel(error)}):`, error);
currentIdx = (currentIdx + 1) % sources.length;
attempts++;
}
}
// Every source exhausted with real failures — surface a user-facing
// error instead of leaving the spinner running forever.
isChecking.value = 'idle';
modalQueryError.value = t('ipcheck.NoData');
};
defineExpose({ openModal });
</script>