mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-13 11:54:29 +08:00
🎁 Sync 2026-08-02 20:24:47
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Aurora dev helper — one floating bar, injected by vite.config.ts into both
|
||||
* contexts it is useful in. Never ships to the router.
|
||||
*
|
||||
* proxied device page (/cgi-bin/luci/…)
|
||||
* · lists every snapshot in .dev/mocks/ so the workflow is reachable
|
||||
* without typing /mocks/ by hand
|
||||
* · jumps straight to this page's own snapshot when one was captured
|
||||
* (matched on <body data-page>)
|
||||
* · captures the open page — the button does what Alt/Option+Shift+S does
|
||||
* served snapshot (/mocks/…)
|
||||
* · names the open snapshot, cycles with ‹ / › or the [ and ] keys
|
||||
* · takes over the snapshot's own LuCI links: they resolve against the
|
||||
* captured snapshots instead of falling through to the proxied router
|
||||
*
|
||||
* Shadow DOM throughout, so theme/patch CSS can't restyle the bar and its
|
||||
* styles can't leak into the page under review. The theme's own floating
|
||||
* toolbar owns the bottom-right corner, so this one sits bottom-left.
|
||||
*
|
||||
* Data contract (injected inline before this script):
|
||||
* window.__AURORA_MOCKS__ = { current: "<file>"|null, mocks: [{ file, page }] }
|
||||
*/
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
if (window.__auroraMockBar) return;
|
||||
window.__auroraMockBar = true;
|
||||
|
||||
const data = window.__AURORA_MOCKS__ || {};
|
||||
const mocks = Array.isArray(data.mocks) ? data.mocks.slice() : [];
|
||||
// A snapshot is static DOM: capturing one would only re-save the theme's own
|
||||
// output, and there is no live page to re-capture.
|
||||
const inMock = location.pathname.startsWith("/mocks/");
|
||||
const COLLAPSE_KEY = "aurora.mockbar.collapsed";
|
||||
|
||||
const mockUrl = (file) => "/mocks/" + encodeURIComponent(file);
|
||||
const displayName = (file) => file.replace(/\.html$/, "");
|
||||
const escapeHtml = (s) =>
|
||||
s.replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
// The page's own identity, used to find its snapshot from a device page.
|
||||
const currentPage = document.body?.dataset?.page || null;
|
||||
const snapshotOfThisPage = () =>
|
||||
currentPage ? mocks.find((m) => m.page === currentPage) : undefined;
|
||||
|
||||
const LAST_DEVICE_KEY = "aurora.mockbar.device";
|
||||
const readStore = (store, key) => {
|
||||
try {
|
||||
return window[store].getItem(key);
|
||||
} catch {
|
||||
return null; // private mode
|
||||
}
|
||||
};
|
||||
const writeStore = (store, key, value) => {
|
||||
try {
|
||||
if (value === null) window[store].removeItem(key);
|
||||
else window[store].setItem(key, value);
|
||||
} catch {
|
||||
/* private mode — the bar just forgets between reloads */
|
||||
}
|
||||
};
|
||||
|
||||
// Where "back to the device" goes from inside a snapshot. data-page is the
|
||||
// request path joined with "-", so splitting it back apart is wrong whenever
|
||||
// a segment contains a dash of its own (admin-status-disks-info). LuCI's own
|
||||
// inline bootstrap carries the segments verbatim, so read those first and
|
||||
// keep the lossy split as the fallback.
|
||||
const devicePath = () => {
|
||||
const page = mocks.find((m) => m.file === data.current)?.page;
|
||||
const bootstrap = document.documentElement.innerHTML.match(
|
||||
/"requestpath"\s*:\s*\[([^\]]*)\]/,
|
||||
);
|
||||
const segs = bootstrap?.[1]
|
||||
.match(/"((?:[^"\\]|\\.)*)"/g)
|
||||
?.map((s) => JSON.parse(s));
|
||||
// Only trust the bootstrap when it agrees with the snapshot's identity —
|
||||
// a hand-assembled mock can carry the segments of the page it was built
|
||||
// from.
|
||||
if (segs && (!page || segs.join("-") === page))
|
||||
return "/cgi-bin/luci/" + segs.join("/");
|
||||
if (page) return "/cgi-bin/luci/" + page.split("-").join("/");
|
||||
return readStore("sessionStorage", LAST_DEVICE_KEY) || "/cgi-bin/luci/";
|
||||
};
|
||||
|
||||
// ---- shell ---------------------------------------------------------------
|
||||
|
||||
const host = document.createElement("aurora-mock-bar");
|
||||
const root = host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2147483000;
|
||||
font: 12px/1.5 system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 18, 24, 0.82);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.pill[hidden],
|
||||
.dot[hidden],
|
||||
.step[hidden],
|
||||
.here[hidden],
|
||||
.back[hidden],
|
||||
.capture[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.step,
|
||||
.here,
|
||||
.back,
|
||||
.capture,
|
||||
.close {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.step:hover,
|
||||
.here:hover,
|
||||
.back:hover,
|
||||
.capture:hover,
|
||||
.close:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
opacity: 1;
|
||||
}
|
||||
.here {
|
||||
color: #4ade80;
|
||||
opacity: 1;
|
||||
}
|
||||
.close {
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.name {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.name:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.count {
|
||||
opacity: 0.6;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: rgba(18, 18, 24, 0.5);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.dot:hover {
|
||||
background: rgba(18, 18, 24, 0.85);
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
background: rgba(18, 18, 24, 0.92);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.panel a {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.panel a:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.panel a.current {
|
||||
color: #4ade80;
|
||||
}
|
||||
.panel a.current::before {
|
||||
content: "● ";
|
||||
font-size: 9px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px 2px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.foot a {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.foot a:hover {
|
||||
color: #fff;
|
||||
background: none;
|
||||
}
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
max-width: 320px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(18, 18, 24, 0.92);
|
||||
color: #ffd166;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
<div class="panel" hidden></div>
|
||||
<div class="toast"></div>
|
||||
<div class="pill">
|
||||
<button class="step prev" title="上一个快照 [">‹</button>
|
||||
<button class="here" title="打开本页快照">◆</button>
|
||||
<button class="name" title="全部快照"></button>
|
||||
<button class="step next" title="下一个快照 ]">›</button>
|
||||
<button class="back" title="回到真机页">↩</button>
|
||||
<button class="capture" title="捕获此页(Alt/Option+Shift+S)">⊕</button>
|
||||
<button class="close" title="收起">✕</button>
|
||||
</div>
|
||||
<button class="dot" title="Aurora 快照" hidden></button>
|
||||
`;
|
||||
|
||||
const panel = root.querySelector(".panel");
|
||||
const toastEl = root.querySelector(".toast");
|
||||
const pill = root.querySelector(".pill");
|
||||
const dot = root.querySelector(".dot");
|
||||
const nameBtn = root.querySelector(".name");
|
||||
const hereBtn = root.querySelector(".here");
|
||||
const backBtn = root.querySelector(".back");
|
||||
const captureBtn = root.querySelector(".capture");
|
||||
const prevBtn = root.querySelector(".prev");
|
||||
const nextBtn = root.querySelector(".next");
|
||||
|
||||
let toastTimer;
|
||||
const toast = (text, ok) => {
|
||||
toastEl.textContent = text;
|
||||
toastEl.style.color = ok ? "#4ade80" : "#ffd166";
|
||||
toastEl.classList.add("show");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2800);
|
||||
};
|
||||
|
||||
// ---- rendering -----------------------------------------------------------
|
||||
|
||||
const currentIndex = () =>
|
||||
Math.max(
|
||||
mocks.findIndex((m) => m.file === data.current),
|
||||
0,
|
||||
);
|
||||
|
||||
const render = () => {
|
||||
const here = snapshotOfThisPage();
|
||||
const count = mocks.length;
|
||||
|
||||
prevBtn.hidden = nextBtn.hidden = !inMock || count < 2;
|
||||
captureBtn.hidden = inMock;
|
||||
hereBtn.hidden = inMock || !here;
|
||||
backBtn.hidden = !inMock;
|
||||
if (inMock) backBtn.title = `回到真机页 ${devicePath()}`;
|
||||
|
||||
if (inMock) {
|
||||
nameBtn.innerHTML =
|
||||
escapeHtml(displayName(data.current || "")) +
|
||||
`<span class="count">${currentIndex() + 1}/${count}</span>`;
|
||||
} else if (!count) {
|
||||
nameBtn.textContent = "捕获此页";
|
||||
} else {
|
||||
nameBtn.innerHTML =
|
||||
(here ? "本页有快照" : "快照") + `<span class="count">${count}</span>`;
|
||||
}
|
||||
|
||||
panel.innerHTML =
|
||||
mocks
|
||||
.map((m) => {
|
||||
const isCurrent = inMock
|
||||
? m.file === data.current
|
||||
: !!currentPage && m.page === currentPage;
|
||||
return (
|
||||
`<a href="${mockUrl(m.file)}"` +
|
||||
(isCurrent ? ' class="current"' : "") +
|
||||
` title="${escapeHtml(m.page || m.file)}">` +
|
||||
`${escapeHtml(displayName(m.file))}</a>`
|
||||
);
|
||||
})
|
||||
.join("") +
|
||||
'<div class="foot"><a href="/mocks/">全部快照 ↗</a>' +
|
||||
`<span>${inMock ? "[ / ] 切换" : "Alt/⇧+S 捕获"}</span></div>`;
|
||||
};
|
||||
|
||||
const setCollapsed = (collapsed) => {
|
||||
pill.hidden = collapsed;
|
||||
dot.hidden = !collapsed;
|
||||
if (collapsed) panel.hidden = true;
|
||||
writeStore("localStorage", COLLAPSE_KEY, collapsed ? "1" : null);
|
||||
};
|
||||
|
||||
// ---- capture -------------------------------------------------------------
|
||||
|
||||
const capture = async () => {
|
||||
try {
|
||||
const response = await fetch("/mocks/__save", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Aurora-Capture": "1",
|
||||
},
|
||||
body: "<!doctype html>\n" + document.documentElement.outerHTML,
|
||||
});
|
||||
const info = await response.json().catch(() => ({}));
|
||||
if (!response.ok)
|
||||
throw new Error(info.error || `HTTP ${response.status}`);
|
||||
// Fold the fresh snapshot into the list the page was served with, so the
|
||||
// bar reflects it without a reload.
|
||||
const entry = { file: info.file, page: info.page ?? null };
|
||||
const at = mocks.findIndex((m) => m.file === entry.file);
|
||||
if (at === -1) mocks.push(entry);
|
||||
else mocks[at] = entry;
|
||||
mocks.sort((a, b) => a.file.localeCompare(b.file));
|
||||
render();
|
||||
toast(`已捕获 ${info.file}`, true);
|
||||
} catch (err) {
|
||||
toast(`捕获失败:${(err && err.message) || err}`, false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- wiring --------------------------------------------------------------
|
||||
|
||||
const cycle = (step) => {
|
||||
if (!inMock || mocks.length < 2) return;
|
||||
const next = mocks[(currentIndex() + step + mocks.length) % mocks.length];
|
||||
location.href = mockUrl(next.file);
|
||||
};
|
||||
|
||||
prevBtn.addEventListener("click", () => cycle(-1));
|
||||
nextBtn.addEventListener("click", () => cycle(1));
|
||||
hereBtn.addEventListener("click", () => {
|
||||
const here = snapshotOfThisPage();
|
||||
if (here) location.href = mockUrl(here.file);
|
||||
});
|
||||
backBtn.addEventListener("click", () => {
|
||||
location.href = devicePath();
|
||||
});
|
||||
captureBtn.addEventListener("click", capture);
|
||||
nameBtn.addEventListener("click", () => {
|
||||
if (!inMock && !mocks.length) return capture();
|
||||
panel.hidden = !panel.hidden;
|
||||
});
|
||||
root
|
||||
.querySelector(".close")
|
||||
.addEventListener("click", () => setCollapsed(true));
|
||||
dot.addEventListener("click", () => setCollapsed(false));
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!panel.hidden && !event.composedPath().includes(host)) {
|
||||
panel.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
setCollapsed(readStore("localStorage", COLLAPSE_KEY) === "1");
|
||||
document.documentElement.appendChild(host);
|
||||
// Remembered as the last resort for "back to the device" from a snapshot
|
||||
// that carries neither a usable bootstrap nor a data-page.
|
||||
if (!inMock)
|
||||
writeStore(
|
||||
"sessionStorage",
|
||||
LAST_DEVICE_KEY,
|
||||
location.pathname + location.search,
|
||||
);
|
||||
|
||||
// ---- navigation takeover (snapshots only) --------------------------------
|
||||
|
||||
const pageFromHref = (href) => {
|
||||
try {
|
||||
const url = new URL(href, location.href);
|
||||
if (url.origin !== location.origin) return null;
|
||||
const match = url.pathname.match(/^\/cgi-bin\/luci(?:$|\/(.*))/);
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match[1] || "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (inMock) {
|
||||
const byPage = new Map(
|
||||
mocks.filter((m) => m.page).map((m) => [m.page, m.file]),
|
||||
);
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const anchor = target && target.closest("a[href]");
|
||||
if (!anchor) return;
|
||||
const page = pageFromHref(anchor.getAttribute("href"));
|
||||
if (page === null) return;
|
||||
// Never let a click inside a mock fall through to the proxied router.
|
||||
event.preventDefault();
|
||||
const file = byPage.get(page);
|
||||
if (file) location.href = mockUrl(file);
|
||||
else
|
||||
toast(
|
||||
`未捕获快照:${page || "/cgi-bin/luci"}(真机页 Alt/Option+Shift+S 可捕获)`,
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- keyboard ------------------------------------------------------------
|
||||
|
||||
const isEditable = (el) =>
|
||||
el instanceof Element &&
|
||||
(el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
|
||||
|
||||
addEventListener("keydown", (event) => {
|
||||
if (isEditable(event.target)) return;
|
||||
if (
|
||||
!inMock &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.code === "KeyS"
|
||||
) {
|
||||
event.preventDefault();
|
||||
capture();
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (event.key === "[") cycle(-1);
|
||||
else if (event.key === "]") cycle(1);
|
||||
});
|
||||
|
||||
if (!inMock) {
|
||||
window.__auroraMockCapture = capture;
|
||||
console.info(
|
||||
"[aurora] mock bar ready — 左下角可捕获/打开快照,Alt/Option+Shift+S(或 __auroraMockCapture())保存当前页到 .dev/mocks/",
|
||||
);
|
||||
}
|
||||
})();
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Aurora dev helper — injected by the /cgi-bin dev proxy (vite.config.ts)
|
||||
* into real device-served LuCI pages. Never ships to the router.
|
||||
*
|
||||
* Alt/Option+Shift+S — or __auroraMockCapture() from the console — POSTs the
|
||||
* live DOM to /mocks/__save, which writes .dev/mocks/<data-page>.html for the
|
||||
* /mocks/ workflow (see "Mock Pages" in .dev/docs/DEVELOPMENT.md).
|
||||
*/
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
// Mock pages are served, not proxied, so this only runs on real pages —
|
||||
// the guard is just belt and braces against a stale captured tag.
|
||||
if (window.__auroraMockCapture || location.pathname.startsWith("/mocks/"))
|
||||
return;
|
||||
|
||||
let toastEl;
|
||||
let toastTimer;
|
||||
const toast = (text, ok) => {
|
||||
if (!toastEl) {
|
||||
toastEl = document.createElement("div");
|
||||
toastEl.style.cssText =
|
||||
"position:fixed;left:50%;bottom:32px;transform:translateX(-50%);" +
|
||||
"z-index:2147483647;padding:9px 14px;border-radius:10px;" +
|
||||
"font:13px/1.5 system-ui,-apple-system,sans-serif;" +
|
||||
"background:rgba(18,18,24,.9);box-shadow:0 8px 32px rgba(0,0,0,.35);" +
|
||||
"backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
|
||||
"transition:opacity 160ms ease;pointer-events:none;max-width:80vw";
|
||||
document.documentElement.appendChild(toastEl);
|
||||
}
|
||||
toastEl.textContent = text;
|
||||
toastEl.style.color = ok ? "#4ade80" : "#ffd166";
|
||||
toastEl.style.opacity = "1";
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (toastEl.style.opacity = "0"), 3000);
|
||||
};
|
||||
|
||||
const capture = async () => {
|
||||
try {
|
||||
const response = await fetch("/mocks/__save", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Aurora-Capture": "1",
|
||||
},
|
||||
body: "<!doctype html>\n" + document.documentElement.outerHTML,
|
||||
});
|
||||
const info = await response.json().catch(() => ({}));
|
||||
if (!response.ok)
|
||||
throw new Error(info.error || `HTTP ${response.status}`);
|
||||
toast(`已捕获 ${info.file} — 在 /mocks/ 打开`, true);
|
||||
} catch (err) {
|
||||
toast(`捕获失败:${(err && err.message) || err}`, false);
|
||||
}
|
||||
};
|
||||
|
||||
window.__auroraMockCapture = capture;
|
||||
|
||||
addEventListener("keydown", (event) => {
|
||||
if (
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.code === "KeyS"
|
||||
) {
|
||||
event.preventDefault();
|
||||
capture();
|
||||
}
|
||||
});
|
||||
|
||||
console.info(
|
||||
"[aurora] mock capture ready — Alt/Option+Shift+S(或 __auroraMockCapture())保存当前页到 .dev/mocks/",
|
||||
);
|
||||
})();
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* Aurora dev helper — injected by mock-pages-plugin (vite.config.ts) into
|
||||
* every page served under /mocks/. Never ships to the router.
|
||||
*
|
||||
* 1. Navigation takeover: clicks on the snapshot's own LuCI links
|
||||
* (/cgi-bin/luci/…) resolve against the captured snapshots — matched by
|
||||
* each snapshot's <body data-page>, exact match — and jump to the matching
|
||||
* mock instead of falling through to the proxied router. Uncaptured
|
||||
* targets are blocked with a hint naming the missing snapshot.
|
||||
* 2. Floating switcher (bottom-left; the theme's own floating toolbar owns
|
||||
* the bottom-right corner). Shadow DOM, so theme/patch CSS can't restyle
|
||||
* it and its styles can't leak into the page under review. Lists every
|
||||
* snapshot, cycles with the ‹/› buttons or the [ and ] keys, links back
|
||||
* to the /mocks/ index.
|
||||
*
|
||||
* Data contract (injected inline before this script):
|
||||
* window.__AURORA_MOCKS__ = { current: "<file>", mocks: [{ file, page }] }
|
||||
*/
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const data = window.__AURORA_MOCKS__;
|
||||
if (!data || !Array.isArray(data.mocks) || !data.mocks.length) return;
|
||||
|
||||
const mocks = data.mocks;
|
||||
const byPage = new Map(
|
||||
mocks.filter((m) => m.page).map((m) => [m.page, m.file]),
|
||||
);
|
||||
const current = Math.max(
|
||||
mocks.findIndex((m) => m.file === data.current),
|
||||
0,
|
||||
);
|
||||
|
||||
const mockUrl = (file) => "/mocks/" + encodeURIComponent(file);
|
||||
const displayName = (file) => file.replace(/\.html$/, "");
|
||||
const escapeHtml = (s) =>
|
||||
s.replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
|
||||
// ---- floating switcher ---------------------------------------------------
|
||||
|
||||
const host = document.createElement("aurora-mock-nav");
|
||||
const root = host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2147483000;
|
||||
font: 12px/1.5 system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 18, 24, 0.82);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.step {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.step:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
opacity: 1;
|
||||
}
|
||||
.name {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.name:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.count {
|
||||
opacity: 0.6;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
background: rgba(18, 18, 24, 0.92);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.panel a {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.panel a:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.panel a.current {
|
||||
color: #4ade80;
|
||||
}
|
||||
.panel a.current::before {
|
||||
content: "● ";
|
||||
font-size: 9px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px 2px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.foot a {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.foot a:hover {
|
||||
color: #fff;
|
||||
background: none;
|
||||
}
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
max-width: 320px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(18, 18, 24, 0.92);
|
||||
color: #ffd166;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
<div class="panel" hidden></div>
|
||||
<div class="toast"></div>
|
||||
<div class="pill">
|
||||
<button class="step prev" title="上一个快照 [">‹</button>
|
||||
<button class="name" title="全部快照"></button>
|
||||
<button class="step next" title="下一个快照 ]">›</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const panel = root.querySelector(".panel");
|
||||
const toastEl = root.querySelector(".toast");
|
||||
const nameBtn = root.querySelector(".name");
|
||||
|
||||
nameBtn.innerHTML =
|
||||
escapeHtml(displayName(data.current)) +
|
||||
`<span class="count">${current + 1}/${mocks.length}</span>`;
|
||||
|
||||
panel.innerHTML =
|
||||
mocks
|
||||
.map(
|
||||
(m) =>
|
||||
`<a href="${mockUrl(m.file)}"` +
|
||||
(m.file === data.current ? ' class="current"' : "") +
|
||||
` title="${escapeHtml(m.page || m.file)}">` +
|
||||
`${escapeHtml(displayName(m.file))}</a>`,
|
||||
)
|
||||
.join("") +
|
||||
'<div class="foot"><a href="/mocks/">全部快照 ↗</a><span>[ / ] 切换</span></div>';
|
||||
|
||||
const cycle = (step) => {
|
||||
if (mocks.length < 2) return;
|
||||
const next = mocks[(current + step + mocks.length) % mocks.length];
|
||||
location.href = mockUrl(next.file);
|
||||
};
|
||||
|
||||
root.querySelector(".prev").addEventListener("click", () => cycle(-1));
|
||||
root.querySelector(".next").addEventListener("click", () => cycle(1));
|
||||
nameBtn.addEventListener("click", () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!panel.hidden && !event.composedPath().includes(host)) {
|
||||
panel.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
let toastTimer;
|
||||
const toast = (text) => {
|
||||
toastEl.textContent = text;
|
||||
toastEl.classList.add("show");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2600);
|
||||
};
|
||||
|
||||
document.documentElement.appendChild(host);
|
||||
|
||||
// ---- navigation takeover -------------------------------------------------
|
||||
|
||||
const pageFromHref = (href) => {
|
||||
try {
|
||||
const url = new URL(href, location.href);
|
||||
if (url.origin !== location.origin) return null;
|
||||
const match = url.pathname.match(/^\/cgi-bin\/luci(?:$|\/(.*))/);
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match[1] || "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const anchor = target && target.closest("a[href]");
|
||||
if (!anchor) return;
|
||||
const page = pageFromHref(anchor.getAttribute("href"));
|
||||
if (page === null) return;
|
||||
// Never let a click inside a mock fall through to the proxied router.
|
||||
event.preventDefault();
|
||||
const file = byPage.get(page);
|
||||
if (file) location.href = mockUrl(file);
|
||||
else
|
||||
toast(
|
||||
`未捕获快照:${page || "/cgi-bin/luci"}(真机页 Alt/Option+Shift+S 可捕获)`,
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const isEditable = (el) =>
|
||||
el instanceof Element &&
|
||||
(el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
|
||||
|
||||
addEventListener("keydown", (event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (isEditable(event.target)) return;
|
||||
if (event.key === "[") cycle(-1);
|
||||
else if (event.key === "]") cycle(1);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user