// ==UserScript==
// @name CNB 组织列表排序助手
// @namespace https://cnb.cool/
// @version 3.0.1
// @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 路径: /users/{username}/groups */
getApiGroupsPath() {
const match = location.pathname.match(/^\/u\/([^/]+)\/groups/);
if (match) return `/users/${match[1]}/groups`;
return '/user/groups'; // fallback
},
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.getApiGroupsPath()}?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.getApiGroupsPath()}?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') || url.includes('/users/') && url.includes('/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();
})();