
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<meta name="color-scheme" content="dark" />
<title>当前 IP 信息</title>
<style>
:root{
--txt:#eaf2ff;
--muted:rgba(234,242,255,.68);
--muted2:rgba(234,242,255,.46);
--line:rgba(160,200,255,.18);
--accent:#82b7ff;
--good:#39d98a;
--warn:#ffb347;
--bad:#ff5d5d;
--shadow:0 14px 34px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.05);
}
html, body{
margin:0;
width:100%;
height:100%;
overflow:hidden;
background:transparent;
font-family:"Microsoft YaHei", sans-serif;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
*{box-sizing:border-box}
button{font:inherit}
.card{
width:100%;
height:100%;
position:relative;
overflow:hidden;
border-radius:18px;
border:1px solid var(--line);
background:
radial-gradient(120% 100% at 0% 0%, rgba(130,183,255,.14), transparent 58%),
radial-gradient(90% 120% at 100% 0%, rgba(167,139,250,.10), transparent 50%),
linear-gradient(160deg, rgba(15,25,39,.98) 0%, rgba(10,17,28,.98) 100%);
box-shadow:var(--shadow);
color:var(--txt);
padding:14px 14px 12px;
display:flex;
flex-direction:column;
gap:10px;
}
.card::before{
content:"";
position:absolute;
inset:0;
border-radius:18px;
padding:1px;
background:linear-gradient(180deg, rgba(255,255,255,.13), rgba(255,255,255,0) 26%, rgba(255,255,255,0) 80%, rgba(255,255,255,.07));
-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite:xor;
mask-composite:exclude;
pointer-events:none;
opacity:.9;
}
.card::after{
content:"";
position:absolute;
inset:-2px;
border-radius:20px;
background:linear-gradient(135deg, rgba(255,255,255,.06), transparent 28%, transparent 72%, rgba(255,255,255,.04));
pointer-events:none;
mix-blend-mode:screen;
opacity:.55;
}
.topbar{
position:relative;
z-index:1;
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
min-height:34px;
}
.title-wrap{
min-width:0;
display:flex;
flex-direction:column;
gap:4px;
flex:1 1 auto;
}
.title{
margin:0;
font-size:15px;
line-height:1.1;
font-weight:700;
letter-spacing:.2px;
color:var(--txt);
user-select:none;
}
.title .sub{
font-weight:600;
color:var(--muted);
margin-left:6px;
font-size:12px;
}
.actions{
display:flex;
align-items:center;
gap:8px;
flex:0 0 auto;
}
.btn{
appearance:none;
outline:none;
height:28px;
padding:0 11px;
border-radius:10px;
color:var(--txt);
background:linear-gradient(180deg, rgba(130,183,255,.16), rgba(130,183,255,.08));
border:1px solid rgba(130,183,255,.22);
box-shadow:inset 0 1px 0 rgba(255,255,255,.08);
cursor:pointer;
transition:transform .12s ease, background .12s ease, border-color .12s ease, opacity .12s ease, box-shadow .12s ease;
font-size:12px;
font-weight:700;
letter-spacing:.2px;
user-select:none;
white-space:nowrap;
}
.btn:hover{
background:linear-gradient(180deg, rgba(130,183,255,.22), rgba(130,183,255,.12));
border-color:rgba(130,183,255,.34);
transform:translateY(-1px);
box-shadow:inset 0 1px 0 rgba(255,255,255,.10);
}
.btn:active{
transform:translateY(0);
opacity:.92;
}
.btn.secondary{
background:rgba(255,255,255,.04);
border-color:rgba(255,255,255,.09);
color:var(--muted);
}
.btn.secondary:hover{
background:rgba(255,255,255,.07);
border-color:rgba(160,200,255,.14);
color:var(--txt);
}
.btn[disabled]{
cursor:not-allowed;
opacity:.58;
transform:none;
}
.content{
position:relative;
z-index:1;
flex:1 1 auto;
min-height:0;
display:grid;
grid-template-columns:104px minmax(0,1fr);
gap:12px;
align-items:center;
}
.scorebox{
width:104px;
height:104px;
border-radius:22px;
position:relative;
display:flex;
align-items:center;
justify-content:center;
background:
radial-gradient(circle at 50% 28%, rgba(255,255,255,.08), transparent 38%),
linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02));
border:1px solid rgba(255,255,255,.08);
box-shadow:inset 0 1px 0 rgba(255,255,255,.06);
overflow:hidden;
}
.ring{
width:80px;
height:80px;
border-radius:50%;
background:
radial-gradient(circle at center, rgba(9,17,28,.98) 0 46%, transparent 47%),
conic-gradient(var(--ring-color) var(--ring-angle), rgba(255,255,255,.08) 0);
display:flex;
align-items:center;
justify-content:center;
position:relative;
box-shadow:0 0 0 1px rgba(255,255,255,.05) inset;
}
.ring::before{
content:"";
position:absolute;
inset:6px;
border-radius:50%;
border:1px solid rgba(255,255,255,.06);
box-shadow:inset 0 1px 0 rgba(255,255,255,.05);
}
.score-inner{
position:relative;
z-index:1;
text-align:center;
line-height:1;
}
.score{
font-size:24px;
font-weight:800;
letter-spacing:-.6px;
color:var(--ring-color);
}
.score-unit{
display:block;
margin-top:4px;
font-size:10px;
color:var(--muted2);
letter-spacing:.8px;
}
.score-tag{
position:absolute;
bottom:7px;
left:50%;
transform:translateX(-50%);
font-size:10px;
color:var(--muted2);
letter-spacing:.4px;
white-space:nowrap;
}
.info{
min-width:0;
display:flex;
flex-direction:column;
gap:7px;
}
.row{
display:flex;
align-items:flex-start;
gap:8px;
min-width:0;
}
.label{
flex:0 0 auto;
width:42px;
color:var(--muted2);
font-size:11px;
line-height:18px;
letter-spacing:.2px;
}
.value{
min-width:0;
flex:1 1 auto;
color:var(--txt);
font-size:12px;
line-height:18px;
word-break:break-all;
}
.value.muted{color:var(--muted)}
.chips{
display:flex;
flex-wrap:wrap;
gap:6px;
margin-top:1px;
max-height:50px;
overflow:auto;
padding-right:2px;
}
.chip{
height:22px;
padding:0 8px;
display:inline-flex;
align-items:center;
border-radius:999px;
font-size:11px;
border:1px solid rgba(255,255,255,.08);
background:rgba(255,255,255,.04);
color:var(--muted);
white-space:nowrap;
}
.chip.good{
color:#bff6de;
border-color:rgba(57,217,138,.2);
background:rgba(57,217,138,.08);
}
.chip.warn{
color:#ffe0b3;
border-color:rgba(255,179,71,.2);
background:rgba(255,179,71,.08);
}
.chip.bad{
color:#ffd0d0;
border-color:rgba(255,93,93,.2);
background:rgba(255,93,93,.08);
}
.bottom{
position:relative;
z-index:1;
display:flex;
justify-content:space-between;
align-items:center;
gap:10px;
min-height:28px;
}
.status{
min-width:0;
display:flex;
flex-direction:column;
gap:2px;
justify-content:center;
}
.status .line1{
font-size:11px;
color:var(--muted);
line-height:1.1;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.status .line2{
font-size:10px;
color:var(--muted2);
line-height:1.1;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.tiny{
font-size:10px;
color:var(--muted2);
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
max-width:42%;
}
.loading{
display:inline-flex;
align-items:center;
gap:8px;
}
.spinner{
width:12px;
height:12px;
border-radius:50%;
border:2px solid rgba(255,255,255,.14);
border-top-color:rgba(130,183,255,.9);
animation:spin .85s linear infinite;
flex:0 0 auto;
}
@keyframes spin{to{transform:rotate(360deg)}}
.skeleton{
position:relative;
overflow:hidden;
background:rgba(255,255,255,.05);
border-radius:8px;
color:transparent !important;
min-height:12px;
}
.skeleton::after{
content:"";
position:absolute;
inset:0;
transform:translateX(-100%);
background:linear-gradient(90deg, transparent, rgba(255,255,255,.12), transparent);
animation:shimmer 1.2s infinite;
}
@keyframes shimmer{
100%{transform:translateX(100%)}
}
.error{color:#ffd0d0 !important}
::-webkit-scrollbar{
width:6px;
height:6px;
}
::-webkit-scrollbar-track{
background:rgba(255,255,255,.035);
border-radius:999px;
}
::-webkit-scrollbar-thumb{
background:rgba(130,183,255,.25);
border-radius:999px;
}
::-webkit-scrollbar-thumb:hover{
background:rgba(130,183,255,.38);
}
@media (max-width:330px){
.card{padding:12px 12px 10px}
.content{grid-template-columns:94px minmax(0,1fr)}
.scorebox{width:94px;height:94px;border-radius:20px}
.ring{width:74px;height:74px}
.score{font-size:22px}
.label{width:38px}
.actions{gap:6px}
.btn{padding:0 9px}
}
</style>
</head>
<body>
<div class="card" id="app">
<div class="topbar">
<div class="title-wrap">
<div class="title">当前 IP<span class="sub">网络信息</span></div>
</div>
<div class="actions">
<button class="btn secondary" id="copyBtn" type="button">复制IP</button>
<button class="btn" id="refreshBtn" type="button">刷新</button>
</div>
</div>
<div class="content">
<div class="scorebox">
<div class="ring" id="ring" style="--ring-angle:0deg;--ring-color:var(--accent)">
<div class="score-inner">
<div class="score" id="scoreText">--</div>
<span class="score-unit">INFO</span>
</div>
</div>
<div class="score-tag" id="riskTag">网络类型</div>
</div>
<div class="info">
<div class="row">
<div class="label">IP</div>
<div class="value" id="ipText">正在获取…</div>
</div>
<div class="row">
<div class="label">位置</div>
<div class="value muted" id="locationText">正在获取…</div>
</div>
<div class="row">
<div class="label">ISP</div>
<div class="value muted" id="ispText">正在获取…</div>
</div>
<div class="row">
<div class="label">信息</div>
<div class="chips" id="chips"></div>
</div>
</div>
</div>
<div class="bottom">
<div class="status">
<div class="line1" id="statusLine">
<span class="loading"><span class="spinner"></span><span>正在拉取当前 IP 数据</span></span>
</div>
<div class="line2" id="subStatus">宿主请求失败时会回退到缓存。</div>
</div>
<div class="tiny" id="updatedAt">--</div>
</div>
</div>
<script>
(() => {
const STORE_KEY = "fudao.current.ip.info.cache.v1";
const IP_API_URL = "http://ip-api.com/json/?lang=zh-CN";
const $ = (id) => document.getElementById(id);
const state = {
loading: false,
data: null,
updatedAt: "",
hostReady: false
};
function nowText(ts = Date.now()) {
const d = new Date(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())}:${pad(d.getSeconds())}`;
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function safeText(v, fallback = "未知") {
if (v === null || v === undefined) return fallback;
const s = String(v).trim();
return s ? s : fallback;
}
function escapeHTML(str) {
return String(str).replace(/[&<>"']/g, (m) => ({
"&":"&",
"<":"<",
">":">",
"\"":""",
"'":"'"
}[m]));
}
function normalizeStateValue(res) {
if (res == null) return null;
if (typeof res === "string") return res;
if (typeof res === "object") {
if (typeof res.value === "string") return res.value;
if (typeof res.text === "string") return res.text;
if (typeof res.data === "string") return res.data;
if (typeof res.result === "string") return res.result;
try { return JSON.stringify(res); } catch (_) { return null; }
}
return String(res);
}
function parseJSONMaybe(raw) {
if (raw == null) return null;
if (typeof raw === "object") return raw;
if (typeof raw === "string") {
try { return JSON.parse(raw); } catch (_) { return null; }
}
return null;
}
function loadLocal() {
try {
const raw = localStorage.getItem(STORE_KEY);
return raw ? parseJSONMaybe(raw) : null;
} catch (_) {
return null;
}
}
function saveLocal(data) {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(data));
} catch (_) {}
}
async function invokeYanm(method, args) {
if (!window.yanm || typeof window.yanm.invoke !== "function") {
throw new Error("宿主未就绪");
}
const ret = window.yanm.invoke(method, args || {});
return ret && typeof ret.then === "function" ? await ret : ret;
}
async function readHostCache() {
try {
const res = await invokeYanm("state.read", {
key: STORE_KEY,
defaultValue: ""
});
return parseJSONMaybe(normalizeStateValue(res));
} catch (_) {
return null;
}
}
async function writeHostCache(data) {
try {
await invokeYanm("state.write", {
key: STORE_KEY,
value: JSON.stringify(data)
});
} catch (_) {}
}
async function persist(data) {
saveLocal(data);
await writeHostCache(data);
}
async function getTextByHttp(url, timeoutMs = 10000) {
const res = await invokeYanm("http.get", {
url,
headers: {
"Accept": "application/json,text/plain,*/*"
},
timeoutMs
});
if (typeof res === "string") return res;
if (res && typeof res === "object") {
if (res.ok === false) {
throw new Error(res.error || res.message || ("HTTP " + (res.status || "ERR")));
}
if (typeof res.text === "string") return res.text;
if (typeof res.content === "string") return res.content;
if (typeof res.body === "string") return res.body;
if (typeof res.data === "string") return res.data;
return JSON.stringify(res);
}
return String(res || "");
}
async function getJsonByHttp(url, timeoutMs = 10000) {
const text = await getTextByHttp(url, timeoutMs);
try {
return JSON.parse(text);
} catch (_) {
throw new Error("返回内容不是有效 JSON");
}
}
function analyzeNetwork(isp, org, asText) {
const text = `${safeText(isp, "")} ${safeText(org, "")} ${safeText(asText, "")}`.toLowerCase();
const homeKeywords = [
"电信", "移动", "联通", "宽带", "china mobile", "china unicom", "chinanet",
"telecom", "unicom", "comcast", "verizon", "spectrum", "cox", "frontier",
"optimum", "sky", "virgin media", "bt ", "broadband", "fiber", "cable"
];
const cloudKeywords = [
"vps", "vpn", "proxy", "hosting", "cloud", "server", "datacenter", "data center",
"colo", "colocation", "dedicated", "aws", "amazon", "google", "microsoft", "azure",
"digitalocean", "linode", "ovh", "hetzner", "vultr", "contabo", "leaseweb",
"alibaba", "tencent", "oracle", "cloudflare"
];
const mobileKeywords = [
"mobile", "wireless", "cellular", "lte", "5g", "4g"
];
const isCloud = cloudKeywords.some(k => text.includes(k.toLowerCase()));
const isHome = homeKeywords.some(k => text.includes(k.toLowerCase()));
const isMobile = mobileKeywords.some(k => text.includes(k.toLowerCase()));
let score = 52;
if (isHome) score = 82;
if (isMobile) score = 70;
if (isCloud) score = 34;
if (/vpn|proxy|datacenter|hosting|cloud|server/i.test(text)) score -= 12;
score = clamp(score, 0, 100);
let type = "普通网络";
if (isCloud) type = "机房 / 云服务";
else if (isMobile) type = "移动网络";
else if (isHome) type = "家宽 / 运营商";
return {
score,
type,
isCloud,
isHome,
isMobile
};
}
function getColor(score) {
if (score >= 75) return "#39d98a";
if (score >= 45) return "#ffb347";
return "#ff5d5d";
}
function renderNullState(message) {
$("ipText").textContent = "无法获取数据";
$("locationText").textContent = message || "请检查网络或宿主 http.get 能力";
$("ispText").textContent = "—";
$("scoreText").textContent = "--";
$("scoreText").style.color = "var(--txt)";
$("ring").style.setProperty("--ring-angle", "0deg");
$("ring").style.setProperty("--ring-color", "var(--accent)");
$("riskTag").textContent = "网络类型";
$("chips").innerHTML = '<span class="chip bad">无有效数据</span>';
$("updatedAt").textContent = "--";
}
function render(payload) {
if (!payload || !payload.data) {
renderNullState(payload && payload.message ? payload.message : "");
return;
}
const d = payload.data;
const score = clamp(Number(d.score || 0), 0, 100);
const color = getColor(score);
$("ipText").textContent = safeText(d.ip);
$("locationText").textContent = safeText(d.location);
$("ispText").textContent = safeText(d.isp);
$("scoreText").textContent = String(score);
$("scoreText").style.color = color;
$("ring").style.setProperty("--ring-angle", `${score * 3.6}deg`);
$("ring").style.setProperty("--ring-color", color);
$("riskTag").textContent = safeText(d.type, "网络类型");
const cloudClass = d.isCloud ? "warn" : "good";
const homeClass = d.isHome ? "good" : "warn";
const mobileClass = d.isMobile ? "warn" : "";
$("chips").innerHTML = [
`<span class="chip ${homeClass}">${d.isHome ? "疑似家宽" : "非典型家宽"}</span>`,
`<span class="chip ${cloudClass}">${d.isCloud ? "疑似机房" : "非明显机房"}</span>`,
`<span class="chip ${mobileClass}">${d.isMobile ? "移动网络" : "固定网络"}</span>`
].join("");
$("updatedAt").textContent = payload.updatedAt ? `更新于 ${payload.updatedAt}` : "--";
}
function setLoading(yes) {
state.loading = yes;
$("refreshBtn").disabled = yes;
$("statusLine").innerHTML = yes
? '<span class="loading"><span class="spinner"></span><span>正在拉取当前 IP 数据</span></span>'
: '<span>就绪</span>';
["ipText", "locationText", "ispText", "scoreText"].forEach((id) => {
const node = $(id);
if (node) node.classList.toggle("skeleton", yes);
});
}
function setStatusError(message) {
$("statusLine").innerHTML = `<span class="error">${escapeHTML(message)}</span>`;
}
async function refresh() {
if (state.loading) return;
if (!window.yanm || typeof window.yanm.invoke !== "function") {
const cached = loadLocal();
if (cached && cached.data) {
state.data = cached.data;
state.updatedAt = cached.updatedAt || "";
render(cached);
setStatusError("宿主未就绪,当前显示本地缓存");
} else {
renderNullState("等待浮岛宿主注入 window.yanm");
setStatusError("宿主未就绪");
}
return;
}
setLoading(true);
$("subStatus").textContent = "正在通过浮岛宿主 http.get 获取公网 IP 信息。";
try {
const ipInfo = await getJsonByHttp(IP_API_URL, 10000);
if (!ipInfo || ipInfo.status !== "success") {
throw new Error(ipInfo && ipInfo.message ? String(ipInfo.message) : "IP 接口返回失败");
}
const ip = safeText(ipInfo.query, "");
if (!ip) throw new Error("IP 为空");
const country = safeText(ipInfo.country, "未知");
const regionName = safeText(ipInfo.regionName, "未知");
const city = safeText(ipInfo.city, "");
const isp = safeText(ipInfo.isp, "未知");
const org = safeText(ipInfo.org, "");
const asText = safeText(ipInfo.as, "");
const network = analyzeNetwork(isp, org, asText);
const payload = {
data: {
ip: ip,
location: [country, regionName, city].filter(Boolean).join(" "),
isp: isp,
org: org,
as: asText,
timezone: safeText(ipInfo.timezone, ""),
score: network.score,
type: network.type,
isCloud: network.isCloud,
isHome: network.isHome,
isMobile: network.isMobile
},
updatedAt: nowText(),
source: "ip-api.com"
};
state.data = payload.data;
state.updatedAt = payload.updatedAt;
render(payload);
await persist(payload);
$("statusLine").innerHTML = "<span>当前 IP 信息已更新</span>";
$("subStatus").textContent = payload.data.timezone ? `时区:${payload.data.timezone}` : "数据来自当前公网出口。";
} catch (err) {
const msg = err && err.message ? err.message : "未知错误";
const cached = loadLocal();
if (cached && cached.data) {
state.data = cached.data;
state.updatedAt = cached.updatedAt || "";
render(cached);
setStatusError("刷新失败,已回退到缓存");
$("subStatus").textContent = msg;
} else {
renderNullState(`获取失败:${msg}`);
setStatusError(`刷新失败:${msg}`);
$("subStatus").textContent = "请检查网络、接口或宿主 http.get 能力。";
}
} finally {
setLoading(false);
}
}
async function copyIp() {
const ip = state.data && state.data.ip ? state.data.ip : "";
if (!ip) {
setStatusError("没有可复制的 IP");
return;
}
try {
if (window.yanm && typeof window.yanm.invoke === "function") {
await invokeYanm("clipboard.write", { text: ip });
} else if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(ip);
} else {
throw new Error("剪贴板不可用");
}
$("statusLine").innerHTML = "<span>IP 已复制到剪贴板</span>";
} catch (_) {
setStatusError("复制失败");
}
}
async function hydrateFromCache() {
const local = loadLocal();
if (local && local.data) {
state.data = local.data;
state.updatedAt = local.updatedAt || "";
render(local);
$("statusLine").innerHTML = "<span>已载入本地缓存</span>";
}
if (window.yanm && typeof window.yanm.invoke === "function") {
const host = await readHostCache();
if (host && host.data) {
state.data = host.data;
state.updatedAt = host.updatedAt || "";
render(host);
saveLocal(host);
$("statusLine").innerHTML = "<span>已载入宿主缓存</span>";
}
}
}
function waitForHost() {
if (window.yanm && typeof window.yanm.invoke === "function") {
state.hostReady = true;
hydrateFromCache().then(refresh);
return;
}
setTimeout(waitForHost, 250);
}
function initFallback() {
$("ipText").textContent = "正在获取…";
$("locationText").textContent = "正在获取…";
$("ispText").textContent = "正在获取…";
$("scoreText").textContent = "--";
$("riskTag").textContent = "网络类型";
$("chips").innerHTML = `
<span class="chip">等待数据</span>
<span class="chip">等待数据</span>
<span class="chip">等待数据</span>
`;
$("updatedAt").textContent = "--";
$("statusLine").innerHTML = '<span class="loading"><span class="spinner"></span><span>正在等待浮岛宿主</span></span>';
$("subStatus").textContent = "第一屏先显示本地兜底,宿主就绪后自动刷新。";
}
function bindUI() {
$("refreshBtn").addEventListener("click", refresh);
$("copyBtn").addEventListener("click", copyIp);
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "r") {
e.preventDefault();
refresh();
}
if (e.key === "F5") {
e.preventDefault();
refresh();
}
});
}
function boot() {
initFallback();
bindUI();
hydrateFromCache();
setTimeout(waitForHost, 100);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once:true });
} else {
boot();
}
})();
</script>
</body>
</html>