NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name 查看网址
// @namespace https://github.com/11ze
// @version 0.4.2
// @description 2026-08-14 修复重复参数键编辑后丢失和参数值中 ? 后内容截断
// @author 11ze
// @license MIT
// @match *://*/*
// @noframes
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItc2VhcmNoIj48Y2lyY2xlIGN4PSIxMSIgY3k9IjExIiByPSI4Ij48L2NpcmNsZT48cGF0aCBkPSJtMjEgMjEtNC4zNS00LjM1Ij48L3BhdGg+PC9zdmc+
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
// ==================== 样式常量 ====================
const ANIM = {
slideIn: '11ze-url-viewer-slide-in',
slideOut: '11ze-url-viewer-slide-out',
duration: 200,
timing: 'cubic-bezier(0.4, 0, 0.2, 1)',
};
const COLORS = {
bg: '#ffffff',
border: '#e2e8f0',
borderLight: 'rgba(0,0,0,0.06)',
textPrimary: '#0f172a',
textSecondary: '#475569',
textMuted: '#64748b',
textHost: '#1e3a8a',
blue: '#3b82f6',
blueHover: '#2563eb',
blueBg: '#eff6ff',
blueBgLight: '#f8fafc',
success: '#10b981',
error: '#ef4444',
};
// ==================== 工具函数 ====================
function setStyles(el, styles) {
Object.assign(el.style, styles);
}
function setHover(el, hoverStyles, normalStyles = {}) {
const original = {};
for (const key of Object.keys(hoverStyles)) {
original[key] = el.style[key];
}
Object.assign(original, normalStyles);
el.addEventListener('mouseover', () => setStyles(el, hoverStyles));
el.addEventListener('mouseout', () => setStyles(el, original));
}
function runExitAnimation(el, callback) {
el.style.animation = `${ANIM.slideOut} 0.2s ${ANIM.timing}`;
setTimeout(() => {
el.remove();
callback?.();
}, ANIM.duration);
}
function createEl(tag, styles = {}, props = {}) {
const el = document.createElement(tag);
setStyles(el, styles);
Object.assign(el, props);
return el;
}
// ==================== 样式定义 ====================
const popupStyles = {
position: 'fixed',
top: 'calc(50vh - 40%)',
left: 'calc(50vw - 260px)',
zIndex: '9999',
backgroundColor: COLORS.bg,
padding: '24px',
maxHeight: '80vh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
width: '520px',
fontSize: '14px',
borderRadius: '12px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08), 0 8px 30px rgba(0,0,0,0.12), 0 20px 60px rgba(0,0,0,0.08)',
border: `1px solid ${COLORS.borderLight}`,
};
const fullUrlStyles = {
overflowWrap: 'break-word',
padding: '16px',
backgroundColor: '#f8fafc',
borderRadius: '10px',
border: '1px solid rgba(148, 163, 184, 0.2)',
fontSize: '13px',
lineHeight: '1.6',
color: '#334155',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
marginBottom: '16px',
};
const separatorStyles = {
height: '1px',
backgroundColor: COLORS.border,
margin: '12px 0',
width: '100%',
};
const hostDivStyles = {
display: 'flex',
marginBottom: '12px',
padding: '14px 16px',
backgroundColor: COLORS.blueBg,
borderRadius: '8px',
borderLeft: `3px solid ${COLORS.blue}`,
};
const hostSpanStyles = {
textAlign: 'left',
fontSize: '15px',
fontWeight: '600',
color: COLORS.textHost,
};
const tableStyles = {
minWidth: '400px',
border: `1px solid ${COLORS.border}`,
borderRadius: '8px',
textAlign: 'left',
fontSize: '13px',
borderCollapse: 'separate',
borderSpacing: '0',
overflow: 'hidden',
};
const cellStyles = {
padding: '14px 16px',
cursor: 'pointer',
borderBottom: '1px solid #f1f5f9',
transition: 'background-color 0.15s ease',
};
const keySpanStyles = {
cursor: 'pointer',
padding: '6px 10px',
borderRadius: '6px',
fontWeight: '600',
fontSize: '13px',
transition: 'color 0.15s ease',
};
const valueSpanStyles = {
cursor: 'pointer',
padding: '6px 10px',
borderRadius: '6px',
fontSize: '13px',
transition: 'color 0.15s ease',
};
const valueInputStyles = {
width: '100%',
border: `1px solid ${COLORS.border}`,
borderRadius: '6px',
padding: '6px 10px',
fontSize: '13px',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
color: COLORS.textSecondary,
backgroundColor: COLORS.blueBgLight,
outline: 'none',
cursor: 'text',
transition: 'border-color 0.15s ease, background-color 0.15s ease',
boxSizing: 'border-box',
};
const buttonBarStyles = {
display: 'flex',
gap: '8px',
marginTop: '8px',
width: '100%',
};
const modeGroupStyles = {
flexDirection: 'column',
gap: '8px',
width: '100%',
};
const primaryButtonStyles = {
padding: '12px 16px',
cursor: 'pointer',
fontSize: '13px',
background: COLORS.blue,
border: 'none',
borderRadius: '8px',
color: '#ffffff',
fontWeight: '600',
transition: 'all 0.2s ease',
height: '42px',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
flex: '1',
};
const closeButtonStyles = {
padding: '12px 16px',
cursor: 'pointer',
fontSize: '13px',
background: COLORS.bg,
border: `1px solid ${COLORS.border}`,
borderRadius: '8px',
color: COLORS.textMuted,
fontWeight: '600',
transition: 'all 0.2s ease',
height: '42px',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
width: '100%',
};
let toastZIndex = 10000;
const toastStyles = {
position: 'fixed',
padding: '10px 16px',
borderRadius: '8px',
fontSize: '13px',
fontWeight: '500',
color: '#ffffff',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
};
// ==================== 动画样式 ====================
GM_addStyle(`
input[data-param-key]:focus {
border-color: ${COLORS.blue};
background-color: ${COLORS.blueBg};
}
@keyframes ${ANIM.slideIn} {
from {
opacity: 0;
transform: translateY(-20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes ${ANIM.slideOut} {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
}
`);
// ==================== 复制功能 ====================
function copyTextToClipboard(text) {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text);
}
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
textArea.style.pointerEvents = 'none';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
return document.execCommand('copy')
? Promise.resolve()
: Promise.reject(new Error('复制失败'));
} catch (err) {
return Promise.reject(err);
} finally {
document.body.removeChild(textArea);
}
}
function showToast(message, type, x, y) {
const toast = createEl('div', toastStyles);
toast.textContent = message;
toast.style.background = type === 'success' ? COLORS.success : COLORS.error;
toast.style.left = `${x + 16}px`;
toast.style.top = `${y + 16}px`;
toast.style.zIndex = String(toastZIndex++); // 递增 z-index 避免覆盖
toast.style.animation = `${ANIM.slideIn} 0.2s ${ANIM.timing}`;
document.body.appendChild(toast);
setTimeout(() => {
runExitAnimation(toast);
}, 1000);
}
function copyToClipboard(text, x, y) {
copyTextToClipboard(text)
.then(() => showToast('✓ 已复制', 'success', x, y))
.catch((err) => {
console.error('复制失败:', err);
showToast('✗ 复制失败', 'error', x, y);
});
}
// ==================== URL 解析 ====================
/**
* 把 URL 拆成 host 段列表,每段携带自己的查询参数
* 段内取第一个 ? 之后的全部作为查询串,参数值里的 ? 不截断
* 调用方需先对整串 decodeURIComponent:让参数里编码的 #
* 炸开成独立段,iframe 嵌套路由才能分节展示
*/
function parseUrl(url) {
if (typeof url !== 'string' || !url) {
console.error('parseUrl: 参数必须是非空字符串');
return [];
}
const segments = [];
for (const part of url.split('#')) {
if (!part) continue;
const [host, ...queryParts] = part.split('?');
const queryString = queryParts.join('?');
const params = [];
if (queryString) {
for (const [key, value] of new URLSearchParams(queryString).entries()) {
params.push({ key: key, value: value });
}
}
segments.push({ host: host, params: params });
}
return segments;
}
// ==================== DOM 创建函数 ====================
function createSeparator() {
return createEl('div', separatorStyles);
}
function createHostDiv(host, level = 0) {
const hostDiv = createEl('div', hostDivStyles);
hostDiv.style.marginLeft = `${level * 24}px`;
const hostSpan = createEl('span', hostSpanStyles);
hostSpan.textContent = host;
hostDiv.appendChild(hostSpan);
return hostDiv;
}
function createTable(level = 0) {
const table = createEl('table', tableStyles);
table.style.marginLeft = `${level * 24}px`;
return table;
}
function addParamRow(table, param, hashIndex = 0) {
const row = table.insertRow();
const cell1 = row.insertCell(0);
const cell2 = row.insertCell(1);
// 参数名单元格
const keySpan = createEl('span', keySpanStyles, { textContent: param.key });
keySpan.title = '点击复制参数名';
cell1.onclick = (e) => copyToClipboard(param.key, e.clientX, e.clientY);
setStyles(cell1, { ...cellStyles, color: COLORS.textPrimary });
setHover(cell1, { backgroundColor: COLORS.blueBg }, { backgroundColor: '' });
setHover(keySpan, { color: COLORS.blueHover }, { color: '' });
cell1.appendChild(keySpan);
// 参数值单元格
const valueSpan = createEl('span', valueSpanStyles, { textContent: param.value });
valueSpan.title = '点击复制参数值';
valueSpan.dataset.paramKey = param.key;
valueSpan.dataset.hashIndex = String(hashIndex);
cell2.onclick = (e) => copyToClipboard(param.value, e.clientX, e.clientY);
setStyles(cell2, { ...cellStyles, color: COLORS.textSecondary });
setHover(cell2, { backgroundColor: COLORS.blueBg }, { backgroundColor: '' });
setHover(valueSpan, { color: COLORS.blueHover }, { color: '' });
cell2.appendChild(valueSpan);
// 第一行添加顶部圆角
if (table.rows.length === 1) {
cell1.style.borderRadius = '8px 0 0 0';
cell2.style.borderRadius = '0 8px 0 0';
}
row.style.transition = 'none';
}
// ==================== 模式切换 ====================
function switchToEditMode(popup, bar) {
const valueSpans = popup.querySelectorAll('span[data-param-key]');
valueSpans.forEach((span) => {
const input = createEl('input', valueInputStyles, {
value: span.textContent,
});
input.dataset.paramKey = span.dataset.paramKey;
input.dataset.hashIndex = span.dataset.hashIndex;
input.dataset.originalValue = span.textContent;
const cell = span.closest('td');
cell.onclick = null;
cell.style.cursor = 'text';
span.replaceWith(input);
});
// 切换按钮: 隐藏查看模式按钮,显示编辑模式按钮
bar.querySelector('.view-mode').style.display = 'none';
bar.querySelector('.edit-mode').style.display = 'flex';
}
function switchToViewMode(popup, bar) {
const inputs = popup.querySelectorAll('input[data-param-key]');
inputs.forEach((input) => {
const span = createEl('span', valueSpanStyles, {
textContent: input.dataset.originalValue,
});
span.title = '点击复制参数值';
span.dataset.paramKey = input.dataset.paramKey;
span.dataset.hashIndex = input.dataset.hashIndex;
setHover(span, { color: COLORS.blueHover }, { color: '' });
const cell = input.closest('td');
cell.onclick = (e) => copyToClipboard(span.textContent, e.clientX, e.clientY);
cell.style.cursor = 'pointer';
input.replaceWith(span);
});
// 切换按钮
bar.querySelector('.edit-mode').style.display = 'none';
bar.querySelector('.view-mode').style.display = 'flex';
}
// ==================== URL 重建 ====================
function buildUrlFromPanel(popup) {
const inputs = popup.querySelectorAll('input[data-param-key]');
// 按 hash-index 分组
const grouped = {};
inputs.forEach((input) => {
const idx = input.dataset.hashIndex;
if (!grouped[idx]) grouped[idx] = [];
grouped[idx].push({ key: input.dataset.paramKey, value: input.value });
});
const url = new URL(window.location.href);
// 重建主 URL 参数 (hash-index=0)
if (grouped[0]) {
const params = new URLSearchParams();
grouped[0].forEach(({ key, value }) => params.append(key, value));
url.search = params.toString();
}
// 重建 hash 片段中的参数 (hash-index>0)
const hashTables = Array.from(popup.querySelectorAll('table[data-hash-index]'))
.filter((table) => table.dataset.hashIndex !== '0')
.sort((a, b) => Number(a.dataset.hashIndex) - Number(b.dataset.hashIndex));
if (hashTables.length > 0) {
const newHash = hashTables.map((table) => {
const idx = table.dataset.hashIndex;
const hostPath = table.dataset.hashHost;
const params = new URLSearchParams();
if (grouped[idx]) {
grouped[idx].forEach(({ key, value }) => params.append(key, value));
}
return hostPath + (params.toString() ? '?' + params.toString() : '');
}).join('#');
url.hash = newHash;
}
return url.toString();
}
// ==================== 底部按钮栏 ====================
function createBottomBar(popup) {
const bar = createEl('div', buttonBarStyles);
const hasParams = popup.querySelectorAll('span[data-param-key]').length > 0;
// 查看模式按钮组
const viewModeGroup = createEl('div', { ...modeGroupStyles, display: 'flex' });
viewModeGroup.classList.add('view-mode');
const closeButton = createEl('button', closeButtonStyles, { textContent: '关闭(Ctrl + U)' });
closeButton.onclick = () => { popup.remove(); };
setHover(closeButton, { borderColor: COLORS.textMuted }, { borderColor: COLORS.border });
if (hasParams) {
const editButton = createEl('button', primaryButtonStyles, { textContent: '编辑' });
editButton.onclick = () => switchToEditMode(popup, bar);
setHover(editButton, { background: COLORS.blueHover }, { background: COLORS.blue });
viewModeGroup.appendChild(editButton);
} else {
closeButton.style.flex = '1';
}
viewModeGroup.appendChild(closeButton);
// 编辑模式按钮组
const editModeGroup = createEl('div', { ...modeGroupStyles, display: 'none' });
editModeGroup.classList.add('edit-mode');
const goButton = createEl('button', primaryButtonStyles, { textContent: '跳转' });
goButton.onclick = () => {
window.location.href = buildUrlFromPanel(popup);
window.location.reload();
};
setHover(goButton, { background: COLORS.blueHover }, { background: COLORS.blue });
const cancelButton = createEl('button', closeButtonStyles, { textContent: '取消' });
cancelButton.onclick = () => switchToViewMode(popup, bar);
setHover(cancelButton, { borderColor: COLORS.textMuted }, { borderColor: COLORS.border });
editModeGroup.appendChild(goButton);
editModeGroup.appendChild(cancelButton);
bar.appendChild(viewModeGroup);
bar.appendChild(editModeGroup);
return bar;
}
// ==================== 主函数 ====================
function main() {
const urlInfo = parseUrl(decodeURIComponent(window.location.href));
const popup = createEl('div', popupStyles, {
id: '11ze-url-reader-popup',
className: 'popup url-reader-popup',
});
// 完整 URL 显示
const fullUrlDiv = createEl('div', fullUrlStyles, {
textContent: decodeURIComponent(window.location.href),
});
popup.appendChild(fullUrlDiv);
popup.appendChild(createSeparator());
// 构建参数列表;出现过表之后,每个 host 段前都加分隔线
let separatorNeeded = false;
urlInfo.forEach((segment, index) => {
if (separatorNeeded) {
popup.appendChild(createSeparator());
}
popup.appendChild(createHostDiv(segment.host, index));
if (segment.params.length === 0) return;
const table = createTable(index);
table.dataset.hashHost = segment.host;
table.dataset.hashIndex = String(index);
popup.appendChild(table);
for (const param of segment.params) {
addParamRow(table, param, index);
}
separatorNeeded = true;
});
// 底部按钮栏
popup.appendChild(createBottomBar(popup));
document.body.appendChild(popup);
popup.style.animation = `${ANIM.slideIn} 0.3s ${ANIM.timing}`;
// 点击外部关闭
const closePopup = (event) => {
if (!popup.contains(event.target) && event.target.id !== 'url-reader-menu-item') {
popup.remove();
document.removeEventListener('click', closePopup);
}
};
document.addEventListener('click', closePopup);
}
// 测试钩子:浏览器中 __URL_VIEWER_TEST__ 不存在,此分支永不执行
if (window.__URL_VIEWER_TEST__) {
window.__URL_VIEWER_TEST__.hooks = {
parseUrl: parseUrl,
buildUrlFromPanel: buildUrlFromPanel,
};
}
GM_registerMenuCommand('查看 (Ctrl + U)', main);
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'u') {
e.preventDefault();
const existing = document.getElementById('11ze-url-reader-popup');
if (existing) {
existing.remove();
} else {
main();
}
}
});
})();