11ze / 查看网址

// ==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();
      }
    }
  });
})();