Greasy Fork

来自缓存

Greasy Fork is available in English.

标签分组插件

标签分组插件 - 创建分组、添加标签、一键打开

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         标签分组插件
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  标签分组插件 - 创建分组、添加标签、一键打开
// @author       Capy
// @license      MIT
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_openInTab
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // 只在顶层窗口运行,避免 iframe 内重复注入
  if (window.top !== window.self) return;

  // ========== 数据层 ==========
  const STORAGE_KEY = 'tab_groups_data';

  function loadGroups() {
    try {
      return JSON.parse(GM_getValue(STORAGE_KEY, '[]'));
    } catch {
      return [];
    }
  }

  function saveGroups(groups) {
    GM_setValue(STORAGE_KEY, JSON.stringify(groups));
  }

  function generateId() {
    return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
  }

  // ========== 预设颜色(赤橙黄绿青蓝紫) ==========
  const GROUP_COLORS = [
    { name: '赤', value: '#ea4335' },
    { name: '橙', value: '#fa7b17' },
    { name: '黄', value: '#fbbc04' },
    { name: '绿', value: '#34a853' },
    { name: '青', value: '#24c1e0' },
    { name: '蓝', value: '#4285f4' },
    { name: '紫', value: '#a142f4' },
    { name: '粉', value: '#f439a0' },
    { name: '灰', value: '#9aa0a6' },
  ];

  // 根据已有分组数量,自动获取下一个彩虹色
  function getNextRainbowColor() {
    const current = loadGroups();
    return GROUP_COLORS[current.length % GROUP_COLORS.length].value;
  }

  // ========== 样式注入 ==========
  GM_addStyle(`
    /* 分组颜色指示点容器 — 居中于主按钮(14px)上方 */
    #tgm-indicator-dots {
      position: fixed;
      width: 14px;
      z-index: 2147483645;
      display: flex;
      flex-direction: column-reverse;
      align-items: center;
      gap: 4px;
    }
    .tgm-indicator-dot {
      width: 10px;
      height: 10px;
      border-radius: 50%;
      flex-shrink: 0;
      transition: transform 0.15s, opacity 0.2s;
      opacity: 0.85;
      cursor: pointer;
      pointer-events: auto;
    }
    .tgm-indicator-dot:hover {
      transform: scale(1.6);
      opacity: 1;
    }

    /* 颜色修改弹出框 */
    .tgm-color-popup {
      position: absolute;
      left: 100%;
      top: 50%;
      transform: translateY(-50%);
      margin-left: 8px;
      background: rgba(30, 30, 30, 0.82);
      backdrop-filter: blur(16px);
      -webkit-backdrop-filter: blur(16px);
      border: 1px solid rgba(255, 255, 255, 0.12);
      border-radius: 8px;
      padding: 6px;
      display: flex;
      gap: 4px;
      flex-wrap: wrap;
      width: 120px;
      box-shadow: 0 4px 16px rgba(0,0,0,0.35);
      z-index: 20;
    }
    .tgm-color-popup-dot {
      width: 20px;
      height: 20px;
      border-radius: 50%;
      cursor: pointer;
      border: 2px solid transparent;
      transition: border-color 0.1s, transform 0.1s;
    }
    .tgm-color-popup-dot:hover {
      transform: scale(1.2);
    }
    .tgm-color-popup-dot.tgm-current {
      border-color: #fff;
    }

    #tgm-toggle-btn {
      position: fixed;
      z-index: 2147483646;
      width: 14px;
      height: 14px;
      border-radius: 50%;
      background: #4285f4;
      border: none;
      color: transparent;
      font-size: 0;
      cursor: pointer;
      display: block;
      box-shadow: 0 0 6px rgba(66,133,244,0.5);
      transition: transform 0.2s, box-shadow 0.2s;
      user-select: none;
      padding: 0;
    }
    #tgm-toggle-btn:hover {
      transform: scale(1.4);
      box-shadow: 0 0 10px rgba(66,133,244,0.7);
    }

    #tgm-panel {
      position: fixed;
      z-index: 2147483647;
      width: 260px;
      height: 480px;
      min-width: 200px;
      min-height: 200px;
      max-width: 90vw;
      max-height: calc(100vh - 60px);
      background: rgba(22, 22, 22, 0.72);
      backdrop-filter: blur(20px) saturate(1.4);
      -webkit-backdrop-filter: blur(20px) saturate(1.4);
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 12px;
      box-shadow: 0 8px 32px rgba(0,0,0,0.45);
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      font-size: 13px;
      color: #e0e0e0;
      display: none;
      flex-direction: column;
      overflow: hidden;
    }
    #tgm-panel.tgm-visible {
      display: flex;
    }

    /* 拖拽调整大小的手柄 */
    .tgm-resize-handle {
      position: absolute;
      z-index: 10;
    }
    .tgm-resize-right {
      top: 0;
      right: -3px;
      width: 6px;
      height: 100%;
      cursor: ew-resize;
    }
    .tgm-resize-top {
      top: -3px;
      left: 0;
      width: 100%;
      height: 6px;
      cursor: ns-resize;
    }
    .tgm-resize-corner {
      top: -5px;
      right: -5px;
      width: 10px;
      height: 10px;
      cursor: nesw-resize;
    }

    #tgm-panel * {
      box-sizing: border-box;
    }

    .tgm-header {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: space-between;
      padding: 10px 12px;
      border-bottom: 1px solid rgba(255, 255, 255, 0.08);
      flex-shrink: 0;
      gap: 6px;
    }
    .tgm-header-title {
      font-size: 14px;
      font-weight: 600;
    }
    .tgm-header-actions {
      display: flex;
      gap: 4px;
      flex-wrap: wrap;
    }
    .tgm-btn {
      background: rgba(255, 255, 255, 0.08);
      border: 1px solid rgba(255, 255, 255, 0.12);
      color: #e0e0e0;
      border-radius: 6px;
      padding: 4px 10px;
      font-size: 12px;
      cursor: pointer;
      transition: background 0.12s;
      white-space: nowrap;
    }
    .tgm-btn:hover {
      background: rgba(255, 255, 255, 0.14);
    }
    .tgm-btn-primary {
      background: rgba(66, 133, 244, 0.75);
      border-color: rgba(66, 133, 244, 0.5);
      color: #fff;
    }
    .tgm-btn-primary:hover {
      background: rgba(90, 155, 244, 0.85);
    }
    .tgm-btn-danger {
      background: transparent;
      border-color: transparent;
      color: #ea4335;
      padding: 4px 6px;
    }
    .tgm-btn-danger:hover {
      background: rgba(234,67,53,0.15);
    }

    .tgm-body {
      flex: 1;
      overflow-y: auto;
      padding: 8px;
    }
    .tgm-body::-webkit-scrollbar {
      width: 5px;
    }
    .tgm-body::-webkit-scrollbar-thumb {
      background: rgba(255, 255, 255, 0.15);
      border-radius: 3px;
    }

    .tgm-empty {
      text-align: center;
      color: #888;
      padding: 32px 16px;
      line-height: 1.6;
    }

    /* 分组卡片 */
    .tgm-group {
      margin-bottom: 6px;
      border-radius: 8px;
      border: 1px solid rgba(255, 255, 255, 0.08);
      overflow: hidden;
      background: rgba(255, 255, 255, 0.05);
    }
    .tgm-group-header {
      display: flex;
      align-items: center;
      padding: 8px 10px;
      cursor: pointer;
      gap: 8px;
      user-select: none;
    }
    .tgm-group-header:hover {
      background: rgba(255,255,255,0.06);
    }
    .tgm-group-dot {
      width: 10px;
      height: 10px;
      border-radius: 50%;
      flex-shrink: 0;
      cursor: pointer;
      transition: transform 0.12s;
      position: relative;
    }
    .tgm-group-dot:hover {
      transform: scale(1.4);
    }
    .tgm-group-arrow {
      font-size: 10px;
      transition: transform 0.15s;
      flex-shrink: 0;
      color: #888;
    }
    .tgm-group-arrow.tgm-collapsed {
      transform: rotate(-90deg);
    }
    .tgm-group-name {
      flex: 1;
      font-weight: 500;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .tgm-group-count {
      font-size: 11px;
      color: #888;
      flex-shrink: 0;
    }
    .tgm-group-actions {
      display: flex;
      gap: 2px;
      flex-shrink: 0;
      opacity: 0;
      transition: opacity 0.12s;
    }
    .tgm-group-header:hover .tgm-group-actions {
      opacity: 1;
    }
    .tgm-group-action-btn {
      background: none;
      border: none;
      color: #aaa;
      cursor: pointer;
      padding: 2px 4px;
      border-radius: 4px;
      font-size: 14px;
      line-height: 1;
    }
    .tgm-group-action-btn:hover {
      background: rgba(255,255,255,0.1);
      color: #fff;
    }

    .tgm-group-body {
      border-top: 1px solid rgba(255, 255, 255, 0.06);
    }
    .tgm-group-body.tgm-hidden {
      display: none;
    }

    /* 标签页项 */
    .tgm-tab {
      display: flex;
      align-items: center;
      padding: 6px 10px 6px 28px;
      gap: 8px;
      cursor: pointer;
      transition: background 0.1s;
    }
    .tgm-tab:hover {
      background: rgba(255,255,255,0.06);
    }
    .tgm-tab-favicon {
      width: 14px;
      height: 14px;
      flex-shrink: 0;
      border-radius: 2px;
    }
    .tgm-tab-title {
      flex: 1;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      font-size: 12px;
    }
    .tgm-tab-remove {
      opacity: 0;
      background: none;
      border: none;
      color: #888;
      cursor: pointer;
      font-size: 14px;
      padding: 0 2px;
      line-height: 1;
      flex-shrink: 0;
    }
    .tgm-tab:hover .tgm-tab-remove {
      opacity: 1;
    }
    .tgm-tab-remove:hover {
      color: #ea4335;
    }

    /* 新建分组表单 */
    .tgm-form {
      padding: 10px;
      border-top: 1px solid rgba(255, 255, 255, 0.08);
      flex-shrink: 0;
    }
    .tgm-form-row {
      display: flex;
      gap: 6px;
      margin-bottom: 8px;
    }
    .tgm-input {
      flex: 1;
      background: rgba(255, 255, 255, 0.06);
      border: 1px solid rgba(255, 255, 255, 0.12);
      border-radius: 6px;
      color: #e0e0e0;
      padding: 6px 8px;
      font-size: 12px;
      outline: none;
    }
    .tgm-input:focus {
      border-color: rgba(66, 133, 244, 0.7);
    }
    .tgm-color-picker {
      display: flex;
      gap: 4px;
      flex-wrap: wrap;
    }
    .tgm-color-dot {
      width: 18px;
      height: 18px;
      border-radius: 50%;
      cursor: pointer;
      border: 2px solid transparent;
      transition: border-color 0.1s, transform 0.1s;
    }
    .tgm-color-dot:hover {
      transform: scale(1.15);
    }
    .tgm-color-dot.tgm-selected {
      border-color: #fff;
    }

    /* 添加到分组的下拉菜单 */
    .tgm-add-menu {
      position: absolute;
      background: rgba(30, 30, 30, 0.82);
      backdrop-filter: blur(16px);
      -webkit-backdrop-filter: blur(16px);
      border: 1px solid rgba(255, 255, 255, 0.12);
      border-radius: 8px;
      padding: 4px;
      box-shadow: 0 4px 16px rgba(0,0,0,0.3);
      min-width: 180px;
      z-index: 10;
    }
    .tgm-add-menu-item {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 6px 10px;
      cursor: pointer;
      border-radius: 4px;
      font-size: 12px;
    }
    .tgm-add-menu-item:hover {
      background: rgba(255,255,255,0.08);
    }

    /* 拖拽排序 */
    .tgm-group.tgm-dragging,
    .tgm-tab.tgm-dragging {
      opacity: 0.4;
    }
    .tgm-group.tgm-drag-over-top {
      border-top: 2px solid #4285f4;
    }
    .tgm-group.tgm-drag-over-bottom {
      border-bottom: 2px solid #4285f4;
    }
    .tgm-tab.tgm-drag-over-top {
      box-shadow: inset 0 2px 0 #4285f4;
    }
    .tgm-tab.tgm-drag-over-bottom {
      box-shadow: inset 0 -2px 0 #4285f4;
    }

    /* Toast 提示 */
    .tgm-toast {
      position: fixed;
      bottom: 80px;
      left: 50%;
      transform: translateX(-50%) translateY(10px);
      background: rgba(40, 40, 40, 0.85);
      backdrop-filter: blur(12px);
      -webkit-backdrop-filter: blur(12px);
      color: #fff;
      padding: 8px 16px;
      border-radius: 8px;
      font-size: 13px;
      z-index: 2147483647;
      opacity: 0;
      transition: opacity 0.2s, transform 0.2s;
      pointer-events: none;
    }
    .tgm-toast.tgm-show {
      opacity: 1;
      transform: translateX(-50%) translateY(0);
    }
  `);

  // ========== 面板尺寸持久化 ==========
  const SIZE_KEY = 'tgm_panel_size';

  function loadPanelSize() {
    try {
      return JSON.parse(GM_getValue(SIZE_KEY, 'null'));
    } catch {
      return null;
    }
  }

  function savePanelSize(w, h) {
    GM_setValue(SIZE_KEY, JSON.stringify({ width: w, height: h }));
  }

  // ========== 按钮位置持久化 ==========
  const POS_KEY = 'tgm_btn_pos';

  function loadBtnPos() {
    try {
      return JSON.parse(GM_getValue(POS_KEY, 'null'));
    } catch {
      return null;
    }
  }

  function saveBtnPos(left, bottom) {
    GM_setValue(POS_KEY, JSON.stringify({ left, bottom }));
  }

  function getBtnPos() {
    return loadBtnPos() || { left: 16, bottom: 16 };
  }

  // 根据按钮位置同步更新三个元素的坐标
  function updatePositions() {
    const pos = getBtnPos();
    const vw = window.innerWidth;
    const vh = window.innerHeight;

    // 主按钮
    toggleBtn.style.left = pos.left + 'px';
    toggleBtn.style.bottom = pos.bottom + 'px';

    // 指示圆点:在按钮正上方
    indicatorContainer.style.left = pos.left + 'px';
    indicatorContainer.style.bottom = (pos.bottom + 18) + 'px';

    // 面板:智能定位,避免超出屏幕
    const pw = panel.offsetWidth || 260;
    const ph = panel.offsetHeight || 480;
    const margin = 4;

    // 水平:默认与按钮左对齐,超右则左移
    let panelLeft = pos.left;
    panelLeft = Math.min(panelLeft, vw - pw - margin);
    panelLeft = Math.max(margin, panelLeft);

    // 垂直:默认在按钮上方,空间不够则在按钮下方
    let panelBottom;
    const spaceAbove = vh - (pos.bottom + 22); // 按钮上方到顶部的像素
    if (spaceAbove >= ph + margin) {
      // 上方空间足够
      panelBottom = pos.bottom + 22;
    } else {
      // 上方不够,尝试限制在屏幕内
      panelBottom = Math.min(pos.bottom + 22, vh - ph - margin);
      panelBottom = Math.max(margin, panelBottom);
    }

    panel.style.left = panelLeft + 'px';
    panel.style.bottom = panelBottom + 'px';
  }

  // ========== UI 构建 ==========
  let groups = loadGroups();
  let panelVisible = false;
  let creatingGroup = false;
  let selectedColor = getNextRainbowColor();
  let collapsedGroups = new Set(JSON.parse(GM_getValue('tgm_collapsed', '[]')));

  // 分组颜色指示圆点(显示在主按钮上方)
  const indicatorContainer = document.createElement('div');
  indicatorContainer.id = 'tgm-indicator-dots';
  document.body.appendChild(indicatorContainer);

  function renderIndicatorDots() {
    indicatorContainer.innerHTML = '';
    const currentGroups = loadGroups();
    currentGroups.forEach(g => {
      const dot = document.createElement('div');
      dot.className = 'tgm-indicator-dot';
      dot.style.background = g.color;
      dot.title = `${g.name}(${g.tabs.length} 个标签页,点击打开全部)`;
      dot.addEventListener('click', (e) => {
        e.stopPropagation();
        if (g.tabs.length > 0) {
          g.tabs.forEach(tab => GM_openInTab(tab.url, { active: false }));
          showToast(`已打开「${g.name}」的 ${g.tabs.length} 个标签页`);
        } else {
          showToast(`「${g.name}」没有标签页`);
        }
      });
      indicatorContainer.appendChild(dot);
    });
  }

  renderIndicatorDots();

  // 浮动按钮
  const toggleBtn = document.createElement('button');
  toggleBtn.id = 'tgm-toggle-btn';
  toggleBtn.textContent = '';
  toggleBtn.title = '标签分组插件';
  document.body.appendChild(toggleBtn);

  // 主面板
  const panel = document.createElement('div');
  panel.id = 'tgm-panel';

  // 应用保存的尺寸
  const savedSize = loadPanelSize();
  if (savedSize) {
    panel.style.width = savedSize.width + 'px';
    panel.style.height = savedSize.height + 'px';
  }

  // 添加初始 resize 手柄
  ['right', 'top', 'corner'].forEach(dir => {
    const handle = document.createElement('div');
    handle.className = `tgm-resize-handle tgm-resize-${dir}`;
    handle.dataset.dir = dir;
    panel.appendChild(handle);
  });

  document.body.appendChild(panel);

  // 初始化位置
  updatePositions();

  // ========== 主按钮拖拽移动 ==========
  (function initBtnDrag() {
    let isDragging = false;
    let hasMoved = false;
    let startX, startY, startLeft, startBottom;
    const DRAG_THRESHOLD = 4; // 移动超过 4px 才算拖拽

    toggleBtn.addEventListener('mousedown', (e) => {
      e.preventDefault();
      e.stopPropagation();
      isDragging = true;
      hasMoved = false;
      startX = e.clientX;
      startY = e.clientY;
      const pos = getBtnPos();
      startLeft = pos.left;
      startBottom = pos.bottom;
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    });

    function onMove(e) {
      if (!isDragging) return;
      const dx = e.clientX - startX;
      const dy = startY - e.clientY; // 向上移 = bottom 增大
      if (!hasMoved && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
      hasMoved = true;
      const newLeft = Math.max(0, Math.min(startLeft + dx, window.innerWidth - 14));
      const newBottom = Math.max(0, Math.min(startBottom + dy, window.innerHeight - 14));
      saveBtnPos(newLeft, newBottom);
      updatePositions();
    }

    function onUp() {
      isDragging = false;
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
      if (!hasMoved) {
        // 没有拖动 = 点击,切换面板
        if (panelVisible) closePanel();
        else openPanel();
      }
    }
  })();

  // ========== 拖拽调整大小 ==========
  (function initResize() {
    let dragging = null;
    let startX, startY, startW, startH;

    panel.addEventListener('mousedown', (e) => {
      const handle = e.target.closest('.tgm-resize-handle');
      if (!handle) return;
      e.preventDefault();
      e.stopPropagation();
      dragging = handle.dataset.dir;
      startX = e.clientX;
      startY = e.clientY;
      startW = panel.offsetWidth;
      startH = panel.offsetHeight;
      document.addEventListener('mousemove', onMouseMove);
      document.addEventListener('mouseup', onMouseUp);
    });

    function onMouseMove(e) {
      if (!dragging) return;
      const dx = e.clientX - startX;
      const dy = startY - e.clientY; // 向上拖 = 增大高度

      if (dragging === 'right' || dragging === 'corner') {
        const newW = Math.max(200, Math.min(startW + dx, window.innerWidth * 0.9));
        panel.style.width = newW + 'px';
      }
      if (dragging === 'top' || dragging === 'corner') {
        const newH = Math.max(200, Math.min(startH + dy, window.innerHeight - 60));
        panel.style.height = newH + 'px';
      }
    }

    function onMouseUp() {
      if (dragging) {
        savePanelSize(panel.offsetWidth, panel.offsetHeight);
        dragging = null;
      }
      document.removeEventListener('mousemove', onMouseMove);
      document.removeEventListener('mouseup', onMouseUp);
    }
  })();

  // Toast
  const toast = document.createElement('div');
  toast.className = 'tgm-toast';
  document.body.appendChild(toast);

  function showToast(msg) {
    toast.textContent = msg;
    toast.classList.add('tgm-show');
    setTimeout(() => toast.classList.remove('tgm-show'), 1800);
  }

  function getFavicon(url) {
    try {
      const u = new URL(url);
      return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`;
    } catch {
      return '';
    }
  }

  // ========== 拖拽排序状态 ==========
  let dragState = null; // { type: 'group'|'tab', idx, groupIdx?, tabIdx? }

  function clearDragOverClasses(container) {
    container.querySelectorAll('.tgm-drag-over-top, .tgm-drag-over-bottom').forEach(el => {
      el.classList.remove('tgm-drag-over-top', 'tgm-drag-over-bottom');
    });
  }

  function render() {
    groups = loadGroups();
    panel.innerHTML = '';

    // 头部
    const header = document.createElement('div');
    header.className = 'tgm-header';
    header.innerHTML = `
      <span class="tgm-header-title">标签分组插件</span>
      <div class="tgm-header-actions">
        <button class="tgm-btn tgm-btn-primary" id="tgm-new-group">新建组</button>
        <button class="tgm-btn" id="tgm-add-current">+ 当前页</button>
        <button class="tgm-btn" id="tgm-export" title="导出 JSON">导出</button>
        <button class="tgm-btn" id="tgm-import" title="导入 JSON">导入</button>
      </div>
    `;
    panel.appendChild(header);

    // 主体
    const body = document.createElement('div');
    body.className = 'tgm-body';

    if (groups.length === 0 && !creatingGroup) {
      body.innerHTML = '<div class="tgm-empty">还没有标签页组<br>点击「新建组」开始</div>';
    } else {
      groups.forEach((group, groupIdx) => {
        const card = document.createElement('div');
        card.className = 'tgm-group';
        card.draggable = true;
        card.dataset.groupIdx = groupIdx;

        const isCollapsed = collapsedGroups.has(group.id);

        // 组头
        const gh = document.createElement('div');
        gh.className = 'tgm-group-header';
        gh.innerHTML = `
          <span class="tgm-group-arrow ${isCollapsed ? 'tgm-collapsed' : ''}">▼</span>
          <span class="tgm-group-dot" style="background:${group.color}" data-group-id="${group.id}" title="点击更改颜色"></span>
          <span class="tgm-group-name">${escapeHtml(group.name)}</span>
          <span class="tgm-group-count">${group.tabs.length}</span>
          <div class="tgm-group-actions">
            <button class="tgm-group-action-btn" data-action="open-all" data-id="${group.id}" title="打开全部">⧉</button>
            <button class="tgm-group-action-btn" data-action="delete-group" data-id="${group.id}" title="删除组">✕</button>
          </div>
        `;

        // 点击折叠/展开
        gh.addEventListener('click', (e) => {
          if (e.target.closest('.tgm-group-actions')) return;
          if (e.target.closest('.tgm-group-dot')) return;
          if (collapsedGroups.has(group.id)) {
            collapsedGroups.delete(group.id);
          } else {
            collapsedGroups.add(group.id);
          }
          GM_setValue('tgm_collapsed', JSON.stringify([...collapsedGroups]));
          render();
        });

        // --- 分组拖拽排序 ---
        card.addEventListener('dragstart', (e) => {
          e.dataTransfer.setData('text/plain', '');
          e.dataTransfer.effectAllowed = 'move';
          dragState = { type: 'group', idx: groupIdx };
          requestAnimationFrame(() => card.classList.add('tgm-dragging'));
        });
        card.addEventListener('dragend', () => {
          card.classList.remove('tgm-dragging');
          clearDragOverClasses(body);
          dragState = null;
        });
        card.addEventListener('dragover', (e) => {
          if (!dragState || dragState.type !== 'group') return;
          e.preventDefault();
          e.dataTransfer.dropEffect = 'move';
          clearDragOverClasses(body);
          const rect = card.getBoundingClientRect();
          const mid = rect.top + rect.height / 2;
          if (e.clientY < mid) {
            card.classList.add('tgm-drag-over-top');
          } else {
            card.classList.add('tgm-drag-over-bottom');
          }
        });
        card.addEventListener('drop', (e) => {
          e.preventDefault();
          if (!dragState || dragState.type !== 'group') return;
          const fromIdx = dragState.idx;
          const rect = card.getBoundingClientRect();
          const mid = rect.top + rect.height / 2;
          let toIdx = e.clientY < mid ? groupIdx : groupIdx + 1;
          if (fromIdx < toIdx) toIdx--;
          if (fromIdx !== toIdx) {
            const moved = groups.splice(fromIdx, 1)[0];
            groups.splice(toIdx, 0, moved);
            saveGroups(groups);
            render();
          }
          clearDragOverClasses(body);
          dragState = null;
        });

        card.appendChild(gh);

        // 组内标签页
        const gb = document.createElement('div');
        gb.className = `tgm-group-body ${isCollapsed ? 'tgm-hidden' : ''}`;

        group.tabs.forEach((tab, tabIdx) => {
          const tabEl = document.createElement('div');
          tabEl.className = 'tgm-tab';
          tabEl.draggable = true;
          tabEl.dataset.groupIdx = groupIdx;
          tabEl.dataset.tabIdx = tabIdx;
          tabEl.innerHTML = `
            <img class="tgm-tab-favicon" src="${getFavicon(tab.url)}" onerror="this.style.display='none'">
            <span class="tgm-tab-title" title="${escapeHtml(tab.url)}">${escapeHtml(tab.title)}</span>
            <button class="tgm-tab-remove" data-group="${group.id}" data-tab="${tab.id}" title="移除">✕</button>
          `;
          // 点击打开标签页
          tabEl.addEventListener('click', (e) => {
            if (e.target.closest('.tgm-tab-remove')) return;
            GM_openInTab(tab.url, { active: true });
          });

          // --- 标签页拖拽排序 ---
          tabEl.addEventListener('dragstart', (e) => {
            e.stopPropagation();
            e.dataTransfer.setData('text/plain', '');
            e.dataTransfer.effectAllowed = 'move';
            dragState = { type: 'tab', groupIdx, tabIdx };
            requestAnimationFrame(() => tabEl.classList.add('tgm-dragging'));
          });
          tabEl.addEventListener('dragend', () => {
            tabEl.classList.remove('tgm-dragging');
            clearDragOverClasses(body);
            dragState = null;
          });
          tabEl.addEventListener('dragover', (e) => {
            if (!dragState || dragState.type !== 'tab') return;
            e.preventDefault();
            e.stopPropagation();
            e.dataTransfer.dropEffect = 'move';
            clearDragOverClasses(body);
            const rect = tabEl.getBoundingClientRect();
            const mid = rect.top + rect.height / 2;
            if (e.clientY < mid) {
              tabEl.classList.add('tgm-drag-over-top');
            } else {
              tabEl.classList.add('tgm-drag-over-bottom');
            }
          });
          tabEl.addEventListener('drop', (e) => {
            e.preventDefault();
            e.stopPropagation();
            if (!dragState || dragState.type !== 'tab') return;
            const fromGIdx = dragState.groupIdx;
            const fromTIdx = dragState.tabIdx;
            const toGIdx = groupIdx;
            const rect = tabEl.getBoundingClientRect();
            const mid = rect.top + rect.height / 2;
            let toTIdx = e.clientY < mid ? tabIdx : tabIdx + 1;
            // 同组内移动
            if (fromGIdx === toGIdx) {
              if (fromTIdx < toTIdx) toTIdx--;
              if (fromTIdx !== toTIdx) {
                const moved = groups[fromGIdx].tabs.splice(fromTIdx, 1)[0];
                groups[fromGIdx].tabs.splice(toTIdx, 0, moved);
              }
            } else {
              // 跨组移动
              const moved = groups[fromGIdx].tabs.splice(fromTIdx, 1)[0];
              groups[toGIdx].tabs.splice(toTIdx, 0, moved);
            }
            saveGroups(groups);
            render();
            clearDragOverClasses(body);
            dragState = null;
          });

          gb.appendChild(tabEl);
        });

        card.appendChild(gb);
        body.appendChild(card);
      });
    }

    panel.appendChild(body);

    // 新建组表单
    if (creatingGroup) {
      const form = document.createElement('div');
      form.className = 'tgm-form';
      form.innerHTML = `
        <div class="tgm-form-row">
          <input class="tgm-input" id="tgm-group-name" placeholder="输入组名..." autofocus>
        </div>
        <div class="tgm-color-picker" style="margin-bottom:8px">
          ${GROUP_COLORS.map(c =>
            `<div class="tgm-color-dot ${c.value === selectedColor ? 'tgm-selected' : ''}"
                  style="background:${c.value}" data-color="${c.value}" title="${c.name}"></div>`
          ).join('')}
        </div>
        <div class="tgm-form-row">
          <button class="tgm-btn" id="tgm-cancel-create">取消</button>
          <button class="tgm-btn tgm-btn-primary" id="tgm-confirm-create">创建</button>
        </div>
      `;
      panel.appendChild(form);
    }

    // 重新添加 resize 手柄(因为 innerHTML 清除了)
    ['right', 'top', 'corner'].forEach(dir => {
      const handle = document.createElement('div');
      handle.className = `tgm-resize-handle tgm-resize-${dir}`;
      handle.dataset.dir = dir;
      panel.appendChild(handle);
    });

    bindEvents();
    renderIndicatorDots();
  }

  function bindEvents() {
    // 导出
    const exportBtn = panel.querySelector('#tgm-export');
    if (exportBtn) exportBtn.addEventListener('click', exportGroups);

    // 导入
    const importBtn = panel.querySelector('#tgm-import');
    if (importBtn) importBtn.addEventListener('click', importGroups);

    // 新建组按钮
    const newGroupBtn = panel.querySelector('#tgm-new-group');
    if (newGroupBtn) {
      newGroupBtn.addEventListener('click', () => {
        creatingGroup = true;
        selectedColor = getNextRainbowColor();
        render();
        const input = panel.querySelector('#tgm-group-name');
        if (input) input.focus();
      });
    }

    // 取消创建
    const cancelBtn = panel.querySelector('#tgm-cancel-create');
    if (cancelBtn) {
      cancelBtn.addEventListener('click', () => {
        creatingGroup = false;
        render();
      });
    }

    // 确认创建
    const confirmBtn = panel.querySelector('#tgm-confirm-create');
    if (confirmBtn) {
      confirmBtn.addEventListener('click', () => {
        const input = panel.querySelector('#tgm-group-name');
        const name = (input?.value || '').trim();
        if (!name) {
          showToast('请输入组名');
          return;
        }
        groups.push({
          id: generateId(),
          name,
          color: selectedColor,
          tabs: [],
          createdAt: Date.now(),
        });
        saveGroups(groups);
        creatingGroup = false;
        selectedColor = getNextRainbowColor();
        render();
        showToast('分组已创建');
      });
    }

    // 回车创建
    const nameInput = panel.querySelector('#tgm-group-name');
    if (nameInput) {
      nameInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') {
          e.preventDefault();
          confirmBtn?.click();
        }
        if (e.key === 'Escape') {
          creatingGroup = false;
          render();
        }
      });
    }

    // 颜色选择
    panel.querySelectorAll('.tgm-color-dot').forEach(dot => {
      dot.addEventListener('click', () => {
        selectedColor = dot.dataset.color;
        panel.querySelectorAll('.tgm-color-dot').forEach(d => d.classList.remove('tgm-selected'));
        dot.classList.add('tgm-selected');
      });
    });

    // 添加当前页
    const addCurrentBtn = panel.querySelector('#tgm-add-current');
    if (addCurrentBtn) {
      addCurrentBtn.addEventListener('click', (e) => {
        if (groups.length === 0) {
          showToast('请先创建一个分组');
          return;
        }
        showAddMenu(e.target);
      });
    }

    // 组操作按钮
    panel.querySelectorAll('.tgm-group-action-btn').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const action = btn.dataset.action;
        const id = btn.dataset.id;

        if (action === 'open-all') {
          const group = groups.find(g => g.id === id);
          if (group && group.tabs.length > 0) {
            group.tabs.forEach(tab => GM_openInTab(tab.url, { active: false }));
            showToast(`已打开 ${group.tabs.length} 个标签页`);
          } else {
            showToast('该组没有标签页');
          }
        }

        if (action === 'delete-group') {
          const group = groups.find(g => g.id === id);
          if (group && confirm(`确定删除分组「${group.name}」吗?`)) {
            groups = groups.filter(g => g.id !== id);
            saveGroups(groups);
            render();
            showToast('分组已删除');
          }
        }
      });
    });

    // 点击分组颜色圆点 -> 弹出颜色选择器
    panel.querySelectorAll('.tgm-group-dot[data-group-id]').forEach(dot => {
      dot.addEventListener('click', (e) => {
        e.stopPropagation();
        // 关闭已有的颜色弹出框
        panel.querySelectorAll('.tgm-color-popup').forEach(p => p.remove());

        const groupId = dot.dataset.groupId;
        const group = groups.find(g => g.id === groupId);
        if (!group) return;

        const popup = document.createElement('div');
        popup.className = 'tgm-color-popup';
        GROUP_COLORS.forEach(c => {
          const cd = document.createElement('div');
          cd.className = `tgm-color-popup-dot ${c.value === group.color ? 'tgm-current' : ''}`;
          cd.style.background = c.value;
          cd.title = c.name;
          cd.addEventListener('click', (ev) => {
            ev.stopPropagation();
            group.color = c.value;
            saveGroups(groups);
            popup.remove();
            render();
          });
          popup.appendChild(cd);
        });

        // 定位在圆点旁边
        const gh = dot.closest('.tgm-group-header');
        gh.style.position = 'relative';
        popup.style.position = 'absolute';
        popup.style.left = '30px';
        popup.style.top = '50%';
        popup.style.transform = 'translateY(-50%)';
        gh.appendChild(popup);

        // 点其他地方关闭
        const closePopup = (ev) => {
          if (!popup.contains(ev.target)) {
            popup.remove();
            document.removeEventListener('mousedown', closePopup, true);
          }
        };
        setTimeout(() => document.addEventListener('mousedown', closePopup, true), 0);
      });
    });

    // 移除标签页
    panel.querySelectorAll('.tgm-tab-remove').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const groupId = btn.dataset.group;
        const tabId = btn.dataset.tab;
        const group = groups.find(g => g.id === groupId);
        if (group) {
          group.tabs = group.tabs.filter(t => t.id !== tabId);
          saveGroups(groups);
          render();
        }
      });
    });
  }

  function showAddMenu(anchor) {
    // 移除已有菜单
    panel.querySelectorAll('.tgm-add-menu').forEach(m => m.remove());

    const menu = document.createElement('div');
    menu.className = 'tgm-add-menu';

    groups.forEach(group => {
      const item = document.createElement('div');
      item.className = 'tgm-add-menu-item';
      item.innerHTML = `
        <span class="tgm-group-dot" style="background:${group.color}"></span>
        <span>${escapeHtml(group.name)}</span>
      `;
      item.addEventListener('click', () => {
        // 检查是否已存在
        const exists = group.tabs.some(t => t.url === location.href);
        if (exists) {
          showToast('当前页已在该组中');
          menu.remove();
          return;
        }
        group.tabs.push({
          id: generateId(),
          title: document.title || location.href,
          url: location.href,
          addedAt: Date.now(),
        });
        saveGroups(groups);
        menu.remove();
        render();
        showToast(`已添加到「${group.name}」`);
      });
      menu.appendChild(item);
    });

    // 定位:在 header 区域内使用相对定位
    const header = panel.querySelector('.tgm-header');
    header.style.position = 'relative';
    menu.style.top = '100%';
    menu.style.right = '0';
    menu.style.marginTop = '4px';
    header.appendChild(menu);

    // 点击其他地方关闭
    const closeMenu = (e) => {
      if (!menu.contains(e.target)) {
        menu.remove();
        document.removeEventListener('click', closeMenu, true);
      }
    };
    setTimeout(() => document.addEventListener('click', closeMenu, true), 0);
  }

  function escapeHtml(str) {
    const div = document.createElement('div');
    div.textContent = str;
    return div.innerHTML;
  }

  // ========== 导入导出 ==========
  function exportGroups() {
    const data = {
      version: 1,
      exportedAt: new Date().toISOString(),
      groups: loadGroups(),
      panelSize: loadPanelSize(),
    };
    const json = JSON.stringify(data, null, 2);
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `tab-groups-${new Date().toISOString().slice(0, 10)}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
    showToast('已导出 JSON 文件');
  }

  function importGroups() {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.json';
    input.style.display = 'none';
    input.addEventListener('change', () => {
      const file = input.files?.[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = () => {
        try {
          const raw = JSON.parse(reader.result);
          // 支持两种格式:带 version 包装的和纯数组
          const imported = Array.isArray(raw) ? raw : (raw.groups || []);
          if (!Array.isArray(imported)) throw new Error('格式错误');
          // 校验每个 group 的基本结构
          for (const g of imported) {
            if (!g.name || !Array.isArray(g.tabs)) throw new Error('分组数据不完整');
            if (!g.id) g.id = generateId();
            if (!g.color) g.color = GROUP_COLORS[0].value;
            for (const t of g.tabs) {
              if (!t.url) throw new Error('标签页缺少 URL');
              if (!t.id) t.id = generateId();
              if (!t.title) t.title = t.url;
            }
          }
          // 合并还是替换?让用户选择
          const existing = loadGroups();
          if (existing.length > 0) {
            const merge = confirm('检测到已有分组数据。\n\n确定 = 合并(保留已有 + 追加导入)\n取消 = 替换(清空已有,只保留导入)');
            if (merge) {
              const merged = [...existing, ...imported];
              saveGroups(merged);
            } else {
              saveGroups(imported);
            }
          } else {
            saveGroups(imported);
          }
          // 恢复面板尺寸
          if (raw.panelSize && raw.panelSize.width && raw.panelSize.height) {
            savePanelSize(raw.panelSize.width, raw.panelSize.height);
            panel.style.width = raw.panelSize.width + 'px';
            panel.style.height = raw.panelSize.height + 'px';
          }
          render();
          showToast(`已导入 ${imported.length} 个分组`);
        } catch (err) {
          showToast('导入失败: ' + (err.message || '文件格式错误'));
        }
      };
      reader.readAsText(file);
      input.remove();
    });
    document.body.appendChild(input);
    input.click();
  }

  // ========== 事件绑定 ==========
  function openPanel() {
    panelVisible = true;
    panel.classList.add('tgm-visible');
    render();
  }

  function closePanel() {
    panelVisible = false;
    panel.classList.remove('tgm-visible');
  }

  // 点击面板外部关闭
  document.addEventListener('mousedown', (e) => {
    if (!panelVisible) return;
    if (panel.contains(e.target) || toggleBtn.contains(e.target) || indicatorContainer.contains(e.target)) return;
    closePanel();
  });

  // Escape 关闭面板
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && panelVisible) {
      closePanel();
    }
  });

  // 快捷键 Alt+G 切换面板
  document.addEventListener('keydown', (e) => {
    if (e.altKey && e.key === 'g') {
      e.preventDefault();
      if (panelVisible) closePanel();
      else openPanel();
    }
  });

  // Tampermonkey 菜单入口
  GM_registerMenuCommand('打开标签分组插件', () => {
    if (!panelVisible) openPanel();
  });

  GM_registerMenuCommand('快速添加当前页到分组', () => {
    if (!panelVisible) openPanel();
    setTimeout(() => {
      const btn = panel.querySelector('#tgm-add-current');
      if (btn) btn.click();
    }, 100);
  });

})();