// ==UserScript== // @name CNB 组织列表排序助手 // @namespace https://cnb.cool/ // @version 3.0.0 // @description 对 CNB 平台组织列表进行智能排序(置顶+权限),支持点击调整顺序 // @author Hsred // @match https://cnb.cool/*/groups* // @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 // @homepageURL https://cnb.cool/hsred/cnb-plugin // ==/UserScript== (function() { 'use strict'; // ============================================================ // 配置模块 // ============================================================ const CONFIG = { STORAGE_KEY: 'cnb_group_sort_order', VERSION: 2, DEBOUNCE_MS: 600, 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 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; } `; // ============================================================ // 持久化模块 // ============================================================ 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) { console.error('[CNB Sorter] 保存失败:', 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 && Array.isArray(data.order)) return data; return null; } catch (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) { return false; } }, }; // ============================================================ // 数据提取模块 // ============================================================ const DataExtractor = { _apiDataCache: new Map(), 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) { return null; } }, extractAllCards() { const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (!container) return []; const cards = []; container.querySelectorAll(CONFIG.CARD_SELECTOR).forEach(el => { const info = this.extractCard(el); if (info) cards.push({ ...info, element: el }); }); return cards; }, setApiData(path, data) { this._apiDataCache.set(path, data); }, getApiData(path) { return this._apiDataCache.get(path); }, 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, }; } return card; }); }, }; // ============================================================ // API 数据获取模块 // ============================================================ const ApiFetcher = { 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); }); console.log(`[CNB Sorter] API 第${page}页: ${items.length} 个组织`); if (items.length < CONFIG.PAGE_SIZE) break; page++; } console.log(`[CNB Sorter] API 获取完成: ${allGroups.length} 个组织`); } catch (e) { console.error('[CNB Sorter] API 获取失败:', e); } return allGroups; }, /** 只获取首页数据用于初始化补全 access_role */ async fetchFirstPage() { try { const url = `${CONFIG.API_BASE}${CONFIG.API_GROUPS_PATH}?page=1&page_size=${CONFIG.PAGE_SIZE}`; const resp = await fetch(url, { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (!resp.ok) return []; const items = await resp.json(); if (Array.isArray(items)) { items.forEach(item => { if (item.path) DataExtractor.setApiData(item.path, item); }); return items; } } catch (e) { /* ignore */ } return []; }, }; // ============================================================ // 排序引擎模块 // ============================================================ const SortEngine = { currentMode: CONFIG.SORT_MODES.SMART, originalOrder: [], // path 字符串数组 customOrder: [], // path 字符串数组 defaultSortComparator(a, b) { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; if (a.pinned && b.pinned && a.pinned_time && b.pinned_time) { const d = new Date(b.pinned_time) - new Date(a.pinned_time); if (d !== 0) return d; } const rA = CONFIG.ROLE_PRIORITY[a.access_role] || 0; const rB = CONFIG.ROLE_PRIORITY[b.access_role] || 0; if (rA !== rB) return rB - rA; 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 injectPanel() { if (document.getElementById('cgs-float-panel')) { this.floatWrap = document.getElementById('cgs-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 = 'cgs-float-panel'; this.panelEl = document.createElement('div'); this.panelEl.className = 'cgs-panel cgs-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 是否是组织列表页 (/u/用户名/groups) */ isGroupsPage() { return /^\/u\/[^/]+\/groups/.test(location.pathname); }, /** 根据当前页面路径显隐悬浮按钮 */ updateVisibility() { if (!this.floatWrap) return; this.floatWrap.style.display = this.isGroupsPage() ? '' : 'none'; }, _togglePanel() { this.isCollapsed = !this.isCollapsed; this.panelEl.classList.toggle('cgs-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('active')); btn.classList.add('active'); const mode = btn.getAttribute('data-mode'); SortEngine.setMode(mode); this._updateMoveBtns(mode); this._clearSelection(); const hint = this.panelEl.querySelector('#cgs-hint'); if (hint) { hint.textContent = mode === CONFIG.SORT_MODES.CUSTOM ? '点击卡片选中后移动' : mode === CONFIG.SORT_MODES.ORIGINAL ? '显示原始加载顺序' : '自动按规则排序'; } if (this.onModeChange) this.onModeChange(mode); }); }); // 上移 this.panelEl.querySelector('#cgs-up-btn')?.addEventListener('click', () => this._moveCard(-1)); // 下移 this.panelEl.querySelector('#cgs-down-btn')?.addEventListener('click', () => this._moveCard(1)); // 保存 this.panelEl.querySelector('#cgs-save-btn')?.addEventListener('click', () => { if (this.onSave) this.onSave(); const btn = this.panelEl.querySelector('#cgs-save-btn'); btn.textContent = '✓ 已保存'; btn.style.background = '#00A870'; setTimeout(() => { btn.textContent = '💾 保存'; btn.style.background = ''; }, 1500); }); // 重置 this.panelEl.querySelector('#cgs-reset-btn')?.addEventListener('click', () => { if (this.onReset) this.onReset(); }); // 加载全部 this.panelEl.querySelector('#cgs-loadall-btn')?.addEventListener('click', () => { if (this.onLoadAll) this.onLoadAll(); }); }, _updateMoveBtns(mode) { const moveBtns = this.panelEl.querySelector('#cgs-move-btns'); if (moveBtns) moveBtns.classList.toggle('visible', mode === CONFIG.SORT_MODES.CUSTOM); }, /** 选中/取消选中卡片 */ toggleCardSelection(cardEl, path) { if (this.selectedCard === path) { this._clearSelection(); return; } this._clearSelection(); this.selectedCard = path; cardEl.classList.add('cgs-selected'); this._updateMoveBtnState(); }, _clearSelection() { document.querySelectorAll('.cgs-selected').forEach(el => el.classList.remove('cgs-selected')); this.selectedCard = null; this._updateMoveBtnState(); }, _updateMoveBtnState() { const upBtn = this.panelEl.querySelector('#cgs-up-btn'); const downBtn = this.panelEl.querySelector('#cgs-down-btn'); if (!upBtn || !downBtn) return; const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (!container || !this.selectedCard) { upBtn.disabled = true; downBtn.disabled = true; return; } const cardEls = Array.from(container.querySelectorAll(CONFIG.CARD_SELECTOR)); const idx = cardEls.findIndex(el => { const link = el.querySelector(CONFIG.CARD_LINK_SELECTOR); return link && link.getAttribute('href')?.replace(/^\//, '') === this.selectedCard; }); upBtn.disabled = idx <= 0; downBtn.disabled = idx < 0 || idx >= cardEls.length - 1; }, /** 移动选中卡片 direction: -1 上移, +1 下移 */ _moveCard(direction) { if (!this.selectedCard) return; const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (!container) return; const cardEls = Array.from(container.querySelectorAll(CONFIG.CARD_SELECTOR)); const idx = cardEls.findIndex(el => { const link = el.querySelector(CONFIG.CARD_LINK_SELECTOR); return link && link.getAttribute('href')?.replace(/^\//, '') === 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]; if (direction === -1) { container.insertBefore(srcEl, targetEl); } else { container.insertBefore(srcEl, targetEl.nextSibling); } // 保持选中状态 srcEl.classList.add('cgs-selected'); this._updateMoveBtnState(); // 滚动到可见 srcEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); if (this.onSortChanged) this.onSortChanged(); }, addBadges(cardEl, cardInfo) { // 移除旧 badge cardEl.querySelectorAll('.cgs-pinned-badge, .cgs-role-badge').forEach(el => el.remove()); const h1El = cardEl.querySelector('h1'); if (!h1El) return; if (cardInfo.pinned) { const badge = document.createElement('span'); badge.className = 'cgs-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 = `cgs-role-badge ${roleClass}`; badge.textContent = roleLabel; h1El.appendChild(badge); }, showSortingOverlay(show) { let overlay = document.getElementById('cgs-sorting-overlay'); if (show) { if (!overlay) { overlay = document.createElement('div'); overlay.id = 'cgs-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, cardClickHandler: null, _hasInitialSorted: false, async init() { console.log('[CNB Sorter] 初始化...'); // 1. 拦截 fetch 以捕获页面自然请求 this._interceptFetch(); // 2. 加载已保存的排序偏好 const saved = Storage.load(); if (saved) { SortEngine.customOrder = saved.order || []; SortEngine.currentMode = saved.mode || CONFIG.SORT_MODES.SMART; } // 3. 绑定 UI 回调 UIManager.onModeChange = () => this._applySort(true); UIManager.onSave = () => this._saveCurrentOrder(); UIManager.onReset = () => this._resetSort(); UIManager.onLoadAll = () => this._loadAllGroups(); UIManager.onSortChanged = () => this._handleManualSortChange(); // 4. 等待页面渲染 this._waitForList(() => { this.isInitialized = true; this._setupObserver(); this._injectUI(); // 5. 主动获取 API 数据补全 access_role,然后排序 this._initialFetchAndSort(); }); }, /** 初始化时获取 API 数据补全 access_role,补全未加载的卡片 */ async _initialFetchAndSort() { const allGroups = await ApiFetcher.fetchAllGroups(); const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (!container || allGroups.length === 0) { this._applySort(); return; } // 检查当前页面上已渲染的卡片数量 const existingCards = container.querySelectorAll(CONFIG.CARD_SELECTOR); // 如果 API 数据量大于已有卡片,用 API 数据补全缺失的卡片 if (allGroups.length > existingCards.length) { this._renderMissingCards(container, allGroups); } this._applySort(); console.log(`[CNB Sorter] ✅ 就绪,已获取 ${allGroups.length} 个组织`); }, /** 用 API 数据补全缺失的卡片(仅追加页面未渲染的,不替换已有的) */ _renderMissingCards(container, allGroups) { // 收集已有卡片的 path const existingPaths = new Set(); container.querySelectorAll(CONFIG.CARD_SELECTOR).forEach(el => { const link = el.querySelector(CONFIG.CARD_LINK_SELECTOR); const path = link?.getAttribute('href')?.replace(/^\//, '').replace(/\/$/, ''); if (path) existingPaths.add(path); }); // 为 API 中有但页面尚未渲染的组织创建卡片 let addedCount = 0; allGroups.forEach(group => { if (existingPaths.has(group.path)) return; const card = this._createCardElement(group); if (card) { container.appendChild(card); addedCount++; } }); console.log(`[CNB Sorter] 补全了 ${addedCount} 张缺失卡片 (API: ${allGroups.length}, 已有: ${existingPaths.size})`); }, /** 根据 API 数据创建组织卡片元素,模仿原始页面结构 */ _createCardElement(group) { try { // 外层带边框容器(与 CARD_SELECTOR 匹配) const wrapper = document.createElement('div'); wrapper.className = 'flex flex-col border border-pri rounded-[3px] '; wrapper.setAttribute('data-cgs-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); // 统计信息 ul 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 = `${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 = `${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 = `${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 = ``; content.appendChild(pinWrap); } inner.appendChild(content); card.appendChild(inner); wrapper.appendChild(card); return wrapper; } catch (e) { console.error('[CNB Sorter] 创建卡片失败:', e); return null; } }, _interceptFetch() { // 避免重复拦截 if (this._fetchIntercepted) return; this._fetchIntercepted = true; const originalFetch = window.fetch.bind(window); window.fetch = async (...args) => { const response = await originalFetch(...args); const url = args[0]?.toString() || ''; if (url.includes('/user/groups')) { try { const clone = response.clone(); const json = await clone.json(); if (Array.isArray(json)) { json.forEach(item => { if (item.path) DataExtractor.setApiData(item.path, item); }); console.log(`[CNB Sorter] 拦截捕获: ${json.length} 个组织`); } } catch (err) { /* ignore */ } } return response; }; }, _waitForList(callback) { const check = () => { const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (container) callback(container); else setTimeout(check, 500); }; check(); }, _setupObserver() { if (this.observer) return; this.observer = new MutationObserver(mutations => { // 排序期间不处理去重(appendChild 移动不是新增) 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))) { newCardEls.push(node); } } } } if (newCardEls.length > 0) { // 去重:页面懒加载产生的与脚本创建的卡片重复时,移除脚本创建的 this._removeDuplicateNewCards(newCardEls); // 立即为新卡片打标签,不等待防抖排序 this._addBadgesToNewCards(newCardEls); this._debouncedApplySort(); } }); const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (container) this.observer.observe(container, { childList: true, subtree: true }); }, /** Observer 去重:页面懒加载新增的卡片与脚本创建的重复时,优先保留页面原生的 */ _removeDuplicateNewCards(newCardEls) { const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (!container) return; // 收集新增卡片的 path const newPaths = []; newCardEls.forEach(el => { const cardEl = el.matches?.(CONFIG.CARD_SELECTOR) ? el : el.querySelector?.(CONFIG.CARD_SELECTOR); if (!cardEl) return; const link = cardEl.querySelector(CONFIG.CARD_LINK_SELECTOR); const path = link?.getAttribute('href')?.replace(/^\//, '').replace(/\/$/, ''); if (path) newPaths.push({ path, el: cardEl }); }); if (newPaths.length === 0) return; // 对于新增的页面原生卡片,检查是否有同 path 的脚本创建卡片 const toRemove = []; newPaths.forEach(({ path, el: newEl }) => { // 只关心页面自身渲染的卡片(无 data-cgs-created 标记) if (newEl.hasAttribute?.('data-cgs-created')) return; // 查找同 path 的脚本创建卡片 container.querySelectorAll(CONFIG.CARD_SELECTOR).forEach(cardEl => { if (cardEl === newEl) return; if (!cardEl.hasAttribute?.('data-cgs-created')) return; const link = cardEl.querySelector(CONFIG.CARD_LINK_SELECTOR); const existPath = link?.getAttribute('href')?.replace(/^\//, '').replace(/\/$/, ''); if (existPath === path) { toRemove.push(cardEl); } }); }); toRemove.forEach(el => { console.log(`[CNB Sorter] 页面原生卡片已加载,移除脚本创建的: ${el.querySelector(CONFIG.CARD_LINK_SELECTOR)?.getAttribute('href')}`); 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); }); }, _debouncedApplySort() { clearTimeout(this.sortTimer); this.sortTimer = setTimeout(() => this._applySort(), CONFIG.DEBOUNCE_MS); }, _injectUI() { UIManager.injectPanel(); if (UIManager.panelEl) { UIManager.panelEl.querySelectorAll('.cgs-mode-btn').forEach(btn => { btn.classList.toggle('active', btn.getAttribute('data-mode') === SortEngine.currentMode); }); UIManager._updateMoveBtns(SortEngine.currentMode); } }, _applySort(forceReorder = false) { const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (!container) return; const rawCards = DataExtractor.extractAllCards(); if (rawCards.length === 0) return; const cards = DataExtractor.enrichWithApiData(rawCards); // 保存原始顺序(仅首次,排序前保存) if (SortEngine.originalOrder.length === 0) { SortEngine.originalOrder = cards.map(c => c.path); } const { sorted, mode } = SortEngine.sort(cards); // 检查是否需要重排 const currentPaths = Array.from(container.querySelectorAll(CONFIG.CARD_SELECTOR)).map(el => { const link = el.querySelector(CONFIG.CARD_LINK_SELECTOR); return link ? link.getAttribute('href')?.replace(/^\//, '') : ''; }).filter(Boolean); const sortedPaths = sorted.map(c => c.path); const needsReorder = currentPaths.length === sortedPaths.length && currentPaths.some((p, i) => p !== sortedPaths[i]); const isCustomMode = mode === CONFIG.SORT_MODES.CUSTOM; if (needsReorder || forceReorder) { // 仅初始化或手动操作时显示遮罩,滚动加载不显示 if (forceReorder || !this._hasInitialSorted) { UIManager.showSortingOverlay(true); } this._isSorting = true; requestAnimationFrame(() => { container.classList.add('cgs-no-transition'); // 记录每个非卡片元素的原始位置,重排后恢复 const nonCardPositions = []; Array.from(container.children).forEach((child, index) => { if (!child.matches(CONFIG.CARD_SELECTOR)) { nonCardPositions.push({ element: child, index }); } }); // 重排卡片:先将所有卡片按排序顺序 appendChild sorted.forEach(cardInfo => { if (cardInfo.element) { container.appendChild(cardInfo.element); UIManager.addBadges(cardInfo.element, cardInfo); this._setupCardInteraction(cardInfo.element, cardInfo.path, isCustomMode); } }); // 将非卡片元素插回原始位置 nonCardPositions.forEach(({ element, index }) => { if (index >= container.children.length) { container.appendChild(element); } else { container.insertBefore(element, container.children[index]); } }); container.classList.remove('cgs-no-transition'); UIManager.showSortingOverlay(false); this._isSorting = false; this._hasInitialSorted = true; console.log(`[CNB Sorter] 排序完成 (${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; } cardEl.classList.toggle('cgs-selectable', isCustomMode); if (!isCustomMode) { cardEl.classList.remove('cgs-selected'); return; } const handler = (e) => { // 不拦截链接点击 if (e.target.closest('a[href]')) return; e.preventDefault(); e.stopPropagation(); UIManager.toggleCardSelection(cardEl, path); }; cardEl._cgsClickHandler = handler; cardEl.addEventListener('click', handler); }, /** 用户手动移动卡片后的回调 */ _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 => { btn.classList.toggle('active', btn.getAttribute('data-mode') === CONFIG.SORT_MODES.CUSTOM); }); UIManager._updateMoveBtns(CONFIG.SORT_MODES.CUSTOM); } } const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (container) { const currentCards = DataExtractor.extractAllCards(); SortEngine.updateCustomOrder(currentCards); } }, async _loadAllGroups() { const loadAllBtn = UIManager.panelEl?.querySelector('#cgs-loadall-btn'); if (loadAllBtn) { loadAllBtn.disabled = true; loadAllBtn.textContent = '加载中...'; } const hint = UIManager.panelEl?.querySelector('#cgs-hint'); if (hint) hint.textContent = '正在加载全部组织...'; const allGroups = await ApiFetcher.fetchAllGroups(); const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (allGroups.length > 0 && container) { const existingCards = container.querySelectorAll(CONFIG.CARD_SELECTOR); if (allGroups.length > existingCards.length) { this._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 = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); 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 = []; UIManager._clearSelection(); if (UIManager.panelEl) { UIManager.panelEl.querySelectorAll('.cgs-mode-btn').forEach(btn => { btn.classList.toggle('active', btn.getAttribute('data-mode') === CONFIG.SORT_MODES.SMART); }); UIManager._updateMoveBtns(CONFIG.SORT_MODES.SMART); const hint = UIManager.panelEl.querySelector('#cgs-hint'); if (hint) hint.textContent = '自动按规则排序'; } this._applySort(true); }, }; // ============================================================ // 启动 + SPA 路由监听 // ============================================================ // 初次启动 MainController.init(); // SPA 页内导航监听:检测容器被替换时自动重新初始化 let _bodyObserver = null; let _lastContainer = null; // 延迟记录初始容器,等 init 完成后 setTimeout(() => { _lastContainer = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); }, 2000); function watchSpaNavigation() { if (_bodyObserver) return; // 监听 URL 变化(SPA 路由切换) let _lastUrl = location.href; const checkUrlChange = () => { if (location.href !== _lastUrl) { _lastUrl = location.href; UIManager.updateVisibility(); } }; _bodyObserver = new MutationObserver(() => { checkUrlChange(); const container = document.querySelector(CONFIG.LIST_CONTAINER_SELECTOR); if (!container) { // 容器被移除,清空 observer 引用 if (MainController.observer) { MainController.observer.disconnect(); MainController.observer = null; } _lastContainer = null; return; } // 容器存在但是新的(DOM 元素变了)= SPA 导航 if (container !== _lastContainer && MainController.isInitialized) { _lastContainer = container; console.log('[CNB Sorter] 检测到 SPA 导航,重新初始化...'); MainController._hasInitialSorted = false; SortEngine.originalOrder = []; MainController._setupObserver(); MainController._initialFetchAndSort(); } }); _bodyObserver.observe(document.body, { childList: true, subtree: true }); } watchSpaNavigation(); })();