码桶

发现社区成员的开源项目

ExitIpInfo.vue10.2 KB
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useClipboard } from '@vueuse/core'
import { useNetworkStore } from '@/stores/network'
import { useUserStore } from '@/stores/user'

const { copy, copied } = useClipboard()
const networkStore = useNetworkStore()
const userStore = useUserStore()

interface IpData {
  ip: string
  address: string
  countryCode?: string
}

const localIp = ref<IpData | null>(null)
const cloudflareIp = ref<IpData | null>(null)
const localLatency = ref<{ ms: number | string; level: 'good' | 'ok' | 'bad' | '' }>({ ms: '-', level: '' })
const cloudflareLatency = ref<{ ms: number | string; level: 'good' | 'ok' | 'bad' | '' }>({ ms: '-', level: '' })
const loading = ref(true)
const isVisible = ref(true)

let timers: number[] = []
let apiFailed = false

function getLevel(ms: number): 'good' | 'ok' | 'bad' {
  if (ms <= 30) return 'good'
  if (ms >= 100) return 'bad'
  return 'ok'
}

function formatAddress(info: any, ip: string) {
  const parts = Array.isArray(info?.regions_short)
    ? [...info.regions_short]
    : Array.isArray(info?.regions)
      ? [...info.regions]
      : []

  if (info?.country?.code && info.country.code !== 'CN' && info.country.name) {
    parts.unshift(info.country.name)
  }
  if (info?.as) {
    parts.push(info.as.info || info.as.name)
  }
  if (info?.type) {
    parts.push(info.type)
  }

  const address = parts.filter(Boolean).join(' ')
  return {
    ip,
    address: address || ip,
    countryCode: info?.country?.code,
  }
}

async function cachedQuery(ip: string) {
  const cacheKey = 'cache_ip'
  const cache = JSON.parse(localStorage.getItem(cacheKey) || '{}')
  const cached = cache[ip]
  const now = Date.now() / 1000

  if (cached && now - cached.time <= 60 * 60 * 24) {
    return cached
  }

  if (apiFailed) throw new Error('api unavailable')

  let info
  try {
    const resp = await userStore.apiRequest(`ip?ip=${encodeURIComponent(ip)}`)
    info = resp?.data || resp || {}
  } catch {
    apiFailed = true
    throw new Error('api unavailable')
  }
  cache[ip] = { ...info, time: now }
  localStorage.setItem(cacheKey, JSON.stringify(cache))
  return info
}

async function watchLocalIp() {
  if (isVisible.value && !apiFailed) {
    try {
      const resp = await userStore.apiRequest('ip')
      const info = resp?.data || resp || {}
      if (info.ip) {
        const cacheKey = 'cache_ip'
        const cache = JSON.parse(localStorage.getItem(cacheKey) || '{}')
        cache[info.ip] = { ...info, time: Date.now() / 1000 }
        localStorage.setItem(cacheKey, JSON.stringify(cache))
        localIp.value = formatAddress(info, info.ip)
        loading.value = false
      }
    } catch {
      apiFailed = true
    }
  }
  timers.push(window.setTimeout(watchLocalIp, 60000))
}

async function watchCloudflare() {
  if (isVisible.value) {
    try {
      const start = Date.now()
      const response = await fetch('https://cp.cloudflare.com/cdn-cgi/trace', { referrerPolicy: 'no-referrer' })
      const lay = Date.now() - start
      const resp = await response.text()
      const match = resp.match(/ip=([0-9a-f.:]+)/)
      if (match) {
        const info = await cachedQuery(match[1])
        cloudflareIp.value = formatAddress(info, match[1])
        cloudflareLatency.value = { ms: lay, level: getLevel(lay) }
        networkStore.setCloudflareLatency(lay)
      }
    } catch {
      cloudflareLatency.value = { ms: '-', level: '' }
      networkStore.setCloudflareLatency(0)
    }
  }
  timers.push(window.setTimeout(watchCloudflare, 1000))
}

async function getCNLatency() {
  if (isVisible.value) {
    try {
      const start = Date.now()
      await fetch('https://connectivitycheck.platform.hicloud.com/generate_204', {
        method: 'HEAD', cache: 'no-store', mode: 'no-cors', referrerPolicy: 'no-referrer',
      })
      const ms = Date.now() - start
      localLatency.value = { ms, level: getLevel(ms) }
      networkStore.setLocalLatency(ms)
    } catch {
      localLatency.value = { ms: '-', level: '' }
      networkStore.setLocalLatency(0)
    }
  }
  timers.push(window.setTimeout(getCNLatency, 1000))
}

function onVisibilityChange() {
  isVisible.value = document.visibilityState === 'visible'
}

function copyIp(ip: string) {
  copy(ip)
}

onMounted(() => {
  document.addEventListener('visibilitychange', onVisibilityChange)
  watchLocalIp()
  watchCloudflare()
  getCNLatency()
})

onUnmounted(() => {
  document.removeEventListener('visibilitychange', onVisibilityChange)
  timers.forEach(t => clearTimeout(t))
})
</script>

