自动捕获页面 i18n JSON 请求,点击任意文字即可查找对应的多语言 key(支持精确匹配、反查匹配、变量模糊匹配)。提供 Key/翻译值搜索、页面高亮定位、React 组件源码跳转(VS Code/Cursor/WebStorm/GoLand)、硬编码文本检测、多语言对照与缺失检测,一键复制 key。
// ==UserScript==
// @name i18n Key Finder
// @namespace https://github.com/i18n-find
// @version 1.0.12
// @description 自动捕获页面 i18n JSON 请求,点击任意文字即可查找对应的多语言 key(支持精确匹配、反查匹配、变量模糊匹配)。提供 Key/翻译值搜索、页面高亮定位、React 组件源码跳转(VS Code/Cursor/WebStorm/GoLand)、硬编码文本检测、多语言对照与缺失检测,一键复制 key。
// @license MIT
// @include *://*shopline*/*
// @include *://localhost/*
// @include *://localhost:*/*
// @include *://127.0.0.1/*
// @include *://127.0.0.1:*/*
// @match *://*.smartpushedm.com/*
// @connect *
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @run-at document-start
// ==/UserScript==
(function() {
"use strict";
var _GM_getValue = (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
var _GM_setValue = (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
var _GM_xmlhttpRequest = (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
var siteKey = location.hostname;
function loadSources() {
const raw = _GM_getValue(`i18n_sources_${siteKey}`, "");
if (!raw) return [];
try {
return JSON.parse(raw);
} catch {
return [];
}
}
function saveSources(list) {
_GM_setValue(`i18n_sources_${siteKey}`, JSON.stringify(list));
}
var sources = loadSources();
var I18N_URL_RE = /\/i18n\/.*\.json/;
function extractName(url) {
try {
const parts = new URL(url).pathname.split("/");
const i18nIdx = parts.indexOf("i18n");
if (i18nIdx >= 0 && i18nIdx + 1 < parts.length) return parts[i18nIdx + 1];
} catch {}
return "auto";
}
function extractLang(url) {
try {
const lang = (new URL(url).pathname.split("/").pop() || "").split(".")[0];
if (lang) return lang;
} catch {}
return "unknown";
}
function addCapturedSource(url) {
if (sources.some((s) => s.url === url)) return;
const name = extractName(url);
sources.push({
name,
url
});
saveSources(sources);
clearCache();
refreshSettingsPanel();
}
function setupRequestCapture() {
const origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
const urlStr = url.toString();
if (I18N_URL_RE.test(urlStr)) addCapturedSource(urlStr);
return origOpen.apply(this, [
method,
url,
...rest
]);
};
const origFetch = window.fetch;
window.fetch = function(input, init) {
const url = input instanceof Request ? input.url : input.toString();
if (I18N_URL_RE.test(url)) addCapturedSource(url);
return origFetch.call(this, input, init);
};
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) if (I18N_URL_RE.test(entry.name)) addCapturedSource(entry.name);
}).observe({
type: "resource",
buffered: true
});
}
var i18nMap = null;
var flatMap = null;
var keySourceMap = null;
var perSourceFlat = new Map();
var sourceMeta = new Map();
var fuzzyIndex = [];
var searchIndex = [];
var loading = false;
var loadedKey = "";
function flattenJson(obj, prefix, result) {
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const val = obj[key];
if (typeof val === "string") result.set(fullKey, val);
else if (val && typeof val === "object" && !Array.isArray(val)) flattenJson(val, fullKey, result);
}
}
function buildReverseMap(flat) {
const reverse = new Map();
for (const [key, val] of flat) {
const trimmed = val.trim();
if (!trimmed) continue;
const existing = reverse.get(trimmed);
if (existing) existing.push(key);
else reverse.set(trimmed, [key]);
}
return reverse;
}
function clearCache() {
i18nMap = null;
flatMap = null;
keySourceMap = null;
perSourceFlat = new Map();
sourceMeta = new Map();
fuzzyIndex = [];
searchIndex = [];
loadedKey = "";
}
function sourcesCacheKey() {
return sources.map((s) => s.url).sort().join("|");
}
function fetchOneJson(url) {
return new Promise((resolve, reject) => {
_GM_xmlhttpRequest({
method: "GET",
url,
onload(res) {
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
reject(e);
}
},
onerror: (err) => reject(err)
});
});
}
function fetchI18nData() {
if (sources.length === 0) return Promise.reject(new Error("未配置数据源,请等待自动捕获或手动添加"));
const key = sourcesCacheKey();
if (i18nMap && flatMap && loadedKey === key) return Promise.resolve({
flat: flatMap,
reverse: i18nMap
});
if (loadedKey !== key) clearCache();
if (loading) return new Promise((resolve) => {
const timer = setInterval(() => {
if (i18nMap && flatMap) {
clearInterval(timer);
resolve({
flat: flatMap,
reverse: i18nMap
});
}
}, 100);
});
loading = true;
return Promise.all(sources.map((s) => fetchOneJson(s.url).catch(() => null))).then((results) => {
const merged = new Map();
const srcMap = new Map();
const perSrc = new Map();
const metaMap = new Map();
for (let i = 0; i < results.length; i++) {
const json = results[i];
if (!json) continue;
const tmp = new Map();
flattenJson(json, "", tmp);
const { name, url } = sources[i];
const lang = extractLang(url);
perSrc.set(url, tmp);
metaMap.set(url, {
name,
lang,
label: lang !== "unknown" ? `${name}/${lang}` : name
});
for (const [k, v] of tmp) if (!merged.has(k)) {
merged.set(k, v);
srcMap.set(k, name);
}
}
flatMap = merged;
keySourceMap = srcMap;
perSourceFlat = perSrc;
sourceMeta = metaMap;
i18nMap = buildReverseMap(merged);
const varRe = /\{\{.*?\}\}|\$\{.*?\}|\{[a-zA-Z_]\w*\}/g;
const fIdx = [];
for (const [k, v] of merged) if (varRe.test(v)) {
varRe.lastIndex = 0;
const s = v.replace(varRe, "").trim();
if (s) fIdx.push({
key: k,
stripped: s
});
}
fuzzyIndex = fIdx;
const sIdx = [];
for (const [k, v] of merged) sIdx.push({
key: k,
value: v,
keyLower: k.toLowerCase(),
valueLower: v.toLowerCase()
});
searchIndex = sIdx;
loadedKey = key;
return {
flat: flatMap,
reverse: i18nMap
};
}).finally(() => {
loading = false;
});
}
var inlineScriptBlocked = false;
function detectInlineScriptBlocked() {
try {
const probe = document.createElement("script");
probe.textContent = `document.documentElement.dataset.i18nCspProbe='1';`;
document.documentElement.appendChild(probe);
probe.remove();
inlineScriptBlocked = document.documentElement.dataset.i18nCspProbe !== "1";
delete document.documentElement.dataset.i18nCspProbe;
} catch {
inlineScriptBlocked = true;
}
}
function isExactMatchReady() {
return document.documentElement.dataset.i18nHookReady === "1";
}
function tryHookI18n() {
const s = document.createElement("script");
s.textContent = `(function(){
if(window.__i18nKeyMap)return;
window.__i18nUsedKeys=Object.create(null);
window.__i18nKeyMap=[];
var keyIdx={};
function hook(inst){
if(!inst||inst.__i18nHooked)return false;
var orig=inst.t.bind(inst);
inst.t=function(){var key=arguments[0];
var result=orig.apply(null,arguments);
if(typeof key==='string'){
window.__i18nUsedKeys[key]=1;
if(typeof result==='string'){
var idx=keyIdx[key];
if(idx===undefined){idx=window.__i18nKeyMap.length;window.__i18nKeyMap.push(key);keyIdx[key]=idx;}
var bits=idx.toString(2),m='\\u200B\\u200B\\u200B';
for(var i=0;i<bits.length;i++)m+=bits[i]==='0'?'\\u200B':'\\u200C';
return m+'\\u200D'+result;}}
return result;};
inst.__i18nHooked=true;
document.documentElement.dataset.i18nHookReady='1';
var op=history.pushState;
history.pushState=function(){window.__i18nUsedKeys=Object.create(null);window.__i18nKeyMap=[];keyIdx={};return op.apply(this,arguments);};
window.addEventListener('popstate',function(){window.__i18nUsedKeys=Object.create(null);window.__i18nKeyMap=[];keyIdx={};});
setTimeout(function(){if(inst.emit)inst.emit('languageChanged',inst.language);},200);
return true;
}
function attempt(){return hook(window.__i18n__);}
if(!attempt()){var n=0,id=setInterval(function(){if(attempt()||++n>30)clearInterval(id);},500);}
})();`;
document.documentElement.appendChild(s);
s.remove();
}
var ZWS = String.fromCharCode(8203);
var ZWNJ = String.fromCharCode(8204);
var ZWJ = String.fromCharCode(8205);
var MARKER_START = "";
var MARKER_RE = new RegExp(`[${ZWS}${ZWNJ}${ZWJ}]`, "g");
function extractExactKey(text) {
const si = text.indexOf(MARKER_START);
if (si < 0) return null;
let i = si + 3;
let bits = "";
while (i < text.length) {
const c = text[i];
if (c === ZWJ) break;
if (c === ZWS) bits += "0";
else if (c === ZWNJ) bits += "1";
else break;
i++;
}
if (!bits || text[i] !== ZWJ) return null;
const idx = parseInt(bits, 2);
try {
const el = document.createElement("script");
el.textContent = `document.documentElement.dataset.i18nExact=(window.__i18nKeyMap&&window.__i18nKeyMap[${idx}])||'';`;
document.documentElement.appendChild(el);
el.remove();
return document.documentElement.dataset.i18nExact || null;
} catch {
return null;
}
}
function stripMarkers(text) {
return text.replace(MARKER_RE, "");
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === "\"" ? """ : "'");
}
var _usedKeysCache = null;
var _usedKeysCacheTime = 0;
function readUsedKeysFromPage() {
const now = Date.now();
if (_usedKeysCache && now - _usedKeysCacheTime < 1e3) return _usedKeysCache;
try {
const el = document.createElement("script");
el.textContent = `document.documentElement.dataset.i18nUsed=JSON.stringify(Object.keys(window.__i18nUsedKeys||{}));`;
document.documentElement.appendChild(el);
el.remove();
const raw = document.documentElement.dataset.i18nUsed;
if (raw) {
_usedKeysCache = new Set(JSON.parse(raw));
_usedKeysCacheTime = now;
return _usedKeysCache;
}
} catch {}
return new Set();
}
function isKeyUsedOnPage(key) {
return readUsedKeysFromPage().has(key);
}
function findKeys(map, text) {
const trimmed = text.trim();
if (!trimmed) return {
used: [],
other: []
};
let all = map.get(trimmed) ?? [];
let fuzzy = false;
if (all.length === 0 && fuzzyIndex.length > 0) {
fuzzy = true;
const matched = [];
for (const entry of fuzzyIndex) if (entry.stripped.includes(trimmed)) {
matched.push(entry.key);
if (matched.length >= 50) break;
}
all = matched;
if (all.length === 0) fuzzy = false;
}
const pageKeys = readUsedKeysFromPage();
if (pageKeys.size === 0) return {
used: [],
other: all,
fuzzy
};
const used = [];
const other = [];
for (const k of all) (pageKeys.has(k) ? used : other).push(k);
return {
used,
other,
fuzzy
};
}
var toastEl = null;
var toastTimer = null;
function showToast(msg) {
if (!toastEl || !document.body.contains(toastEl)) {
toastEl = document.createElement("div");
toastEl.style.cssText = `
position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:2147483647;
background:#333;color:#fff;padding:8px 20px;border-radius:6px;font-size:13px;
font-family:'SF Mono',Monaco,monospace;box-shadow:0 4px 12px rgba(0,0,0,0.15);
transition:opacity 0.3s;pointer-events:none;white-space:nowrap;
`;
document.body.appendChild(toastEl);
}
toastEl.textContent = msg;
toastEl.style.opacity = "1";
toastEl.style.display = "block";
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
if (toastEl) toastEl.style.opacity = "0";
toastTimer = setTimeout(() => {
if (toastEl) toastEl.style.display = "none";
}, 300);
}, 1500);
}
function renderMultiLang(key) {
if (perSourceFlat.size <= 1) return "";
const entries = [];
for (const [url, flat] of perSourceFlat) {
const val = flat.get(key);
if (val !== void 0) entries.push({
src: sourceMeta.get(url)?.label ?? url,
val
});
}
if (entries.length <= 1) return "";
const items = entries.map((e) => {
const display = e.val.length > 60 ? e.val.slice(0, 60) + "..." : e.val;
return `<div style="padding:2px 0;font-size:11px;"><span style="color:#8b5cf6;font-weight:600;">[${escapeHtml(e.src)}]</span> <span style="color:#555;">${escapeHtml(display)}</span></div>`;
}).join("");
return `
<div class="i18n-toggle-multilang" style="margin:4px 0 2px;color:#8b5cf6;font-size:11px;cursor:pointer;user-select:none;">多语言对照(${entries.length})▸</div>
<div class="i18n-multilang-list" style="display:none;padding:4px 8px;margin:2px 0;background:#faf5ff;border:1px solid #e9d5ff;border-radius:4px;">${items}</div>
`;
}
var highlightedEls = [];
var highlightBadge = null;
function clearHighlights() {
for (const el of highlightedEls) {
el.style.removeProperty("outline");
el.style.removeProperty("background-color");
}
highlightedEls = [];
if (highlightBadge) highlightBadge.style.display = "none";
}
function showHighlightBadge(count) {
if (!highlightBadge || !document.body.contains(highlightBadge)) {
highlightBadge = document.createElement("div");
highlightBadge.style.cssText = `
position:fixed;top:20px;right:20px;z-index:2147483647;
background:#f59e0b;color:#fff;padding:8px 16px;border-radius:20px;
font-size:13px;font-family:'SF Mono',Monaco,monospace;cursor:pointer;
box-shadow:0 2px 8px rgba(245,158,11,0.3);
`;
highlightBadge.addEventListener("click", clearHighlights);
document.body.appendChild(highlightBadge);
}
highlightBadge.textContent = `高亮中: ${count} 处 (点击清除)`;
highlightBadge.style.display = "block";
}
function findElementsUsingKey(key) {
const s = document.createElement("script");
s.textContent = `document.documentElement.dataset.i18nKeyIdx=(function(){var m=window.__i18nKeyMap||[];for(var i=0;i<m.length;i++){if(m[i]===${JSON.stringify(key)})return i;}return -1;})().toString();`;
document.documentElement.appendChild(s);
s.remove();
const idx = parseInt(document.documentElement.dataset.i18nKeyIdx || "-1", 10);
delete document.documentElement.dataset.i18nKeyIdx;
let marker = "";
if (idx >= 0) {
const bits = idx.toString(2);
marker = MARKER_START;
for (const b of bits) marker += b === "0" ? ZWS : ZWNJ;
marker += ZWJ;
}
const translatedValue = flatMap?.get(key) ?? "";
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const matched = new Set();
const result = [];
let node;
while (node = walker.nextNode()) {
const text = node.textContent;
if (!text) continue;
const parent = node.parentElement;
if (!parent || matched.has(parent)) continue;
if (parent.closest("#i18n-finder-tooltip,#i18n-finder-panel,#i18n-finder-settings,#i18n-finder-help,#i18n-finder-toolbar,#i18n-react-overlay,#i18n-react-label")) continue;
let hit = false;
if (marker && text.includes(marker)) hit = true;
else if (!marker && translatedValue && stripMarkers(text).trim() === translatedValue.trim()) hit = true;
if (hit) {
matched.add(parent);
result.push(parent);
}
}
return result;
}
function highlightKeyOnPage(key) {
clearHighlights();
const els = findElementsUsingKey(key);
for (const el of els) {
el.style.outline = "2px solid #f59e0b";
el.style.backgroundColor = "rgba(245,158,11,0.1)";
highlightedEls.push(el);
}
if (highlightedEls.length > 0) {
highlightedEls[0].scrollIntoView({
behavior: "smooth",
block: "center"
});
showHighlightBadge(highlightedEls.length);
showToast(`高亮了 ${highlightedEls.length} 处`);
} else showToast("当前页面未找到使用该 key 的元素");
}
var hardcodeHighlights = [];
var hardcodePanel = null;
var hardcodeActive = false;
var hcButton = null;
function clearHardcodeHighlights() {
for (const el of hardcodeHighlights) {
el.style.removeProperty("outline");
el.style.removeProperty("background-color");
}
hardcodeHighlights = [];
if (hardcodePanel) hardcodePanel.style.display = "none";
hardcodeActive = false;
if (hcButton) {
hcButton.style.background = "#fff";
hcButton.style.color = "#666";
hcButton.style.borderColor = "#ddd";
}
}
function detectHardcoded() {
if (hardcodeActive) {
clearHardcodeHighlights();
return;
}
if (inlineScriptBlocked) {
showToast("当前站点 CSP 限制内联脚本,硬编码检测不可用");
return;
}
if (!isExactMatchReady()) {
showToast("需要精确匹配模式才能检测硬编码(未检测到 window.__i18n__)");
return;
}
clearHardcodeHighlights();
const SKIP_TAGS = new Set([
"SCRIPT",
"STYLE",
"NOSCRIPT",
"IFRAME",
"SVG",
"CODE",
"PRE"
]);
const seen = new Set();
const candidates = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while (node = walker.nextNode()) {
const raw = node.textContent;
if (!raw) continue;
const text = raw.trim();
if (text.length < 2) continue;
const parent = node.parentElement;
if (!parent || seen.has(parent)) continue;
if (SKIP_TAGS.has(parent.tagName)) continue;
if (parent.closest("#i18n-finder-tooltip,#i18n-finder-panel,#i18n-finder-settings,#i18n-finder-help,#i18n-finder-toolbar,#i18n-hardcode-panel")) continue;
if (raw.includes(MARKER_START)) continue;
if (/^[\d\s.,:;!?@#$%^&*()+={}\[\]|\\/<>'"~`\-_]+$/.test(text)) continue;
if (!/[一-鿿㐀-䶿a-zA-Z]{2,}/u.test(text)) continue;
seen.add(parent);
candidates.push({
el: parent,
text: text.length > 40 ? text.slice(0, 40) + "..." : text
});
}
const results = candidates.filter((c) => c.el.offsetParent || c.el.tagName === "BODY" || c.el.tagName === "HTML");
for (const r of results) {
r.el.style.outline = "2px dashed #ef4444";
r.el.style.backgroundColor = "rgba(239,68,68,0.08)";
hardcodeHighlights.push(r.el);
}
hardcodeActive = true;
if (results.length === 0) {
showToast("未检测到硬编码文本");
hardcodeActive = false;
return;
}
showToast(`检测到 ${results.length} 处疑似硬编码`);
showHardcodePanel(results);
}
function showHardcodePanel(items) {
if (!hardcodePanel || !document.body.contains(hardcodePanel)) {
hardcodePanel = document.createElement("div");
hardcodePanel.id = "i18n-hardcode-panel";
hardcodePanel.style.cssText = `
position:fixed;z-index:2147483647;width:380px;max-height:500px;
background:#fff;border:1px solid #e0e0e0;border-radius:10px;
box-shadow:0 4px 20px rgba(0,0,0,0.12);font-family:'SF Mono',Monaco,monospace;
font-size:13px;display:none;overflow:hidden;
`;
document.body.appendChild(hardcodePanel);
}
const pos = getToolbarPos();
hardcodePanel.style.right = `${pos.right + 60}px`;
hardcodePanel.style.bottom = `${pos.bottom}px`;
hardcodePanel.innerHTML = `
<div style="padding:12px 16px;border-bottom:1px solid #eee;display:flex;align-items:center;">
<div style="font-weight:600;color:#ef4444;font-size:14px;">硬编码检测</div>
<div style="flex:1;"></div>
<span style="font-size:11px;color:#999;margin-right:8px;">共 ${items.length} 处</span>
<div id="i18n-hc-close" style="cursor:pointer;color:#999;font-size:18px;padding:0 4px;line-height:1;">×</div>
</div>
<div style="max-height:420px;overflow-y:auto;padding:8px 16px;">
${items.map((r, i) => `
<div class="i18n-hc-item" data-idx="${i}" style="padding:6px 8px;margin:4px 0;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;cursor:pointer;font-size:12px;">
<div style="color:#333;word-break:break-all;">${escapeHtml(r.text)}</div>
<div style="color:#999;font-size:10px;margin-top:2px;"><${r.el.tagName.toLowerCase()}>${r.el.className ? "." + escapeHtml(String(r.el.className).split(" ")[0]) : ""}</div>
</div>
`).join("")}
</div>
`;
hardcodePanel.style.display = "block";
clampPanelInViewport(hardcodePanel);
hardcodePanel.querySelector("#i18n-hc-close").addEventListener("click", clearHardcodeHighlights);
hardcodePanel.querySelectorAll(".i18n-hc-item").forEach((item) => {
item.addEventListener("click", () => {
const idx = parseInt(item.dataset.idx, 10);
if (hardcodeHighlights[idx]) {
hardcodeHighlights[idx].scrollIntoView({
behavior: "smooth",
block: "center"
});
const el = hardcodeHighlights[idx];
el.style.outline = "3px solid #ef4444";
setTimeout(() => {
el.style.outline = "2px dashed #ef4444";
}, 800);
}
});
});
}
var missingPanel = null;
async function detectMissingTranslations() {
try {
await fetchI18nData();
} catch {
showToast("加载 i18n 数据失败");
return;
}
if (perSourceFlat.size <= 1) {
showToast("需要至少 2 个数据源才能对比");
return;
}
const groups = new Map();
for (const [url, meta] of sourceMeta) {
const arr = groups.get(meta.name);
if (arr) arr.push(url);
else groups.set(meta.name, [url]);
}
const sections = [];
for (const [name, urls] of groups) {
if (urls.length < 2) continue;
const langs = urls.map((u) => sourceMeta.get(u)?.lang ?? u);
const allKeys = new Set();
for (const u of urls) for (const k of perSourceFlat.get(u).keys()) allKeys.add(k);
const rows = [];
for (const k of allKeys) {
const missing = [];
for (let i = 0; i < urls.length; i++) {
const v = perSourceFlat.get(urls[i]).get(k);
if (v === void 0 || v.trim() === "") missing.push(langs[i]);
}
if (missing.length > 0) rows.push({
key: k,
missing
});
}
rows.sort((a, b) => b.missing.length - a.missing.length || a.key.localeCompare(b.key));
sections.push({
name,
langs,
rows
});
}
if (sections.length === 0) {
showToast("未发现可对比的数据源(同项目需 ≥2 种语言)");
return;
}
const totalMissing = sections.reduce((s, sec) => s + sec.rows.length, 0);
if (totalMissing === 0) {
showToast("未发现缺失,多语言翻译完整");
return;
}
showToast(`发现 ${totalMissing} 个 key 存在语言缺失`);
showMissingPanel(sections);
}
function showMissingPanel(sections) {
if (!missingPanel || !document.body.contains(missingPanel)) {
missingPanel = document.createElement("div");
missingPanel.id = "i18n-missing-panel";
missingPanel.style.cssText = `
position:fixed;z-index:2147483647;width:440px;max-height:560px;
background:#fff;border:1px solid #e0e0e0;border-radius:10px;
box-shadow:0 4px 20px rgba(0,0,0,0.12);font-family:'SF Mono',Monaco,monospace;
font-size:13px;display:none;overflow:hidden;flex-direction:column;
`;
document.body.appendChild(missingPanel);
}
hideAllPanels();
const pos = getToolbarPos();
missingPanel.style.right = `${pos.right + 60}px`;
missingPanel.style.bottom = `${pos.bottom}px`;
const body = sections.map((sec) => {
const head = `<div style="margin:10px 0 4px;font-weight:600;color:#8b5cf6;font-size:12px;">${escapeHtml(sec.name)} <span style="color:#999;font-weight:400;">(${escapeHtml(sec.langs.join(" / "))}) · 缺失 ${sec.rows.length}</span></div>`;
if (sec.rows.length === 0) return head + `<div style="color:#16a34a;font-size:11px;padding:2px 0;">无缺失</div>`;
const rows = sec.rows.slice(0, 200).map((r) => {
const ek = escapeHtml(r.key);
return `
<div class="i18n-missing-item" data-key="${ek}" style="padding:6px 8px;margin:3px 0;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;cursor:pointer;">
<div style="color:#333;font-size:12px;word-break:break-all;">${ek}</div>
<div style="margin-top:2px;font-size:10px;color:#999;">缺失: ${r.missing.map((l) => `<span style="color:#ef4444;">${escapeHtml(l)}</span>`).join("、")}</div>
</div>`;
}).join("");
const more = sec.rows.length > 200 ? `<div style="color:#999;font-size:11px;padding:4px 0;">仅显示前 200 条,共 ${sec.rows.length} 条</div>` : "";
return head + rows + more;
}).join("");
missingPanel.innerHTML = `
<div style="padding:12px 16px;border-bottom:1px solid #eee;display:flex;align-items:center;flex-shrink:0;">
<div style="font-weight:600;color:#333;font-size:14px;">多语言缺失检测</div>
<div style="flex:1;"></div>
<div id="i18n-missing-copy" style="font-size:11px;color:#2563eb;cursor:pointer;margin-right:10px;">复制全部</div>
<div id="i18n-missing-close" style="cursor:pointer;color:#999;font-size:18px;padding:0 4px;line-height:1;">×</div>
</div>
<div style="padding:8px 16px;overflow-y:auto;flex:1;">${body}</div>
`;
missingPanel.style.display = "flex";
clampPanelInViewport(missingPanel);
missingPanel.querySelector("#i18n-missing-close").addEventListener("click", () => {
missingPanel.style.display = "none";
});
missingPanel.querySelector("#i18n-missing-copy").addEventListener("click", () => {
const lines = [];
let count = 0;
for (const sec of sections) for (const r of sec.rows) {
lines.push(`${r.key}\t${r.missing.join(",")}`);
count++;
}
copyToClipboard(lines.join("\n"), `已复制 ${count} 条缺失记录`);
});
missingPanel.querySelectorAll(".i18n-missing-item").forEach((item) => {
item.addEventListener("click", () => {
const key = item.dataset.key;
if (!key) return;
copyToClipboard(key);
const el = item;
el.style.background = "#e6f9ee";
el.style.borderColor = "#34d399";
setTimeout(() => {
el.style.background = "#fffbeb";
el.style.borderColor = "#fde68a";
}, 600);
});
});
}
var EDITORS = [
{
key: "vscode",
label: "VS Code"
},
{
key: "cursor",
label: "Cursor"
},
{
key: "zed",
label: "Zed"
},
{
key: "webstorm",
label: "WebStorm"
},
{
key: "goland",
label: "GoLand"
},
{
key: "idea",
label: "IDEA"
}
];
function getPreferredEditor() {
return _GM_getValue("i18n_editor", "vscode");
}
function setPreferredEditor(editor) {
_GM_setValue("i18n_editor", editor);
}
function findReactSource(el) {
const id = `_rs_${Date.now()}`;
el.setAttribute("data-rs-id", id);
const s = document.createElement("script");
s.textContent = `(function(){
var el=document.querySelector('[data-rs-id="${id}"]');
if(!el)return;el.removeAttribute('data-rs-id');
function gf(n){var ks=Object.keys(n);for(var i=0;i<ks.length;i++){if(ks[i].startsWith('__reactFiber$')||ks[i].startsWith('__reactInternalInstance$'))return n[ks[i]];}return null;}
var cur=el;while(cur){var fb=gf(cur);if(fb){var f=fb;while(f){
if(f._debugSource){var nm='';if(f.type)nm=f.type.displayName||f.type.name||(typeof f.type==='string'?f.type:'');
document.documentElement.dataset.rsResult=JSON.stringify({fileName:f._debugSource.fileName,lineNumber:f._debugSource.lineNumber,columnNumber:f._debugSource.columnNumber||0,componentName:nm||'Component'});return;}
f=f.return;}}cur=cur.parentElement;}
document.documentElement.dataset.rsResult='';})();`;
document.documentElement.appendChild(s);
s.remove();
const raw = document.documentElement.dataset.rsResult;
delete document.documentElement.dataset.rsResult;
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function findReactComponentChain(el) {
if (inlineScriptBlocked) return [];
const id = `_rc_${Date.now()}`;
el.setAttribute("data-rc-id", id);
const s = document.createElement("script");
s.textContent = `(function(){
var el=document.querySelector('[data-rc-id="${id}"]');
if(!el){document.documentElement.dataset.rcResult='[]';return;}
el.removeAttribute('data-rc-id');
function gf(n){var ks=Object.keys(n);for(var i=0;i<ks.length;i++){if(ks[i].startsWith('__reactFiber$')||ks[i].startsWith('__reactInternalInstance$'))return n[ks[i]];}return null;}
function sp(o,d){
if(d>3)return'\\u2026';if(o==null)return o;var t=typeof o;
if(t==='string')return o.length>80?o.slice(0,80)+'\\u2026':o;
if(t==='number'||t==='boolean')return o;
if(t==='function')return'[Function'+(o.name?': '+o.name:'')+']';
if(o&&o.$$typeof)return'[ReactElement]';
if(Array.isArray(o)){if(o.length>5)return'[Array('+o.length+')]';return o.slice(0,5).map(function(x){return sp(x,d+1)});}
if(t==='object'){try{var r={},ks=Object.keys(o),c=0;for(var i=0;i<ks.length&&c<(d===0?50:8);i++){if(ks[i]==='children')continue;r[ks[i]]=sp(o[ks[i]],d+1);c++;}if(ks.length>c+1)r['\\u2026']='+'+(ks.length-c)+' more';return r;}catch(e){return'[Object]';}}
return String(o);}
function eh(fb){
var ic=fb.type&&fb.type.prototype&&fb.type.prototype.isReactComponent;
if(ic)return{hooks:[],classState:sp(fb.memoizedState,0)};
var dht=fb._debugHookTypes||[];
var hks=[],h=fb.memoizedState,idx=0;
while(h&&idx<30){
var tn=dht[idx]||'';
var sk=tn==='useEffect'||tn==='useLayoutEffect'||tn==='useInsertionEffect'||tn==='useDebugValue'||tn==='useImperativeHandle';
if(!sk){var vl=null;
if(tn==='useState'||tn==='useReducer'){vl=h.memoizedState;}
else if(tn==='useRef'){vl=h.memoizedState?h.memoizedState.current:null;}
else if(tn==='useMemo'||tn==='useCallback'){vl=Array.isArray(h.memoizedState)?h.memoizedState[0]:h.memoizedState;}
else if(tn){vl=h.memoizedState;}
else{if(h.queue!=null){tn='useState';vl=h.memoizedState;}
else if(h.memoizedState!=null&&typeof h.memoizedState==='object'){var ms=h.memoizedState;
if(ms.tag!==undefined&&ms.create!==undefined)sk=true;
else if('current' in ms){tn='useRef';vl=ms.current;}
else if(Array.isArray(ms)&&ms.length===2&&Array.isArray(ms[1])){tn='useMemo';vl=ms[0];}
else{tn='unknown';vl=ms;}}
else{tn='unknown';vl=h.memoizedState;}}}
if(!sk&&tn)hks.push({type:tn,value:sp(vl,0),idx:idx});
h=h.next;idx++;}
return{hooks:hks,classState:null};}
var chain=[],seen={};
var cur=el;while(cur){var fb=gf(cur);if(fb){var f=fb;while(f){
if(f._debugSource&&f.type){var nm=f.type.displayName||f.type.name||'';
if(nm&&nm.charCodeAt(0)>=65&&nm.charCodeAt(0)<=90){var uid=nm+'|'+f._debugSource.fileName+':'+f._debugSource.lineNumber;
if(!seen[uid]){seen[uid]=1;var p={};try{p=sp(f.memoizedProps||{},0);}catch(e){}
var hk={hooks:[],classState:null};try{hk=eh(f);}catch(e){}
chain.push({fileName:f._debugSource.fileName,lineNumber:f._debugSource.lineNumber,columnNumber:f._debugSource.columnNumber||0,componentName:nm,props:p,hooks:hk.hooks,classState:hk.classState});}}}
f=f.return;}break;}cur=cur.parentElement;}
document.documentElement.dataset.rcResult=JSON.stringify(chain);
})();`;
document.documentElement.appendChild(s);
s.remove();
const raw = document.documentElement.dataset.rcResult;
delete document.documentElement.dataset.rcResult;
if (!raw) return [];
try {
const chain = JSON.parse(raw);
chain.reverse();
return chain;
} catch {
return [];
}
}
function openViaProtocol(url) {
const a = document.createElement("a");
a.href = url;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), 1e3);
}
function openInEditor(source) {
const editor = getPreferredEditor();
const { fileName, lineNumber, columnNumber } = source;
if (editor === "webstorm" || editor === "goland" || editor === "idea") {
openViaProtocol(`${editor}://open?file=${encodeURIComponent(fileName)}&line=${lineNumber}&column=${columnNumber}`);
_GM_xmlhttpRequest({
method: "GET",
url: `http://localhost:63342/api/file/${fileName}:${lineNumber}:${columnNumber}`,
onload() {},
onerror() {}
});
return;
}
openViaProtocol(`${editor}://file/${fileName}:${lineNumber}:${columnNumber}`);
}
function jumpToKeySource(key) {
if (inlineScriptBlocked) {
showToast("当前站点 CSP 限制内联脚本,源码跳转不可用");
return;
}
const els = findElementsUsingKey(key);
if (els.length === 0) {
showToast("当前页面未找到使用该 key 的元素");
return;
}
const source = findReactSource(els[0]);
if (source) {
els[0].scrollIntoView({
behavior: "smooth",
block: "center"
});
openInEditor(source);
showToast(`跳转源码: <${source.componentName}>`);
} else showToast("未找到 React 源码(需开发模式)");
}
function isDevMode() {
const host = location.hostname;
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return true;
if (location.port && location.port !== "80" && location.port !== "443") return true;
return false;
}
var devMode = isDevMode();
var inspectorOverlay = null;
var inspectorLabel = null;
var altHeld = false;
var metaHeld = false;
var metaTimer = null;
var lastHoverEl = null;
var lastInspectorChain = [];
function getInspectorOverlay() {
if (!inspectorOverlay || !document.body.contains(inspectorOverlay)) {
inspectorOverlay = document.createElement("div");
inspectorOverlay.id = "i18n-react-overlay";
inspectorOverlay.style.cssText = `position:fixed;z-index:2147483646;border:2px solid #8b5cf6;background:rgba(139,92,246,0.08);pointer-events:none;display:none;border-radius:4px;`;
document.body.appendChild(inspectorOverlay);
}
return inspectorOverlay;
}
function getInspectorLabel() {
if (!inspectorLabel || !document.body.contains(inspectorLabel)) {
inspectorLabel = document.createElement("div");
inspectorLabel.id = "i18n-react-label";
inspectorLabel.style.cssText = `position:fixed;z-index:2147483647;background:#8b5cf6;color:#fff;padding:3px 10px;border-radius:4px;font-size:12px;font-family:'SF Mono',Monaco,'Cascadia Code',monospace;pointer-events:none;display:none;white-space:nowrap;box-shadow:0 2px 8px rgba(139,92,246,0.3);max-width:700px;overflow:hidden;text-overflow:ellipsis;`;
document.body.appendChild(inspectorLabel);
}
return inspectorLabel;
}
function updateInspectorOverlay(el) {
const chain = findReactComponentChain(el);
lastInspectorChain = chain;
const nearest = chain[chain.length - 1];
if (!nearest) {
hideInspectorOverlay();
return;
}
const rect = el.getBoundingClientRect();
const overlay = getInspectorOverlay();
overlay.style.top = `${rect.top}px`;
overlay.style.left = `${rect.left}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
overlay.style.display = "block";
const label = getInspectorLabel();
const shortFile = nearest.fileName.split("/").slice(-2).join("/");
const editorName = EDITORS.find((e) => e.key === getPreferredEditor())?.label ?? "VS Code";
const ancestors = chain.slice(0, -1);
let breadcrumb = "";
if (ancestors.length > 0) breadcrumb = `<span style="opacity:0.5;">${(ancestors.length > 3 ? ["…", ...ancestors.slice(-3).map((c) => c.componentName)] : ancestors.map((c) => c.componentName)).join(" › ")}</span> <span style="opacity:0.3;">›</span> `;
label.innerHTML = `${breadcrumb}<span style="font-weight:600;"><${nearest.componentName}></span> <span style="opacity:0.8;">${shortFile}:${nearest.lineNumber}</span> <span style="opacity:0.6;font-size:10px;">→ ${editorName}</span>`;
label.title = nearest.fileName;
label.style.display = "block";
label.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - 600))}px`;
label.style.top = `${rect.top > 26 ? rect.top - 26 : rect.bottom + 4}px`;
}
function hideInspectorOverlay() {
if (inspectorOverlay) inspectorOverlay.style.display = "none";
if (inspectorLabel) inspectorLabel.style.display = "none";
lastHoverEl = null;
}
var inspectorPanel = null;
var selectedChainIdx = 0;
function formatPropsHtml(obj, depth) {
if (obj === null) return "<span style=\"color:#d97706;\">null</span>";
if (obj === void 0) return "<span style=\"color:#d97706;\">undefined</span>";
const t = typeof obj;
if (t === "number" || t === "boolean") return `<span style="color:#d97706;">${obj}</span>`;
if (t === "string") {
const s = obj;
if (s.startsWith("[") && s.endsWith("]") || s === "…") return `<span style="color:#888;">${escapeHtml(s)}</span>`;
return `<span style="color:#16a34a;">"${escapeHtml(s)}"</span>`;
}
if (Array.isArray(obj)) {
if (obj.length === 0) return "<span style=\"color:#888;\">[]</span>";
return `[${obj.map((v) => formatPropsHtml(v, depth + 1)).join(", ")}]`;
}
if (t === "object") {
const entries = Object.entries(obj);
if (entries.length === 0) return "<span style=\"color:#888;\">{}</span>";
if (depth > 3) return "<span style=\"color:#888;\">{…}</span>";
const pad = " ".repeat(depth + 1);
const closePad = " ".repeat(depth);
return `{\n${entries.map(([k, v]) => `${pad}<span style="color:#9333ea;">${escapeHtml(k)}</span>: ${formatPropsHtml(v, depth + 1)}`).join(",\n")}\n${closePad}}`;
}
return escapeHtml(String(obj));
}
function renderInspectorContent(chain) {
if (!inspectorPanel) return;
const prevScroll = inspectorPanel.querySelector("#i18n-inspector-scroll")?.scrollTop ?? 0;
const selected = chain[selectedChainIdx] ?? chain[chain.length - 1];
const editorName = EDITORS.find((e) => e.key === getPreferredEditor())?.label ?? "VS Code";
const chainHtml = chain.map((info, i) => {
const shortFile = info.fileName.split("/").slice(-2).join("/");
const isSel = i === selectedChainIdx;
const bg = isSel ? "#f0f0ff" : "transparent";
const border = isSel ? "1px solid #c7d2fe" : "1px solid transparent";
return `
<div class="i18n-chain-item" data-idx="${i}" style="padding:5px 8px;margin:1px 0;margin-left:${i * 12}px;background:${bg};border:${border};border-radius:6px;cursor:pointer;display:flex;align-items:center;gap:6px;transition:background 0.15s;">
<span style="color:#ccc;font-size:10px;">${i < chain.length - 1 ? "├" : "└"}</span>
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
<span style="color:#8b5cf6;font-weight:${isSel ? "700" : "500"};font-size:12px;"><${escapeHtml(info.componentName)}></span>
<span style="color:#bbb;font-size:10px;margin-left:4px;">${escapeHtml(shortFile)}:${info.lineNumber}</span>
</div>
<div class="i18n-chain-jump" data-idx="${i}" style="flex-shrink:0;color:#2563eb;font-size:10px;cursor:pointer;padding:2px 6px;border-radius:3px;background:#eff6ff;border:1px solid #bfdbfe;" title="在 ${editorName} 中打开">→ IDE</div>
</div>`;
}).join("");
const propsHtml = Object.entries(selected.props).length === 0 ? "<div style=\"color:#999;padding:4px 0;font-size:12px;\">无 Props</div>" : `<pre style="margin:0;padding:8px;background:#fafafa;border:1px solid #eee;border-radius:6px;font-size:11px;line-height:1.5;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-all;">${formatPropsHtml(selected.props, 0)}</pre>`;
let hooksTitle;
let hooksHtml;
if (selected.classState !== null && selected.classState !== void 0) {
hooksTitle = "State";
hooksHtml = `<pre style="margin:0;padding:8px;background:#fafafa;border:1px solid #eee;border-radius:6px;font-size:11px;line-height:1.5;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-all;">${formatPropsHtml(selected.classState, 0)}</pre>`;
} else if (selected.hooks && selected.hooks.length > 0) {
hooksTitle = `Hooks (${selected.hooks.length})`;
const hcMap = {
useState: ["#2563eb", "#eff6ff"],
useReducer: ["#2563eb", "#eff6ff"],
state: ["#2563eb", "#eff6ff"],
useRef: ["#16a34a", "#f0fdf4"],
ref: ["#16a34a", "#f0fdf4"],
useMemo: ["#9333ea", "#faf5ff"],
useCallback: ["#9333ea", "#faf5ff"],
memo: ["#9333ea", "#faf5ff"],
useContext: ["#d97706", "#fffbeb"],
useTransition: ["#0891b2", "#ecfeff"]
};
hooksHtml = `<div style="padding:4px 8px;background:#fafafa;border:1px solid #eee;border-radius:6px;max-height:200px;overflow:auto;">${selected.hooks.map((h) => {
const [c, bg] = hcMap[h.type] ?? ["#888", "#f5f5f5"];
return `<div style="padding:3px 0;font-size:11px;display:flex;align-items:flex-start;gap:6px;">
<span style="color:#bbb;font-size:10px;flex-shrink:0;min-width:14px;text-align:right;">${h.idx}</span>
<span style="color:${c};background:${bg};padding:0 6px;border-radius:3px;font-size:10px;flex-shrink:0;line-height:18px;">${escapeHtml(h.type)}</span>
<span style="flex:1;min-width:0;word-break:break-all;line-height:18px;">${formatPropsHtml(h.value, 1)}</span>
</div>`;
}).join("")}</div>`;
} else {
hooksTitle = "Hooks";
hooksHtml = "<div style=\"color:#999;padding:4px 0;font-size:12px;\">无 Hooks</div>";
}
inspectorPanel.innerHTML = `
<div style="padding:10px 16px;border-bottom:1px solid #eee;display:flex;align-items:center;flex-shrink:0;">
<div style="font-weight:600;color:#333;font-size:13px;">组件检查器</div>
<div style="flex:1;"></div>
<span style="font-size:10px;color:#bbb;margin-right:8px;">${editorName} · ${chain.length} 层</span>
<div id="i18n-inspector-close" style="cursor:pointer;color:#999;font-size:18px;padding:0 4px;line-height:1;">×</div>
</div>
<div id="i18n-inspector-scroll" style="flex:1;overflow-y:auto;">
<div style="padding:8px 12px 4px;">
<div style="font-size:11px;color:#888;margin-bottom:4px;">组件链路 <span style="color:#bbb;">· 点击查看 Props / Hooks</span></div>
${chainHtml}
</div>
<div style="padding:4px 12px 4px;">
<div style="font-size:11px;color:#888;margin-bottom:4px;">Props · <span style="color:#8b5cf6;"><${escapeHtml(selected.componentName)}></span></div>
${propsHtml}
</div>
<div style="padding:4px 12px 12px;">
<div style="font-size:11px;color:#888;margin-bottom:4px;">${hooksTitle} · <span style="color:#8b5cf6;"><${escapeHtml(selected.componentName)}></span></div>
${hooksHtml}
</div>
</div>
`;
const scrollEl = inspectorPanel.querySelector("#i18n-inspector-scroll");
if (scrollEl) scrollEl.scrollTop = prevScroll;
inspectorPanel.querySelector("#i18n-inspector-close").addEventListener("click", hideInspectorPanel);
inspectorPanel.querySelectorAll(".i18n-chain-item").forEach((item) => {
item.addEventListener("click", (e) => {
if (e.target.closest(".i18n-chain-jump")) return;
selectedChainIdx = parseInt(item.dataset.idx, 10);
renderInspectorContent(chain);
});
});
inspectorPanel.querySelectorAll(".i18n-chain-jump").forEach((btn) => {
btn.addEventListener("click", () => {
const info = chain[parseInt(btn.dataset.idx, 10)];
if (info) {
openInEditor(info);
showToast(`跳转: <${info.componentName}>`);
}
});
});
}
function showInspectorPanel(chain, x, y) {
selectedChainIdx = chain.length - 1;
if (!inspectorPanel || !document.body.contains(inspectorPanel)) {
inspectorPanel = document.createElement("div");
inspectorPanel.id = "i18n-inspector-panel";
inspectorPanel.style.cssText = `
position:fixed;z-index:2147483647;width:440px;max-height:560px;
background:#fff;border:1px solid #e0e0e0;border-radius:10px;
box-shadow:0 8px 30px rgba(0,0,0,0.15);font-family:'SF Mono',Monaco,'Cascadia Code',monospace;
font-size:13px;display:none;overflow:hidden;flex-direction:column;
`;
document.body.appendChild(inspectorPanel);
}
renderInspectorContent(chain);
const pw = 440;
let left = x + 15;
let top = y + 15;
if (left + pw > window.innerWidth - 10) left = x - pw - 15;
if (top + 560 > window.innerHeight - 10) top = Math.max(10, window.innerHeight - 570);
inspectorPanel.style.left = `${Math.max(10, left)}px`;
inspectorPanel.style.top = `${Math.max(10, top)}px`;
inspectorPanel.style.right = "auto";
inspectorPanel.style.bottom = "auto";
inspectorPanel.style.display = "flex";
}
function hideInspectorPanel() {
if (inspectorPanel) inspectorPanel.style.display = "none";
}
function createTooltip() {
const el = document.createElement("div");
el.id = "i18n-finder-tooltip";
el.style.cssText = `
position: fixed;
z-index: 2147483647;
background: #fff;
color: #333;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px 16px;
font-size: 13px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
max-width: 600px;
max-height: 400px;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
display: none;
line-height: 1.6;
word-break: break-all;
`;
document.body.appendChild(el);
return el;
}
var tooltip = null;
var clickEnabled = false;
var escHeld = false;
function getTooltip() {
if (!tooltip || !document.body.contains(tooltip)) tooltip = createTooltip();
return tooltip;
}
function showTooltip(x, y, html) {
const tip = getTooltip();
tip.innerHTML = html;
tip.style.display = "block";
const rect = tip.getBoundingClientRect();
const left = Math.min(x + 10, window.innerWidth - rect.width - 10);
const top = y + rect.height + 20 > window.innerHeight ? y - rect.height - 10 : y + 10;
tip.style.left = `${Math.max(0, left)}px`;
tip.style.top = `${Math.max(0, top)}px`;
}
function hideTooltip() {
if (tooltip) tooltip.style.display = "none";
}
function copyToClipboard(text, toastMsg) {
navigator.clipboard.writeText(text).catch(() => {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
});
showToast(toastMsg ?? `已复制: ${text}`);
}
function renderResult(text, result) {
const escaped = escapeHtml(text.length > 50 ? text.slice(0, 50) + "..." : text);
const { used, other, fuzzy } = result;
if (used.length + other.length === 0) return `<div style="color:#e53e3e;">未找到匹配: "${escaped}"</div>`;
const keyHtml = (k, green) => {
const src = keySourceMap?.get(k);
const srcTag = src ? `<span style="color:#999;font-size:10px;margin-left:6px;">[${escapeHtml(src)}]</span>` : "";
const templateVal = fuzzy && flatMap ? flatMap.get(k) ?? "" : "";
const templateTag = templateVal ? `<div style="color:#999;font-size:11px;margin-top:2px;">模板: ${escapeHtml(templateVal.length > 60 ? templateVal.slice(0, 60) + "..." : templateVal)}</div>` : "";
const bg = green ? "#f0fdf4" : "#f5f7fa";
const bc = green ? "#86efac" : "#e8ecf1";
const fg = green ? "#16a34a" : "#2563eb";
const ek = escapeHtml(k);
return `<div style="padding:4px 8px;margin:4px 0;background:${bg};border:1px solid ${bc};border-radius:4px;cursor:pointer;" class="i18n-key-item" data-key="${ek}">
<span style="color:${fg};">${ek}</span>${srcTag}${`<span class="i18n-highlight-btn" data-highlight-key="${ek}" style="color:#f59e0b;font-size:10px;margin-left:6px;cursor:pointer;text-decoration:underline;">高亮</span>`}${`<span class="i18n-source-btn" data-source-key="${ek}" style="color:#8b5cf6;font-size:10px;margin-left:6px;cursor:pointer;text-decoration:underline;">源码</span>`}${templateTag}${renderMultiLang(k)}
</div>`;
};
let html = `<div style="margin-bottom:6px;color:#888;font-size:12px;">文本: "${escaped}"</div>`;
if (fuzzy) html += `<div style="margin-bottom:6px;color:#f59e0b;font-size:11px;">变量模糊匹配</div>`;
if (used.length > 0) {
html += `<div style="margin-bottom:4px;color:#16a34a;font-size:12px;font-weight:600;">当前页面使用(点击复制):</div>`;
html += used.map((k) => keyHtml(k, true)).join("");
}
if (other.length > 0) if (used.length > 0) {
html += `<div style="margin:8px 0 4px;color:#bbb;font-size:11px;cursor:pointer;user-select:none;" class="i18n-toggle-other">其他匹配(${other.length})▸</div>`;
html += `<div class="i18n-other-keys" style="display:none;">${other.map((k) => keyHtml(k, false)).join("")}</div>`;
} else {
html += `<div style="margin-bottom:4px;color:#888;font-size:12px;">找到 ${other.length} 个 key(点击复制):</div>`;
html += other.map((k) => keyHtml(k, false)).join("");
}
return html;
}
function getClickedText(e) {
const el = e.target;
if (!el || el.id === "i18n-finder-tooltip" || el.closest("#i18n-finder-tooltip")) return "";
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
if (!el.value && el.placeholder) return el.placeholder;
}
const selection = window.getSelection();
if (selection && selection.toString().trim()) return selection.toString();
const range = document.caretRangeFromPoint(e.clientX, e.clientY);
if (range?.startContainer.nodeType === Node.TEXT_NODE) return range.startContainer.textContent ?? "";
if (el.childNodes.length === 1 && el.childNodes[0].nodeType === Node.TEXT_NODE) return el.textContent ?? "";
return "";
}
async function handleClick(e) {
if (devMode && e.metaKey && !e.altKey) {
const target = e.target;
if (!target || target.closest("#i18n-finder-tooltip, #i18n-finder-panel, #i18n-finder-toolbar, #i18n-finder-settings, #i18n-react-overlay, #i18n-react-label, #i18n-inspector-panel")) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const chain = lastInspectorChain.length > 0 ? lastInspectorChain : findReactComponentChain(target);
if (chain.length > 0) {
hideInspectorOverlay();
showInspectorPanel(chain, e.clientX, e.clientY);
} else showTooltip(e.clientX, e.clientY, "<div style=\"color:#e53e3e;\">未找到 React 组件源码(需要开发模式)</div>");
return;
}
if (devMode && e.altKey) {
const target = e.target;
if (!target || target.closest("#i18n-finder-tooltip, #i18n-finder-panel, #i18n-finder-toolbar, #i18n-finder-settings, #i18n-react-overlay, #i18n-react-label, #i18n-inspector-panel")) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const source = findReactSource(target);
if (source) {
openInEditor(source);
const overlay = getInspectorOverlay();
overlay.style.borderColor = "#22c55e";
overlay.style.background = "rgba(34, 197, 94, 0.15)";
setTimeout(hideInspectorOverlay, 300);
} else showTooltip(e.clientX, e.clientY, "<div style=\"color:#e53e3e;\">未找到 React 组件源码(需要开发模式)</div>");
return;
}
if (!clickEnabled) return;
const target = e.target;
if (!target) return;
if (target.closest("#i18n-finder-tooltip, #i18n-finder-panel, #i18n-finder-toolbar, #i18n-finder-settings")) return;
if (escHeld) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
const rawText = getClickedText(e);
if (!rawText) {
hideTooltip();
return;
}
const rawExactKey = extractExactKey(rawText);
const text = stripMarkers(rawText).trim();
if (!text) {
hideTooltip();
return;
}
const exactKey = rawExactKey && /^[a-zA-Z][\w.]*$/.test(rawExactKey) ? rawExactKey : null;
if (exactKey) {
const src = keySourceMap?.get(exactKey);
const srcTag = src ? `<span style="color:#999;font-size:10px;margin-left:6px;">[${escapeHtml(src)}]</span>` : "";
const escaped = escapeHtml(text.length > 50 ? text.slice(0, 50) + "..." : text);
const hlBtn = `<span class="i18n-highlight-btn" data-highlight-key="${exactKey}" style="color:#f59e0b;font-size:10px;margin-left:6px;cursor:pointer;text-decoration:underline;">高亮</span>`;
const srcBtn = `<span class="i18n-source-btn" data-source-key="${exactKey}" style="color:#8b5cf6;font-size:10px;margin-left:6px;cursor:pointer;text-decoration:underline;">源码</span>`;
showTooltip(e.clientX, e.clientY, `
<div style="margin-bottom:6px;color:#888;font-size:12px;">文本: "${escaped}"</div>
<div style="margin-bottom:4px;color:#16a34a;font-size:12px;font-weight:600;">精确匹配:</div>
<div style="margin-bottom:6px;color:#e53e3e;font-size:11px;line-height:1.4;">此 key 可能在网站多处使用,修改前请全局搜索确认影响范围</div>
<div style="padding:4px 8px;margin:4px 0;background:#f0fdf4;border:1px solid #86efac;border-radius:4px;cursor:pointer;" class="i18n-key-item" data-key="${exactKey}">
<span style="color:#16a34a;">${exactKey}</span>${srcTag}${hlBtn}${srcBtn}${renderMultiLang(exactKey)}
</div>
`);
return;
}
showTooltip(e.clientX, e.clientY, "<div style=\"color:#888;\">加载中...</div>");
try {
const { reverse } = await fetchI18nData();
const result = findKeys(reverse, text);
showTooltip(e.clientX, e.clientY, renderResult(text, result));
} catch {
showTooltip(e.clientX, e.clientY, "<div style=\"color:#e53e3e;\">加载 i18n 数据失败</div>");
}
}
function hideAllPanels() {
if (searchPanel) searchPanel.style.display = "none";
if (settingsPanel) settingsPanel.style.display = "none";
if (helpPanel) helpPanel.style.display = "none";
hideInspectorPanel();
}
function getToolbarPos() {
const bar = document.getElementById("i18n-finder-toolbar");
if (bar) return {
right: parseInt(bar.style.right) || 20,
bottom: parseInt(bar.style.bottom) || 160
};
return {
right: 20,
bottom: 160
};
}
function clampPanelInViewport(panel) {
requestAnimationFrame(() => {
const rect = panel.getBoundingClientRect();
if (rect.top < 10) panel.style.bottom = `${window.innerHeight - panel.offsetHeight - 10}px`;
if (rect.left < 10) panel.style.right = `${window.innerWidth - panel.offsetWidth - 10}px`;
});
}
var searchPanel = null;
function createSearchPanel() {
const pos = getToolbarPos();
const panel = document.createElement("div");
panel.id = "i18n-finder-panel";
panel.style.cssText = `
position: fixed;
right: ${pos.right + 60}px;
bottom: ${pos.bottom}px;
z-index: 2147483647;
width: 420px;
max-height: 500px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 13px;
display: none;
overflow: hidden;
`;
const tabStyle = (active) => `padding:4px 12px;border-radius:4px;cursor:pointer;font-size:12px;transition:all 0.2s;` + (active ? "background:#2563eb;color:#fff;" : "background:#f0f2f5;color:#666;");
panel.innerHTML = `
<div style="padding:12px 16px;border-bottom:1px solid #eee;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<div style="display:flex;gap:4px;">
<div id="i18n-tab-key" style="${tabStyle(true)}">按 Key</div>
<div id="i18n-tab-value" style="${tabStyle(false)}">按翻译值</div>
</div>
<div style="flex:1;"></div>
<div id="i18n-search-close" style="cursor:pointer;color:#999;font-size:18px;padding:0 4px;line-height:1;">×</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<input id="i18n-search-input" type="text" placeholder="输入 key 搜索..."
style="flex:1;min-width:0;box-sizing:border-box;background:#f5f7fa;border:1px solid #e0e0e0;border-radius:6px;padding:8px 12px;
color:#333;font-size:13px;font-family:inherit;outline:none;" />
<label id="i18n-exact-toggle" style="display:none;align-items:center;gap:4px;cursor:pointer;white-space:nowrap;user-select:none;font-size:12px;color:#999;">
<div id="i18n-exact-switch" style="width:32px;height:18px;border-radius:9px;background:#ddd;position:relative;transition:background 0.2s;flex-shrink:0;">
<div style="width:14px;height:14px;border-radius:50%;background:#fff;position:absolute;top:2px;left:2px;transition:left 0.2s;box-shadow:0 1px 2px rgba(0,0,0,0.15);"></div>
</div>
精准
</label>
</div>
</div>
<div id="i18n-search-results" style="max-height:420px;overflow-y:auto;padding:8px 16px;"></div>
`;
document.body.appendChild(panel);
return panel;
}
var searchMode = "key";
var exactMatchMap = {
key: false,
value: false
};
function getSearchPanel() {
if (!searchPanel || !document.body.contains(searchPanel)) {
searchPanel = createSearchPanel();
const input = searchPanel.querySelector("#i18n-search-input");
const results = searchPanel.querySelector("#i18n-search-results");
const close = searchPanel.querySelector("#i18n-search-close");
const tabKey = searchPanel.querySelector("#i18n-tab-key");
const tabValue = searchPanel.querySelector("#i18n-tab-value");
const activeStyle = "padding:4px 12px;border-radius:4px;cursor:pointer;font-size:12px;transition:all 0.2s;background:#2563eb;color:#fff;";
const inactiveStyle = "padding:4px 12px;border-radius:4px;cursor:pointer;font-size:12px;transition:all 0.2s;background:#f0f2f5;color:#666;";
const exactToggle = searchPanel.querySelector("#i18n-exact-toggle");
const exactSwitch = searchPanel.querySelector("#i18n-exact-switch");
const exactDot = exactSwitch.querySelector("div");
function updateExactUI() {
exactToggle.style.display = searchMode === "value" ? "flex" : "none";
const on = exactMatchMap[searchMode];
exactSwitch.style.background = on ? "#2563eb" : "#ddd";
exactDot.style.left = on ? "16px" : "2px";
exactToggle.style.color = on ? "#2563eb" : "#999";
}
function setTab(mode) {
searchMode = mode;
tabKey.style.cssText = mode === "key" ? activeStyle : inactiveStyle;
tabValue.style.cssText = mode === "value" ? activeStyle : inactiveStyle;
input.placeholder = mode === "key" ? "输入 key 搜索..." : "输入翻译值搜索...";
updateExactUI();
if (input.value.trim()) doSearch(input.value.trim(), results);
}
tabKey.addEventListener("click", () => setTab("key"));
tabValue.addEventListener("click", () => setTab("value"));
exactToggle.addEventListener("click", () => {
exactMatchMap[searchMode] = !exactMatchMap[searchMode];
updateExactUI();
if (input.value.trim()) doSearch(input.value.trim(), results);
});
let debounceTimer;
input.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
doSearch(input.value.trim(), results);
}, 200);
});
close.addEventListener("click", () => {
searchPanel.style.display = "none";
});
input.addEventListener("keydown", (e) => {
e.stopPropagation();
});
results.addEventListener("click", (e) => {
const target = e.target;
if (target.classList.contains("i18n-highlight-btn")) {
e.stopPropagation();
const key = target.dataset.highlightKey;
if (key) highlightKeyOnPage(key);
return;
}
if (target.classList.contains("i18n-source-btn")) {
e.stopPropagation();
const key = target.dataset.sourceKey;
if (key) jumpToKeySource(key);
return;
}
if (target.classList.contains("i18n-toggle-multilang")) {
const box = target.nextElementSibling;
if (box) {
const show = box.style.display === "none";
box.style.display = show ? "block" : "none";
target.textContent = target.textContent.replace(show ? "▸" : "▾", show ? "▾" : "▸");
}
return;
}
const item = target.closest(".i18n-search-item");
if (item?.dataset.key) {
copyToClipboard(item.dataset.key);
item.style.background = "#e6f9ee";
item.style.borderColor = "#34d399";
setTimeout(() => {
item.style.background = "#f5f7fa";
item.style.borderColor = "#e8ecf1";
}, 600);
}
});
}
return searchPanel;
}
function highlightText(text, query) {
if (!query) return escapeHtml(text);
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx < 0) return escapeHtml(text);
const before = text.slice(0, idx);
const match = text.slice(idx, idx + query.length);
const after = text.slice(idx + query.length);
return `${escapeHtml(before)}<mark style="background:#fef08a;color:inherit;padding:0;border-radius:2px;">${escapeHtml(match)}</mark>${escapeHtml(after)}`;
}
async function doSearch(query, container) {
if (!query) {
container.innerHTML = "<div style=\"color:#999;padding:8px 0;\">输入关键词开始搜索</div>";
return;
}
try {
await fetchI18nData();
} catch {
container.innerHTML = "<div style=\"color:#e53e3e;\">加载 i18n 数据失败</div>";
return;
}
const matches = [];
const lowerQuery = query.toLowerCase();
for (const item of searchIndex) {
const target = searchMode === "key" ? item.keyLower : item.valueLower;
if (exactMatchMap[searchMode] ? target === lowerQuery : target.includes(lowerQuery)) {
matches.push({
key: item.key,
value: item.value
});
if (matches.length >= 50) break;
}
}
if (matches.length === 0) {
container.innerHTML = `<div style="color:#e53e3e;padding:8px 0;">未找到匹配 "${escapeHtml(query)}" 的结果</div>`;
return;
}
container.innerHTML = `
<div style="color:#999;padding:4px 0 8px;font-size:12px;">找到 ${matches.length}${matches.length >= 50 ? "+" : ""} 个结果(点击 key 复制)</div>
${matches.map((m) => {
const ek = escapeHtml(m.key);
const displayKey = searchMode === "key" ? highlightText(m.key, query) : `<span style="color:#2563eb;">${ek}</span>`;
const rawVal = m.value.length > 80 ? m.value.slice(0, 80) + "..." : m.value;
const highlightedVal = searchMode === "value" ? highlightText(rawVal, query) : escapeHtml(rawVal);
const srcLabel = keySourceMap?.get(m.key);
const hlBtn = `<span class="i18n-highlight-btn" data-highlight-key="${ek}" style="color:#f59e0b;font-size:10px;margin-left:6px;cursor:pointer;text-decoration:underline;">高亮</span>`;
const srcBtn = `<span class="i18n-source-btn" data-source-key="${ek}" style="color:#8b5cf6;font-size:10px;margin-left:6px;cursor:pointer;text-decoration:underline;">源码</span>`;
return `
<div class="i18n-search-item" data-key="${ek}" style="padding:8px;margin:4px 0;background:#f5f7fa;border:1px solid #e8ecf1;border-radius:6px;cursor:pointer;">
<div style="font-size:12px;margin-bottom:4px;">${searchMode === "key" ? displayKey : `<span style="color:#2563eb;">${ek}</span>`}${srcLabel ? `<span style="color:#999;font-size:10px;margin-left:6px;">[${escapeHtml(srcLabel)}]</span>` : ""}${isKeyUsedOnPage(m.key) ? `<span style="color:#16a34a;font-size:10px;margin-left:6px;">● 当前页面</span>` : ""}${hlBtn}${srcBtn}</div>
<div style="color:#555;font-size:13px;">${highlightedVal}</div>
${renderMultiLang(m.key)}
</div>`;
}).join("")}
`;
}
function toggleSearchPanel() {
const panel = getSearchPanel();
const visible = panel.style.display !== "none";
hideAllPanels();
if (visible) return;
else {
const pos = getToolbarPos();
panel.style.right = `${pos.right + 60}px`;
panel.style.bottom = `${pos.bottom}px`;
panel.style.display = "block";
clampPanelInViewport(panel);
panel.querySelector("#i18n-search-input").focus();
if (!flatMap) fetchI18nData().catch(() => {});
}
}
var settingsPanel = null;
function renderSourceList() {
if (sources.length === 0) return "<div style=\"color:#999;padding:8px 0;font-size:12px;\">暂无数据源,等待页面请求自动捕获...</div>";
return sources.map((s, i) => `
<div class="i18n-source-item" style="display:flex;align-items:center;gap:8px;padding:8px;margin:4px 0;background:#f5f7fa;border:1px solid #e8ecf1;border-radius:6px;">
<div style="flex:1;min-width:0;">
<div style="font-size:12px;font-weight:600;color:#333;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${s.name}</div>
<div style="font-size:11px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:2px;">${s.url}</div>
</div>
<div class="i18n-source-del" data-idx="${i}" style="font-size:14px;color:#ccc;cursor:pointer;padding:0 2px;">×</div>
</div>`).join("");
}
function createSettingsPanel() {
const pos = getToolbarPos();
const panel = document.createElement("div");
panel.id = "i18n-finder-settings";
panel.style.cssText = `
position: fixed;
right: ${pos.right + 60}px;
bottom: ${pos.bottom}px;
z-index: 2147483647;
width: 420px;
max-height: 520px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 13px;
display: none;
overflow: hidden;
`;
panel.innerHTML = `
<div style="padding:12px 16px;border-bottom:1px solid #eee;display:flex;align-items:center;">
<div style="font-weight:600;color:#333;font-size:14px;">数据源管理</div>
<div style="flex:1;"></div>
<div style="font-size:11px;color:#999;margin-right:8px;">共 ${sources.length} 个,已合并加载</div>
<div id="i18n-settings-close" style="cursor:pointer;color:#999;font-size:18px;padding:0 4px;line-height:1;">×</div>
</div>
<div style="padding:8px 16px;border-bottom:1px solid #eee;display:flex;align-items:center;gap:8px;">
<div style="font-size:12px;color:#666;white-space:nowrap;">Option+Click 编辑器:</div>
<div id="i18n-editor-selector" style="display:flex;gap:4px;flex-wrap:wrap;"></div>
</div>
<div style="padding:12px 16px;border-bottom:1px solid #eee;">
<input id="i18n-source-name" type="text" placeholder="名称(如:smart-push prod)"
style="width:100%;box-sizing:border-box;background:#f5f7fa;border:1px solid #e0e0e0;border-radius:6px;padding:8px 12px;
color:#333;font-size:13px;font-family:inherit;outline:none;margin-bottom:8px;" />
<input id="i18n-source-url" type="text" placeholder="JSON URL"
style="width:100%;box-sizing:border-box;background:#f5f7fa;border:1px solid #e0e0e0;border-radius:6px;padding:8px 12px;
color:#333;font-size:13px;font-family:inherit;outline:none;margin-bottom:8px;" />
<div id="i18n-source-add" style="display:inline-block;padding:6px 16px;background:#2563eb;color:#fff;border-radius:6px;cursor:pointer;font-size:12px;">添加</div>
</div>
<div style="padding:8px 16px;border-bottom:1px solid #eee;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<div id="i18n-hc-btn" style="display:inline-flex;align-items:center;gap:6px;padding:6px 16px;background:#fff;border:1px solid #ddd;border-radius:6px;cursor:pointer;font-size:12px;color:#666;transition:all 0.2s;">
<span style="font-size:14px;">🔎</span> 硬编码检测
</div>
<div id="i18n-missing-btn" style="display:inline-flex;align-items:center;gap:6px;padding:6px 16px;background:#fff;border:1px solid #ddd;border-radius:6px;cursor:pointer;font-size:12px;color:#666;transition:all 0.2s;">
<span style="font-size:14px;">🌐</span> 多语言缺失
</div>
</div>
<span id="i18n-hc-hint" style="font-size:11px;color:#999;display:block;margin-top:6px;">硬编码检测需精确匹配 · 多语言缺失需同项目 ≥2 种语言</span>
</div>
<div id="i18n-source-list" style="max-height:300px;overflow-y:auto;padding:8px 16px;"></div>
`;
document.body.appendChild(panel);
return panel;
}
function refreshSettingsPanel() {
if (!settingsPanel) return;
const list = settingsPanel.querySelector("#i18n-source-list");
list.innerHTML = renderSourceList();
bindSourceEvents(list);
}
function bindSourceEvents(list) {
list.querySelectorAll(".i18n-source-del").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt(btn.dataset.idx, 10);
sources.splice(idx, 1);
saveSources(sources);
clearCache();
refreshSettingsPanel();
});
});
}
function getSettingsPanel() {
if (!settingsPanel || !document.body.contains(settingsPanel)) {
settingsPanel = createSettingsPanel();
const close = settingsPanel.querySelector("#i18n-settings-close");
const addBtn = settingsPanel.querySelector("#i18n-source-add");
const nameInput = settingsPanel.querySelector("#i18n-source-name");
const urlInput = settingsPanel.querySelector("#i18n-source-url");
close.addEventListener("click", () => {
settingsPanel.style.display = "none";
});
const editorSel = settingsPanel.querySelector("#i18n-editor-selector");
function renderEditorButtons() {
const current = getPreferredEditor();
editorSel.innerHTML = EDITORS.map((e) => `<div class="i18n-editor-opt" data-editor="${e.key}" style="padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px;transition:all 0.2s;${e.key === current ? "background:#8b5cf6;color:#fff;" : "background:#f0f2f5;color:#666;"}">${e.label}</div>`).join("");
}
renderEditorButtons();
editorSel.addEventListener("click", (e) => {
const btn = e.target.closest(".i18n-editor-opt");
if (!btn?.dataset.editor) return;
setPreferredEditor(btn.dataset.editor);
renderEditorButtons();
});
addBtn.addEventListener("click", () => {
const name = nameInput.value.trim();
const url = urlInput.value.trim();
if (!name || !url) return;
if (sources.some((s) => s.url === url)) return;
sources.push({
name,
url
});
saveSources(sources);
clearCache();
nameInput.value = "";
urlInput.value = "";
refreshSettingsPanel();
});
[nameInput, urlInput].forEach((el) => {
el.addEventListener("keydown", (e) => e.stopPropagation());
});
hcButton = settingsPanel.querySelector("#i18n-hc-btn");
const hcHint = settingsPanel.querySelector("#i18n-hc-hint");
hcButton.addEventListener("click", () => {
detectHardcoded();
hcButton.style.background = hardcodeActive ? "#ef4444" : "#fff";
hcButton.style.color = hardcodeActive ? "#fff" : "#666";
hcButton.style.borderColor = hardcodeActive ? "#ef4444" : "#ddd";
hcHint.textContent = hardcodeActive ? "再次点击「硬编码检测」清除标记" : "硬编码检测需精确匹配 · 多语言缺失需同项目 ≥2 种语言";
});
settingsPanel.querySelector("#i18n-missing-btn").addEventListener("click", () => {
detectMissingTranslations();
});
refreshSettingsPanel();
}
return settingsPanel;
}
function toggleSettingsPanel() {
const panel = getSettingsPanel();
const visible = panel.style.display !== "none";
hideAllPanels();
if (visible) return;
else {
const pos = getToolbarPos();
panel.style.right = `${pos.right + 60}px`;
panel.style.bottom = `${pos.bottom}px`;
panel.style.display = "block";
clampPanelInViewport(panel);
refreshSettingsPanel();
}
}
function createToolbar() {
const saved = _GM_getValue("i18n_toolbar_pos", "");
let posRight = 20, posBottom = 160;
if (saved) try {
const p = JSON.parse(saved);
posRight = p.right;
posBottom = p.bottom;
} catch {}
const bar = document.createElement("div");
bar.id = "i18n-finder-toolbar";
bar.style.cssText = `
position: fixed;
right: ${posRight}px;
bottom: ${posBottom}px;
z-index: 2147483647;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
transition: gap 0.3s;
`;
const handle = document.createElement("div");
handle.style.cssText = `
width: 48px;
height: 18px;
border-radius: 9px;
background: #e5e7eb;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
user-select: none;
transition: background 0.2s;
`;
handle.innerHTML = `
<div style="display:flex;flex-direction:column;gap:3px;">
<div style="display:flex;gap:4px;"><div style="width:3px;height:3px;border-radius:50%;background:#999;"></div><div style="width:3px;height:3px;border-radius:50%;background:#999;"></div></div>
<div style="display:flex;gap:4px;"><div style="width:3px;height:3px;border-radius:50%;background:#999;"></div><div style="width:3px;height:3px;border-radius:50%;background:#999;"></div></div>
</div>`;
handle.addEventListener("mouseenter", () => {
if (!dragging) handle.style.background = "#d1d5db";
});
handle.addEventListener("mouseleave", () => {
if (!dragging) handle.style.background = "#e5e7eb";
});
let dragging = false;
let dragMoved = false;
let startX = 0, startY = 0, origRight = posRight, origBottom = posBottom;
handle.addEventListener("mousedown", (e) => {
hideAllPanels();
dragging = true;
dragMoved = false;
startX = e.clientX;
startY = e.clientY;
origRight = parseInt(bar.style.right) || 0;
origBottom = parseInt(bar.style.bottom) || 0;
handle.style.cursor = "grabbing";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!dragging) return;
dragMoved = true;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newRight = Math.max(0, Math.min(origRight - dx, window.innerWidth - bar.offsetWidth - 10));
const newBottom = Math.max(0, Math.min(origBottom - dy, window.innerHeight - bar.offsetHeight - 10));
bar.style.right = `${newRight}px`;
bar.style.bottom = `${newBottom}px`;
});
let collapsed = _GM_getValue("i18n_toolbar_collapsed", false);
const buttons = [];
function applyCollapsed() {
for (const btn of buttons) {
btn.style.maxHeight = collapsed ? "0" : "48px";
btn.style.opacity = collapsed ? "0" : "1";
btn.style.margin = collapsed ? "0" : "";
btn.style.pointerEvents = collapsed ? "none" : "";
btn.style.overflow = "hidden";
}
bar.style.gap = collapsed ? "0" : "8px";
handle.title = collapsed ? "点击展开工具栏" : "点击收起 · 拖拽移动";
}
document.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
handle.style.cursor = "grab";
if (dragMoved) _GM_setValue("i18n_toolbar_pos", JSON.stringify({
right: parseInt(bar.style.right) || 0,
bottom: parseInt(bar.style.bottom) || 0
}));
else {
collapsed = !collapsed;
_GM_setValue("i18n_toolbar_collapsed", collapsed);
if (collapsed) hideAllPanels();
applyCollapsed();
if (!collapsed) setTimeout(() => {
const maxB = Math.max(0, window.innerHeight - bar.offsetHeight - 10);
if ((parseInt(bar.style.bottom) || 0) > maxB) {
bar.style.bottom = `${maxB}px`;
_GM_setValue("i18n_toolbar_pos", JSON.stringify({
right: parseInt(bar.style.right) || 0,
bottom: maxB
}));
}
}, 320);
}
});
window.addEventListener("resize", () => {
const maxR = Math.max(0, window.innerWidth - bar.offsetWidth - 10);
const maxB = Math.max(0, window.innerHeight - bar.offsetHeight - 10);
bar.style.right = `${Math.max(0, Math.min(parseInt(bar.style.right) || 0, maxR))}px`;
bar.style.bottom = `${Math.max(0, Math.min(parseInt(bar.style.bottom) || 0, maxB))}px`;
});
const btnStyle = `
width: 48px;
height: 48px;
max-height: 48px;
border-radius: 50%;
background: #fff;
color: #888;
border: 1px solid #ddd;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
font-family: 'SF Mono', Monaco, monospace;
user-select: none;
transition: all 0.2s, max-height 0.3s, opacity 0.3s, margin 0.3s;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
`;
const settingsBtn = document.createElement("div");
settingsBtn.innerHTML = "⚙";
settingsBtn.style.cssText = btnStyle;
settingsBtn.style.fontSize = "18px";
settingsBtn.addEventListener("click", toggleSettingsPanel);
const searchBtn = document.createElement("div");
searchBtn.textContent = "Key";
searchBtn.style.cssText = btnStyle;
searchBtn.addEventListener("click", toggleSearchPanel);
const clickBtn = document.createElement("div");
clickBtn.textContent = "i18n";
clickBtn.style.cssText = btnStyle;
clickBtn.style.position = "relative";
const exactDot = document.createElement("div");
exactDot.style.cssText = `position:absolute;top:5px;right:5px;width:7px;height:7px;border-radius:50%;background:#cbd5e1;box-shadow:0 0 0 1.5px #fff;transition:background 0.3s;`;
clickBtn.appendChild(exactDot);
const applyExactStatus = (ready) => {
exactDot.style.background = ready ? "#22c55e" : "#cbd5e1";
clickBtn.title = ready ? "i18n 精确匹配已就绪" : "精确匹配不可用,当前仅反查匹配";
};
let exactPolls = 0;
const exactTimer = setInterval(() => {
const ready = isExactMatchReady();
if (ready || ++exactPolls >= 12) {
applyExactStatus(ready);
clearInterval(exactTimer);
}
}, 1200);
clickBtn.addEventListener("click", () => {
clickEnabled = !clickEnabled;
clickBtn.style.background = clickEnabled ? "#2563eb" : "#fff";
clickBtn.style.color = clickEnabled ? "#fff" : "#888";
clickBtn.style.borderColor = clickEnabled ? "#2563eb" : "#ddd";
if (!clickEnabled) hideTooltip();
if (clickEnabled && !i18nMap) fetchI18nData().catch(() => {});
if (clickEnabled) applyExactStatus(isExactMatchReady());
});
const helpBtn = document.createElement("div");
helpBtn.textContent = "?";
helpBtn.style.cssText = btnStyle;
helpBtn.style.fontSize = "18px";
helpBtn.addEventListener("click", toggleHelpPanel);
buttons.push(helpBtn, settingsBtn, searchBtn, clickBtn);
bar.appendChild(handle);
bar.appendChild(helpBtn);
bar.appendChild(settingsBtn);
bar.appendChild(searchBtn);
bar.appendChild(clickBtn);
document.body.appendChild(bar);
applyCollapsed();
}
var helpPanel = null;
function toggleHelpPanel() {
if (!helpPanel || !document.body.contains(helpPanel)) {
helpPanel = document.createElement("div");
helpPanel.id = "i18n-finder-help";
const pos = getToolbarPos();
helpPanel.style.cssText = `
position:fixed;right:${pos.right + 60}px;bottom:${pos.bottom}px;z-index:2147483647;width:380px;
background:#fff;border:1px solid #e0e0e0;border-radius:10px;max-height:80vh;
box-shadow:0 4px 20px rgba(0,0,0,0.12);font-size:13px;display:none;overflow:hidden;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;flex-direction:column;
`;
helpPanel.innerHTML = `
<div style="padding:12px 16px;border-bottom:1px solid #eee;display:flex;align-items:center;">
<div style="font-weight:600;color:#333;font-size:14px;">i18n Key Finder 使用说明</div>
<div style="flex:1;"></div>
<div id="i18n-help-close" style="cursor:pointer;color:#999;font-size:18px;padding:0 4px;line-height:1;">×</div>
</div>
<div style="padding:12px 16px;line-height:1.8;color:#444;overflow-y:auto;flex:1;">
<div style="font-weight:600;color:#2563eb;margin-bottom:4px;">工具栏按钮</div>
<div><b style="color:#333;">?</b> — 本说明</div>
<div><b style="color:#333;">⚙</b> — 数据源管理(自动捕获 i18n JSON 请求)</div>
<div><b style="color:#333;">Key</b> — 搜索面板(按 key 或翻译值搜索)</div>
<div><b style="color:#333;">i18n</b> — 点击取词模式(开启后点击页面文字查 key)</div>
<div style="font-weight:600;color:#2563eb;margin:8px 0 4px;">点击取词</div>
<div>1. 点击 <b style="color:#2563eb;">i18n</b> 按钮开启(变蓝)</div>
<div>2. 点击页面上的任意翻译文字</div>
<div>3. 弹出匹配结果,点击 key 即可复制</div>
<div>4. 如果文字是链接,按住 <kbd style="background:#f0f0f0;padding:1px 6px;border:1px solid #ddd;border-radius:3px;font-size:12px;">Esc</kbd> 再点击可阻止跳转</div>
<div style="font-weight:600;color:#2563eb;margin:8px 0 4px;">匹配模式</div>
<div><span style="color:#16a34a;font-weight:600;">精确匹配</span> — 需要在项目的 i18n 初始化文件中添加一行代码将实例暴露到 window:</div>
<div style="margin:4px 0;padding:6px 8px;background:#1e1e1e;color:#d4d4d4;border-radius:4px;font-family:monospace;font-size:11px;line-height:1.5;white-space:pre;overflow-x:auto;"><span style="color:#c586c0;">import</span> i18n <span style="color:#c586c0;">from</span> <span style="color:#ce9178;">'i18next'</span>;\n;(<span style="color:#4ec9b0;">window</span> <span style="color:#c586c0;">as</span> <span style="color:#4ec9b0;">any</span>).__i18n__ = i18n\n<span style="color:#c586c0;">export default</span> i18n;</div>
<div style="color:#888;font-size:11px;">添加后重新部署,脚本会自动 hook t() 精确定位每段文本的 key</div>
<div style="color:#888;font-size:11px;"><b style="color:#16a34a;">i18n</b> 按钮右上角圆点:<span style="color:#16a34a;">绿色</span>=精确匹配就绪,<span style="color:#94a3b8;">灰色</span>=仅反查匹配</div>
<div><span style="color:#2563eb;font-weight:600;">反查匹配</span> — 通过翻译值反查所有可能的 key,按页面使用情况过滤排序</div>
<div><span style="color:#f59e0b;font-weight:600;">变量模糊匹配</span> — 当精确和反查均无结果时,自动对含变量占位符(<code style="background:#f0f0f0;padding:1px 4px;border-radius:3px;font-size:11px;">\${...}</code>、<code style="background:#f0f0f0;padding:1px 4px;border-radius:3px;font-size:11px;">{{...}}</code>)的翻译值去除变量后做子串匹配</div>
<div style="font-weight:600;color:#f59e0b;margin:8px 0 4px;">页面高亮 / 源码跳转</div>
<div>查找到 key 后,结果项提供两个按钮:</div>
<div><span style="color:#f59e0b;text-decoration:underline;">高亮</span> — 页面上所有使用该 key 的元素加黄框并滚动到第一处(右上角徽章可清除)</div>
<div><span style="color:#8b5cf6;text-decoration:underline;">源码</span> — 跳转到调用该 key 的 React 组件源码位置</div>
<div style="color:#888;font-size:11px;">两者均需精确匹配模式;源码跳转还需 React 开发模式</div>
<div style="font-weight:600;color:#8b5cf6;margin:8px 0 4px;">多语言对照</div>
<div>当存在多个数据源时,key 下方显示 <span style="color:#8b5cf6;">多语言对照</span> 展开项</div>
<div style="color:#888;font-size:11px;">点击可查看该 key 在各数据源中的翻译值</div>
<div style="font-weight:600;color:#ef4444;margin:8px 0 4px;">硬编码检测</div>
<div>在 <b style="color:#333;">⚙</b> 数据源管理中点击「硬编码检测」按钮</div>
<div>自动扫描页面,用红色虚线框标出未经 t() 翻译的文本</div>
<div style="color:#888;font-size:11px;">需要精确匹配模式(window.__i18n__ 已暴露)</div>
<div style="font-weight:600;color:#8b5cf6;margin:8px 0 4px;">多语言缺失检测</div>
<div>在 <b style="color:#333;">⚙</b> 数据源管理中点击「多语言缺失」按钮</div>
<div>对比同一项目的多个语言数据源,列出某些语言缺失(或为空)的 key</div>
<div style="color:#888;font-size:11px;">需加载同项目 ≥2 种语言文件,可一键复制全部缺失记录</div>
<div style="font-weight:600;color:#8b5cf6;margin:8px 0 4px;">React 源码跳转</div>
<div>按住 <kbd style="background:#f0f0f0;padding:1px 6px;border:1px solid #ddd;border-radius:3px;font-size:12px;">Option</kbd>(Alt) 悬停查看组件面包屑链路 + 源码位置</div>
<div><kbd style="background:#f0f0f0;padding:1px 6px;border:1px solid #ddd;border-radius:3px;font-size:12px;">Option</kbd> + 点击 → 直接跳转编辑器</div>
<div style="color:#888;font-size:11px;">支持 VS Code、Cursor、Zed、WebStorm、GoLand、IDEA,可在设置中切换</div>
<div style="color:#888;font-size:11px;">打开时会激活并置顶编辑器窗口;JetBrains 系首次需在浏览器允许打开协议</div>
<div style="font-weight:600;color:#8b5cf6;margin:8px 0 4px;">组件检查器</div>
<div>按住 <kbd style="background:#f0f0f0;padding:1px 6px;border:1px solid #ddd;border-radius:3px;font-size:12px;">⌘ Command</kbd> 悬停查看组件链路(同 Option)</div>
<div><kbd style="background:#f0f0f0;padding:1px 6px;border:1px solid #ddd;border-radius:3px;font-size:12px;">⌘ Command</kbd> + 点击 → 打开组件检查器面板</div>
<div style="margin:4px 0 2px;font-size:12px;">面板包含三个区域:</div>
<div style="padding-left:12px;font-size:12px;"><b style="color:#333;">组件链路</b> — 从根组件到当前元素的完整组件树,每层可独立跳转 IDE</div>
<div style="padding-left:12px;font-size:12px;"><b style="color:#333;">Props</b> — 当前选中组件的属性,语法高亮显示</div>
<div style="padding-left:12px;font-size:12px;"><b style="color:#333;">Hooks / State</b> — 函数组件显示 Hooks 列表(useState、useRef、useMemo 等),类组件显示 State 对象</div>
<div style="color:#888;font-size:11px;">点击链路中的不同组件可切换查看其 Props 和 Hooks</div>
<div style="color:#888;font-size:11px;">以上功能均需 React 开发模式(_debugSource / _debugHookTypes 信息)</div>
<div style="font-weight:600;color:#e53e3e;margin:8px 0 4px;">注意</div>
<div style="color:#e53e3e;">同一个 key 可能在网站多处使用,修改翻译前请全局搜索确认影响范围</div>
</div>
`;
document.body.appendChild(helpPanel);
helpPanel.querySelector("#i18n-help-close").addEventListener("click", () => {
helpPanel.style.display = "none";
});
}
const visible = helpPanel.style.display !== "none";
hideAllPanels();
if (!visible) {
const pos = getToolbarPos();
helpPanel.style.right = `${pos.right + 60}px`;
helpPanel.style.bottom = `${pos.bottom}px`;
helpPanel.style.display = "flex";
clampPanelInViewport(helpPanel);
}
}
function initUI() {
detectInlineScriptBlocked();
createToolbar();
tryHookI18n();
document.addEventListener("click", (e) => {
const t = e.target;
if (t && !t.closest("#i18n-finder-panel, #i18n-finder-settings, #i18n-finder-help, #i18n-finder-toolbar, #i18n-hardcode-panel, #i18n-inspector-panel")) hideAllPanels();
handleClick(e);
}, true);
getTooltip().addEventListener("click", (e) => {
const target = e.target;
if (target.classList.contains("i18n-toggle-other")) {
const box = target.nextElementSibling;
if (box) {
const show = box.style.display === "none";
box.style.display = show ? "block" : "none";
target.textContent = target.textContent.replace(show ? "▸" : "▾", show ? "▾" : "▸");
}
return;
}
if (target.classList.contains("i18n-highlight-btn")) {
e.stopPropagation();
const key = target.dataset.highlightKey;
if (key) highlightKeyOnPage(key);
return;
}
if (target.classList.contains("i18n-source-btn")) {
e.stopPropagation();
const key = target.dataset.sourceKey;
if (key) jumpToKeySource(key);
return;
}
if (target.classList.contains("i18n-toggle-multilang")) {
const box = target.nextElementSibling;
if (box) {
const show = box.style.display === "none";
box.style.display = show ? "block" : "none";
target.textContent = target.textContent.replace(show ? "▸" : "▾", show ? "▾" : "▸");
}
return;
}
const item = target.closest(".i18n-key-item");
if (item?.dataset.key) {
copyToClipboard(item.dataset.key);
item.style.background = "#e6f9ee";
item.style.borderColor = "#34d399";
setTimeout(() => {
const inOther = item.closest(".i18n-other-keys");
item.style.background = inOther ? "#f5f7fa" : "#f0fdf4";
item.style.borderColor = inOther ? "#e8ecf1" : "#86efac";
}, 600);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") escHeld = true;
if (!devMode) return;
if (e.key === "Alt") {
altHeld = true;
document.documentElement.classList.add("i18n-inspector-active");
}
if (e.key === "Meta") {
if (metaTimer) clearTimeout(metaTimer);
metaTimer = setTimeout(() => {
metaHeld = true;
document.documentElement.classList.add("i18n-inspector-active");
}, 200);
}
if (e.metaKey && e.key !== "Meta") {
if (metaTimer) {
clearTimeout(metaTimer);
metaTimer = null;
}
metaHeld = false;
document.documentElement.classList.remove("i18n-inspector-active");
hideInspectorOverlay();
}
}, true);
document.addEventListener("keyup", (e) => {
if (e.key === "Escape") escHeld = false;
if (!devMode) return;
if (e.key === "Alt") {
altHeld = false;
if (!metaHeld) {
document.documentElement.classList.remove("i18n-inspector-active");
hideInspectorOverlay();
}
}
if (e.key === "Meta") {
if (metaTimer) {
clearTimeout(metaTimer);
metaTimer = null;
}
metaHeld = false;
if (!altHeld) {
document.documentElement.classList.remove("i18n-inspector-active");
hideInspectorOverlay();
}
}
}, true);
if (devMode) {
let inspectorRafId = 0;
document.addEventListener("mousemove", (e) => {
if (!altHeld && !metaHeld) return;
cancelAnimationFrame(inspectorRafId);
inspectorRafId = requestAnimationFrame(() => {
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || el === lastHoverEl) return;
if (el.closest("#i18n-react-overlay, #i18n-react-label, #i18n-finder-toolbar, #i18n-inspector-panel")) return;
lastHoverEl = el;
updateInspectorOverlay(el);
});
}, true);
window.addEventListener("blur", () => {
if (altHeld || metaHeld) {
altHeld = false;
metaHeld = false;
document.documentElement.classList.remove("i18n-inspector-active");
hideInspectorOverlay();
}
});
const inspectorStyle = document.createElement("style");
inspectorStyle.textContent = ".i18n-inspector-active, .i18n-inspector-active * { cursor: crosshair !important; }";
document.head.appendChild(inspectorStyle);
}
}
setupRequestCapture();
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", initUI);
else initUI();
})();