mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
178 lines
7.4 KiB
Vue
178 lines
7.4 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" />
|
|
</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).
|
|
// - 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 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 } 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 = lang.value === 'zh' ? 'zh-CN' : 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}:`, 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>
|