// ==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 = `