<template>
  <div class="fl-card flex-1">
    <div class="flex items-center justify-between mb-3">
      <div class="flex items-center gap-2">
        <div class="w-5 h-5 flex items-center justify-center" style="color: var(--text-secondary)">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
            <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
            <circle cx="12" cy="10" r="3" />
          </svg>
        </div>
        <h3 class="text-[14px] font-semibold tracking-tight" style="color: var(--text-primary)">
          出口地址
        </h3>
      </div>
      <span v-if="copied" class="text-[11px]" style="color: var(--color-success)">已复制</span>
    </div>

    <div v-if="loading" class="flex items-center gap-2 py-2">
      <div
        class="w-3 h-3 rounded-full fl-spin"
        style="border: 1.5px solid var(--border); border-top-color: var(--text-primary)"
      />
      <span class="text-[12px]" style="color: var(--text-muted)">正在检测...</span>
    </div>

    <TransitionGroup v-else name="ip-row-fade" tag="div" class="ip-list">
      <div
        v-if="localIp && localIp.countryCode === 'CN'"
        key="local"
        class="ip-row"
        :data-ip="localIp.ip"
        @click="copyIp(localIp.ip)"
      >
        <div class="ip-main">
          <span class="ip-label">
            国内
          </span>
          <span class="ip-address" :title="localIp.ip">{{ localIp.address }}</span>
        </div>
        <span class="ip-latency" :class="`latency-${localLatency.level || 'none'}`">
          {{ localLatency.ms }} ms
        </span>
      </div>

      <div
        v-if="cloudflareIp && cloudflareIp.countryCode !== 'CN'"
        key="foreign"
        class="ip-row"
        :data-ip="cloudflareIp.ip"
        @click="copyIp(cloudflareIp.ip)"
      >
        <div class="ip-main">
          <span class="ip-label">
            国外
          </span>
          <span class="ip-address" :title="cloudflareIp.ip">{{ cloudflareIp.address }}</span>
        </div>
        <span class="ip-latency" :class="`latency-${cloudflareLatency.level || 'none'}`">
          {{ cloudflareLatency.ms }} ms
        </span>
      </div>
    </TransitionGroup>
  </div>
</template>

<style scoped>
.ip-list {
  position: relative;
  display: grid;
  gap: 6px;
  margin-top: 4px;
}

.ip-row {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 36px;
  padding: 7px 10px;
  border: 0;
  border-radius: 7px;
  background: var(--bg-subtle);
  cursor: pointer;
  transition: background 0.15s ease;
}

.ip-row:hover {
  background: var(--bg-input-hover);
}

.ip-row::after {
  content: attr(data-ip);
  position: absolute;
  left: 50%;
  bottom: calc(100% + 10px);
  z-index: 30;
  width: max-content;
  max-width: min(240px, calc(100vw - 36px));
  padding: 7px 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-card);
  box-shadow: var(--shadow-lg);
  color: var(--text-primary);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 750;
  line-height: 1.35;
  letter-spacing: -0.01em;
  white-space: normal;
  overflow-wrap: anywhere;
  word-break: break-word;
  text-align: center;
  pointer-events: none;
  opacity: 0;
  transform: translateX(-50%) translateY(5px);
  transition: opacity 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
}

.ip-row::before {
  content: '';
  position: absolute;
  left: 50%;
  bottom: calc(100% + 5px);
  z-index: 31;
  width: 10px;
  height: 10px;
  border-right: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  background: var(--bg-card);
  pointer-events: none;
  opacity: 0;
  transform: translateX(-50%) rotate(45deg);
  transition: opacity 0.16s ease;
}

.ip-row:hover::after {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

.ip-row:hover::before {
  opacity: 1;
}

.ip-row-fade-enter-active,
.ip-row-fade-leave-active {
  transition: opacity 0.18s ease, transform 0.18s ease;
}

.ip-row-fade-enter-from,
.ip-row-fade-leave-to {
  opacity: 0;
  transform: translateY(-4px);
}

.ip-row-fade-leave-active {
  position: absolute;
}

.ip-row-fade-move {
  transition: transform 0.18s ease;
}

.ip-main {
  display: flex;
  align-items: center;
  gap: 9px;
  min-width: 0;
  flex: 1;
}

.ip-label {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 18px;
  flex: 0 0 auto;
  border: 0;
  border-radius: 5px;
  background: rgba(255, 255, 255, 0.66);
  color: var(--text-muted);
  font-size: 10px;
  font-weight: 750;
}

.ip-address {
  min-width: 0;
  overflow: hidden;
  color: var(--text-primary);
  font-size: 12px;
  font-weight: 550;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ip-latency {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 54px;
  height: 22px;
  flex: 0 0 auto;
  border-radius: 6px;
  background: var(--bg-subtle);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 750;
  font-variant-numeric: tabular-nums;
}

.latency-good {
  background: rgba(80, 227, 164, 0.12);
  color: #16a34a;
}

.latency-ok {
  background: rgba(245, 166, 35, 0.14);
  color: #d97706;
}

.latency-bad {
  background: rgba(238, 0, 0, 0.08);
  color: #dc2626;
}

.latency-none {
  color: var(--text-muted);
}

@media (max-width: 520px) {
  .ip-row {
    align-items: center;
    flex-direction: row;
    gap: 8px;
  }

  .ip-latency {
    align-self: center;
  }

  .ip-row::after {
    left: auto;
    right: 0;
    padding-inline: 11px;
    transform: translateY(5px);
  }

  .ip-row:hover::after {
    transform: translateY(0);
  }

  .ip-row::before {
    left: auto;
    right: 28px;
  }
}
</style>