// ==UserScript== // @name CNB 组织列表排序助手 // @namespace https://cnb.cool/ // @version 4.0.0 // @description 对 CNB 平台组织列表进行智能排序(置顶+权限),支持点击调整顺序、拖拽排序、暗色主题 // @author Hsred // @match https://cnb.cool/* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addStyle // @run-at document-idle // @scriptUrl https://cnb.cool/hsred/cnb-plugin/-/git/raw/main/cnb-group-sorter.user.js // @license MIT // @icon https://docs.cnb.cool/images/logo/svg/Symbol-Color.svg // @homepage https://cnb.cool/hsred/cnb-plugin // ==/UserScript== (function() { 'use strict'; // ============================================================ // 配置模块 // ============================================================ const CONFIG = { STORAGE_KEY: 'cnb_group_sort_order', VERSION: 2, DEBOUNCE_MS: 600, DEBOUNCE_LEADING_MS: 300, CACHE_MAX_SIZE: 200, ROLE_PRIORITY: { 'Owner': 4, 'Master': 3, 'Admin': 3, 'Developer': 2, 'Maintainer': 2, 'Reporter': 1, 'Guest': 1, 'Unknown': 0, }, ROLE_LABELS: { 'Owner': '负责人', 'Master': '管理员', 'Admin': '管理员', 'Developer': '开发者', 'Maintainer': '开发者', 'Reporter': '助手', 'Guest': '访客', 'Unknown': '访客', }, CARD_SELECTOR: 'div.flex.flex-col.border.border-pri.rounded-\\[3px\\]', CARD_LINK_SELECTOR: 'a[href^="/"]', LIST_CONTAINER_SELECTOR: 'div.flex.flex-col.gap-4.pb-25.w-full.h-full', SORT_MODES: { SMART: 'smart', CUSTOM: 'custom', ORIGINAL: 'original', }, API_BASE: 'https://cnb.cool', API_GROUPS_PATH: '/user/groups', PAGE_SIZE: 50, }; // ============================================================ // 常量模块 - 统一魔法字符串 // ============================================================ const CSS_CLASS = { SELECTED: 'cgs-selected', SELECTABLE: 'cgs-selectable', COLLAPSED: 'cgs-collapsed', NO_TRANSITION: 'cgs-no-transition', PINNED_BADGE: 'cgs-pinned-badge', ROLE_BADGE: 'cgs-role-badge', MOVE_BTNS: 'cgs-move-btns', VISIBLE: 'visible', ACTIVE: 'active', DRAGGING: 'cgs-dragging', DRAG_OVER: 'cgs-drag-over', }; const DATA_ATTR = { CREATED: 'data-cgs-created', MODE: 'data-mode', DRAG_PATH: 'data-cgs-drag-path', }; const ID = { FLOAT_PANEL: 'cgs-float-panel', HINT: 'cgs-hint', MOVE_BTNS: 'cgs-move-btns', TOP_BTN: 'cgs-top-btn', UP_BTN: 'cgs-up-btn', DOWN_BTN: 'cgs-down-btn', BOTTOM_BTN: 'cgs-bottom-btn', SAVE_BTN: 'cgs-save-btn', RESET_BTN: 'cgs-reset-btn', LOADALL_BTN: 'cgs-loadall-btn', SORTING_OVERLAY: 'cgs-sorting-overlay', }; const EVENT = { MODE_CHANGE: 'mode-change', SAVE: 'save', RESET: 'reset', LOAD_ALL: 'load-all', SORT_CHANGED: 'sort-changed', INIT_COMPLETE: 'init-complete', }; // ============================================================ // SVG 图标常量 // ============================================================ const SVG_ICONS = { USERGROUP: '', CODE: '', SUB_GROUP: '', PIN: '', }; // ============================================================ // 日志模块 - 统一日志输出 // ============================================================ const Logger = { PREFIX: '[CNB Sorter]', LEVEL: { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }, currentLevel: 1, // INFO _log(level, ...args) { if (level >= this.currentLevel) { const method = level === this.LEVEL.ERROR ? 'error' : level === this.LEVEL.WARN ? 'warn' : 'log'; console[method](this.PREFIX, ...args); } }, debug(...args) { this._log(this.LEVEL.DEBUG, ...args); }, info(...args) { this._log(this.LEVEL.INFO, ...args); }, warn(...args) { this._log(this.LEVEL.WARN, ...args); }, error(...args) { this._log(this.LEVEL.ERROR, ...args); }, }; // ============================================================ // 事件总线模块 - 替代回调属性 // ============================================================ const EventBus = { _listeners: new Map(), on(event, callback) { if (!this._listeners.has(event)) { this._listeners.set(event, new Set()); } this._listeners.get(event).add(callback); return () => this.off(event, callback); }, off(event, callback) { const callbacks = this._listeners.get(event); if (callbacks) { callbacks.delete(callback); } }, emit(event, ...args) { const callbacks = this._listeners.get(event); if (callbacks) { callbacks.forEach(cb => { try { cb(...args); } catch (e) { Logger.error(`EventBus emit error [${event}]:`, e); } }); } }, clear() { this._listeners.clear(); }, }; // ============================================================ // DOM 辅助模块 - DOM 查询、元素交换、滚动定位 // ============================================================ const DOMHelper = { getContainer() { return document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); }, getCards(container) { const el = container || this.getContainer(); return el ? Array.from(el.querySelectorAll(CONFIG.CARD_SELECTOR)) : []; }, getCardPath(cardEl) { const link = cardEl?.querySelector(CONFIG.CARD_LINK_SELECTOR); return link?.getAttribute('href')?.replace(/^\//, '').replace(/\/$/, '') || null; }, findCardByPath(container, path) { if (!path) return null; const cards = this.getCards(container); return cards.find(el => this.getCardPath(el) === path) || null; }, getCardIndex(container, path) { if (!path) return -1; const cards = this.getCards(container); return cards.findIndex(el => this.getCardPath(el) === path); }, swapElements(container, srcEl, targetEl, direction) { if (direction === -1) { container.insertBefore(srcEl, targetEl); } else { container.insertBefore(srcEl, targetEl.nextSibling); } }, scrollToElement(el, options = { behavior: 'smooth', block: 'nearest' }) { el?.scrollIntoView(options); }, addClass(el, className) { el?.classList.add(className); }, removeClass(el, className) { el?.classList.remove(className); }, toggleClass(el, className, force) { el?.classList.toggle(className, force); }, hasAttribute(el, attr) { return el?.hasAttribute(attr) || false; }, setAttribute(el, attr, value) { el?.setAttribute(attr, value); }, queryById(id) { return document.getElementById(id); }, queryAll(selector, root = document) { return Array.from(root.querySelectorAll(selector)); }, }; // ============================================================ // 样式模块 // ============================================================ const STYLES = ` /* ============ 悬浮面板 - 毛玻璃风格 ============ */ #cgs-float-panel { position: fixed; left: 16px; bottom: 16px; z-index: 99999; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", system-ui, sans-serif; font-size: 13px; user-select: none; } /* 收纳按钮 */ .cgs-toggle-btn { width: 42px; height: 42px; border-radius: 50%; border: 1px solid rgba(255, 98, 0, 0.25); background: rgba(255, 255, 255, 0.72); backdrop-filter: saturate(180%) blur(20px); -webkit-backdrop-filter: saturate(180%) blur(20px); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 1px rgba(0, 0, 0, 0.1); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); color: #FF6200; font-size: 18px; } .cgs-toggle-btn:hover { transform: scale(1.08); box-shadow: 0 4px 16px rgba(255, 98, 0, 0.2), 0 0 1px rgba(0, 0, 0, 0.1); border-color: rgba(255, 98, 0, 0.5); } .cgs-toggle-btn:active { transform: scale(0.96); } /* 面板主体 */ .cgs-panel { position: absolute; left: 0; bottom: 52px; width: 240px; background: rgba(255, 255, 255, 0.72); backdrop-filter: saturate(180%) blur(20px); -webkit-backdrop-filter: saturate(180%) blur(20px); border: 1px solid rgba(255, 255, 255, 0.5); border-radius: 14px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04); padding: 14px; display: flex; flex-direction: column; gap: 10px; transform-origin: bottom left; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease; } .cgs-panel.cgs-collapsed { transform: scale(0.9) translateY(8px); opacity: 0; pointer-events: none; } /* 面板标题 */ .cgs-panel-header { display: flex; align-items: center; justify-content: space-between; } .cgs-label { font-weight: 600; font-size: 14px; color: #1D1D1F; letter-spacing: -0.01em; } .cgs-hint { font-size: 11px; color: rgba(0, 0, 0, 0.45); } /* 模式切换 */ .cgs-mode-group { display: flex; background: rgba(0,0,0,0.04); border-radius: 8px; padding: 2px; gap: 2px; } .cgs-mode-btn { flex: 1; padding: 5px 0; border: none; background: transparent; border-radius: 6px; font-size: 12px; cursor: pointer; color: rgba(0,0,0,0.55); font-weight: 500; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); white-space: nowrap; } .cgs-mode-btn:hover { color: #FF6200; } .cgs-mode-btn.active { background: #fff; color: #FF6200; box-shadow: 0 1px 4px rgba(0,0,0,0.08), 0 0 0 0.5px rgba(0,0,0,0.04); font-weight: 600; } /* 操作按钮行 */ .cgs-actions { display: flex; gap: 6px; } .cgs-action-btn { flex: 1; padding: 6px 0; border: none; border-radius: 8px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; display: inline-flex; align-items: center; justify-content: center; gap: 4px; } .cgs-action-btn:active { transform: scale(0.96); } .cgs-action-btn.primary { background: #FF6200; color: #fff; } .cgs-action-btn.primary:hover { background: #E55A00; } .cgs-action-btn.danger { background: rgba(255,98,0,0.08); color: #FF6200; } .cgs-action-btn.danger:hover { background: rgba(255,98,0,0.15); } /* 自定义排序 - 上下移动按钮 */ .cgs-move-btns { display: none; gap: 4px; } .cgs-move-btns.visible { display: flex; } .cgs-move-btn { flex: 1; padding: 5px 0; border: 1px solid rgba(255,98,0,0.25); border-radius: 8px; background: rgba(255,98,0,0.04); color: #FF6200; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease; text-align: center; } .cgs-move-btn:hover { background: rgba(255,98,0,0.12); border-color: rgba(255,98,0,0.5); } .cgs-move-btn:disabled { opacity: 0.35; cursor: not-allowed; } .cgs-move-btn:active:not(:disabled) { transform: scale(0.95); } /* 加载全部按钮 */ .cgs-load-all-btn { width: 100%; padding: 7px 0; border: 1px dashed rgba(255,98,0,0.3); border-radius: 8px; background: rgba(255,98,0,0.04); color: #FF6200; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; } .cgs-load-all-btn:hover { background: rgba(255,98,0,0.1); border-color: rgba(255,98,0,0.5); } .cgs-load-all-btn:disabled { opacity: 0.5; cursor: not-allowed; } /* ============ 卡片交互样式 ============ */ /* 自定义模式下可点击选中 */ .cgs-selectable { cursor: pointer; transition: outline 0.15s ease, box-shadow 0.15s ease; } .cgs-selectable:hover { box-shadow: 0 0 0 2px rgba(255,98,0,0.15); } .cgs-selected { outline: 2px solid #FF6200 !important; outline-offset: -1px; box-shadow: 0 0 12px rgba(255,98,0,0.18) !important; } .cgs-no-transition * { transition: none !important; } /* 置顶标记 - 毛玻璃 */ .cgs-pinned-badge { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; background: rgba(255,98,0,0.72); backdrop-filter: saturate(150%) blur(12px); -webkit-backdrop-filter: saturate(150%) blur(12px); border: 1px solid rgba(255,255,255,0.3); color: #fff; font-size: 10px; border-radius: 10px; margin-left: 6px; font-weight: 500; vertical-align: middle; box-shadow: 0 1px 4px rgba(255,98,0,0.2); } /* 权限标记 - 毛玻璃 */ .cgs-role-badge { display: inline-flex; align-items: center; padding: 2px 8px; font-size: 10px; border-radius: 10px; margin-left: 6px; vertical-align: middle; font-weight: 500; backdrop-filter: saturate(150%) blur(12px); -webkit-backdrop-filter: saturate(150%) blur(12px); border: 1px solid rgba(255,255,255,0.3); box-shadow: 0 1px 4px rgba(0,0,0,0.08); } .cgs-role-owner { background: rgba(227,77,89,0.72); color: #fff; border-color: rgba(255,255,255,0.3); box-shadow: 0 1px 4px rgba(227,77,89,0.2); } .cgs-role-master { background: rgba(245,166,35,0.72); color: #fff; border-color: rgba(255,255,255,0.3); box-shadow: 0 1px 4px rgba(245,166,35,0.2); } .cgs-role-admin { background: rgba(245,166,35,0.72); color: #fff; border-color: rgba(255,255,255,0.3); box-shadow: 0 1px 4px rgba(245,166,35,0.2); } .cgs-role-developer { background: rgba(0,168,112,0.72); color: #fff; border-color: rgba(255,255,255,0.3); box-shadow: 0 1px 4px rgba(0,168,112,0.2); } .cgs-role-maintainer { background: rgba(0,168,112,0.72); color: #fff; border-color: rgba(255,255,255,0.3); box-shadow: 0 1px 4px rgba(0,168,112,0.2); } .cgs-role-reporter { background: rgba(139,92,246,0.72); color: #fff; border-color: rgba(255,255,255,0.3); box-shadow: 0 1px 4px rgba(139,92,246,0.2); } .cgs-role-guest { background: rgba(142,142,147,0.45); color: rgba(255,255,255,0.85); border-color: rgba(255,255,255,0.2); box-shadow: 0 1px 4px rgba(0,0,0,0.12); } .cgs-role-unknown { background: rgba(142,142,147,0.45); color: rgba(255,255,255,0.85); border-color: rgba(255,255,255,0.2); box-shadow: 0 1px 4px rgba(0,0,0,0.12); } /* 排序遮罩 */ .cgs-sorting-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.5); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 9999; font-size: 14px; color: #FF6200; font-weight: 500; } /* ============ 暗色主题适配 ============ */ @media (prefers-color-scheme: dark) { .cgs-toggle-btn { background: rgba(30, 30, 30, 0.78); border-color: rgba(255, 98, 0, 0.4); color: #FF6200; } .cgs-toggle-btn:hover { border-color: rgba(255, 98, 0, 0.6); box-shadow: 0 4px 16px rgba(255, 98, 0, 0.3); } .cgs-panel { background: rgba(30, 30, 30, 0.78); border-color: rgba(255, 255, 255, 0.1); } .cgs-label { color: #E5E5E7; } .cgs-hint { color: rgba(255, 255, 255, 0.55); } .cgs-mode-group { background: rgba(255, 255, 255, 0.08); } .cgs-mode-btn { color: rgba(255, 255, 255, 0.55); } .cgs-mode-btn:hover { color: #FF6200; } .cgs-mode-btn.active { background: rgba(255, 98, 0, 0.15); color: #FF6200; } .cgs-sorting-overlay { background: rgba(0, 0, 0, 0.5); } .cgs-move-btn { background: rgba(255, 98, 0, 0.1); border-color: rgba(255, 98, 0, 0.4); } .cgs-move-btn:hover { background: rgba(255, 98, 0, 0.2); } .cgs-action-btn.primary { background: #FF6200; color: #fff; } .cgs-action-btn.primary:hover { background: #E55A00; } .cgs-action-btn.danger { background: rgba(255,98,0,0.12); color: #FF6200; } .cgs-action-btn.danger:hover { background: rgba(255,98,0,0.2); } .cgs-load-all-btn { background: rgba(255, 98, 0, 0.08); border-color: rgba(255, 98, 0, 0.35); } .cgs-load-all-btn:hover { background: rgba(255, 98, 0, 0.15); } .cgs-selectable:hover { box-shadow: 0 0 0 2px rgba(255,98,0,0.25); } .cgs-selected { outline: 2px solid #FF6200 !important; box-shadow: 0 0 16px rgba(255,98,0,0.3) !important; } .cgs-pinned-badge { background: rgba(255,98,0,0.85); } .cgs-role-badge { background: rgba(60, 60, 60, 0.8); border-color: rgba(255,255,255,0.15); } .cgs-role-owner { background: rgba(227,77,89,0.85); } .cgs-role-master, .cgs-role-admin { background: rgba(245,166,35,0.85); } .cgs-role-developer, .cgs-role-maintainer { background: rgba(0,168,112,0.85); } .cgs-role-reporter { background: rgba(139,92,246,0.85); } .cgs-role-guest, .cgs-role-unknown { background: rgba(80,80,80,0.7); } .cgs-drag-over::before { background: #FF6200; } } /* 拖拽排序样式 */ .cgs-dragging { opacity: 0.5; cursor: grabbing !important; } .cgs-drag-over { position: relative; } .cgs-drag-over::before { content: ''; position: absolute; left: 0; right: 0; top: -2px; height: 3px; background: #FF6200; border-radius: 2px; z-index: 10; } `; // ============================================================ // 持久化模块 // ============================================================ const Storage = { save(data) { try { const payload = { version: CONFIG.VERSION, order: data.order || [], mode: data.mode || CONFIG.SORT_MODES.SMART, updatedAt: new Date().toISOString() }; if (typeof GM_setValue === 'function') { GM_setValue(CONFIG.STORAGE_KEY, JSON.stringify(payload)); } else { localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(payload)); } return true; } catch (e) { Logger.error('保存失败:', e); return false; } }, load() { try { let raw; if (typeof GM_getValue === 'function') { raw = GM_getValue(CONFIG.STORAGE_KEY, null); } else { raw = localStorage.getItem(CONFIG.STORAGE_KEY); } if (!raw) return null; const data = JSON.parse(raw); if (!data) return null; // 兼容无 version 字段的旧数据(v1) if (data.version === undefined) { Logger.info('检测到 v1 数据格式,自动升级'); // v1 数据格式:{ order: [], mode: '' } if (Array.isArray(data.order)) { // 返回升级后的数据(version 设为当前版本) return { version: CONFIG.VERSION, order: data.order, mode: data.mode || CONFIG.SORT_MODES.SMART }; } return null; } // 版本不匹配时返回 null(触发重置) if (data.version !== CONFIG.VERSION) { Logger.warn(`数据版本不匹配 (存储: v${data.version}, 当前: v${CONFIG.VERSION}),将重置`); return null; } if (Array.isArray(data.order)) return data; return null; } catch (e) { Logger.error('加载数据失败:', e); return null; } }, clear() { try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(CONFIG.STORAGE_KEY); } else { localStorage.removeItem(CONFIG.STORAGE_KEY); } return true; } catch (e) { Logger.error('清除数据失败:', e); return false; } }, }; // ============================================================ // 数据提取模块 // ============================================================ const DataExtractor = { _apiDataCache: new Map(), setApiData(path, data) { // LRU 淘汰:超过上限时删除最早的条目 if (this._apiDataCache.size >= CONFIG.CACHE_MAX_SIZE) { const firstKey = this._apiDataCache.keys().next().value; this._apiDataCache.delete(firstKey); } this._apiDataCache.set(path, data); }, getApiData(path) { return this._apiDataCache.get(path); }, clearCache() { this._apiDataCache.clear(); }, extractCard(cardEl) { try { const linkEl = cardEl.querySelector(CONFIG.CARD_LINK_SELECTOR); if (!linkEl) return null; const href = linkEl.getAttribute('href') || ''; const path = href.replace(/^\//, '').replace(/\/$/, ''); if (!path) return null; const h1El = cardEl.querySelector('h1'); let name = path, remark = ''; if (h1El) { const nameSpan = h1El.querySelector(':scope > :first-child'); if (nameSpan) name = nameSpan.textContent.trim(); const remarkSpan = h1El.querySelector('span.font-normal'); if (remarkSpan) remark = remarkSpan.textContent.replace(/[()()]/g, '').trim(); } // 置顶图标:#pin-filled(置顶状态)或 #pin(未置顶/普通) const pinnedIcon = cardEl.querySelector('#pin-filled'); // #pin 图标也视为置顶(页面用 #pin 显示置顶标记) const pinIcon = cardEl.querySelector('#pin'); return { id: path, path, name, remark, description: '', pinned: !!(pinnedIcon || pinIcon), access_role: 'Unknown' }; } catch (e) { Logger.error('提取卡片信息失败:', e); return null; } }, extractAllCards() { const container = DOMHelper.getContainer(); if (!container) return []; const cards = []; DOMHelper.getCards(container).forEach(el => { const info = this.extractCard(el); if (info) cards.push({ ...info, element: el }); }); return cards; }, enrichWithApiData(cards) { return cards.map(card => { const apiData = this._apiDataCache.get(card.path); if (apiData) { return { ...card, id: apiData.id || card.path, access_role: apiData.access_role || card.access_role, pinned: typeof apiData.pinned === 'boolean' ? apiData.pinned : card.pinned, pinned_time: apiData.pinned_time || card.pinned_time, all_member_count: apiData.all_member_count, }; } return card; }); }, }; // ============================================================ // API 数据获取模块 // ============================================================ const ApiFetcher = { _fetchIntercepted: false, /** 拦截 fetch 以捕获页面自然请求 */ interceptFetch() { if (this._fetchIntercepted) return; this._fetchIntercepted = true; const originalFetch = window.fetch.bind(window); const self = this; window.fetch = async (...args) => { const response = await originalFetch(...args); const url = args[0]?.toString() || ''; // 精确匹配 URL pathname try { const urlObj = new URL(url, CONFIG.API_BASE); if (urlObj.pathname === CONFIG.API_GROUPS_PATH) { self._handleGroupsResponse(response.clone()); } } catch (e) { // URL 解析失败时回退到 includes 匹配 if (url.includes('/user/groups')) { try { self._handleGroupsResponse(response.clone()); } catch (err) { /* ignore */ } } } return response; }; }, /** 处理 groups API 响应 */ async _handleGroupsResponse(cloneResponse) { try { const json = await cloneResponse.json(); if (Array.isArray(json)) { json.forEach(item => { if (item.path) DataExtractor.setApiData(item.path, item); }); Logger.info(`拦截捕获: ${json.length} 个组织`); } } catch (err) { Logger.warn('解析 groups 响应失败:', err); } }, async fetchAllGroups() { const allGroups = []; try { let page = 1; while (true) { const url = `${CONFIG.API_BASE}${CONFIG.API_GROUPS_PATH}?page=${page}&page_size=${CONFIG.PAGE_SIZE}`; const resp = await fetch(url, { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (!resp.ok) break; const items = await resp.json(); if (!Array.isArray(items) || items.length === 0) break; items.forEach(item => { if (item.path) DataExtractor.setApiData(item.path, item); allGroups.push(item); }); Logger.info(`API 第${page}页: ${items.length} 个组织`); if (items.length < CONFIG.PAGE_SIZE) break; page++; } Logger.info(`API 获取完成: ${allGroups.length} 个组织`); } catch (e) { Logger.error('API 获取失败:', e); } return allGroups; }, }; // ============================================================ // 卡片渲染模块 - 从 MainController 抽取 // ============================================================ const CardRenderer = { /** 根据 API 数据创建组织卡片元素,模仿原始页面结构 */ createCardElement(group) { try { const wrapper = document.createElement('div'); wrapper.className = 'flex flex-col border border-pri rounded-[3px] '; DOMHelper.setAttribute(wrapper, DATA_ATTR.CREATED, 'true'); const card = document.createElement('div'); card.className = 'flex items-center justify-between pl-2 pr-6 h-[68px] bg-panel-pri hover:bg-panel-pri-hover'; const inner = document.createElement('div'); inner.className = 'flex items-center justify-between flex-1 h-[48px] cursor-pointer min-w-0'; const spacer = document.createElement('div'); spacer.className = 'w-4 h-6 false'; inner.appendChild(spacer); const content = document.createElement('div'); content.className = 'px-2 flex items-center justify-start flex-1 h-full gap-2 rounded-[3px] min-w-0 overflow-hidden'; const link = document.createElement('a'); link.href = '/' + group.path; link.className = 'flex items-center justify-start flex-1 gap-2 overflow-hidden'; link.setAttribute('target', '_self'); const avatarWrap = document.createElement('div'); avatarWrap.className = 'cnb-avatar overflow-hidden flex-shrink-0 dark:brightness-90 flex-none'; avatarWrap.style.cssText = 'width: 32px; height: 32px;'; const avatarImg = document.createElement('img'); avatarImg.alt = group.name || group.path; avatarImg.loading = 'lazy'; avatarImg.width = 32; avatarImg.height = 32; avatarImg.decoding = 'async'; avatarImg.className = 'opacity-100 object-cover'; avatarImg.src = `https://cnb.cool/${group.path}/-/logos/s`; avatarImg.style.cssText = 'color: transparent; height: 32px; width: 32px;'; avatarWrap.appendChild(avatarImg); link.appendChild(avatarWrap); const nameArea = document.createElement('div'); nameArea.className = 'flex-auto min-w-0'; const h1 = document.createElement('h1'); h1.className = 'text-base truncate text-pri'; const nameSpan = document.createElement('span'); nameSpan.textContent = group.name || group.path; h1.appendChild(nameSpan); if (group.remark) { const remarkSpan = document.createElement('span'); remarkSpan.className = 'font-normal text-sec'; remarkSpan.textContent = `(${group.remark})`; h1.appendChild(remarkSpan); } nameArea.appendChild(h1); link.appendChild(nameArea); const statsUl = document.createElement('ul'); statsUl.className = 'flex items-center gap-4 font-mono text-sm text-ter'; if (group.member_count !== undefined) { const li = document.createElement('li'); li.className = 'flex items-center gap-1'; li.innerHTML = `${SVG_ICONS.USERGROUP}${group.member_count}`; statsUl.appendChild(li); } if (group.all_sub_repo_count !== undefined) { const li = document.createElement('li'); li.className = 'flex items-center gap-1'; li.innerHTML = `${SVG_ICONS.CODE}${group.all_sub_repo_count}`; statsUl.appendChild(li); } if (group.all_sub_group_count !== undefined) { const li = document.createElement('li'); li.className = 'flex items-center gap-1'; li.innerHTML = `${SVG_ICONS.SUB_GROUP}${group.all_sub_group_count}`; statsUl.appendChild(li); } link.appendChild(statsUl); content.appendChild(link); if (group.pinned) { const pinWrap = document.createElement('div'); pinWrap.className = 'flex items-center w-4 ml-2'; pinWrap.innerHTML = SVG_ICONS.PIN; content.appendChild(pinWrap); } inner.appendChild(content); card.appendChild(inner); wrapper.appendChild(card); return wrapper; } catch (e) { Logger.error('创建卡片失败:', e); return null; } }, /** 用 API 数据补全缺失的卡片(仅追加页面未渲染的,不替换已有的) */ renderMissingCards(container, allGroups) { const existingPaths = new Set(); DOMHelper.getCards(container).forEach(el => { const path = DOMHelper.getCardPath(el); if (path) existingPaths.add(path); }); let addedCount = 0; allGroups.forEach(group => { if (existingPaths.has(group.path)) return; const card = this.createCardElement(group); if (card) { container.appendChild(card); addedCount++; } }); Logger.info(`补全了 ${addedCount} 张缺失卡片 (API: ${allGroups.length}, 已有: ${existingPaths.size})`); }, }; // ============================================================ // 排序引擎模块 // ============================================================ const SortEngine = { currentMode: CONFIG.SORT_MODES.SMART, originalOrder: [], // path 字符串数组 customOrder: [], // path 字符串数组 defaultSortComparator(a, b) { // 1. 置顶优先 if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; // 2. 角色优先级 const rA = CONFIG.ROLE_PRIORITY[a.access_role] || 0; const rB = CONFIG.ROLE_PRIORITY[b.access_role] || 0; if (rA !== rB) return rB - rA; // 3. 人数越少越靠前 const countA = Math.max(1, a.all_member_count || 0); const countB = Math.max(1, b.all_member_count || 0); if (countA !== countB) return countA - countB; // 4. 名称字母序 return (a.name || a.path).localeCompare(b.name || b.path, 'zh-CN'); }, smartSort(cards) { return [...cards].sort(this.defaultSortComparator); }, customSort(cards, customOrder) { const orderMap = new Map(customOrder.map((p, i) => [p, i])); return [...cards].sort((a, b) => { const iA = orderMap.has(a.path) ? orderMap.get(a.path) : 9999; const iB = orderMap.has(b.path) ? orderMap.get(b.path) : 9999; if (iA !== iB) return iA - iB; return this.defaultSortComparator(a, b); }); }, originalSort(cards) { // originalOrder 是 path 字符串数组 if (this.originalOrder.length === 0) return cards; const orderMap = new Map(this.originalOrder.map((p, i) => [p, i])); return [...cards].sort((a, b) => { const iA = orderMap.has(a.path) ? orderMap.get(a.path) : 9999; const iB = orderMap.has(b.path) ? orderMap.get(b.path) : 9999; return iA - iB; }); }, sort(cards) { switch (this.currentMode) { case CONFIG.SORT_MODES.CUSTOM: if (this.customOrder.length > 0) return { sorted: this.customSort(cards, this.customOrder), mode: this.currentMode }; return { sorted: this.smartSort(cards), mode: CONFIG.SORT_MODES.SMART }; case CONFIG.SORT_MODES.ORIGINAL: return { sorted: this.originalSort(cards), mode: this.currentMode }; default: return { sorted: this.smartSort(cards), mode: CONFIG.SORT_MODES.SMART }; } }, setMode(mode) { if (Object.values(CONFIG.SORT_MODES).includes(mode)) this.currentMode = mode; }, updateCustomOrder(sortedCards) { this.customOrder = sortedCards.map(c => c.path); }, }; // ============================================================ // UI 注入模块 // ============================================================ const UIManager = { panelEl: null, floatWrap: null, isCollapsed: true, selectedCard: null, // 当前选中的卡片 path _currentUsername: null, setUsername(username) { this._currentUsername = username; }, injectPanel() { if (DOMHelper.queryById(ID.FLOAT_PANEL)) { this.floatWrap = DOMHelper.queryById(ID.FLOAT_PANEL); this.panelEl = this.floatWrap.querySelector('.cgs-panel'); return this.panelEl; } if (typeof GM_addStyle === 'function') { GM_addStyle(STYLES); } else { const s = document.createElement('style'); s.textContent = STYLES; document.head.appendChild(s); } this.floatWrap = document.createElement('div'); this.floatWrap.id = ID.FLOAT_PANEL; this.panelEl = document.createElement('div'); this.panelEl.className = `cgs-panel ${CSS_CLASS.COLLAPSED}`; this.panelEl.innerHTML = `
组织排序 自动按规则排序
`; const toggleBtn = document.createElement('button'); toggleBtn.className = 'cgs-toggle-btn'; toggleBtn.innerHTML = '⇅'; toggleBtn.title = '展开/收起排序面板'; toggleBtn.addEventListener('click', () => this._togglePanel()); this.floatWrap.appendChild(this.panelEl); this.floatWrap.appendChild(toggleBtn); document.body.appendChild(this.floatWrap); // 根据当前路径决定是否显示 this.updateVisibility(); this._bindEvents(); return this.panelEl; }, /** 从 URL 提取目标用户名 */ getPageUsername() { const match = location.pathname.match(/^\/u\/([^/]+)\/groups/); return match ? match[1] : null; }, /** 判断当前 URL 是否是自己的组织列表页 */ isOwnGroupsPage() { if (!this._currentUsername) return false; const pageUser = this.getPageUsername(); return pageUser === this._currentUsername; }, /** 判断当前 URL 是否是组织列表页(他人或自己) */ isGroupsPage() { return /^\/u\/[^/]+\/groups/.test(location.pathname); }, /** 根据当前页面路径显隐悬浮按钮 */ updateVisibility() { if (!this.floatWrap) return; // 只有自己的组织页面才显示 this.floatWrap.style.display = this.isOwnGroupsPage() ? '' : 'none'; }, _togglePanel() { this.isCollapsed = !this.isCollapsed; DOMHelper.toggleClass(this.panelEl, CSS_CLASS.COLLAPSED, this.isCollapsed); }, _bindEvents() { // 模式切换 this.panelEl.querySelectorAll('.cgs-mode-btn').forEach(btn => { btn.addEventListener('click', () => { this.panelEl.querySelectorAll('.cgs-mode-btn').forEach(b => b.classList.remove(CSS_CLASS.ACTIVE)); DOMHelper.addClass(btn, CSS_CLASS.ACTIVE); const mode = btn.getAttribute(DATA_ATTR.MODE); SortEngine.setMode(mode); this._updateMoveBtns(mode); this._clearSelection(); const hint = this.panelEl.querySelector(`#${ID.HINT}`); if (hint) { hint.textContent = mode === CONFIG.SORT_MODES.CUSTOM ? '点击卡片选中后移动' : mode === CONFIG.SORT_MODES.ORIGINAL ? '显示原始加载顺序' : '自动按规则排序'; } EventBus.emit(EVENT.MODE_CHANGE, mode); }); }); // 置顶 this.panelEl.querySelector(`#${ID.TOP_BTN}`)?.addEventListener('click', () => this._moveCardToExtreme(-1)); // 上移 this.panelEl.querySelector(`#${ID.UP_BTN}`)?.addEventListener('click', () => this._moveCard(-1)); // 下移 this.panelEl.querySelector(`#${ID.DOWN_BTN}`)?.addEventListener('click', () => this._moveCard(1)); // 置底 this.panelEl.querySelector(`#${ID.BOTTOM_BTN}`)?.addEventListener('click', () => this._moveCardToExtreme(1)); // 保存 this.panelEl.querySelector(`#${ID.SAVE_BTN}`)?.addEventListener('click', () => { EventBus.emit(EVENT.SAVE); const btn = this.panelEl.querySelector(`#${ID.SAVE_BTN}`); btn.textContent = '✓ 已保存'; btn.style.background = '#00A870'; setTimeout(() => { btn.textContent = '💾 保存'; btn.style.background = ''; }, 1500); }); // 重置 this.panelEl.querySelector(`#${ID.RESET_BTN}`)?.addEventListener('click', () => { EventBus.emit(EVENT.RESET); }); // 加载全部 this.panelEl.querySelector(`#${ID.LOADALL_BTN}`)?.addEventListener('click', () => { EventBus.emit(EVENT.LOAD_ALL); }); }, _updateMoveBtns(mode) { const moveBtns = this.panelEl.querySelector(`#${ID.MOVE_BTNS}`); if (moveBtns) DOMHelper.toggleClass(moveBtns, CSS_CLASS.VISIBLE, mode === CONFIG.SORT_MODES.CUSTOM); }, /** 选中/取消选中卡片 */ toggleCardSelection(cardEl, path) { if (this.selectedCard === path) { this._clearSelection(); return; } this._clearSelection(); this.selectedCard = path; DOMHelper.addClass(cardEl, CSS_CLASS.SELECTED); this._updateMoveBtnState(); }, _clearSelection() { DOMHelper.queryAll(`.${CSS_CLASS.SELECTED}`).forEach(el => el.classList.remove(CSS_CLASS.SELECTED)); this.selectedCard = null; this._updateMoveBtnState(); }, _updateMoveBtnState() { const upBtn = this.panelEl?.querySelector(`#${ID.UP_BTN}`); const downBtn = this.panelEl?.querySelector(`#${ID.DOWN_BTN}`); if (!upBtn || !downBtn) return; const container = DOMHelper.getContainer(); if (!container || !this.selectedCard) { upBtn.disabled = true; downBtn.disabled = true; return; } const idx = DOMHelper.getCardIndex(container, this.selectedCard); const cardEls = DOMHelper.getCards(container); upBtn.disabled = idx <= 0; downBtn.disabled = idx < 0 || idx >= cardEls.length - 1; }, /** 移动选中卡片 direction: -1 上移, +1 下移 */ _moveCard(direction) { if (!this.selectedCard) return; const container = DOMHelper.getContainer(); if (!container) return; const cardEls = DOMHelper.getCards(container); const idx = DOMHelper.getCardIndex(container, this.selectedCard); if (idx < 0) return; const targetIdx = idx + direction; if (targetIdx < 0 || targetIdx >= cardEls.length) return; // DOM 交换 const srcEl = cardEls[idx]; const targetEl = cardEls[targetIdx]; DOMHelper.swapElements(container, srcEl, targetEl, direction); // 保持选中状态 DOMHelper.addClass(srcEl, CSS_CLASS.SELECTED); this._updateMoveBtnState(); // 滚动到可见 DOMHelper.scrollToElement(srcEl); EventBus.emit(EVENT.SORT_CHANGED); }, /** 置顶/置底 direction: -1 置顶, +1 置底 */ _moveCardToExtreme(direction) { if (!this.selectedCard) return; const container = DOMHelper.getContainer(); if (!container) return; const cardEls = DOMHelper.getCards(container); const idx = DOMHelper.getCardIndex(container, this.selectedCard); if (idx < 0) return; const srcEl = cardEls[idx]; if (direction === -1) { // 置顶:找到第一个卡片元素,插入到它之前 const firstCard = cardEls[0]; if (firstCard && srcEl !== firstCard) { container.insertBefore(srcEl, firstCard); } } else { // 置底:找到最后一个卡片元素,插入到它之后 const lastCard = cardEls[cardEls.length - 1]; if (lastCard && srcEl !== lastCard) { container.insertBefore(srcEl, lastCard.nextSibling); } } // 保持选中状态 DOMHelper.addClass(srcEl, CSS_CLASS.SELECTED); this._updateMoveBtnState(); // 滚动到可见 DOMHelper.scrollToElement(srcEl); EventBus.emit(EVENT.SORT_CHANGED); }, addBadges(cardEl, cardInfo) { // 移除旧 badge cardEl.querySelectorAll(`.${CSS_CLASS.PINNED_BADGE}, .${CSS_CLASS.ROLE_BADGE}`).forEach(el => el.remove()); const h1El = cardEl.querySelector('h1'); if (!h1El) return; if (cardInfo.pinned) { const badge = document.createElement('span'); badge.className = CSS_CLASS.PINNED_BADGE; badge.innerHTML = '📌 置顶'; h1El.appendChild(badge); } const roleLabel = CONFIG.ROLE_LABELS[cardInfo.access_role] || CONFIG.ROLE_LABELS['Unknown']; const roleClass = `cgs-role-${cardInfo.access_role.toLowerCase()}`; const badge = document.createElement('span'); badge.className = `${CSS_CLASS.ROLE_BADGE} ${roleClass}`; badge.textContent = roleLabel; h1El.appendChild(badge); }, showSortingOverlay(show) { let overlay = DOMHelper.queryById(ID.SORTING_OVERLAY); if (show) { if (!overlay) { overlay = document.createElement('div'); overlay.id = ID.SORTING_OVERLAY; overlay.className = 'cgs-sorting-overlay'; overlay.innerHTML = '🔄 正在排序...'; document.body.appendChild(overlay); } overlay.style.display = 'flex'; } else if (overlay) { overlay.style.display = 'none'; } }, }; // ============================================================ // 主控模块 // ============================================================ const MainController = { observer: null, sortTimer: null, isInitialized: false, _isSorting: false, _hasInitialSorted: false, _isLeadingCall: true, // leading edge 防抖标记 cardCache: new Map(), // 卡片缓存 Map async init() { Logger.info('初始化...'); // 1. 拦截 fetch 以捕获页面自然请求 ApiFetcher.interceptFetch(); // 2. 加载已保存的排序偏好 const saved = Storage.load(); if (saved) { SortEngine.customOrder = saved.order || []; SortEngine.currentMode = saved.mode || CONFIG.SORT_MODES.SMART; } // 3. 获取当前登录用户名 await this._fetchCurrentUsername(); // 4. 检查是否是他人页面,如果是则清空数据并禁用 if (UIManager.isGroupsPage() && !UIManager.isOwnGroupsPage()) { Logger.info('检测到他人页面,禁用排序功能'); DataExtractor.clearCache(); UIManager.updateVisibility(); return; } // 5. 通过 EventBus 绑定事件 EventBus.on(EVENT.MODE_CHANGE, () => this._applySort(true)); EventBus.on(EVENT.SAVE, () => this._saveCurrentOrder()); EventBus.on(EVENT.RESET, () => this._resetSort()); EventBus.on(EVENT.LOAD_ALL, () => this._loadAllGroups()); EventBus.on(EVENT.SORT_CHANGED, () => this._handleManualSortChange()); // 6. 等待页面渲染 this._waitForList(() => { this.isInitialized = true; this._setupObserver(); this._injectUI(); // 7. 主动获取 API 数据补全 access_role,然后排序 this._initialFetchAndSort(); // 8. 通知初始化完成 EventBus.emit(EVENT.INIT_COMPLETE); }); }, /** 获取当前登录用户名并缓存 */ async _fetchCurrentUsername() { try { const resp = await fetch(`${CONFIG.API_BASE}/user`, { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (!resp.ok) return; const data = await resp.json(); if (data && data.username) { UIManager.setUsername(data.username); Logger.info(`当前用户: ${data.username}`); } } catch (e) { Logger.error('获取当前用户失败:', e); } }, /** 初始化时获取 API 数据补全 access_role,补全未加载的卡片 */ async _initialFetchAndSort() { const allGroups = await ApiFetcher.fetchAllGroups(); const container = DOMHelper.getContainer(); if (!container || allGroups.length === 0) { this._applySort(); return; } const existingCards = DOMHelper.getCards(container); if (allGroups.length > existingCards.length) { CardRenderer.renderMissingCards(container, allGroups); } this._applySort(); Logger.info(`✅ 就绪,已获取 ${allGroups.length} 个组织`); }, _waitForList(callback) { const check = () => { const container = DOMHelper.getContainer(); if (container) callback(container); else setTimeout(check, 500); }; check(); }, _setupObserver() { if (this.observer) return; this.observer = new MutationObserver(mutations => { if (this._isSorting) return; const newCardEls = []; for (const m of mutations) { if (m.type === 'childList') { for (const node of m.addedNodes) { if (node.nodeType === 1 && (node.matches?.(CONFIG.CARD_SELECTOR) || node.querySelector?.(CONFIG.CARD_SELECTOR))) { const cardEl = node.matches?.(CONFIG.CARD_SELECTOR) ? node : node.querySelector?.(CONFIG.CARD_SELECTOR); const path = cardEl ? DOMHelper.getCardPath(cardEl) : null; // 如果卡片已在缓存中,检查是否是不同元素(页面原生替换脚本创建的场景) if (path && this.cardCache.has(path)) { const cachedEl = this.cardCache.get(path); // 同一个元素被重新插入(排序移动),跳过 if (cachedEl === cardEl) continue; // 不同元素同 path:页面原生卡片替换脚本创建的,需要处理 // 将脚本创建的卡片加入移除列表 if (DOMHelper.hasAttribute(cachedEl, DATA_ATTR.CREATED) && !DOMHelper.hasAttribute(cardEl, DATA_ATTR.CREATED)) { Logger.info(`检测到页面原生卡片替换脚本创建的: ${path}`); cachedEl.remove(); this.cardCache.delete(path); } else { continue; } } newCardEls.push(node); } } // 增量更新缓存:移除已删除的卡片 for (const node of m.removedNodes) { if (node.nodeType === 1) { const path = DOMHelper.getCardPath(node.matches?.(CONFIG.CARD_SELECTOR) ? node : node.querySelector?.(CONFIG.CARD_SELECTOR)); if (path) this.cardCache.delete(path); } } } } if (newCardEls.length > 0) { this._removeDuplicateNewCards(newCardEls); this._addBadgesToNewCards(newCardEls); // 增量更新缓存:新增卡片 newCardEls.forEach(el => { const cardEl = el.matches?.(CONFIG.CARD_SELECTOR) ? el : el.querySelector?.(CONFIG.CARD_SELECTOR); if (cardEl) { const path = DOMHelper.getCardPath(cardEl); if (path) this.cardCache.set(path, cardEl); } }); this._debouncedApplySort(); } }); const container = DOMHelper.getContainer(); if (container) this.observer.observe(container, { childList: true }); }, /** Observer 去重:页面原生卡片加载后移除脚本创建的同 path 卡片 */ _removeDuplicateNewCards(newCardEls) { const container = DOMHelper.getContainer(); if (!container) return; // 收集新增的页面原生卡片(无 data-cgs-created 标记) const nativeNewPaths = []; newCardEls.forEach(el => { const cardEl = el.matches?.(CONFIG.CARD_SELECTOR) ? el : el.querySelector?.(CONFIG.CARD_SELECTOR); if (!cardEl) return; // 只关心页面自身渲染的卡片 if (DOMHelper.hasAttribute(cardEl, DATA_ATTR.CREATED)) return; const path = DOMHelper.getCardPath(cardEl); if (path) nativeNewPaths.push(path); }); if (nativeNewPaths.length === 0) return; // 找出所有与新增原生卡片同 path 的脚本创建卡片,全部移除 const toRemove = []; DOMHelper.getCards(container).forEach(cardEl => { const path = DOMHelper.getCardPath(cardEl); if (path && nativeNewPaths.includes(path) && DOMHelper.hasAttribute(cardEl, DATA_ATTR.CREATED)) { toRemove.push(cardEl); } }); toRemove.forEach(el => { const path = DOMHelper.getCardPath(el); Logger.info(`页面原生卡片已加载,移除脚本创建的: ${path}`); this.cardCache.delete(path); el.remove(); }); // 从 newCardEls 中移除已删除的引用 for (let i = newCardEls.length - 1; i >= 0; i--) { if (!newCardEls[i].isConnected) { newCardEls.splice(i, 1); } } }, /** 立即为新出现的卡片添加标签 */ _addBadgesToNewCards(newCardEls) { newCardEls.forEach(el => { const cardEl = el.matches?.(CONFIG.CARD_SELECTOR) ? el : el.querySelector?.(CONFIG.CARD_SELECTOR); if (!cardEl) return; const info = DataExtractor.extractCard(cardEl); if (!info) return; const enriched = DataExtractor.enrichWithApiData([info])[0]; if (enriched) UIManager.addBadges(cardEl, enriched); }); }, /** Leading edge 防抖:首次触发 300ms,后续连续触发 600ms */ _debouncedApplySort() { clearTimeout(this.sortTimer); if (this._isLeadingCall) { this._isLeadingCall = false; this.sortTimer = setTimeout(() => { this._applySort(); this._isLeadingCall = true; }, CONFIG.DEBOUNCE_LEADING_MS); } else { this.sortTimer = setTimeout(() => { this._applySort(); this._isLeadingCall = true; }, CONFIG.DEBOUNCE_MS); } }, _injectUI() { UIManager.injectPanel(); if (UIManager.panelEl) { UIManager.panelEl.querySelectorAll('.cgs-mode-btn').forEach(btn => { DOMHelper.toggleClass(btn, CSS_CLASS.ACTIVE, btn.getAttribute(DATA_ATTR.MODE) === SortEngine.currentMode); }); UIManager._updateMoveBtns(SortEngine.currentMode); } }, _applySort(forceReorder = false) { const container = DOMHelper.getContainer(); if (!container) return; let rawCards = DataExtractor.extractAllCards(); if (rawCards.length === 0) return; // 去重安全网:同 path 保留页面原生卡片(无 data-cgs-created),移除脚本创建的重复卡片 const seenPaths = new Map(); const deduplicated = []; rawCards.forEach(card => { const existing = seenPaths.get(card.path); if (!existing) { seenPaths.set(card.path, card); deduplicated.push(card); } else { // 优先保留页面原生卡片(无 data-cgs-created 标记) const existingIsCreated = DOMHelper.hasAttribute(existing.element, DATA_ATTR.CREATED); const currentIsCreated = DOMHelper.hasAttribute(card.element, DATA_ATTR.CREATED); if (existingIsCreated && !currentIsCreated) { // 当前是原生卡片,替换掉之前的脚本创建卡片 const idx = deduplicated.findIndex(c => c.path === card.path); if (idx !== -1) deduplicated[idx] = card; seenPaths.set(card.path, card); } // 移除被淘汰的重复卡片的 DOM 元素 const toRemove = existingIsCreated && !currentIsCreated ? existing.element : card.element; if (toRemove?.isConnected) { Logger.info(`去重移除重复卡片: ${card.path}`); toRemove.remove(); } } }); rawCards = deduplicated; const cards = DataExtractor.enrichWithApiData(rawCards); // 更新缓存 cards.forEach(c => { if (c.element) this.cardCache.set(c.path, c.element); }); // 保存原始顺序(仅首次,排序前保存) if (SortEngine.originalOrder.length === 0) { SortEngine.originalOrder = cards.map(c => c.path); } const { sorted, mode } = SortEngine.sort(cards); // 检查是否需要重排 const sortedPaths = sorted.map(c => c.path); const sortedPathSet = new Set(sortedPaths); const currentCardEls = DOMHelper.getCards(container); const currentPaths = currentCardEls.map(el => DOMHelper.getCardPath(el)).filter(Boolean); const needsReorder = currentPaths.some((p, i) => p !== sortedPaths[i]) || currentPaths.length !== sortedPaths.length; // 清理 DOM 中不在 sorted 数组中的残留卡片(去重遗漏等场景) currentCardEls.forEach(el => { const path = DOMHelper.getCardPath(el); if (path && !sortedPathSet.has(path)) { Logger.info(`移除不在排序结果中的残留卡片: ${path}`); el.remove(); this.cardCache.delete(path); } }); const isCustomMode = mode === CONFIG.SORT_MODES.CUSTOM; if (needsReorder || forceReorder) { if (forceReorder || !this._hasInitialSorted) { UIManager.showSortingOverlay(true); } this._isSorting = true; // 断开 Observer 避免排序过程中触发 if (this.observer) this.observer.disconnect(); requestAnimationFrame(() => { DOMHelper.addClass(container, CSS_CLASS.NO_TRANSITION); // 找到第一个卡片的位置作为插入起点 const allChildren = Array.from(container.children); let firstCardIndex = allChildren.findIndex(child => child.matches(CONFIG.CARD_SELECTOR)); if (firstCardIndex === -1) firstCardIndex = allChildren.length; // 获取锚点元素(第一个卡片之前的非卡片元素,如搜索框) // 这个元素在排序过程中不会被移除,可以作为稳定的插入参考点 const anchorElement = firstCardIndex > 0 ? allChildren[firstCardIndex - 1] : null; // 先从 DOM 中移除所有卡片(不触发 Observer,因为已断开) // 移除容器中所有匹配卡片选择器的元素,避免残留 DOMHelper.getCards(container).forEach(el => el.remove()); // 按排序顺序将卡片插入到锚点元素之后 // 使用动态锚点:每次插入后更新锚点为刚插入的卡片 // 这样下一个卡片会插入到当前卡片之后,保持正确顺序 let currentAnchor = anchorElement; sorted.forEach(cardInfo => { if (cardInfo.element) { if (currentAnchor) { // 有锚点元素(如搜索框或上一个卡片),将卡片插入到它之后 container.insertBefore(cardInfo.element, currentAnchor.nextSibling); } else { // 没有锚点元素,将卡片插入到容器开头 container.insertBefore(cardInfo.element, container.firstChild); } currentAnchor = cardInfo.element; // 更新锚点为刚插入的卡片 UIManager.addBadges(cardInfo.element, cardInfo); this._setupCardInteraction(cardInfo.element, cardInfo.path, isCustomMode); } }); DOMHelper.removeClass(container, CSS_CLASS.NO_TRANSITION); UIManager.showSortingOverlay(false); this._isSorting = false; this._hasInitialSorted = true; // 重新连接 Observer if (this.observer) this.observer.observe(container, { childList: true }); Logger.info(`排序完成 (${mode}): ${sorted.length} 个组织`); }); } else { sorted.forEach(cardInfo => { if (cardInfo.element) { UIManager.addBadges(cardInfo.element, cardInfo); this._setupCardInteraction(cardInfo.element, cardInfo.path, isCustomMode); } }); } }, /** 设置卡片的交互(自定义模式下可点击选中+拖拽) */ _setupCardInteraction(cardEl, path, isCustomMode) { if (cardEl._cgsClickHandler) { cardEl.removeEventListener('click', cardEl._cgsClickHandler); cardEl._cgsClickHandler = null; } // 移除旧的拖拽事件 if (cardEl._cgsDragHandlers) { cardEl.removeEventListener('dragstart', cardEl._cgsDragHandlers.start); cardEl.removeEventListener('dragend', cardEl._cgsDragHandlers.end); cardEl.removeEventListener('dragover', cardEl._cgsDragHandlers.over); cardEl.removeEventListener('dragleave', cardEl._cgsDragHandlers.leave); cardEl.removeEventListener('drop', cardEl._cgsDragHandlers.drop); cardEl._cgsDragHandlers = null; } DOMHelper.toggleClass(cardEl, CSS_CLASS.SELECTABLE, isCustomMode); cardEl.setAttribute('draggable', isCustomMode); if (!isCustomMode) { DOMHelper.removeClass(cardEl, CSS_CLASS.SELECTED); DOMHelper.removeClass(cardEl, CSS_CLASS.DRAGGING); return; } // 点击选中 const clickHandler = (e) => { if (e.target.closest('a[href]')) return; e.preventDefault(); e.stopPropagation(); UIManager.toggleCardSelection(cardEl, path); }; cardEl._cgsClickHandler = clickHandler; cardEl.addEventListener('click', clickHandler); // 拖拽排序 const dragStartHandler = (e) => { e.dataTransfer.setData('text/plain', path); e.dataTransfer.effectAllowed = 'move'; DOMHelper.addClass(cardEl, CSS_CLASS.DRAGGING); cardEl.setAttribute(DATA_ATTR.DRAG_PATH, path); }; const dragEndHandler = () => { DOMHelper.removeClass(cardEl, CSS_CLASS.DRAGGING); DOMHelper.queryAll(`.${CSS_CLASS.DRAG_OVER}`).forEach(el => { DOMHelper.removeClass(el, CSS_CLASS.DRAG_OVER); }); }; const dragOverHandler = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const dragPath = cardEl.getAttribute(DATA_ATTR.DRAG_PATH); if (dragPath !== path) { DOMHelper.addClass(cardEl, CSS_CLASS.DRAG_OVER); } }; const dragLeaveHandler = () => { DOMHelper.removeClass(cardEl, CSS_CLASS.DRAG_OVER); }; const dropHandler = (e) => { e.preventDefault(); DOMHelper.removeClass(cardEl, CSS_CLASS.DRAG_OVER); const fromPath = e.dataTransfer.getData('text/plain'); if (fromPath && fromPath !== path) { this._handleDrop(fromPath, path); } }; cardEl._cgsDragHandlers = { start: dragStartHandler, end: dragEndHandler, over: dragOverHandler, leave: dragLeaveHandler, drop: dropHandler, }; cardEl.addEventListener('dragstart', dragStartHandler); cardEl.addEventListener('dragend', dragEndHandler); cardEl.addEventListener('dragover', dragOverHandler); cardEl.addEventListener('dragleave', dragLeaveHandler); cardEl.addEventListener('drop', dropHandler); }, /** 处理拖拽放置 */ _handleDrop(fromPath, toPath) { const container = DOMHelper.getContainer(); if (!container) return; const cardEls = DOMHelper.getCards(container); const fromIdx = cardEls.findIndex(el => DOMHelper.getCardPath(el) === fromPath); const toIdx = cardEls.findIndex(el => DOMHelper.getCardPath(el) === toPath); if (fromIdx < 0 || toIdx < 0) return; const fromEl = cardEls[fromIdx]; const toEl = cardEls[toIdx]; // 将 from 元素插入到 to 元素之前 container.insertBefore(fromEl, toEl); // 更新选中状态 if (UIManager.selectedCard === fromPath) { DOMHelper.addClass(fromEl, CSS_CLASS.SELECTED); } UIManager._updateMoveBtnState(); DOMHelper.scrollToElement(fromEl); EventBus.emit(EVENT.SORT_CHANGED); }, /** 用户手动移动卡片后的回调 */ _handleManualSortChange() { if (SortEngine.currentMode !== CONFIG.SORT_MODES.CUSTOM) { SortEngine.setMode(CONFIG.SORT_MODES.CUSTOM); if (UIManager.panelEl) { UIManager.panelEl.querySelectorAll('.cgs-mode-btn').forEach(btn => { DOMHelper.toggleClass(btn, CSS_CLASS.ACTIVE, btn.getAttribute(DATA_ATTR.MODE) === CONFIG.SORT_MODES.CUSTOM); }); UIManager._updateMoveBtns(CONFIG.SORT_MODES.CUSTOM); } } const container = DOMHelper.getContainer(); if (container) { const currentCards = DataExtractor.extractAllCards(); SortEngine.updateCustomOrder(currentCards); } }, async _loadAllGroups() { const loadAllBtn = UIManager.panelEl?.querySelector(`#${ID.LOADALL_BTN}`); if (loadAllBtn) { loadAllBtn.disabled = true; loadAllBtn.textContent = '加载中...'; } const hint = UIManager.panelEl?.querySelector(`#${ID.HINT}`); if (hint) hint.textContent = '正在加载全部组织...'; const allGroups = await ApiFetcher.fetchAllGroups(); const container = DOMHelper.getContainer(); if (allGroups.length > 0 && container) { const existingCards = DOMHelper.getCards(container); if (allGroups.length > existingCards.length) { CardRenderer.renderMissingCards(container, allGroups); } this._applySort(true); if (hint) hint.textContent = `已加载 ${allGroups.length} 个组织`; } else if (allGroups.length > 0) { this._applySort(true); if (hint) hint.textContent = `已加载 ${allGroups.length} 个组织`; } else { if (hint) hint.textContent = '加载失败,请重试'; } if (loadAllBtn) { loadAllBtn.disabled = false; loadAllBtn.textContent = '重新加载全部'; } }, _saveCurrentOrder() { const container = DOMHelper.getContainer(); if (!container) return; const cards = DataExtractor.extractAllCards(); SortEngine.updateCustomOrder(cards); Storage.save({ order: SortEngine.customOrder, mode: SortEngine.currentMode }); }, _resetSort() { Storage.clear(); SortEngine.customOrder = []; SortEngine.setMode(CONFIG.SORT_MODES.SMART); SortEngine.originalOrder = []; this.cardCache.clear(); UIManager._clearSelection(); if (UIManager.panelEl) { UIManager.panelEl.querySelectorAll('.cgs-mode-btn').forEach(btn => { DOMHelper.toggleClass(btn, CSS_CLASS.ACTIVE, btn.getAttribute(DATA_ATTR.MODE) === CONFIG.SORT_MODES.SMART); }); UIManager._updateMoveBtns(CONFIG.SORT_MODES.SMART); const hint = UIManager.panelEl.querySelector(`#${ID.HINT}`); if (hint) hint.textContent = '自动按规则排序'; } this._applySort(true); }, }; // ============================================================ // 启动 + SPA 路由监听 // ============================================================ // SPA 页内导航监听:检测容器被替换时自动重新初始化 let _bodyObserver = null; let _lastContainer = null; // 使用 EventBus 监听初始化完成事件,替代硬编码 setTimeout EventBus.on(EVENT.INIT_COMPLETE, () => { _lastContainer = DOMHelper.getContainer(); Logger.debug('SPA 监听已初始化,记录容器引用'); }); function watchSpaNavigation() { if (_bodyObserver) return; // 监听 URL 变化(SPA 路由切换) let _lastUrl = location.href; const checkUrlChange = () => { if (location.href !== _lastUrl) { _lastUrl = location.href; // 检查是否是他人页面 if (UIManager.isGroupsPage() && !UIManager.isOwnGroupsPage()) { Logger.info('切换到他人页面,禁用功能'); DataExtractor.clearCache(); UIManager.updateVisibility(); return; } UIManager.updateVisibility(); } }; _bodyObserver = new MutationObserver(() => { checkUrlChange(); const container = DOMHelper.getContainer(); if (!container) { // 容器被移除,清空 observer 引用 if (MainController.observer) { MainController.observer.disconnect(); MainController.observer = null; } _lastContainer = null; return; } // 容器存在但是新的(DOM 元素变了)= SPA 导航 if (container !== _lastContainer && MainController.isInitialized) { _lastContainer = container; Logger.info('检测到 SPA 导航,重新初始化...'); // 检查是否是他人页面 if (UIManager.isGroupsPage() && !UIManager.isOwnGroupsPage()) { Logger.info('他人页面,仅显示面板'); UIManager.updateVisibility(); return; } MainController._hasInitialSorted = false; SortEngine.originalOrder = []; MainController.cardCache.clear(); MainController._setupObserver(); MainController._initialFetchAndSort(); } }); _bodyObserver.observe(document.body, { childList: true, subtree: true }); } // 初次启动 MainController.init(); watchSpaNavigation(); })();