// ==UserScript==
// @name CNB Cool 增强
// @namespace https://cnb.cool/IIIStudio/Code/Greasemonkey/CNB
// @version 1.8.3
// @description CNB.Cool 综合增强工具:直达链接解码、网格布局切换、收藏夹标签管理、与我有关标签、创建仓库按钮、快捷导航栏(支持拖拽排序/右键编辑/导入导出)、云原生开发模式(点击头像启动)、NPC 快捷指令(指令模板/NPC 列表)、时间线只看评论/自动展开/评论楼层号、引用自动折叠等功能
// @author IIIStudio
// @match *://cnb.cool/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @connect api.cnb.cool
// @connect *
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_VERSION = typeof GM_info !== 'undefined' ? GM_info.script.version : '';
/*
* 约定:样式类 cnb- 前缀、存储键 cnb_ 前缀、模块级可变状态 _xxx 前缀;
* 动词 get 读 / save·set 写 / show 反馈 / render 重建 DOM / bind 挂事件 / ensure 维持状态(幂等)/ is 判定;
* 本地数据只走 GM_getValue · GM_setValue(读频繁的加内存缓存,写后失效);不向控制台输出日志。
*
* 结构导航(按文件顺序):
* 1. 全局常量(KEYS 存储键 / SEL 选择器 / TEXT 文案)与全局页面调度器(唯一 MutationObserver + 节流)
* 2. 数据管理(readJsonStore / writeJsonStore)与通用 UI(toast / confirm / 弹窗外点关闭)
* 3. 收藏夹(Stars 页标签 / 筛选 / 导入导出 / COS 云同步)
* 4. CNB 增强(直达链接、网格布局、创建仓库、输入框复制、下载增强、快捷导航栏)
* 5. NPC 快捷指令(指令模板 / NPC 列表)与评论「引用」自动折叠
* 6. 时间线:侧栏开关(只看评论 / 自动展开)、评论楼层号
* 新增功能请挂到 registerPageProcessor(),不要另起 MutationObserver。
*/
// ===== 样式 =====
const style = document.createElement('style');
style.textContent = `
.cnb-tag-chip {
display: inline-flex; align-items: center; gap: 3px;
padding: 1px 8px; border-radius: 10px; font-size: 11px;
background: rgba(22,119,255,0.1); color: #1677ff;
border: 1px solid rgba(22,119,255,0.2); cursor: default;
white-space: nowrap;
}
.cnb-tag-chip .remove {
cursor: pointer; margin-left: 2px; font-size: 14px;
line-height: 1; color: #999;
}
.cnb-tag-chip .remove:hover { color: #ff4d4f; }
.cnb-tag-add-btn {
cursor: pointer; display: none; align-items: center;
justify-content: center; width: 20px; height: 20px;
border-radius: 50%; background: transparent;
border: 1px dashed #bbb; color: #999; font-size: 16px;
line-height: 1; flex-shrink: 0; margin-left: 4px; transition: all 0.2s;
}
.cnb-enhanced:hover .cnb-tag-add-btn {
display: inline-flex;
}
.cnb-tag-add-btn:hover {
border-color: #1677ff; color: #1677ff;
background: rgba(22,119,255,0.05);
}
.cnb-tag-popup {
position: fixed; background: #fff;
border: 1px solid #e0e0e0; border-radius: 10px;
padding: 14px 16px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
z-index: 99999; min-width: 220px;
}
.cnb-tag-popup input {
width: 100%; box-sizing: border-box;
border: 1px solid #d0d0d0; border-radius: 6px;
padding: 6px 10px; font-size: 13px; outline: none;
}
.cnb-tag-popup input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22,119,255,0.15);
}
.cnb-tag-popup .popup-title {
font-size: 13px; font-weight: 600; margin-bottom: 8px; color: #333;
}
.cnb-tag-popup .popup-suggestions {
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px;
}
.cnb-tag-popup .popup-suggestions span {
padding: 2px 8px; border-radius: 6px; font-size: 12px;
background: #f5f5f5; cursor: pointer;
}
.cnb-tag-popup .popup-suggestions span:hover {
background: #e6f0ff; color: #1677ff;
}
.cnb-tag-popup .popup-actions {
display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px;
}
.cnb-tag-popup .popup-actions button {
border: none; padding: 4px 14px; border-radius: 6px;
font-size: 12px; cursor: pointer;
}
.cnb-tag-popup .popup-actions .btn-ok { background: #1677ff; color: #fff; }
.cnb-tag-popup .popup-actions .btn-ok:hover { background: #4096ff; }
.cnb-tag-popup .popup-actions .btn-cancel { background: #f0f0f0; color: #666; }
.cnb-tag-popup .popup-actions .btn-cancel:hover { background: #d9d9d9; }
.cnb-fav-filter-bar { margin-top: 8px; margin-bottom: 12px; }
.cnb-tags-container {
display: flex; flex-wrap: wrap; align-items: center;
gap: 4px;
}
body.cnb-fav-active .flex.flex-wrap.items-center.justify-between.gap-2 {
display: none !important;
}
.cnb-backup-btn-group {
display: none; gap: 8px; align-items: center;
}
body.cnb-fav-active .cnb-backup-btn-group {
display: flex;
}
.cnb-backup-btn-group button {
padding: 4px 12px; border: 1px solid #dcdcdc; border-radius: 4px;
background: #fff; color: #666; font-size: 13px; cursor: pointer;
transition: all 0.2s; display: flex; align-items: center; gap: 4px;
}
.cnb-backup-btn-group button:hover {
border-color: #1677ff; color: #1677ff;
}
/* 快捷导航栏样式 */
.cnb-quick-access-bar {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
margin-left: 4px;
border-left: 1px solid rgba(0, 0, 0, 0.08);
padding-left: 6px;
}
.cnb-quick-access-item {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 3px;
overflow: hidden;
cursor: pointer;
transition: background-color 0.15s;
flex-shrink: 0;
}
.cnb-quick-access-item:hover {
background-color: var(--gray-pri-hover, rgba(0,0,0,0.06));
}
.cnb-quick-access-item img {
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 2px;
pointer-events: none;
}
/* 添加按钮样式 */
.cnb-qa-add-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 3px;
border: 1px dashed #bbb; color: #999; font-size: 16px;
line-height: 1; cursor: pointer; transition: all 0.2s;
flex-shrink: 0; background: transparent;
}
.cnb-qa-add-btn:hover {
border-color: #1677ff; color: #1677ff; background: rgba(22,119,255,0.05);
}
/* 拖拽落点指示线(伪元素画):dragover 高频,插 / 删节点会唤醒全局 MutationObserver 导致整页重扫卡死。
快捷导航图标与 NPC chip 共用 drop-left / drop-right 标记。 */
.cnb-quick-access-item { cursor: grab; position: relative; }
.cnb-quick-access-item:active { cursor: grabbing; }
.cnb-quick-access-item.dragging {
opacity: 0.4;
transform: scale(0.9);
}
.cnb-quick-access-item.drop-left::after,
.cnb-quick-access-item.drop-right::after,
.cnb-npc-chip.drop-left::after,
.cnb-npc-chip.drop-right::after {
content: ''; position: absolute; top: 5%; height: 90%; width: 3px;
background: #1677ff; border-radius: 2px; pointer-events: none; z-index: 10;
box-shadow: 0 0 6px rgba(22,119,255,0.5);
}
.cnb-quick-access-item.drop-left::after,
.cnb-npc-chip.drop-left::after { left: -4px; }
.cnb-quick-access-item.drop-right::after,
.cnb-npc-chip.drop-right::after { right: -4px; }
/* 快捷导航弹窗 */
.cnb-qa-popup {
position: fixed; background: #fff; border: 1px solid #e0e0e0;
border-radius: 10px; padding: 14px 16px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12); z-index: 99999; min-width: 280px; width: 360px; box-sizing: border-box;
}
.dark .cnb-qa-popup { background: #1a1a1a; border-color: #333; color: #ddd; }
.cnb-qa-popup .popup-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: #333; }
.dark .cnb-qa-popup .popup-title { color: #eee; }
.cnb-qa-popup .popup-field { margin-bottom: 8px; }
.cnb-qa-popup .popup-field label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; }
.dark .cnb-qa-popup .popup-field label { color: #aaa; }
.cnb-qa-popup .popup-field input { width: 100%; box-sizing: border-box; border: 1px solid #d0d0d0; border-radius: 6px; padding: 6px 10px; font-size: 13px; outline: none; background: #fff; color: #333; }
.dark .cnb-qa-popup .popup-field input { background: #222; border-color: #444; color: #eee; }
.cnb-qa-popup .popup-field input:focus { border-color: #1677ff; box-shadow: 0 0 0 2px rgba(22,119,255,0.15); }
.cnb-qa-popup .popup-hint { font-size: 11px; color: #999; margin-top: 4px; line-height: 1.4; }
.cnb-qa-popup .popup-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
.cnb-qa-popup .popup-actions button { border: none; padding: 4px 14px; border-radius: 6px; font-size: 12px; cursor: pointer; }
.cnb-qa-popup .popup-actions .btn-ok { background: #1677ff; color: #fff; }
.cnb-qa-popup .popup-actions .btn-ok:hover { background: #4096ff; }
.cnb-qa-popup .popup-actions .btn-cancel { background: #f0f0f0; color: #666; }
.cnb-qa-popup .popup-actions .btn-cancel:hover { background: #d9d9d9; }
/* 云原生开发开关样式 */
.cnb-qa-ws-toggle-wrap {
display: flex; align-items: center; justify-content: flex-end;
gap: 6px; margin-bottom: 8px; padding-top: 4px;
}
.cnb-qa-ws-toggle-label {
font-size: 12px; color: #666; white-space: nowrap;
}
.dark .cnb-qa-ws-toggle-label { color: #aaa; }
.cnb-qa-ws-switch {
position: relative; width: 40px; height: 22px; flex-shrink: 0;
}
.cnb-qa-ws-switch input { opacity: 0; width: 0; height: 0; }
.cnb-qa-ws-slider {
position: absolute; cursor: pointer; inset: 0;
background-color: #ccc; border-radius: 22px; transition: 0.3s;
}
.cnb-qa-ws-slider:before {
content: ""; position: absolute; height: 16px; width: 16px;
left: 3px; bottom: 3px; background: #fff;
border-radius: 50%; transition: 0.3s;
}
.cnb-qa-ws-switch input:checked + .cnb-qa-ws-slider {
background-color: #1677ff;
}
.cnb-qa-ws-switch input:checked + .cnb-qa-ws-slider:before {
transform: translateX(18px);
}
.cnb-qa-ws-fields {
display: none; border-top: 1px solid #eee; margin-top: 8px; padding-top: 10px;
}
.dark .cnb-qa-ws-fields { border-top-color: #333; }
.cnb-qa-ws-fields.visible { display: block; }
.cnb-qa-ws-fields .popup-field { margin-bottom: 6px; }
.cnb-qa-ws-fields .popup-field:last-child { margin-bottom: 0; }
.cnb-qa-ws-status {
font-size: 11px; color: #999; margin-top: 4px; min-height: 16px;
}
.cnb-qa-ws-status.ok { color: #52c41a; }
.cnb-qa-ws-status.err { color: #ff4d4f; }
.cnb-qa-ws-status.loading { color: #1677ff; }
/* 设置图标样式 */
.cnb-qa-settings-btn {
display:flex;align-items:center;justify-content:center;width:22px;height:22px;
border-radius:4px;cursor:pointer;color:#999;transition:all 0.2s;
background:transparent;border:none;padding:0;flex-shrink:0;
}
.cnb-qa-settings-btn:hover { color:#1677ff;background:rgba(22,119,255,0.08); }
.cnb-qa-cos-popup {
position:fixed;background:#fff;border:1px solid #e0e0e0;border-radius:10px;
padding:16px 18px 14px;box-shadow:0 8px 24px rgba(0,0,0,0.12);
z-index:999999;min-width:320px;width:380px;box-sizing:border-box;
}
.dark .cnb-qa-cos-popup { background:#1a1a1a;border-color:#333;color:#ddd; }
.cnb-qa-cos-popup .cos-title { font-size:14px;font-weight:600;margin-bottom:12px;color:#333;display:flex;align-items:center;gap:6px; }
.dark .cnb-qa-cos-popup .cos-title { color:#eee; }
.cnb-qa-cos-popup .cos-field { margin-bottom:10px; }
.cnb-qa-cos-popup .cos-field label { display:block;font-size:12px;color:#666;margin-bottom:3px;font-weight:500; }
.dark .cnb-qa-cos-popup .cos-field label { color:#aaa; }
.cnb-qa-cos-popup .cos-field input { width:100%;box-sizing:border-box;border:1px solid #d0d0d0;border-radius:6px;padding:7px 10px;font-size:13px;outline:none;background:#fff;color:#333; }
.dark .cnb-qa-cos-popup .cos-field input { background:#222;border-color:#444;color:#eee; }
.cnb-qa-cos-popup .cos-field input:focus { border-color:#1677ff;box-shadow:0 0 0 2px rgba(22,119,255,0.15); }
.cnb-qa-cos-popup .cos-hint { font-size:11px;color:#999;margin-top:3px;line-height:1.4; }
.cnb-qa-cos-popup .cos-actions { display:flex;justify-content:space-between;align-items:center;margin-top:14px;gap:8px; }
.cnb-qa-cos-popup .cos-actions button { border:none;padding:5px 16px;border-radius:6px;font-size:12px;cursor:pointer; }
.cnb-qa-cos-popup .cos-actions .btn-ok { background:#1677ff;color:#fff; }
.cnb-qa-cos-popup .cos-actions .btn-ok:hover { background:#4096ff; }
.cnb-qa-cos-popup .cos-actions .btn-cancel { background:#f0f0f0;color:#666; }
.cnb-qa-cos-popup .cos-actions .btn-cancel:hover { background:#d9d9d9; }
.cnb-qa-cos-popup .cos-actions .btn-test { background:#fafafa;color:#666;border:1px solid #d0d0d0; }
.cnb-qa-cos-popup .cos-actions .btn-test:hover { border-color:#1677ff;color:#1677ff; }
.cnb-qa-cos-popup .cos-status { font-size:11px;margin-top:8px;text-align:center;min-height:16px; }
.cnb-qa-cos-popup .cos-status.ok { color:#52c41a; }
.cnb-qa-cos-popup .cos-status.err { color:#ff4d4f; }
/* 自定义 Toast 通知(右下角弹出;消失时向右滑出)。
配色参考 issue#229 的二次元粉 / 品红基调(粉紫高饱和 + 柔光)。 */
.cnb-toast {
position: fixed; right: 24px; bottom: 24px;
padding: 10px 20px; border-radius: 12px; font-size: 14px; font-weight: 600;
color: #fff; letter-spacing: 0.2px;
z-index: 999999; pointer-events: none; white-space: nowrap;
animation: cnbToastIn 0.26s cubic-bezier(0.22, 1, 0.36, 1);
}
.cnb-toast.cnb-toast-ok {
background: linear-gradient(135deg, #ff9ed2 0%, #ff4fa3 100%);
box-shadow: 0 10px 26px rgba(255, 79, 163, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.cnb-toast.cnb-toast-err {
background: linear-gradient(135deg, #ff7ea8 0%, #e01e5a 100%);
box-shadow: 0 10px 26px rgba(224, 30, 90, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.cnb-toast.cnb-toast-out { animation: cnbToastOut 0.28s cubic-bezier(0.4, 0, 1, 1) forwards; }
@keyframes cnbToastIn { from { opacity:0; transform:translateX(32px); } to { opacity:1; transform:translateX(0); } }
@keyframes cnbToastOut { from { opacity:1; transform:translateX(0); } to { opacity:0; transform:translateX(120%); } }
@keyframes cnbConfirmIn { from { opacity:0; } to { opacity:1; } }
/* 输入框点击复制的浮层提示(跟随光标,1 秒后淡出) */
.cnb-copy-toast {
position: fixed; transform: translateX(-50%);
background: rgba(0,0,0,0.85); color: #fff; padding: 8px 16px;
border-radius: 4px; font-size: 14px; font-weight: 500;
z-index: 99999; pointer-events: none;
animation: cnbFadeIn 0.2s ease; transition: opacity 0.2s ease;
}
@keyframes cnbFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* 自定义确认弹窗 */
.cnb-confirm-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.35);
z-index: 999998; display: flex; align-items: center; justify-content: center;
animation: cnbConfirmIn 0.2s ease both;
}
.cnb-confirm-box {
background: #fff; border-radius: 10px; padding: 20px 24px 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.16); max-width: 400px; width: 90%;
animation: cnbConfirmIn 0.25s ease both;
}
.dark .cnb-confirm-box { background: #1a1a1a; color: #ddd; }
.cnb-confirm-box .cnb-confirm-msg { font-size: 14px; line-height: 1.6; margin-bottom: 16px; white-space: pre-wrap; }
.cnb-confirm-box .cnb-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.cnb-confirm-box .cnb-confirm-actions button {
border: none; padding: 5px 16px; border-radius: 6px; font-size: 13px; cursor: pointer;
}
.cnb-confirm-box .cnb-confirm-ok { background: #1677ff; color: #fff; }
.cnb-confirm-box .cnb-confirm-ok:hover { background: #4096ff; }
.cnb-confirm-box .cnb-confirm-cancel { background: #f0f0f0; color: #666; }
.cnb-confirm-box .cnb-confirm-cancel:hover { background: #d9d9d9; }
.dark .cnb-confirm-box .cnb-confirm-cancel { background: #333; color: #aaa; }
.dark .cnb-confirm-box .cnb-confirm-cancel:hover { background: #444; }
/* 被删除的仓库弹窗 */
.cnb-orphaned-btn {
cursor: pointer; padding: 4px 12px; border: 1px dashed #ff4d4f;
border-radius: 10px; font-size: 11px; background: rgba(255,77,79,0.05);
color: #ff4d4f; white-space: nowrap; transition: all 0.2s;
flex-shrink: 0;
}
.cnb-orphaned-btn:hover {
background: rgba(255,77,79,0.12); border-color: #ff4d4f;
}
.cnb-orphaned-btn.hidden { display: none; }
.cnb-orphaned-popup {
position: fixed; background: #fff; border: 1px solid #e0e0e0;
border-radius: 10px; padding: 14px 16px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12); z-index: 99999;
min-width: 340px; max-width: 500px; max-height: 420px;
overflow-y: auto; box-sizing: border-box;
}
.dark .cnb-orphaned-popup { background: #1a1a1a; border-color: #333; color: #ddd; }
.cnb-orphaned-popup .orphan-title {
font-size: 14px; font-weight: 600; margin-bottom: 10px; color: #ff4d4f;
}
.cnb-orphaned-popup .orphan-list { margin-bottom: 8px; }
.cnb-orphaned-popup .orphan-row {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 8px; border-radius: 6px; margin-bottom: 4px;
background: #fafafa; gap: 8px; font-size: 12px;
}
.dark .cnb-orphaned-popup .orphan-row { background: #1f1f1f; }
.cnb-orphaned-popup .orphan-row .orphan-href {
color: #333; text-decoration: none; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; min-width: 0;
cursor: pointer;
}
.dark .cnb-orphaned-popup .orphan-row .orphan-href { color: #ccc; }
.cnb-orphaned-popup .orphan-row .orphan-tags {
color: #999; white-space: nowrap; flex-shrink: 0;
}
.cnb-orphaned-popup .orphan-row .orphan-del {
color: #ff4d4f; cursor: pointer; font-size: 18px; line-height: 1;
flex-shrink: 0; padding: 0 4px; transition: color 0.2s;
}
.cnb-orphaned-popup .orphan-row .orphan-del:hover { color: #ff7875; }
.cnb-orphaned-popup .orphan-actions {
display: flex; justify-content: space-between; align-items: center;
padding-top: 8px; border-top: 1px solid #eee; margin-top: 4px;
}
.dark .cnb-orphaned-popup .orphan-actions { border-top-color: #333; }
.cnb-orphaned-popup .orphan-actions button {
border: none; padding: 4px 14px; border-radius: 6px;
font-size: 12px; cursor: pointer;
}
.cnb-orphaned-popup .orphan-actions .btn-del-all {
background: rgba(255,77,79,0.08); color: #ff4d4f;
}
.cnb-orphaned-popup .orphan-actions .btn-del-all:hover {
background: rgba(255,77,79,0.18);
}
.cnb-orphaned-popup .orphan-actions .btn-close {
background: #f0f0f0; color: #666;
}
.cnb-orphaned-popup .orphan-actions .btn-close:hover { background: #d9d9d9; }
.dark .cnb-orphaned-popup .orphan-actions .btn-close {
background: #333; color: #aaa;
}
.dark .cnb-orphaned-popup .orphan-actions .btn-close:hover { background: #444; }
/* ===== NPC 快捷指令条(指令模板) ===== */
/* 整体中性配色,蓝色只用于悬停 / 激活点缀,避免整条蓝底与页面其他元素冲突 */
.cnb-npc-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
padding: 6px 8px; margin: 0 0 6px;
border: 1px solid #e5e6eb; border-radius: 8px;
background: #f7f7f7;
}
.dark .cnb-npc-bar { border-color: #333; background: #1f1f1f; }
.cnb-npc-bar .npc-bar-label {
font-size: 12px; font-weight: 600; color: #86909c; flex-shrink: 0;
}
.dark .cnb-npc-bar .npc-bar-label { color: #8c8c8c; }
.cnb-npc-select {
font-size: 12px; max-width: 220px; padding: 2px 6px;
border: 1px solid #e5e6eb !important; border-radius: 6px;
background: #fff !important; color: #1d2129 !important;
outline: none; cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.cnb-npc-select:hover { border-color: #c9cdd4 !important; }
.cnb-npc-select:focus { border-color: #86909c !important; box-shadow: 0 0 0 2px rgba(134,144,156,0.18); }
.dark .cnb-npc-select { background: #2a2a2a !important; border-color: #3a3a3a !important; color: #e5e6eb !important; }
.dark .cnb-npc-select:hover { border-color: #4a4a4a !important; }
.cnb-npc-chip {
padding: 2px 10px; border-radius: 10px; font-size: 12px;
border: 1px solid #e5e6eb; background: #fff;
color: #4e5969; cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.cnb-npc-chip:hover { background: #4e5969; border-color: #4e5969; color: #fff; }
.dark .cnb-npc-chip { background: #2a2a2a; border-color: #3a3a3a; color: #c9cdd4; }
.dark .cnb-npc-chip:hover { background: #5b6472; border-color: #5b6472; color: #fff; }
/* 模板 chip 可拖拽排序(落点指示线见上方共用规则) */
.cnb-npc-bar .cnb-npc-chip { position: relative; cursor: grab; }
.cnb-npc-bar .cnb-npc-chip:active { cursor: grabbing; }
.cnb-npc-bar .cnb-npc-chip.dragging { opacity: 0.4; }
/* 固定项:NPC 标签本身就是开关(开启后发送评论自动带 @NPC),排在最前面,不受「指令模板」配置影响 */
.cnb-npc-bar .cnb-npc-toggle { cursor: pointer; user-select: none; }
.cnb-npc-bar .cnb-npc-toggle:hover { color: #4e5969; }
.cnb-npc-bar .cnb-npc-toggle:focus-visible { outline: 2px solid rgba(78,89,105,0.4); outline-offset: 2px; border-radius: 4px; }
.cnb-npc-bar .cnb-npc-toggle.active { color: #1d2129; font-weight: 700; }
.dark .cnb-npc-bar .cnb-npc-toggle:hover,
.dark .cnb-npc-bar .cnb-npc-toggle.active { color: #e5e6eb; }
.cnb-npc-bar .cnb-npc-bar-gear { margin-left: auto; }
/* NPC 指令条尾部的设置齿轮:本区域内也走灰色,不影响快捷导航栏的同名按钮 */
.cnb-npc-bar .cnb-qa-settings-btn:hover { color: #4e5969; background: rgba(78,89,105,0.08); }
.dark .cnb-npc-bar .cnb-qa-settings-btn:hover { color: #e5e6eb; background: rgba(201,205,212,0.12); }
/* ===== NPC 指令设置弹窗 ===== */
.cnb-npc-popup {
position: fixed; background: #fff; border: 1px solid #e0e0e0;
border-radius: 10px; padding: 14px 16px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12); z-index: 99999;
width: 430px; max-width: 92vw; max-height: calc(100vh - 80px); overflow-y: auto;
box-sizing: border-box;
}
.dark .cnb-npc-popup { background: #1a1a1a; border-color: #333; color: #ddd; }
.cnb-npc-popup .npc-title { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 10px; }
.dark .cnb-npc-popup .npc-title { color: #eee; }
.cnb-npc-popup .npc-tabs { display: flex; gap: 4px; border-bottom: 1px solid #eee; margin-bottom: 10px; }
.dark .cnb-npc-popup .npc-tabs { border-bottom-color: #333; }
.cnb-npc-popup .npc-tab {
border: none; background: transparent; padding: 5px 10px; cursor: pointer;
font-size: 12px; color: #666; border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.dark .cnb-npc-popup .npc-tab { color: #aaa; }
.cnb-npc-popup .npc-tab.active { color: #1d2129; border-bottom-color: #4e5969; font-weight: 600; }
.dark .cnb-npc-popup .npc-tab.active { color: #e5e6eb; border-bottom-color: #c9cdd4; }
.cnb-npc-popup .npc-pane { display: none; }
.cnb-npc-popup .npc-pane.active { display: block; }
.cnb-npc-popup .npc-hint { font-size: 11px; color: #999; line-height: 1.5; margin-bottom: 10px; }
.cnb-npc-popup .npc-hint code {
background: rgba(0,0,0,0.05); border-radius: 3px; padding: 0 3px;
}
.dark .cnb-npc-popup .npc-hint code { background: rgba(255,255,255,0.1); }
.cnb-npc-popup input[type="text"] {
border: 1px solid #d0d0d0; border-radius: 6px; padding: 5px 8px;
font-size: 12px; outline: none; background: #fff; color: #333; box-sizing: border-box;
}
.dark .cnb-npc-popup input[type="text"] { background: #222; border-color: #444; color: #eee; }
.cnb-npc-popup input[type="text"]:focus {
border-color: #86909c; box-shadow: 0 0 0 2px rgba(134,144,156,0.18);
}
.cnb-npc-popup input.invalid { border-color: #ff4d4f; }
.cnb-npc-popup textarea {
border: 1px solid #d0d0d0; border-radius: 6px; padding: 5px 8px;
font-size: 12px; outline: none; background: #fff; color: #333; box-sizing: border-box;
font-family: inherit; line-height: 16px; resize: none;
overflow-y: auto; scrollbar-width: none;
}
.cnb-npc-popup textarea::-webkit-scrollbar { display: none; width: 0; height: 0; }
/* 标签框与指令框强制同高(单行),不受站点全局 textarea 样式 / 行高影响 */
.cnb-npc-popup .npc-row .npc-label-input,
.cnb-npc-popup .npc-row .npc-prompt-input {
height: 28px; min-height: 28px; max-height: 28px;
line-height: 16px; padding: 5px 8px; font-size: 12px; box-sizing: border-box;
}
.dark .cnb-npc-popup textarea { background: #222; border-color: #444; color: #eee; }
.cnb-npc-popup textarea:focus {
border-color: #86909c; box-shadow: 0 0 0 2px rgba(134,144,156,0.18);
}
.cnb-npc-popup textarea.invalid { border-color: #ff4d4f; }
.cnb-npc-popup .npc-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.cnb-npc-popup .npc-row .npc-label-input { width: 86px; flex-shrink: 0; }
.cnb-npc-popup .npc-row .npc-prompt-input { flex: 1; min-width: 0; }
.cnb-npc-popup .npc-row-del {
border: none; background: transparent; color: #bbb; font-size: 16px;
line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 4px;
flex-shrink: 0;
}
.cnb-npc-popup .npc-row-del:hover { color: #ff4d4f; background: rgba(255,77,79,0.08); }
.cnb-npc-popup .npc-btn {
border: 1px solid #d0d0d0; border-radius: 6px; background: #fafafa; color: #666;
font-size: 11px; padding: 4px 10px; cursor: pointer; white-space: nowrap;
}
.cnb-npc-popup .npc-btn:hover { border-color: #86909c; color: #4e5969; }
.dark .cnb-npc-popup .npc-btn { background: #222; border-color: #444; color: #aaa; }
.dark .cnb-npc-popup .npc-btn:hover { border-color: #8c8c8c; color: #e5e6eb; }
.cnb-npc-popup .npc-btn-primary { background: #4e5969; border-color: #4e5969; color: #fff; }
.cnb-npc-popup .npc-btn-primary:hover { background: #5b6472; border-color: #5b6472; color: #fff; }
.cnb-npc-popup .npc-actions {
display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px;
}
.cnb-npc-popup .npc-actions .right { display: flex; gap: 8px; }
.cnb-npc-popup .npc-mention-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.cnb-npc-popup .npc-mention {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 4px 2px 10px; border-radius: 12px;
border: 1px solid #e0e0e0; background: #fafafa; font-size: 12px; color: #333;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.dark .cnb-npc-popup .npc-mention { background: #222; border-color: #444; color: #ddd; }
.cnb-npc-popup .npc-mention .npc-badge {
font-size: 10px; color: #4e5969; background: rgba(78,89,105,0.12);
border-radius: 8px; padding: 1px 6px; font-family: inherit; white-space: nowrap;
}
.dark .cnb-npc-popup .npc-mention .npc-badge { color: #c9cdd4; background: rgba(201,205,212,0.15); }
.cnb-npc-popup .npc-mention button {
border: none; background: transparent; color: #999; cursor: pointer;
font-size: 12px; line-height: 1.2; padding: 1px 5px; border-radius: 4px;
}
.cnb-npc-popup .npc-mention button:hover { background: rgba(78,89,105,0.1); }
.cnb-npc-popup .npc-mention button:hover { color: #4e5969; }
.cnb-npc-popup .npc-mention button.del:hover { color: #ff4d4f; }
.cnb-npc-popup .npc-add-row { display: flex; gap: 6px; align-items: center; }
.cnb-npc-popup .npc-add-row input { flex: 1; min-width: 0; }
.cnb-npc-popup .npc-empty {
font-size: 11px; color: #999; padding: 6px 0 2px;
}
/* ===== 时间线:只看评论过滤 + 侧栏开关 ===== */
/* 状态挂在 类上:React 重建时间线子树也不会丢 */
/* 评论全部保留;活动项里只放行「最后一条 NPC 提及行(npc-mentioned)」,
其余活动(私有切换 / 标签 / 状态变更等)全部隐藏 —— .cnb-last-activity 由脚本标记;
.end 是时间线收尾装饰线,照常保留 */
html.cnb-only-comments .issue-pull-activity:not(.comment):not(.end):not(.cnb-last-activity) {
display: none !important;
}
/* 侧栏开关(只看评论 / 自动展开):向下滚动、原位置滚出视口后整块固定在页眉下方,
长时间翻评论区也能随时切换;滚回原位置自动归位(位置 / 宽度由脚本按占位节点实时对齐) */
.cnb-sidebar-toggles.cnb-toggles-pinned {
position: fixed;
z-index: 50;
background: var(--td-bg-color-container, #fff);
}
.dark .cnb-sidebar-toggles.cnb-toggles-pinned {
background: var(--td-bg-color-container, #1a1a1a);
}
.cnb-sidebar-toggles-placeholder { visibility: hidden; pointer-events: none; }
/* 评论楼层号:附在每个评论头部元信息行末尾(作者 / 时间之后),弱化小徽章 */
.cnb-floor-badge {
display: inline-flex; align-items: center;
height: 18px; padding: 0 6px; margin-left: 6px;
border-radius: 9px; font-size: 11px; line-height: 1;
color: #4e5969; background: rgba(78,89,105,0.10);
white-space: nowrap; user-select: none;
}
.dark .cnb-floor-badge { color: #c9cdd4; background: rgba(201,205,212,0.15); }
/* 只看评论:从 NPC 提及行搬过来的「✓ 计数 / token 用量」跟在楼层号右边 */
.cnb-mention-info { margin-left: 6px; }
`;
document.head.appendChild(style);
// ===== 全局常量:本地存储键 / 站点选择器 / 站点文案 =====
// 存储键集中在此,避免散落各处;选择器与文案(含多语言兜底)也集中在此,上游改版或换语言时只改这一处。
const KEYS = {
favorites: 'cnb_favorites_data',
quickAccess: 'cnb_quick_access_links',
cosConfig: 'cnb_cos_config',
npcPresets: 'cnb_npc_presets',
npcMentions: 'cnb_npc_mentions',
npcSelected: 'cnb_npc_selected',
npcPrefix: 'cnb_npc_prefix',
onlyComments: 'cnb_only_comments',
autoUnfold: 'cnb_auto_unfold',
gridLayout: 'cnbGridLayout' // 历史遗留:网格布局偏好一直存在 localStorage
};
const COS_FILE_PATH = 'CNB/CNB.json'; // COS 同步对象路径(非 GM 存储键)
// 站点选择器(集中管理,上游改版时只改这里)
const SEL = {
issueListSwitcher: '.issue-list-switcher',
repoGrid: '.grid.gap-4.mt-4',
repoGridToggle: '.cnb-grid-layout-toggle',
homepageGrid: '.grid.grid-cols-1.gap-4.mt-4.mb-8',
tabsNavWrap: '.t-tabs__nav-wrap',
quickAccessHost: 'div.flex.overflow-hidden.items-center.mr-10',
markdownEditor: '.markdown-editor'
};
// 文案(数组 = 多语言兜底,按顺序匹配)
const TEXT = {
recentUpdate: ['最近更新'],
execute: ['执行'],
create: ['新建'],
navTabs: ['探索', '推荐', '与我有关'],
privacy: ['私密', 'Private'],
loadMoreDown: ['向下加载更多', 'Load more']
};
// ===== 全局页面调度:所有功能共用一个 MutationObserver + 节流 =====
// React 站点(issue/PR 详情、列表分页、编辑器输入、图片懒加载)DOM 变更极频繁,
// 逐次全量重扫会把主线程占满。这里合并成「立即执行一次 + 之后最多每 200ms 一次」。
const PAGE_PROCESS_MIN_GAP = 200;
const _pageProcessors = [];
let _pageProcessLast = 0;
let _pageProcessTimer = null;
function registerPageProcessor(fn) { _pageProcessors.push(fn); }
function runPageProcessors() {
for (const fn of _pageProcessors) {
try { fn(); } catch (_) { /* 单个功能出错不影响其它功能 */ }
}
}
function schedulePageProcess() {
const gap = Date.now() - _pageProcessLast;
if (gap >= PAGE_PROCESS_MIN_GAP) {
_pageProcessLast = Date.now();
runPageProcessors();
} else if (_pageProcessTimer === null) {
_pageProcessTimer = setTimeout(() => {
_pageProcessTimer = null;
_pageProcessLast = Date.now();
runPageProcessors();
}, PAGE_PROCESS_MIN_GAP - gap);
}
}
let _pageObserver = null;
function ensurePageObserver() {
if (_pageObserver) return;
_pageObserver = new MutationObserver(schedulePageProcess);
_pageObserver.observe(document.body, { childList: true, subtree: true });
}
// ===== SPA 路由监控工具 =====
function isStarsPage() {
return /\/u\/[^/]+\/stars/.test(window.location.pathname);
}
function cleanupIfNotStars() {
document.querySelectorAll('.cnb-fav-tab, .cnb-fav-filter-bar, .cnb-backup-btn-group').forEach(el => el.remove());
document.body.classList.remove('cnb-fav-active');
_favActive = false;
}
// ===== 数据管理 =====
// 统一 JSON 存储读写:读失败 / 无值 → 返回 fallback,各功能不再各写一遍 try/catch + JSON.parse
function readJsonStore(key, fallback) {
try {
const raw = GM_getValue(key, null);
if (raw === null || raw === undefined || raw === '') return fallback;
return JSON.parse(raw);
} catch (e) { return fallback; }
}
function writeJsonStore(key, value) { GM_setValue(key, JSON.stringify(value)); }
function getData() { return readJsonStore(KEYS.favorites, {}); }
function saveData(data) { writeJsonStore(KEYS.favorites, data); }
function pageKey() {
const p = window.location.pathname;
return p.endsWith('/') ? p.slice(0, -1) : p;
}
// HTML 转义:插入 innerHTML 的文本用 escapeHTML,属性值(value="...")用 escapeHTMLAttr
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escapeHTMLAttr(str) {
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
}
function getRepoTags(repoHref) {
const data = getData(), pk = pageKey();
return (data[pk] && data[pk][repoHref] && data[pk][repoHref].tags) || [];
}
function setRepoTags(repoHref, tags) {
const data = getData(), pk = pageKey();
if (!data[pk]) data[pk] = {};
if (!data[pk][repoHref]) data[pk][repoHref] = {};
data[pk][repoHref].tags = tags;
saveData(data);
}
function getAllTags() {
const data = getData(), pk = pageKey();
const all = new Set();
if (data[pk]) Object.values(data[pk]).forEach(item => {
if (item.tags) item.tags.forEach(t => all.add(t));
});
return [...all].sort();
}
function getTaggedCount() {
const data = getData(), pk = pageKey();
let n = 0;
if (data[pk]) Object.values(data[pk]).forEach(item => {
if (item.tags && item.tags.length) n++;
});
return n;
}
function getRepoHref(card) {
const link = card.querySelector('a[href*="/"][target="_self"]');
if (!link) return null;
const h = link.getAttribute('href');
return h.endsWith('/') ? h.slice(0, -1) : h;
}
// 归一化仓库路径:统一成 /owner/repo(去掉 origin、query、hash、末尾斜杠)
function normRepoPath(href) {
if (!href) return '';
try {
const p = new URL(href, location.origin).pathname || '';
return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p;
} catch (_) { return ''; }
}
// 获取所有已打标签但在页面上已不存在的仓库(已删除)
function getOrphanedEntries() {
const data = getData(), pk = pageKey();
const grid = document.querySelector(SEL.repoGrid);
if (!grid || !data[pk]) return [];
// 收集本页卡片里出现过的全部链接(含头像 / 标题等任意锚点)做「宽容匹配」:
// 只要存储里的仓库路径还能在本页任意链接上找到,就视为「还在」,避免因取值方式变化而误报。
const present = new Set();
grid.querySelectorAll(':scope > div a[href]').forEach(a => {
const p = normRepoPath(a.getAttribute('href'));
if (p && p !== '/') present.add(p);
});
// 卡片尚未渲染出来(首屏 / 懒加载)时不要误报
if (present.size === 0) return [];
const orphans = [];
Object.entries(data[pk]).forEach(([href, item]) => {
if (!item.tags || !item.tags.length) return;
const p = normRepoPath(href);
if (p && present.has(p)) return;
orphans.push({ href, tags: item.tags });
});
return orphans;
}
// 删除单个孤儿条目
function deleteOrphanedEntry(href) {
const data = getData(), pk = pageKey();
if (data[pk] && data[pk][href]) {
delete data[pk][href];
// 清理空对象
if (Object.keys(data[pk]).length === 0) delete data[pk];
saveData(data);
}
}
function showOrphanedPopup(triggerEl) {
document.querySelectorAll('.cnb-orphaned-popup').forEach(el => el.remove());
const orphans = getOrphanedEntries();
if (orphans.length === 0) {
showToast('没有发现被删除的仓库标签数据', 'ok');
return;
}
const popup = document.createElement('div');
popup.className = 'cnb-orphaned-popup';
popup.style.opacity = '0';
popup.innerHTML = `
被删除的仓库标签(共 ${orphans.length} 项)
${orphans.map(o => `
${escapeHTML(o.href)}
${o.tags.map(t => `${escapeHTML(t)}`).join('')}
×
`).join('')}
`;
// 先定位再添加到 DOM,避免左上角闪烁
const rect = triggerEl.getBoundingClientRect();
popup.style.left = Math.min(rect.left, window.innerWidth - 510) + 'px';
popup.style.top = (rect.bottom + 6) + 'px';
document.body.appendChild(popup);
// 定位完成后显示
void popup.offsetWidth; // 强制重排,确保 opacity:0 生效后再移除
popup.style.transition = 'opacity 0.15s ease';
popup.style.opacity = '1';
// 行内点击仓库路径尝试打开原页面
popup.querySelectorAll('.orphan-href').forEach(el => {
el.addEventListener('click', () => {
const href = el.closest('.orphan-row').getAttribute('data-href');
if (href) window.open('https://cnb.cool' + href, '_blank');
});
});
// 单条删除
popup.querySelectorAll('.orphan-del').forEach(el => {
el.addEventListener('click', async () => {
const row = el.closest('.orphan-row');
const href = row.getAttribute('data-href');
if (await showConfirm(`确定删除此条标签数据?\n${href}`)) {
deleteOrphanedEntry(href);
row.remove();
const remaining = popup.querySelectorAll('.orphan-row');
if (remaining.length === 0) {
popup.remove();
updateFavCount();
updateOrphanedBtnCount();
showToast('已全部清理', 'ok');
} else {
popup.querySelector('.orphan-title').textContent = `被删除的仓库标签(共 ${remaining.length} 项)`;
const btnDelAll = popup.querySelector('.btn-del-all');
if (btnDelAll) btnDelAll.textContent = `删除全部(${remaining.length} 项)`;
updateFavCount();
updateOrphanedBtnCount();
}
}
});
});
// 删除全部
popup.querySelector('.btn-del-all').addEventListener('click', async () => {
if (await showConfirm(`确定删除全部 ${orphans.length} 项被删除仓库的标签数据吗?`)) {
getOrphanedEntries().forEach(o => deleteOrphanedEntry(o.href));
popup.remove();
updateFavCount();
updateOrphanedBtnCount();
syncFilterTags();
showToast(`已清理 ${orphans.length} 项孤立数据`, 'ok');
}
});
// 关闭
popup.querySelector('.btn-close').addEventListener('click', () => popup.remove());
bindPopupOutsideClose(popup, triggerEl);
}
// 列表渲染稳定判定:卡片数量连续 FAV_GRID_SETTLE_MS 内不再变化,才认为渲染完成。
// 目的是避免首屏只渲染了一部分卡片时,把「还没出来的仓库」误判成「被删除」。
const FAV_GRID_SETTLE_MS = 600;
let _favGridPk = '';
let _favGridCount = -1;
let _favGridChangedAt = 0;
let _orphanRefreshTimer = null;
function isFavGridSettled() {
const pk = pageKey();
if (pk !== _favGridPk) { // 换页 / 换用户 → 重新计时
_favGridPk = pk;
_favGridCount = -1;
_favGridChangedAt = Date.now();
}
const grid = document.querySelector(SEL.repoGrid);
const n = grid ? grid.querySelectorAll(':scope > div').length : 0;
if (n !== _favGridCount) {
_favGridCount = n;
_favGridChangedAt = Date.now();
}
return n > 0 && (Date.now() - _favGridChangedAt) >= FAV_GRID_SETTLE_MS;
}
function updateOrphanedBtnCount() {
const btn = document.querySelector('.cnb-orphaned-btn');
if (!btn) return;
// 列表还在陆续渲染:先保持隐藏,等稳定(或下次调度)再算,避免闪现错误的「被删除 (N)」
if (!isFavGridSettled()) {
btn.classList.add('hidden');
if (_orphanRefreshTimer === null) {
_orphanRefreshTimer = setTimeout(() => {
_orphanRefreshTimer = null;
updateOrphanedBtnCount();
}, 300);
}
return;
}
const n = getOrphanedEntries().length;
if (n === 0) {
btn.classList.add('hidden');
} else {
btn.classList.remove('hidden');
btn.textContent = `被删除的 (${n})`;
}
}
// ===== JSON 备份与还原 =====
async function exportJSON() {
const data = getData();
const qaLinks = getQALinks();
const allData = {
favorites: data,
quickAccess: qaLinks,
npc: getNpcBackup()
};
const jsonStr = JSON.stringify(allData, null, 2);
if (isCosEnabled()) {
try { await cosUpload(jsonStr); showToast('已同步到 COS 云端(' + COS_FILE_PATH + ')', 'ok'); }
catch (e) { showToast('COS 上传失败:' + e.message + ',已改为本地下载', 'err'); doLocalExport(); }
} else { doLocalExport(); }
function doLocalExport() {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(jsonStr);
const anchor = document.createElement('a');
anchor.setAttribute("href", dataStr);
anchor.setAttribute("download", `cnb_cool_backup_${new Date().getTime()}.json`);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
}
async function importJSON() {
if (isCosEnabled()) {
try {
const body = await cosDownload();
const parsedData = typeof body === 'string' ? JSON.parse(body) : JSON.parse(new TextDecoder().decode(body));
await applyImport(parsedData);
return;
} catch (e) {
if (!await showConfirm('COS 下载失败:' + e.message + '\n是否改用本地文件导入?')) return;
}
}
doLocalImport();
function doLocalImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async event => {
try {
const parsedData = JSON.parse(event.target.result);
await applyImport(parsedData);
} catch (err) {
showToast('解析失败,请确保上传的是格式正确的 JSON 备份文件!', 'err');
}
};
reader.readAsText(file);
};
input.click();
}
}
async function applyImport(parsedData) {
const scope = ['标签、收藏与导航栏数据'];
if (parsedData.npc) scope.push('NPC 指令设置');
if (await showConfirm('警告:导入备份将覆盖当前的' + scope.join('、') + ',是否确认继续?')) {
if (parsedData.favorites) { saveData(parsedData.favorites); }
else { saveData(parsedData); }
if (parsedData.quickAccess && Array.isArray(parsedData.quickAccess)) { saveQALinks(parsedData.quickAccess); }
applyNpcBackup(parsedData.npc);
showToast('数据导入成功!页面即将刷新。', 'ok');
location.reload();
}
}
function addBackupButtons() {
if (document.querySelector('.cnb-backup-btn-group')) return;
const headers = document.querySelectorAll('.flex.justify-between.mb-4');
let targetHeader = null;
for(let header of headers) {
if (header.querySelector(SEL.issueListSwitcher) || header.className.includes('max-laptop:flex-col')) {
targetHeader = header;
break;
}
}
if (!targetHeader) return;
const btnGroup = document.createElement('div');
btnGroup.className = 'cnb-backup-btn-group';
btnGroup.innerHTML = `
`;
btnGroup.querySelector('.cnb-upload-btn').addEventListener('click', importJSON);
btnGroup.querySelector('.cnb-download-btn').addEventListener('click', exportJSON);
const rightWrap = targetHeader.lastElementChild;
if (rightWrap && rightWrap.tagName === 'DIV' && rightWrap.classList.contains('flex')) {
rightWrap.appendChild(btnGroup);
} else {
targetHeader.appendChild(btnGroup);
}
}
// ===== 状态 =====
let _favActive = false;
let _favInjected = false; // 是否在本页注入过收藏夹 tab(供非 Stars 页按需清理,避免每轮都扫 DOM)
// ===== 1. 添加收藏夹 tab =====
function addFavoritesTab() {
if (document.querySelector('.cnb-fav-tab')) return;
const radios = document.querySelectorAll('.t-radio-button.issue-list-switcher');
let missionsLabel = null;
for (const lb of radios) {
const txt = lb.querySelector('.t-radio-button__label');
if (txt && txt.textContent.includes('任务集')) { missionsLabel = lb; break; }
}
if (!missionsLabel) return;
const favLabel = document.createElement('label');
favLabel.className = 't-radio-button issue-list-switcher cnb-fav-tab';
favLabel.tabIndex = '0';
favLabel.innerHTML = `
收藏夹
(${getTaggedCount()})
`;
missionsLabel.parentNode.insertBefore(favLabel, missionsLabel.nextSibling);
const group = favLabel.closest('.t-radio-group');
if (!group) return;
group.addEventListener('click', function onTabClick(e) {
const label = e.target.closest('.t-radio-button.issue-list-switcher');
if (!label) return;
const isFav = label.classList.contains('cnb-fav-tab');
if (isFav) {
_favActive = true;
if (window.location.search.includes('tab=')) {
const reposInput = group.querySelector('input[value="repos"]');
if (reposInput) {
reposInput.click();
const checkInterval = setInterval(() => {
forceFavStyle(group, label);
if (!window.location.search.includes('tab=') && document.querySelector(SEL.repoGrid)) {
clearInterval(checkInterval);
toggleFavView(true);
}
}, 50);
setTimeout(() => clearInterval(checkInterval), 3000);
return;
}
}
forceFavStyle(group, label);
toggleFavView(true);
} else {
setTimeout(() => {
_favActive = false;
toggleFavView(false);
const favTab = group.querySelector('.cnb-fav-tab');
if (favTab) {
favTab.classList.remove('t-is-checked');
const favText = favTab.querySelector('.font-bold-placeholder');
if (favText) {
favText.classList.remove('text-pri', 'font-semibold');
favText.classList.add('text-sec');
}
}
label.classList.add('t-is-checked');
const textSpan = label.querySelector('.font-bold-placeholder');
if (textSpan) {
textSpan.classList.remove('text-sec');
textSpan.classList.add('text-pri', 'font-semibold');
}
setTimeout(() => updateBgBlock(label), 10);
}, 20);
}
});
}
function forceFavStyle(group, favLabel) {
favLabel.classList.add('t-is-checked');
const favText = favLabel.querySelector('.font-bold-placeholder');
if (favText) {
favText.classList.remove('text-sec');
favText.classList.add('text-pri', 'font-semibold');
}
group.querySelectorAll('.t-radio-button.issue-list-switcher:not(.cnb-fav-tab)').forEach(label => {
label.classList.remove('t-is-checked');
const textSpan = label.querySelector('.font-bold-placeholder');
if (textSpan) {
textSpan.classList.remove('text-pri', 'font-semibold');
textSpan.classList.add('text-sec');
}
});
setTimeout(() => updateBgBlock(favLabel), 20);
}
function updateBgBlock(el) {
const bg = el.closest('.t-radio-group')?.querySelector('.t-radio-group__bg-block');
if (!bg) return;
const group = el.closest('.t-radio-group');
if (!group) return;
const gr = group.getBoundingClientRect(), er = el.getBoundingClientRect();
bg.style.width = er.width + 'px';
bg.style.height = er.height + 'px';
bg.style.left = (er.left - gr.left) + 'px';
bg.style.top = (er.top - gr.top) + 'px';
}
// ===== 2. 切换收藏夹视图 =====
function toggleFavView(show) {
if (show) {
document.body.classList.add('cnb-fav-active');
} else {
document.body.classList.remove('cnb-fav-active');
}
const grid = document.querySelector(SEL.repoGrid);
if (!grid) return;
const allCards = grid.querySelectorAll(':scope > div');
const filterBar = document.querySelector('.cnb-fav-filter-bar');
if (show) {
allCards.forEach(c => c.style.display = 'none');
if (!filterBar) {
createFilterBar(grid);
} else {
filterBar.style.display = 'block';
requestAnimationFrame(() => {
const allBtn = filterBar.querySelector('.cnb-fav-filter-all');
if (allBtn) {
filterBar.querySelectorAll('.t-radio-button').forEach(b => b.classList.remove('t-is-checked'));
filterBar.querySelectorAll('input').forEach(inp => inp.checked = false);
allBtn.classList.add('t-is-checked');
const inp = allBtn.querySelector('input');
if (inp) inp.checked = true;
updateFilterBg(allBtn);
}
});
}
filterFavCards('all');
} else {
allCards.forEach(c => c.style.display = '');
if (filterBar) filterBar.style.display = 'none';
}
}
function filterFavCards(tag) {
const grid = document.querySelector(SEL.repoGrid);
if (!grid) return;
grid.querySelectorAll(':scope > div').forEach(card => {
const href = getRepoHref(card);
if (!href) { card.style.display = 'none'; return; }
const tags = getRepoTags(href);
if (tag === 'all') card.style.display = tags.length ? '' : 'none';
else card.style.display = tags.includes(tag) ? '' : 'none';
});
}
// ===== 3. 收藏夹筛选栏 =====
function createFilterBar(grid) {
if (document.querySelector('.cnb-fav-filter-bar')) return;
const bar = document.createElement('div');
bar.className = 'cnb-fav-filter-bar';
bar.style.display = 'flex';
bar.style.alignItems = 'center';
bar.style.flexWrap = 'wrap';
bar.style.gap = '6px';
bar.style.position = 'relative';
bar.style.paddingRight = '100px';
const allTags = getAllTags();
let html = '';
bar.innerHTML = html;
// 先隐藏:等列表渲染稳定后由 updateOrphanedBtnCount() 再决定是否显示,避免首屏误报
const orphanBtn = document.createElement('span');
orphanBtn.className = 'cnb-orphaned-btn hidden';
orphanBtn.style.position = 'absolute';
orphanBtn.style.right = '0';
orphanBtn.style.top = '50%';
orphanBtn.style.transform = 'translateY(-50%)';
orphanBtn.textContent = '被删除的 (0)';
orphanBtn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
showOrphanedPopup(this);
});
bar.appendChild(orphanBtn);
grid.parentNode.insertBefore(bar, grid);
updateOrphanedBtnCount(); // 列表已稳定则立刻显示正确计数,否则等待重试
bar.querySelectorAll('.t-radio-button').forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
const parent = this.closest('.t-radio-group');
parent.querySelectorAll('.t-radio-button').forEach(b => b.classList.remove('t-is-checked'));
parent.querySelectorAll('input').forEach(inp => inp.checked = false);
this.classList.add('t-is-checked');
this.querySelector('input').checked = true;
updateFilterBg(this);
filterFavCards(this.getAttribute('data-tag') || 'all');
});
});
const first = bar.querySelector('.t-radio-button');
if (first) updateFilterBg(first);
}
function updateFilterBg(el) {
const bg = el.closest('.t-radio-group')?.querySelector('.t-radio-group__bg-block');
if (!bg) return;
const group = el.closest('.t-radio-group');
if (!group) return;
const gr = group.getBoundingClientRect(), er = el.getBoundingClientRect();
bg.style.width = er.width + 'px';
bg.style.height = er.height + 'px';
bg.style.left = (er.left - gr.left) + 'px';
bg.style.top = (er.top - gr.top) + 'px';
}
// ===== 4. 给卡片添加标签 =====
function enhanceRepoCards() {
const grid = document.querySelector(SEL.repoGrid);
if (!grid) return;
grid.querySelectorAll(':scope > div:not(.cnb-enhanced)').forEach(card => {
card.classList.add('cnb-enhanced');
const href = getRepoHref(card);
if (!href) return;
// 找到目标位置:flex items-center min-w-0 gap-2 (标题行) 内部末尾
const parentContainer = card.querySelector('.flex.items-center.min-w-0.gap-2');
if (!parentContainer) return;
const container = document.createElement('div');
container.className = 'cnb-tags-container';
renderTags(container, card, href);
// 手动添加标签按钮 [+]
const addBtn = document.createElement('span');
addBtn.className = 'cnb-tag-add-btn';
addBtn.textContent = '+';
addBtn.title = '手动添加分类标签';
container.appendChild(addBtn);
addBtn.addEventListener('click', function (e) {
e.stopPropagation();
showTagPopup(this, card, href);
});
parentContainer.appendChild(container);
});
}
function renderTags(container, card, href) {
const tags = getRepoTags(href);
container.querySelectorAll('.cnb-tag-chip').forEach(el => el.remove());
for (const tag of tags) {
const chip = document.createElement('span');
chip.className = 'cnb-tag-chip';
chip.appendChild(document.createTextNode(tag + ' '));
const removeSpan = document.createElement('span');
removeSpan.className = 'remove';
removeSpan.setAttribute('data-tag', tag);
removeSpan.textContent = '\u00D7';
chip.appendChild(removeSpan);
// 确保插在最前面(+号按钮之前)
container.insertBefore(chip, container.querySelector('.cnb-tag-add-btn') || null);
}
container.querySelectorAll('.cnb-tag-chip .remove').forEach(el => {
el.addEventListener('click', function (e) {
e.stopPropagation();
const t = this.getAttribute('data-tag');
const rh = getRepoHref(card);
if (!rh) return;
let tags = getRepoTags(rh).filter(x => x !== t);
setRepoTags(rh, tags);
renderTags(container, card, rh);
updateFavCount();
if (_favActive) {
const act = document.querySelector('.cnb-fav-filter-bar .t-is-checked');
filterFavCards(act ? act.getAttribute('data-tag') || 'all' : 'all');
}
syncFilterTags();
});
});
}
function showTagPopup(btn, card, href) {
document.querySelectorAll('.cnb-tag-popup').forEach(el => el.remove());
const existing = getRepoTags(href);
const allTags = getAllTags();
const popup = document.createElement('div');
popup.className = 'cnb-tag-popup';
popup.innerHTML = `
`;
document.body.appendChild(popup);
const rect = btn.getBoundingClientRect();
popup.style.left = Math.min(rect.left, window.innerWidth - 250) + 'px';
popup.style.top = (rect.bottom + 6) + 'px';
const input = popup.querySelector('input');
function renderSuggestions() {
const val = input.value.trim().toLowerCase();
const sug = popup.querySelector('.popup-suggestions');
sug.innerHTML = '';
allTags.filter(t => !existing.includes(t)).forEach(tag => {
const span = document.createElement('span');
span.textContent = tag;
if (val && !tag.toLowerCase().includes(val)) span.style.display = 'none';
span.addEventListener('click', () => { input.value = tag; });
sug.appendChild(span);
});
}
renderSuggestions();
input.addEventListener('input', renderSuggestions);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') done();
else if (e.key === 'Escape') popup.remove();
});
function done() {
const val = input.value.trim();
if (!val) { popup.remove(); return; }
let tags = getRepoTags(href);
if (!tags.includes(val)) { tags.push(val); setRepoTags(href, tags); }
const c = card.querySelector('.cnb-tags-container');
if (c) renderTags(c, card, href);
popup.remove();
updateFavCount();
if (_favActive) {
const act = document.querySelector('.cnb-fav-filter-bar .t-is-checked');
filterFavCards(act ? act.getAttribute('data-tag') || 'all' : 'all');
}
syncFilterTags();
}
popup.querySelector('.btn-ok').addEventListener('click', done);
popup.querySelector('.btn-cancel').addEventListener('click', () => popup.remove());
bindPopupOutsideClose(popup, btn);
input.focus();
}
function updateFavCount() {
const n = getTaggedCount();
document.querySelectorAll('.cnb-fav-tab .text-ter').forEach(el => { el.textContent = `(${n})`; });
}
function syncFilterTags() {
const bar = document.querySelector('.cnb-fav-filter-bar');
if (!bar) return;
const group = bar.querySelector('.t-radio-group');
if (!group) return;
const allTags = getAllTags();
group.querySelectorAll('.cnb-fav-filter-tag').forEach(btn => {
if (!allTags.includes(btn.getAttribute('data-tag'))) btn.remove();
});
const existing = new Set();
group.querySelectorAll('.cnb-fav-filter-tag').forEach(btn => existing.add(btn.getAttribute('data-tag')));
for (const tag of allTags) {
if (existing.has(tag)) continue;
const escapedTag = escapeHTML(tag);
const label = document.createElement('label');
label.tabIndex = '0';
label.className = 't-radio-button cnb-fav-filter-tag';
label.setAttribute('data-tag', tag);
label.innerHTML = `
${escapedTag}`;
const allBtn = group.querySelector('.cnb-fav-filter-all');
(allBtn || group).after(label);
label.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
group.querySelectorAll('.t-radio-button').forEach(b => b.classList.remove('t-is-checked'));
group.querySelectorAll('input').forEach(inp => inp.checked = false);
this.classList.add('t-is-checked');
this.querySelector('input').checked = true;
updateFilterBg(this);
filterFavCards(this.getAttribute('data-tag'));
});
}
// 同步"被删除的"按钮计数
updateOrphanedBtnCount();
}
// ===== 5. 初始化与路由监控 =====
let _inited = false;
// 收藏夹页处理:挂到共享调度器上,不再单独再挂一个全局 MutationObserver
function processFavoritesPage() {
if (!isStarsPage()) {
// 仅在本页注入过 / 处于收藏夹视图时才需要清理,否则跳过这次 DOM 扫描
if (_favInjected || document.body.classList.contains('cnb-fav-active')) {
cleanupIfNotStars();
_favInjected = false;
}
_favGridPk = ''; // 离开 Stars 页 → 重置渲染稳定判定
_favGridCount = -1;
return false;
}
// 从带 tab 参数的地址回到 Stars 页主体时,退出收藏夹视图
if (window.location.search.includes('tab=') && _favActive) {
_favActive = false;
toggleFavView(false);
const favTab = document.querySelector('.cnb-fav-tab');
if (favTab) {
favTab.classList.remove('t-is-checked');
const favText = favTab.querySelector('.font-bold-placeholder');
if (favText) {
favText.classList.remove('text-pri', 'font-semibold');
favText.classList.add('text-sec');
}
}
}
if (!document.querySelector(SEL.issueListSwitcher)) return false;
if (!document.querySelector('.cnb-fav-tab')) {
addFavoritesTab();
addBackupButtons();
_favInjected = true;
const favTab = document.querySelector('.cnb-fav-tab');
if (favTab && favTab.classList.contains('t-is-checked')) {
toggleFavView(true);
} else if (_favActive) {
const group = document.querySelector('.t-radio-group');
if (group && favTab) forceFavStyle(group, favTab);
}
}
const grid = document.querySelector(SEL.repoGrid);
if (grid && grid.querySelector(':scope > div:not(.cnb-enhanced)')) enhanceRepoCards();
// 「被删除的」计数随卡片陆续渲染自我校正(仅当收藏夹视图里的按钮存在时才重算)
if (document.querySelector('.cnb-orphaned-btn')) updateOrphanedBtnCount();
return true; // 已注入,供兜底轮询提前退出
}
function initFavorites() {
if (_inited) return;
_inited = true;
registerPageProcessor(processFavoritesPage);
ensurePageObserver();
// Stars 页首个渲染可能早于脚本注入:只在 Stars 页做一次短暂兜底轮询(最多 3 秒),命中即停
if (isStarsPage()) {
let left = 30;
const bootTimer = setInterval(() => {
if (processFavoritesPage() || --left <= 0) clearInterval(bootTimer);
}, 100);
}
}
if (document.readyState === 'complete') initFavorites();
else window.addEventListener('load', initFavorites);
// ===== CNB 增强功能 =====
function isCnbDomain() {
return /(^|\.)cnb\.cool$/i.test(location.hostname);
}
function isCnbHomepage() {
return location.hostname === 'cnb.cool' && (location.pathname === '/' || location.pathname === '');
}
// 直达目标解码:获取 cnb.cool /数字?url= 的目标地址
function getCnbGotoTarget(urlLike) {
try {
const u = new URL(urlLike, location.href);
const raw = u.searchParams.get('url') || '';
if (!raw) return '';
// 解码 1-2 次,兼容已编码/双重编码
let t = decodeURIComponent(raw);
if (/%[0-9A-Fa-f]{2}/.test(t)) {
try { t = decodeURIComponent(t); } catch (_) {}
}
// 只允许 http/https
return /^https?:\/\//i.test(t) ? t : '';
} catch (_) {
return '';
}
}
// 若当前位于 cnb.cool 的数字跳转页,立即重定向到真实目标
function handleCnbGotoPage() {
if (!isCnbDomain()) return;
if (!location.pathname.match(/^\/(\d+)$/)) return;
if (!location.search.includes('url=')) return;
const target = getCnbGotoTarget(location.href);
if (target) location.replace(target);
}
// 将页面内所有 数字?url= 链接批量改写为直链
function rewriteCnbGotoLinks(root = document) {
if (!isCnbDomain()) return;
root.querySelectorAll('a[href*="?url="]').forEach(a => {
try {
const href = a.getAttribute('href');
if (!href) return;
const absUrl = new URL(href, location.href).href;
if (!absUrl.includes('cnb.cool/') || !/\/(\d+)\?url=/i.test(absUrl)) return;
const target = getCnbGotoTarget(absUrl);
if (target) a.href = target;
} catch (_) {}
});
}
// 事件委托兜底:拦截点击数字跳转链接并直接打开目标
function cnbGotoClickHandler(e) {
if (!isCnbDomain()) return;
// 查找被点击的 元素
let el = e.target;
while (el && el !== document && !(el instanceof HTMLAnchorElement)) {
el = el.parentElement;
}
if (!(el instanceof HTMLAnchorElement)) return;
const href = el.getAttribute('href');
if (!href) return;
const absUrl = new URL(href, location.href).href;
if (!/\/(\d+)\?url=/i.test(absUrl)) return;
const target = getCnbGotoTarget(absUrl);
if (!target) return;
e.preventDefault();
e.stopPropagation();
// 兼容中键或带修饰键的新开方式
const newTab = e.button === 1 || e.ctrlKey || e.metaKey;
if (newTab) {
window.open(target, '_blank', 'noopener');
} else {
location.href = target;
}
}
// 增强 CNB 页面网格布局
function enhanceGridLayout() {
if (!isCnbDomain()) return;
// 单栏/双栏切换按钮样式
if (!document.getElementById('cnb-grid-toggle-style')) {
const gridStyle = document.createElement('style');
gridStyle.id = 'cnb-grid-toggle-style';
gridStyle.textContent = `
.cnb-grid-toggle-btn {
padding: 4px !important;
}
.cnb-grid-toggle-active {
background-color: #3d3d3d !important;
color: #fff !important;
}
.cnb-grid-toggle-inactive {
background-color: #f3f4f6 !important;
color: #6b7280 !important;
}
.cnb-grid-toggle-inactive:hover {
background-color: #e5e7eb !important;
}
`;
document.head.appendChild(gridStyle);
}
addGridLayoutToggle();
// 应用布局
applyGridLayout();
// "执行"按钮鼠标悬停时自动打开弹窗
enhanceExecuteButtonHover();
addMineTab();
}
// 应用网格布局
function applyGridLayout() {
const isDoubleColumn = localStorage.getItem(KEYS.gridLayout) === 'double';
// 只修改 class="grid grid-cols-1 gap-4 mt-4 mb-8"("最近更新"下面的网格)
document.querySelectorAll(SEL.homepageGrid).forEach(el => {
if (isDoubleColumn) {
if (!el.classList.contains('xl:grid-cols-2')) {
el.classList.add('xl:grid-cols-2');
}
} else {
el.classList.remove('xl:grid-cols-2');
}
});
}
function addGridLayoutToggle() {
// 已经插过就直接返回:否则每轮都要做一次「整页 font-semibold 扫描」(含属性子串选择器)
if (document.querySelector(SEL.repoGridToggle)) return;
// 查找包含"最近更新"文本的元素(更健壮的匹配方式)
let targetDiv = null;
// 方式1:精确匹配包含"最近更新"的元素
const allDivs = document.querySelectorAll('div.font-semibold');
for (const el of allDivs) {
const text = el.textContent.trim();
if (TEXT.recentUpdate.includes(text)) {
targetDiv = el;
break;
}
}
// 方式2:如果方式1失败,尝试模糊匹配
if (!targetDiv) {
const allElements = document.querySelectorAll('[class*="font-semibold"]');
for (const el of allElements) {
if (TEXT.recentUpdate.includes(el.textContent.trim()) && el.closest('.grid') === null) {
targetDiv = el;
break;
}
}
}
if (!targetDiv) return;
if (targetDiv.querySelector(SEL.repoGridToggle)) return;
const isDoubleColumn = localStorage.getItem(KEYS.gridLayout) === 'double';
// 给目标div添加flex布局,让内容两端对齐
targetDiv.classList.add('flex', 'items-center', 'justify-between');
// 保存"最近更新"文本
const textContent = targetDiv.textContent.trim();
targetDiv.textContent = '';
const leftContent = document.createElement('span');
leftContent.textContent = textContent;
const toggleContainer = document.createElement('div');
toggleContainer.className = 'cnb-grid-layout-toggle flex items-center gap-2';
const singleBtn = document.createElement('button');
singleBtn.className = `cnb-grid-toggle-btn rounded-lg transition-colors ${!isDoubleColumn ? 'cnb-grid-toggle-active' : 'cnb-grid-toggle-inactive'}`;
singleBtn.innerHTML = `
`;
singleBtn.title = '单栏';
singleBtn.addEventListener('click', () => {
localStorage.setItem(KEYS.gridLayout, 'single');
updateToggleButtons();
applyGridLayout();
});
const doubleBtn = document.createElement('button');
doubleBtn.className = `cnb-grid-toggle-btn rounded-lg transition-colors ${isDoubleColumn ? 'cnb-grid-toggle-active' : 'cnb-grid-toggle-inactive'}`;
doubleBtn.innerHTML = `
`;
doubleBtn.title = '双栏';
doubleBtn.addEventListener('click', () => {
localStorage.setItem(KEYS.gridLayout, 'double');
updateToggleButtons();
applyGridLayout();
});
function updateToggleButtons() {
const isDoubleColumn = localStorage.getItem(KEYS.gridLayout) === 'double';
if (isDoubleColumn) {
singleBtn.className = 'cnb-grid-toggle-btn rounded-lg transition-colors cnb-grid-toggle-inactive';
doubleBtn.className = 'cnb-grid-toggle-btn rounded-lg transition-colors cnb-grid-toggle-active';
} else {
singleBtn.className = 'cnb-grid-toggle-btn rounded-lg transition-colors cnb-grid-toggle-active';
doubleBtn.className = 'cnb-grid-toggle-btn rounded-lg transition-colors cnb-grid-toggle-inactive';
}
}
toggleContainer.appendChild(singleBtn);
toggleContainer.appendChild(doubleBtn);
// 将左边内容和开关添加到div中
targetDiv.appendChild(leftContent);
targetDiv.appendChild(toggleContainer);
}
// 增强"执行"和"新建"按钮:鼠标悬停时自动打开弹窗
function enhanceExecuteButtonHover() {
if (!isCnbDomain()) return;
// 查找所有按钮,使用更健壮的选择器
document.querySelectorAll('button').forEach(btn => {
// 避免重复绑定
if (btn.dataset.cnbEnhanced) return;
const buttonText = btn.textContent || '';
// 先用廉价的文本判断,命中「新建」才去 querySelector 查 chevron,避免每个按钮都做一次 DOM 查询
const isExecuteButton = TEXT.execute.some(t => buttonText.includes(t));
const isNewButton = TEXT.create.some(t => buttonText.includes(t)) && !!btn.querySelector('svg#chevron-down');
if (!isNewButton && !isExecuteButton) return;
btn.dataset.cnbEnhanced = 'true';
// 鼠标进入时模拟点击打开弹窗
btn.addEventListener('mouseenter', () => {
btn.classList.add('t-popup-open');
setTimeout(() => {
btn.click();
}, 10);
}, { passive: true });
// 鼠标离开时移除 class(不关闭弹窗,让用户可以交互)
btn.addEventListener('mouseleave', () => {
btn.classList.remove('t-popup-open');
}, { passive: true });
});
}
function addMineTab() {
// 在主页和用户主页都显示"与我有关"标签
const isHomepage = isCnbHomepage();
const isUserHomepage = /^\/u\/[^/]+$/.test(location.pathname);
if (!isHomepage && !isUserHomepage) return;
// 查找标签导航容器 - 尝试多种选择器
let navWrap = document.querySelector(SEL.tabsNavWrap);
// 备选:查找带有 tabs 相关 class 的容器
if (!navWrap) {
const tabElements = document.querySelectorAll('[class*="t-tabs"]');
for (const el of tabElements) {
if (el.querySelector('[class*="nav-item"]') || el.classList.contains('t-tabs__nav')) {
navWrap = el;
break;
}
}
}
// 备选:查找包含"探索"、"推荐"等标签的容器
if (!navWrap) {
const tabContainers = document.querySelectorAll('[class*="nav"]');
for (const container of tabContainers) {
const text = container.textContent || '';
if (TEXT.navTabs.some(t => text.includes(t))) {
navWrap = container;
break;
}
}
}
if (!navWrap) return;
if (navWrap.querySelector('.cnb-mine-tab')) return;
const newTabItem = document.createElement('div');
newTabItem.className = 't-tabs__nav-item t-size-m t-is-top cnb-mine-tab';
newTabItem.innerHTML = `
`;
navWrap.appendChild(newTabItem);
}
// CNB 网站功能:添加"创建仓库"按钮
function addCreateRepoButton() {
// 检查是否在 profile 页面,如果是则不添加按钮
if (location.pathname.startsWith('/profile/')) return;
// 查找目标元素:包含"仓库墙"标题
const targetDiv = document.querySelector('h1.font-semibold.text-l');
if (!targetDiv) return;
// 找到 flex gap-1 容器
const parentDiv = targetDiv.closest('.flex.items-center.gap-1');
if (!parentDiv) return;
// 检查是否已经存在原生创建仓库按钮
if (parentDiv.querySelector('.cnb-create-repo-btn')) return;
// 检查是否存在设置按钮,如果没有设置按钮也不需要添加创建仓库按钮
const settingsBtn = parentDiv.querySelector('[id="settings"]');
if (!settingsBtn) return;
const pathParts = location.pathname.split('/').filter(Boolean);
const groupPath = pathParts.join('/');
const createRepoBtn = document.createElement('button');
createRepoBtn.className = 't-button t-button--theme-primary t-button--variant-base cnb-create-repo-btn';
createRepoBtn.innerHTML = `
创建仓库
`;
const createRepoUrl = groupPath ? `https://cnb.cool/new/repos?group=${groupPath}` : 'https://cnb.cool/new/repos';
createRepoBtn.addEventListener('click', () => {
window.location.href = createRepoUrl;
});
// 在设置按钮前插入
if (settingsBtn && settingsBtn.parentElement) {
parentDiv.insertBefore(createRepoBtn, settingsBtn.parentElement);
} else {
parentDiv.appendChild(createRepoBtn);
}
// 在标题后添加一个 spacer,让按钮靠右
const spacer = document.createElement('div');
spacer.style.flex = '1';
targetDiv.after(spacer);
}
function showCopyToast(x, y) {
document.querySelectorAll('.cnb-copy-toast').forEach(el => el.remove());
const toast = document.createElement('div');
toast.className = 'cnb-copy-toast';
toast.textContent = '已复制!';
toast.style.left = x + 'px';
toast.style.top = (y - 50) + 'px';
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 200);
}, 1000);
}
// 弹窗点击外部关闭:延迟一帧绑定,避免「打开弹窗的那次点击」立刻把它关掉。
// 用 composedPath:删除行 / 重建节点会把被点元素先摘出 DOM,冒泡到 document 时 contains 会误判成「点了外部」。
function bindPopupOutsideClose(popup, ...triggers) {
setTimeout(() => {
document.addEventListener('click', function oc(e) {
const path = typeof e.composedPath === 'function' ? e.composedPath() : null;
const inside = path
? path.includes(popup) || triggers.some(t => path.includes(t))
: popup.contains(e.target) || triggers.some(t => t && t.contains(e.target));
if (!inside) {
popup.remove();
document.removeEventListener('click', oc);
}
});
}, 10);
}
// 自定义 Toast 通知(右下角弹出,3 秒后向右滑出消失)
function showToast(msg, type) {
document.querySelectorAll('.cnb-toast').forEach(el => el.remove());
const toast = document.createElement('div');
toast.className = 'cnb-toast cnb-toast-' + (type || 'ok');
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('cnb-toast-out');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// 自定义确认弹窗(返回 Promise)
function showConfirm(msg) {
return new Promise(resolve => {
document.querySelectorAll('.cnb-confirm-overlay').forEach(el => el.remove());
const overlay = document.createElement('div');
overlay.className = 'cnb-confirm-overlay';
overlay.innerHTML = `
`;
overlay.querySelector('.cnb-confirm-ok').addEventListener('click', () => { overlay.remove(); resolve(true); });
overlay.querySelector('.cnb-confirm-cancel').addEventListener('click', () => { overlay.remove(); resolve(false); });
overlay.addEventListener('click', function(e) { if (e.target === overlay) { overlay.remove(); resolve(false); } });
document.body.appendChild(overlay);
});
}
// ===== 通用:拖拽排序(快捷导航图标 / NPC 指令条 chip 共用)=====
/**
* 一组同级元素绑定「左键拖拽换位」。
* 落点只用 class 标记、拖拽中不增删节点:dragover 高频,任何 childList 变动都会唤醒全局
* MutationObserver 触发整页重扫(会卡死)。元素需带 dataset.index,onReorder(from, insertAt) 由调用方落盘、重绘。
*/
function bindDragReorder(items, onReorder) {
const clearDrops = () => items.forEach(el => el.classList.remove('drop-left', 'drop-right'));
items.forEach(item => {
const sideOf = e => (e.clientX < item.getBoundingClientRect().left + item.offsetWidth / 2 ? 'left' : 'right');
let marked = '';
item.addEventListener('dragstart', e => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.dataset.index);
item.classList.add('dragging');
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
clearDrops();
});
item.addEventListener('dragover', e => {
e.preventDefault(); // 不 preventDefault 就不是合法落点
e.dataTransfer.dropEffect = 'move';
const side = sideOf(e);
if (side === marked) return; // 同一落点:不动 DOM
marked = side;
clearDrops();
item.classList.add('drop-' + side);
});
item.addEventListener('dragleave', e => {
if (!marked || item.contains(e.relatedTarget)) return;
marked = '';
item.classList.remove('drop-left', 'drop-right');
});
item.addEventListener('drop', e => {
e.preventDefault();
e.stopPropagation();
const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
const to = parseInt(item.dataset.index, 10);
const insertAt = sideOf(e) === 'left' ? to : to + 1;
clearDrops();
marked = '';
if (isNaN(from) || isNaN(to) || from === to) return;
onReorder(from, from < insertAt ? insertAt - 1 : insertAt);
});
});
}
// 为下载按钮添加点击下载功能和复制直链按钮
function addDownloadButtonFeature() {
// 只在文件查看页(blob)生效:先把其余页面这一整轮「扫描所有按钮」挡掉
if (!window.location.pathname.includes('/blob/')) return;
document.querySelectorAll('button').forEach(button => {
// 避免重复绑定
if (button.dataset.cnbDownloadEnhanced) return;
const hasDownloadSvg = button.querySelector('svg#download');
if (!hasDownloadSvg) return;
button.dataset.cnbDownloadEnhanced = 'true';
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// 获取当前页面 URL 并转换为 raw 下载链接
const currentUrl = window.location.href;
const downloadUrl = currentUrl.replace(/\/blob\//, '/git/raw/');
const link = document.createElement('a');
link.href = downloadUrl;
link.download = '';
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// 在下载按钮左侧加「复制直链」按钮。
// 注意:原生下载按钮外层是 (触发器容器),直接往它内部塞按钮会破坏结构,
// 所以插到它的父级容器里,并把该容器变成 flex,让 [复制直链][下载] 紧贴成一组、圆角互补。
const triggerWrap = button.closest('.t-trigger');
const host = triggerWrap ? triggerWrap.parentElement : button.parentElement;
if (!host) return;
if (host.querySelector('.cnb-copy-url-btn')) {
button.classList.add('rounded-l-none'); // 已有复制按钮 → 仅确保下载按钮是「右半」
(triggerWrap || button).classList.add('-ml-[1px]');
return;
}
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
// 复制按钮在左:去掉右边圆角,与右侧下载按钮拼成一组
copyBtn.className = 'rounded-r-none hover:z-10 px-2 text-sec hover:text-brand-500 t-button t-button--theme-default t-button--variant-outline cnb-copy-url-btn';
copyBtn.title = '复制直链(Raw 地址)';
// 不带 id:原生页面里已经有 id="file-copy",避免重复 id
copyBtn.innerHTML = `
`;
copyBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const downloadUrl = window.location.href.replace(/\/blob\//, '/git/raw/');
try {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(downloadUrl);
} else {
navigator.clipboard.writeText(downloadUrl);
}
showCopyToast(e.clientX, e.clientY);
} catch (err) {
showToast('复制失败,请手动复制', 'err');
}
});
if (!host.classList.contains('flex')) host.classList.add('flex', 'items-center');
host.insertBefore(copyBtn, triggerWrap || button);
button.classList.add('rounded-l-none'); // 下载按钮作为分组「右半」:左角变方
(triggerWrap || button).classList.add('-ml-[1px]'); // 与左侧复制按钮边框重叠 1px,拼成一组
});
}
// 存储键统一见文件顶部的 KEYS / COS_FILE_PATH
function getCosConfig() {
return readJsonStore(KEYS.cosConfig, {});
}
function setCosConfig(cfg) { writeJsonStore(KEYS.cosConfig, cfg); }
function isCosEnabled() {
const cfg = getCosConfig();
return !!(cfg.SecretId && cfg.SecretKey && cfg.Bucket && cfg.Region);
}
// ===== COS 签名工具(基于 Web Crypto API + GM_xmlhttpRequest,无需外部 SDK)=====
async function _sha1Hex(text) {
const buf = new TextEncoder().encode(text);
const hash = await crypto.subtle.digest('SHA-1', buf);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function _hmacSha1Hex(keyText, dataText) {
const keyBuf = new TextEncoder().encode(keyText);
const key = await crypto.subtle.importKey('raw', keyBuf, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(dataText));
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function _cosSign(method, keyPath, host, secretId, secretKey) {
const now = Math.floor(Date.now() / 1000);
const expired = now + 300;
const keyTime = now + ';' + expired;
const signKey = await _hmacSha1Hex(secretKey, keyTime);
const httpStr = method.toLowerCase() + '\n/' + keyPath + '\n\nhost=' + host + '\n';
const sha1Http = await _sha1Hex(httpStr);
const strToSign = 'sha1\n' + keyTime + '\n' + sha1Http + '\n';
const signature = await _hmacSha1Hex(signKey, strToSign);
return 'q-sign-algorithm=sha1&q-ak=' + secretId +
'&q-sign-time=' + keyTime + '&q-key-time=' + keyTime +
'&q-header-list=host&q-url-param-list=&q-signature=' + signature;
}
function cosRequest(method, keyPath, body, cfg) {
const host = cfg.Bucket + '.cos.' + cfg.Region + '.myqcloud.com';
const url = 'https://' + host + '/' + keyPath;
return new Promise((resolve, reject) => {
_cosSign(method, keyPath, host, cfg.SecretId, cfg.SecretKey).then(auth => {
const headers = { 'Authorization': auth, 'Host': host };
if (body) headers['Content-Type'] = 'application/json';
GM_xmlhttpRequest({
method: method,
url: url,
headers: headers,
data: body || undefined,
onload: function(res) {
if (res.status >= 200 && res.status < 300) resolve(res);
else reject(new Error(method + ' COS 失败: HTTP ' + res.status + ' ' + (res.responseText || '')));
},
onerror: function() { reject(new Error(method + ' COS 请求失败')); }
});
}).catch(reject);
});
}
// ===== COS 导入导出 =====
function cosUpload(jsonData) {
const cfg = getCosConfig();
if (!isCosEnabled()) return Promise.reject(new Error('COS 未配置'));
return cosRequest('PUT', COS_FILE_PATH, jsonData, cfg).then(function() {});
}
function cosDownload() {
const cfg = getCosConfig();
if (!isCosEnabled()) return Promise.reject(new Error('COS 未配置'));
return new Promise((resolve, reject) => {
cosRequest('GET', COS_FILE_PATH, null, cfg).then(res => {
resolve(res.responseText);
}).catch(reject);
});
}
function showCosSettingsPopup(triggerBtn) {
document.querySelectorAll('.cnb-qa-cos-popup').forEach(el => el.remove());
const cfg = getCosConfig();
const popup = document.createElement('div');
popup.className = 'cnb-qa-cos-popup';
popup.innerHTML = `
`;
document.body.appendChild(popup);
const rect = triggerBtn.getBoundingClientRect();
popup.style.left = Math.min(rect.left, window.innerWidth - 400) + 'px';
popup.style.top = Math.max(10, rect.bottom - popup.offsetHeight - 6) + 'px';
const statusEl = popup.querySelector('#cos-status');
function setStatus(msg, cls) { statusEl.textContent = msg; statusEl.className = 'cos-status ' + cls; }
// 测试连接
popup.querySelector('#cos-btn-test').addEventListener('click', async () => {
setStatus('正在测试连接...', '');
try {
const testCfg = {
SecretId: popup.querySelector('#cos-input-SecretId').value.trim(),
SecretKey: popup.querySelector('#cos-input-SecretKey').value.trim(),
Bucket: popup.querySelector('#cos-input-Bucket').value.trim(),
Region: popup.querySelector('#cos-input-Region').value.trim()
};
if (!testCfg.SecretId || !testCfg.SecretKey || !testCfg.Bucket || !testCfg.Region) {
setStatus('请填写完整配置信息', 'err'); return;
}
setCosConfig(testCfg);
await cosRequest('HEAD', '', null, testCfg);
setStatus('连接成功!COS 可用', 'ok');
} catch (e) { setStatus('连接失败: ' + e.message, 'err'); }
});
// 保存
popup.querySelector('#cos-btn-save').addEventListener('click', () => {
setCosConfig({
SecretId: popup.querySelector('#cos-input-SecretId').value.trim(),
SecretKey: popup.querySelector('#cos-input-SecretKey').value.trim(),
Bucket: popup.querySelector('#cos-input-Bucket').value.trim(),
Region: popup.querySelector('#cos-input-Region').value.trim()
});
setStatus('设置已保存', 'ok');
setTimeout(() => popup.remove(), 800);
});
popup.querySelector('#cos-btn-cancel').addEventListener('click', () => popup.remove());
bindPopupOutsideClose(popup, triggerBtn);
}
function getQALinks() {
return readJsonStore(KEYS.quickAccess, []);
}
function saveQALinks(links) { writeJsonStore(KEYS.quickAccess, links); }
// 显示添加/编辑弹窗
function showQAPopup(btn, editIndex) {
document.querySelectorAll('.cnb-qa-popup').forEach(el => el.remove());
const links = getQALinks();
const isEdit = typeof editIndex === 'number';
const defaultHref = isEdit ? links[editIndex].href : '';
const defaultImg = isEdit ? links[editIndex].img : '';
const defaultWs = isEdit ? (links[editIndex].workspace || null) : null;
const popup = document.createElement('div');
popup.className = 'cnb-qa-popup';
popup.innerHTML = `
`;
document.body.appendChild(popup);
const rect = btn.getBoundingClientRect();
popup.style.left = Math.min(rect.left, window.innerWidth - 340) + 'px';
popup.style.top = (rect.bottom + 6) + 'px';
const hrefInput = popup.querySelector('#qa-input-href');
const imgInput = popup.querySelector('#qa-input-img');
const hrefLabel = popup.querySelector('#qa-href-label');
const hrefHint = popup.querySelector('#qa-href-hint');
const wsToggle = popup.querySelector('#qa-ws-toggle');
const wsFields = popup.querySelector('.cnb-qa-ws-fields');
const wsBranch = popup.querySelector('#qa-ws-branch');
const wsRef = popup.querySelector('#qa-ws-ref');
const wsToken = popup.querySelector('#qa-ws-token');
const wsTokenEye = popup.querySelector('#qa-ws-token-eye');
// 小眼睛切换密码显示/隐藏
wsTokenEye.addEventListener('click', () => {
if (wsToken.type === 'password') {
wsToken.type = 'text';
wsTokenEye.textContent = '🙈';
wsTokenEye.title = '隐藏';
} else {
wsToken.type = 'password';
wsTokenEye.textContent = '👁';
wsTokenEye.title = '显示';
}
});
// 设置按钮:弹出 COS 配置弹窗
popup.querySelector('#qa-settings-btn').addEventListener('click', (e) => {
e.stopPropagation();
showCosSettingsPopup(popup.querySelector('#qa-settings-btn'));
});
// 仓库地址链接点击时实时计算输入框值并跳转
function handleRepoLinkClick(e) {
e.preventDefault();
let val = hrefInput.value.trim().replace(/^https?:\/\/cnb\.cool/i, '');
if (val && !val.startsWith('/')) val = '/' + val;
if (val) window.open('https://cnb.cool' + val, '_blank');
}
// 云原生开发模式下,链接地址字段变为 API 路径
function syncHrefField() {
if (wsToggle.checked) {
hrefLabel.innerHTML = 'API 路径(仓库地址)';
hrefInput.placeholder = '如 /IIIStudio/Code/Greasemonkey/CNB';
hrefHint.textContent = '支持完整 URL,会自动去掉 https://cnb.cool 前缀';
// 编辑时如果有workspace配置,显示api_path而非解析后的href
if (isEdit && defaultWs && defaultWs.api_path) {
hrefInput.value = defaultWs.api_path;
}
// 绑定点击事件,每次点击时实时计算最新 URL
const link = hrefLabel.querySelector('#qa-repo-link');
if (link) link.addEventListener('click', handleRepoLinkClick);
} else {
hrefLabel.textContent = '链接地址(URL)';
hrefInput.placeholder = '如 /IIIStudio 或 https://example.com';
hrefHint.textContent = '以 / 开头时,图片地址将自动推断为 cnb.cool 组织 logo';
// 恢复为原始href值
if (isEdit && defaultHref) {
hrefInput.value = defaultHref;
}
}
}
syncHrefField();
// 开关切换显示/隐藏云原生开发字段 + 切换链接地址标签
wsToggle.addEventListener('change', () => {
if (wsToggle.checked) {
wsFields.classList.add('visible');
} else {
wsFields.classList.remove('visible');
}
syncHrefField();
});
function done() {
let href = hrefInput.value.trim();
if (!href && !wsToggle.checked) { popup.remove(); return; }
// 统一处理:如果 / 开头,自动补全为 cnb.cool 路径
if (href && !href.startsWith('http://') && !href.startsWith('https://')) {
if (!href.startsWith('/')) href = '/' + href;
}
let img = imgInput.value.trim();
// 自动推断图片地址
if (!img && href) {
let path = href;
if (path.startsWith('https://cnb.cool') || path.startsWith('http://cnb.cool')) {
try { path = new URL(path).pathname; } catch (_) {}
}
if (path.startsWith('/')) {
const parts = path.replace(/^\/+/, '').split('/');
if (parts[0] === 'u' && parts[1]) {
img = 'https://cnb.cool/users/' + parts[1] + '/avatar/s';
} else {
const org = parts[0] || '';
img = 'https://cnb.cool/' + org + '/-/logos/s';
}
}
}
// 保存云原生开发配置(不调用接口)
const links = getQALinks();
const linkData = { href: href, img };
if (wsToggle.checked) {
let apiPath = hrefInput.value.trim().replace(/^https?:\/\/cnb\.cool/i, '');
if (apiPath && !apiPath.startsWith('/')) apiPath = '/' + apiPath;
linkData.workspace = {
api_path: apiPath,
branch: wsBranch.value.trim(),
ref: wsRef.value.trim(),
token: wsToken.value.trim()
};
}
if (isEdit) {
links[editIndex] = linkData;
} else {
links.push(linkData);
}
saveQALinks(links);
popup.remove();
renderQuickAccessBar();
}
popup.querySelector('.btn-ok').addEventListener('click', done);
popup.querySelector('.btn-cancel:not(.btn-delete)').addEventListener('click', () => popup.remove());
if (isEdit) {
popup.querySelector('.btn-delete').addEventListener('click', () => {
const links = getQALinks();
links.splice(editIndex, 1);
saveQALinks(links);
popup.remove();
renderQuickAccessBar();
});
}
// 导入数据(含收藏夹 / NPC 指令设置)
popup.querySelector('#qa-btn-import').addEventListener('click', async () => {
const readBackup = data => {
const qa = (data.quickAccess && Array.isArray(data.quickAccess)) ? data.quickAccess : (Array.isArray(data) ? data : []);
const parts = ['将导入 ' + qa.length + ' 条快捷导航'];
if (data.favorites && Object.keys(data.favorites).length > 0) parts.push('收藏夹标签数据');
if (data.npc) parts.push('NPC 指令设置');
return { qa, msg: parts.join(' + ') + ',是否覆盖数据?' };
};
const applyBackup = (data, qa) => {
if (data.favorites) saveData(data.favorites);
saveQALinks(qa);
applyNpcBackup(data.npc);
popup.remove(); renderQuickAccessBar(); ensureNpcBar(true);
showToast('导入成功!', 'ok');
};
const ask = async data => {
const { qa, msg } = readBackup(data);
if (await showConfirm(msg)) applyBackup(data, qa);
};
if (isCosEnabled()) {
try {
const body = await cosDownload();
await ask(JSON.parse(typeof body === 'string' ? body : new TextDecoder().decode(body)));
} catch (e) {
if (!await showConfirm('COS 下载失败:' + e.message + '\n是否改用本地文件导入?')) return;
doLocalImport();
}
} else { doLocalImport(); }
function doLocalImport() {
const inputEl = document.createElement('input');
inputEl.type = 'file'; inputEl.accept = '.json';
inputEl.onchange = e => {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = async ev => {
try { await ask(JSON.parse(ev.target.result)); }
catch (err) { showToast('导入失败:JSON 格式错误', 'err'); }
};
reader.readAsText(file);
};
inputEl.click();
}
});
// 导出快捷导航数据(含收藏夹 / NPC 指令设置)
popup.querySelector('#qa-btn-export').addEventListener('click', async () => {
const qaLinks = getQALinks();
const data = getData();
const allData = { favorites: data, quickAccess: qaLinks, npc: getNpcBackup() };
const jsonStr = JSON.stringify(allData, null, 2);
if (isCosEnabled()) {
try { await cosUpload(jsonStr); showToast('已同步到 COS 云端(' + COS_FILE_PATH + ')', 'ok'); return; }
catch (e) { if (!await showConfirm('COS 上传失败:' + e.message + '\n是否改用本地下载?')) return; }
}
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url; anchor.download = `cnb_cool_backup_${new Date().getTime()}.json`;
document.body.appendChild(anchor); anchor.click();
document.body.removeChild(anchor); URL.revokeObjectURL(url);
});
// 所有输入框支持回车确认、Escape关闭
[hrefInput, imgInput, wsBranch, wsRef, wsToken].forEach(input => {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); done(); }
else if (e.key === 'Escape') popup.remove();
});
});
bindPopupOutsideClose(popup, btn);
hrefInput.focus();
}
// 渲染快捷导航栏
function renderQuickAccessBar() {
const existingBar = document.querySelector('.cnb-quick-access-bar');
const targetContainer = document.querySelector(SEL.quickAccessHost);
if (!targetContainer) return;
if (existingBar) existingBar.remove();
const bar = document.createElement('div');
bar.className = 'cnb-quick-access-bar';
const links = getQALinks();
links.forEach((link, idx) => {
const item = document.createElement('a');
item.className = 'cnb-quick-access-item';
item.target = '_blank';
item.rel = 'noopener noreferrer';
item.title = link.href + '\n左键拖拽排序 | 右键编辑/删除' + (link.workspace ? '\n云原生开发模式:点击启动云原生开发' : '');
item.innerHTML = `
`;
item.draggable = true;
item.dataset.index = idx;
// 云原生开发模式:点击时调接口获取动态URL再跳转
if (link.workspace && link.workspace.api_path) {
item.addEventListener('click', async (e) => {
e.preventDefault();
// 已在拖拽中则不处理
if (item.classList.contains('dragging')) return;
const ws = link.workspace;
let apiPath = String(ws.api_path || '').replace(/^https?:\/\/cnb\.cool/i, '');
if (!apiPath.startsWith('/')) apiPath = '/' + apiPath;
const branch = String(ws.branch || '').trim();
const ref = String(ws.ref || '').trim();
// 校验关键字段
if (apiPath === '/' || !apiPath || apiPath.includes('{') || apiPath.includes('}')) {
showToast('API 路径无效,请右键重新编辑该快捷导航', 'err');
showQAPopup(item, idx);
return;
}
const imgEl = item.querySelector('img');
const origOpacity = imgEl ? imgEl.style.opacity : '';
if (imgEl) imgEl.style.opacity = '0.4';
try {
const bodyObj = {};
if (branch && branch !== 'string' && branch !== '请填写') bodyObj.branch = branch;
if (ref && ref !== 'string' && ref !== '请填写') bodyObj.ref = ref;
const payload = JSON.stringify(bodyObj);
const result = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `https://api.cnb.cool${apiPath}/-/workspace/start`,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/vnd.cnb.api+json',
...(ws.token ? { 'Authorization': ws.token } : {})
},
data: payload,
onload(res) {
if (res.status >= 200 && res.status < 300) {
try { resolve(JSON.parse(res.responseText)); }
catch (_) { resolve({}); }
} else {
reject(new Error(`HTTP ${res.status}`));
}
},
onerror() { reject(new Error('网络请求失败')); }
});
});
const workspaceUrl = result.url || '';
if (!workspaceUrl) throw new Error('未返回有效的云原生开发地址');
window.open(workspaceUrl, '_blank', 'noopener');
} catch (err) {
showToast('云原生开发启动失败:' + err.message, 'err');
} finally {
if (imgEl) imgEl.style.opacity = origOpacity;
}
});
item.href = '#'; // 阻止默认链接
} else {
item.href = link.href;
}
// 右键删除
item.addEventListener('contextmenu', e => {
e.preventDefault();
showQAPopup(item, idx);
});
bar.appendChild(item);
});
// 拖拽换位(共用实现,见 bindDragReorder)
bindDragReorder([...bar.querySelectorAll('.cnb-quick-access-item')], (from, insertAt) => {
const links = getQALinks();
const [moved] = links.splice(from, 1);
links.splice(insertAt, 0, moved);
saveQALinks(links);
renderQuickAccessBar();
});
const addBtn = document.createElement('span');
addBtn.className = 'cnb-qa-add-btn';
addBtn.textContent = '+';
addBtn.title = '添加快捷导航';
addBtn.addEventListener('click', e => { e.stopPropagation(); showQAPopup(addBtn); });
bar.appendChild(addBtn);
// 直接放到容器末尾
targetContainer.appendChild(bar);
}
// 添加快捷导航栏
function addQuickAccessBar() {
if (!isCnbDomain()) return;
const targetContainer = document.querySelector(SEL.quickAccessHost);
if (!targetContainer) return;
// 已存在且位置正确则跳过
const existingBar = document.querySelector('.cnb-quick-access-bar');
if (existingBar) {
if (targetContainer.contains(existingBar)) {
// 检查是否在"公开"标签后面
const publicTag = targetContainer.querySelector('span.text-ter.border-ter');
if (!publicTag || publicTag.compareDocumentPosition(existingBar) & Node.DOCUMENT_POSITION_FOLLOWING) {
return; // 位置正确
}
// "公开"已加载但bar在前面 → 移动到末尾即可,不需要重建
targetContainer.appendChild(existingBar);
return;
}
// 不在正确容器中 → 重建
existingBar.remove();
}
renderQuickAccessBar();
}
// ===== NPC 快捷指令:指令模板 + NPC 列表 =====
// issue / PR 会话页评论框上方一条指令条(NPC 选择器 + 自动提及开关 + 模板 chip + 设置齿轮),
// 模板与 NPC 候选在齿轮弹窗里维护。本区顺序:常量 → 存储 → 提及处理 → 编辑器读写 → 输入同步 → 指令条 → 弹窗。
const NPC_PRESET_LABEL_MAX = 8;
const NPC_MENTION_MAX = 100;
const NPC_MENTIONS_MAX = 25;
const NPC_POPUP_TOP = 50; // 设置弹窗悬浮位置:距视口顶部(页眉下方)
const NPC_BOT_ICON = '';
// 内置默认指令模板(与 CNBuff 扩展保持一致的默认集)
const DEFAULT_NPC_PRESETS = [
{ label: '总结讨论', prompt: '总结一下这个讨论的要点和结论。' },
{ label: '解答问题', prompt: '帮我回答一下这个问题。' },
{ label: '排查定位', prompt: '帮我分析这个问题的可能原因并给出排查步骤。' },
{ label: '评审代码', prompt: '帮我评审这个合并请求的改动,指出潜在问题、风险点和改进建议。' },
{ label: '解释改动', prompt: '用通俗的语言解释这个改动做了什么、为什么要这么做。' },
{ label: '给出方案', prompt: '针对这个需求给出可行的实现方案,列出大致步骤和需要注意的点。' },
{ label: '补充用例', prompt: '为这个改动建议需要补充的测试用例,重点覆盖边界场景和易错路径。' },
{ label: '起草回复', prompt: '帮我起草一条得体的回复,说明当前进展、结论或后续计划。' }
];
const DEFAULT_NPC_MENTIONS = [
'CodeBuddy',
'npc/CodeBuddy(deepseek-v4.1-flash)',
'npc/CodeBuddy(hy4-preview)'
];
// 格式:CodeBuddy(系统 NPC)或 仓库路径(角色名)(自定义 NPC)
const NPC_NAME_RE = /^(?:[\w.-]+(?:\/[\w.-]+)+\([^()]+\)|\w+)$/;
function readStoredJson(key) {
return readJsonStore(key, null);
}
// 读缓存:processPage 每次 DOM 变更都会走一遍指令条,避免反复 GM_getValue + JSON.parse;写操作同步失效
const npcMemo = {};
function npcCached(key, compute) {
if (!(key in npcMemo)) npcMemo[key] = compute();
return npcMemo[key];
}
const dropNpcCache = key => { delete npcMemo[key]; };
const NPC_EMPTY_PRESETS_TIP = '暂无模板:指令条会只保留固定项 @NPC';
// 指令模板:从未配置 → 内置默认;显式清空(空数组)→ 空数组(指令条只留固定项)
function getNpcPresets() {
return npcCached('presets', () => {
const raw = readStoredJson(KEYS.npcPresets);
if (!Array.isArray(raw)) return DEFAULT_NPC_PRESETS.map(p => ({ label: p.label, prompt: p.prompt }));
return raw.filter(p => p && typeof p.label === 'string' && typeof p.prompt === 'string')
.map(p => ({ label: p.label, prompt: p.prompt }));
}).slice(); // 调用方可能就地改(拖拽排序),返回副本
}
function saveNpcPresets(list) {
GM_setValue(KEYS.npcPresets, JSON.stringify(list.map(p => ({ label: String(p.label).trim(), prompt: String(p.prompt).trim() }))));
dropNpcCache('presets');
}
function resetNpcPresets() { GM_setValue(KEYS.npcPresets, 'null'); dropNpcCache('presets'); }
// NPC 列表:去空、去重、限长限量;空列表回退默认(避免指令条没有可 @ 的对象)
function getNpcMentions() {
return npcCached('mentions', () => {
const raw = readStoredJson(KEYS.npcMentions);
const out = [];
if (Array.isArray(raw)) raw.forEach(item => {
const name = typeof item === 'string' ? item.trim().replace(/^@+/, '').slice(0, NPC_MENTION_MAX) : '';
if (name && out.indexOf(name) === -1 && out.length < NPC_MENTIONS_MAX) out.push(name);
});
return out.length ? out : DEFAULT_NPC_MENTIONS.slice();
}).slice(); // 调用方会 push / filter,返回副本
}
function saveNpcMentions(list) { GM_setValue(KEYS.npcMentions, JSON.stringify(list)); dropNpcCache('mentions'); }
function resetNpcMentions() { GM_setValue(KEYS.npcMentions, 'null'); dropNpcCache('mentions'); }
function getSelectedNpc() {
const list = getNpcMentions();
const saved = npcCached('selected', () => GM_getValue(KEYS.npcSelected, ''));
return list.indexOf(saved) >= 0 ? saved : list[0];
}
function setSelectedNpc(name) { GM_setValue(KEYS.npcSelected, name); dropNpcCache('selected'); }
// 「发送自动带 NPC」开关(默认关闭,状态本地持久化)
function isNpcPrefixOn() { return npcCached('prefix', () => GM_getValue(KEYS.npcPrefix, '0') === '1'); }
function setNpcPrefixOn(on) { GM_setValue(KEYS.npcPrefix, on ? '1' : '0'); npcMemo.prefix = !!on; }
// —— 备份 / 还原:随收藏夹、快捷导航一起进同一份 JSON ——
function getNpcBackup() {
return {
presets: getNpcPresets(),
mentions: getNpcMentions(),
selected: getSelectedNpc(),
prefix: isNpcPrefixOn()
};
}
/** 旧备份没有 npc 字段 → 原样不动当前 NPC 配置 */
function applyNpcBackup(npc) {
if (!npc || typeof npc !== 'object') return false;
if (Array.isArray(npc.presets)) {
const valid = npc.presets.filter(p => p && typeof p.label === 'string' && typeof p.prompt === 'string');
// 空数组是合法状态(清空模板);整段脏数据则跳过,别把现有模板冲掉
if (valid.length || npc.presets.length === 0) saveNpcPresets(valid);
}
if (Array.isArray(npc.mentions)) saveNpcMentions(npc.mentions.filter(m => typeof m === 'string'));
if (typeof npc.prefix === 'boolean') setNpcPrefixOn(npc.prefix);
if (typeof npc.selected === 'string' && npc.selected) setSelectedNpc(npc.selected);
return true;
}
// 草稿开头的提及:@ + NPC 名(CodeBuddy 或 仓库路径(角色名))
const NPC_LEAD_MENTION_RE = /^\s*@((?:[\w.-]+(?:\/[\w.-]+)+\([^()]+\)|[\w.-]+))/;
function getLeadingMention(value) {
const m = String(value || '').match(NPC_LEAD_MENTION_RE);
return m ? m[1] : '';
}
/**
* 开关开启时草稿应有的形态:空草稿保持空;已经有开头提及(自己 @ 了人或别的 NPC)就不动;
* 否则在开头补上 `@NPC `。
*/
function withLeadingNpc(value, npc) {
const text = String(value == null ? '' : value);
if (!text.trim()) return '';
if (getLeadingMention(text)) return text; // 已有完整开头提及(自己 @ 的人或 NPC)→ 原样保留
if (/^\s*@/.test(text)) return text; // 正在手打提及(例如刚敲了一个 @)→ 不插手
return '@' + npc + ' ' + text;
}
/** 替换草稿开头的提及(切换 NPC 时用) */
function replaceLeadingMention(value, npc) {
const rest = String(value).replace(NPC_LEAD_MENTION_RE, '').replace(/^\s+/, '');
return '@' + npc + (rest ? ' ' + rest : ' ');
}
// 规范化用户输入的 NPC 名称,不合法返回空串
function normalizeNpcName(raw) {
const name = String(raw || '').trim().replace(/^@+/, '').trim();
if (!name || name.length > NPC_MENTION_MAX) return '';
return NPC_NAME_RE.test(name) ? name : '';
}
// 生效页面:issue / PR 会话页(页面底部有主评论编辑器)
function isNpcConversationPage() {
return /\/-\/(issues|pulls)\/\d+/.test(location.pathname);
}
// 主评论编辑器 = 文档顺序最后一个 .markdown-editor(inline 编辑的编辑器在时间线中部)
function findNpcEditor() {
const editors = document.querySelectorAll(SEL.markdownEditor);
return editors.length ? editors[editors.length - 1] : null;
}
// 向 React 受控 textarea 写值:必须走原型 setter + input 事件,否则会被重渲染覆盖
function writeNpcTextarea(textarea, next, cursor, focus = true) {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
if (!setter) return false;
setter.call(textarea, next);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
if (typeof textarea.setSelectionRange === 'function') {
const pos = Math.max(0, Math.min(cursor, next.length));
textarea.setSelectionRange(pos, pos);
}
if (focus) textarea.focus();
return true;
}
function insertNpcText(textarea, text) {
if (!textarea) return false;
const start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : textarea.value.length;
const end = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : start;
const next = textarea.value.slice(0, start) + text + textarea.value.slice(end);
if (writeNpcTextarea(textarea, next, start + text.length)) return true;
// 兜底:原型 setter 不可用时退回 execCommand
textarea.focus();
try { return document.execCommand('insertText', false, text); }
catch (e) { return false; }
}
function getNpcEditorTextarea() {
const editor = findNpcEditor();
return editor ? editor.querySelector('textarea') : null;
}
// 是否是「当前页面的主评论框」:只在 issue / PR 会话页、且是底部那个编辑器才算,inline 编辑框不算
function isNpcMainEditor(el) {
if (!isNpcConversationPage()) return false;
if (!el || el.tagName !== 'TEXTAREA') return false;
const editor = findNpcEditor();
return !!editor && editor.contains(el);
}
// 往主评论框插入一段文本(找不到输入框时给提示)
function insertNpcAtEditor(text, okMsg) {
const textarea = getNpcEditorTextarea();
if (!textarea) { showToast('未找到评论输入框', 'err'); return; }
if (insertNpcText(textarea, text)) showToast(okMsg, 'ok');
else showToast('插入失败,请手动输入', 'err');
}
// 改写整段草稿,光标按长度增量偏移(补/换提及都发生在开头)
function writeLeadingNpc(textarea, next) {
const value = textarea.value;
if (next === value) return;
const start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : value.length;
writeNpcTextarea(textarea, next, start + (next.length - value.length));
}
// 开关开启时,用户每次输入后都把开头补上 @NPC(输入 / 粘贴 / IME 都走 input 事件)
function syncNpcDraft(textarea) {
writeLeadingNpc(textarea, withLeadingNpc(textarea.value, getSelectedNpc()));
}
let _npcPrefixBound = false;
function bindNpcPrefixSync() {
if (_npcPrefixBound) return;
_npcPrefixBound = true;
// 输入法合成中的 textarea 绝不能改写 value:会打断合成(表现为拼音串字、中文打不进去)
const composing = new WeakSet();
document.addEventListener('compositionstart', e => {
if (e.target && e.target.tagName === 'TEXTAREA') composing.add(e.target);
}, true);
// 合成结束:等刚上屏的文字落定后再补前缀(两种事件顺序都兼容)
document.addEventListener('compositionend', e => {
const textarea = e.target;
if (!textarea || textarea.tagName !== 'TEXTAREA') return;
composing.delete(textarea);
setTimeout(() => {
if (!isNpcPrefixOn() || composing.has(textarea) || !isNpcMainEditor(textarea)) return;
syncNpcDraft(textarea);
}, 0);
}, true);
// 焦点离开时兜底清掉合成标记
document.addEventListener('blur', e => {
if (e.target && e.target.tagName === 'TEXTAREA') composing.delete(e.target);
}, true);
// 捕获阶段监听,早于 React 的 onChange 读到最终值
document.addEventListener('input', e => {
if (!isNpcPrefixOn() || !isNpcMainEditor(e.target)) return;
if (e.isComposing || composing.has(e.target)) return; // 合成中 → 不动
if (e.inputType === 'insertCompositionText') return; // 合成中间态兜底
syncNpcDraft(e.target);
}, true);
}
// 上传 / 粘贴图片时,编辑器会插入
,像素宽会把评论区撑爆,统一改成百分比
const EDITOR_IMG_WIDTH = '20%';
// 只认「完整属性值」是像素宽度("600" / '600' / 600 / "600px"),必须整段匹配到引号/空白为止,
// 否则 width="20%" 里的 width="20 会被误判并反复追加引号
const EDITOR_IMG_WIDTH_ATTR_RE = /\bwidth\s*=\s*(?:"\d+(?:px)?"|'\d+(?:px)?'|\d+(?:px)?)/i;
function normalizeEditorImgWidth(text) {
if (!text || text.indexOf('
]*>/gi, tag => tag.replace(EDITOR_IMG_WIDTH_ATTR_RE, 'width="' + EDITOR_IMG_WIDTH + '"'));
}
function isMarkdownEditorTextarea(el) {
return !!el && el.tagName === 'TEXTAREA' && !!el.closest(SEL.markdownEditor);
}
// 写回会同步派发 input,期间置位避免重入;完成后值已定型,再次进入也不会再改动
let _fixingImgWidth = false;
function fixEditorImgWidth(textarea, focus) {
if (_fixingImgWidth) return;
const next = normalizeEditorImgWidth(textarea.value);
if (next === textarea.value) return;
const delta = next.length - textarea.value.length;
const start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : next.length;
_fixingImgWidth = true;
try {
writeNpcTextarea(textarea, next, start + delta, focus);
} finally {
_fixingImgWidth = false;
}
}
let _editorImgWidthBound = false;
function bindEditorImgWidthFix() {
if (_editorImgWidthBound) return;
_editorImgWidthBound = true;
const composing = new WeakSet();
document.addEventListener('compositionstart', e => {
if (isMarkdownEditorTextarea(e.target)) composing.add(e.target);
}, true);
document.addEventListener('compositionend', e => {
if (isMarkdownEditorTextarea(e.target)) composing.delete(e.target);
}, true);
// 上传落成文本会派发 input:捕获阶段改写;写回再触发一次 input 时值已一致,不会死循环
document.addEventListener('input', e => {
const textarea = e.target;
if (!isMarkdownEditorTextarea(textarea)) return;
if (e.isComposing || composing.has(textarea)) return;
fixEditorImgWidth(textarea, true);
}, true);
// 兜底:万一编辑器走了程序化赋值(不派发 input),失焦前再校正一次(此处不回焦,避免抢焦点)
document.addEventListener('focusout', e => {
const textarea = e.target;
if (!isMarkdownEditorTextarea(textarea) || composing.has(textarea)) return;
fixEditorImgWidth(textarea, false);
}, true);
}
// ===== 引用折叠:评论菜单点「引用」后,站点把被引内容以 markdown 引用块(> …)写进评论框,
// 这里等它写完,把插入片段里的引用行那一段用 折叠引用
包起来
//(站点在引用后面追加的 @提及 等行留在折叠块外面,回复通知不受影响)=====
const QUOTE_MENU_TEXTS = ['引用', 'Quote']; // 菜单项文案(多语言兜底)
const QUOTE_SUMMARY = '折叠引用'; // 里的字,想改就改这一处
const QUOTE_WAIT_MS = 1500; // 等站点把引用写进输入框的上限
// 从点击目标往上找「引用」菜单项:文本完全相等才算,避免误命中容器 / 正文
function findQuoteMenuItem(target) {
let el = target instanceof Element ? target : null;
for (let i = 0; el && el !== document.body && i < 4; i++, el = el.parentElement) {
if (QUOTE_MENU_TEXTS.includes((el.textContent || '').trim())) return el;
}
return null;
}
// 前后缀对比求「本次插入的片段」(重复引用同一段内容时也不会认错)
function diffInserted(before, after) {
let start = 0;
const min = Math.min(before.length, after.length);
while (start < min && before[start] === after[start]) start++;
let tail = 0;
while (tail < min - start && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail++;
return { start, end: after.length - tail, text: after.slice(start, after.length - tail) };
}
// 插入片段里「引用块」的首尾行号:站点有时会在引用块后面追加 @提及 之类的行
//(引用 NPC 时就是 @CodeBuddy),这些行要留在 外面,只包首尾引用行之间的部分
function quoteBlockBounds(lines) {
const isQuoteLine = line => line.trimStart().startsWith('>');
let first = -1;
let last = -1;
lines.forEach((line, i) => {
if (!isQuoteLine(line)) return;
if (first < 0) first = i;
last = i;
});
return first < 0 ? null : { first, last };
}
function wrapQuoteInDetails(textarea, before) {
const after = textarea.value;
if (after === before) return false; // 编辑器没变(可能点的是别处的「引用」)
const ins = diffInserted(before, after);
const lines = ins.text.split('\n');
const bounds = quoteBlockBounds(lines);
if (!bounds) return false; // 插入片段里没有引用行 → 不是「引用」写进来的,不动
const details = `\n${QUOTE_SUMMARY}
\n\n${lines.slice(bounds.first, bounds.last + 1).join('\n')}\n\n `;
// 折叠块与前后文本之间留空行(原始 HTML 块要独立成段),引用块之外的行原样保留在外面
const headLines = lines.slice(0, bounds.first);
const tailLines = lines.slice(bounds.last + 1);
const head = headLines.length ? headLines.join('\n').replace(/\n*$/, '') + '\n\n' : '';
const tail = tailLines.length ? '\n\n' + tailLines.join('\n').replace(/^\n*/, '') : '';
const patch = head + details + tail;
const next = after.slice(0, ins.start) + patch + after.slice(ins.end);
return writeNpcTextarea(textarea, next, ins.start + patch.length);
}
// 等站点写完再包:值变了且连续两帧稳定;到点仍未稳定也试一次,交给 isQuoteBlock 兜底
function wrapQuoteWhenReady(textarea, before) {
const deadline = Date.now() + QUOTE_WAIT_MS;
let last = textarea.value;
let stable = 0;
const tick = () => {
const value = textarea.value;
if (value !== before) {
if (value === last) stable++;
else { stable = 0; last = value; }
if (stable >= 2 || Date.now() >= deadline) { wrapQuoteInDetails(textarea, before); return; }
}
if (Date.now() < deadline) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
let _quoteDetailsBound = false;
function bindQuoteDetails() {
if (_quoteDetailsBound) return;
_quoteDetailsBound = true;
document.addEventListener('click', e => {
if (!findQuoteMenuItem(e.target)) return;
const textarea = getNpcEditorTextarea(); // 引用写入的是底部主评论框
if (textarea) wrapQuoteWhenReady(textarea, textarea.value);
}, true);
}
// 开关刚打开 / 切换 NPC 时,立刻把当前草稿改成带 NPC 的形态
function applyNpcPrefixToDraft(npc, prevNpc) {
const textarea = getNpcEditorTextarea();
if (!textarea) return;
const lead = getLeadingMention(textarea.value);
const next = (prevNpc && lead && lead === prevNpc)
? replaceLeadingMention(textarea.value, npc) // 开头提及正是我们此前补的 → 换成新选的
: withLeadingNpc(textarea.value, npc); // 否则补上(已有别的提及则不动)
writeLeadingNpc(textarea, next);
}
// 指令条配置指纹:内容变了才重建 DOM,避免 MutationObserver 反复重绘
function npcBarSignature(presets, npc) {
const raw = presets.map(p => p.label + '\u0001' + p.prompt).join('\u0002') + '\u0003' + npc;
let hash = 5381;
for (let i = 0; i < raw.length; i++) hash = ((hash << 5) + hash + raw.charCodeAt(i)) >>> 0;
return 'v' + hash.toString(36) + '-' + presets.length;
}
// 渲染指令条(NPC 选择器 + 模板 chip + 设置齿轮)
function buildNpcBar(presets, signature, npc) {
const bar = document.createElement('div');
bar.className = 'cnb-npc-bar';
bar.dataset.signature = signature;
// 固定项(不吃「指令模板」配置):NPC 标签本身就是开关,点击切换
// 关闭显示 NPC,开启显示 @NPC;开启后发送评论会自动在开头带上 @NPC
const mentionToggle = document.createElement('span');
mentionToggle.className = 'npc-bar-label cnb-npc-toggle';
mentionToggle.setAttribute('role', 'button');
mentionToggle.tabIndex = 0;
bar.appendChild(mentionToggle);
const select = document.createElement('select');
select.className = 'cnb-npc-select';
select.title = '选择要 @ 的 NPC';
getNpcMentions().forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
if (name === npc) option.selected = true;
select.appendChild(option);
});
bar.appendChild(select);
function updateMentionChip() {
const on = isNpcPrefixOn();
const current = getSelectedNpc();
mentionToggle.classList.toggle('active', on);
mentionToggle.setAttribute('aria-pressed', on ? 'true' : 'false');
mentionToggle.textContent = on ? '@NPC' : 'NPC';
mentionToggle.title = on
? '已开启:发送评论自动带上 @' + current + '(点击关闭)'
: '已关闭:点击开启后,发送评论自动带上 @' + current;
}
updateMentionChip();
const toggleNpcMention = e => {
e.preventDefault();
e.stopPropagation();
const on = !isNpcPrefixOn();
setNpcPrefixOn(on);
updateMentionChip();
if (on) {
const current = getSelectedNpc();
applyNpcPrefixToDraft(current); // 立刻把当前草稿补成 @NPC + 正文
showToast('已开启:发送评论自动带上 @' + current, 'ok');
} else {
showToast('已关闭:不再自动补 NPC', 'ok');
}
};
mentionToggle.addEventListener('click', toggleNpcMention);
mentionToggle.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') toggleNpcMention(e);
});
const chipTitle = (name, preset) => '插入:@' + name + ' ' + preset.prompt + '\n左键拖拽可调整顺序';
const chips = presets.map((preset, index) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'cnb-npc-chip';
chip.textContent = preset.label;
chip.title = chipTitle(npc, preset);
chip.draggable = true;
chip.dataset.index = index;
chip.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
insertNpcAtEditor('@' + getSelectedNpc() + ' ' + preset.prompt, '已插入指令模板');
});
bar.appendChild(chip);
return chip;
});
// 拖拽换位(共用实现,见 bindDragReorder):写回模板顺序后重建指令条
bindDragReorder(chips, (from, insertAt) => {
const list = getNpcPresets();
const [moved] = list.splice(from, 1);
list.splice(insertAt, 0, moved);
saveNpcPresets(list);
// 等浏览器收尾本次拖拽(dragend)后再重建,避免拖动源在拖拽中被移除
setTimeout(() => ensureNpcBar(true), 0);
showToast('已调整「' + moved.label + '」的顺序', 'ok');
});
// 切换 NPC:同步 chip 提示;开关开着时把草稿开头的旧 NPC 换成新选的
select.addEventListener('change', () => {
const prev = getSelectedNpc();
setSelectedNpc(select.value);
chips.forEach((chip, i) => { chip.title = chipTitle(select.value, presets[i]); });
updateMentionChip();
if (isNpcPrefixOn()) applyNpcPrefixToDraft(select.value, prev);
});
const gear = document.createElement('button');
gear.type = 'button';
gear.className = 'cnb-qa-settings-btn cnb-npc-bar-gear';
gear.title = 'NPC 指令设置(指令模板 / NPC 列表)';
gear.innerHTML = NPC_BOT_ICON;
gear.addEventListener('click', e => {
e.stopPropagation();
showNpcSettingsPopup(gear);
});
bar.appendChild(gear);
return bar;
}
// 维持指令条:页面/配置变化时重建,非会话页或模板清空时移除
function ensureNpcBar(force) {
if (!isCnbDomain()) return;
bindNpcPrefixSync(); // 「发送自动带 NPC」的输入监听只绑定一次
bindEditorImgWidthFix(); // 上传图片宽度改百分比的输入监听只绑定一次
bindQuoteDetails(); // 「引用」写入后自动包 的点击监听只绑定一次
if (!isNpcConversationPage()) {
document.querySelectorAll('.cnb-npc-bar').forEach(el => el.remove());
return;
}
const editor = findNpcEditor();
if (!editor || !editor.parentNode) return;
const existing = document.querySelector('.cnb-npc-bar');
// 模板可为空:此时指令条只留「NPC 选择器 + 固定 @NPC + 设置齿轮」,设置入口不会丢
const presets = getNpcPresets();
const npc = getSelectedNpc();
const signature = npcBarSignature(presets, npc);
if (!force && existing && existing.dataset.signature === signature && existing.nextElementSibling === editor) {
return; // 已就位且配置未变
}
if (existing) existing.remove();
editor.parentNode.insertBefore(buildNpcBar(presets, signature, npc), editor);
}
// NPC 指令设置弹窗:指令模板(增删改查) + NPC 列表(增删 / 设为默认)
function showNpcSettingsPopup(triggerBtn) {
document.querySelectorAll('.cnb-npc-popup').forEach(el => el.remove());
const popup = document.createElement('div');
popup.className = 'cnb-npc-popup';
popup.innerHTML = `
NPC 指令设置
指令条上的一键插入项:标签 ≤ ${NPC_PRESET_LABEL_MAX} 字,指令不限字数、支持换行;
插入内容为 @NPC 指令。最前面的 @NPC 是固定开关
(开启后发送评论自动在开头补上当前 NPC,不占用本列表)。清空全部模板后,指令条只保留固定项与设置入口。
指令条左侧「NPC 选择器」的候选,第一个是默认。系统 NPC 写 CodeBuddy,
自定义 NPC 写 仓库路径(角色名)(不用带 @),最多 ${NPC_MENTIONS_MAX} 个。
`;
document.body.appendChild(popup);
// 定位:水平居中 + 距视口顶部固定 50px(悬浮在页眉下方,不随内容高度变化)
function placePopup() {
if (!popup.isConnected) return;
popup.style.top = NPC_POPUP_TOP + 'px';
popup.style.left = Math.max(Math.round((window.innerWidth - popup.offsetWidth) / 2), 8) + 'px';
}
placePopup();
// 窗口尺寸变化时重新水平居中(弹窗已关闭就自行解绑)
const onPopupResize = () => {
if (!popup.isConnected) { window.removeEventListener('resize', onPopupResize); return; }
placePopup();
};
window.addEventListener('resize', onPopupResize);
// Tab 切换
popup.querySelectorAll('.npc-tab').forEach(tab => {
tab.addEventListener('click', () => {
popup.querySelectorAll('.npc-tab').forEach(t => t.classList.toggle('active', t === tab));
popup.querySelectorAll('.npc-pane').forEach(p => p.classList.toggle('active', p.dataset.pane === tab.dataset.tab));
});
});
// 两个页签改配置后都要顺带刷新页面上的指令条
const refreshBar = () => ensureNpcBar(true);
const presetPane = setupNpcPresetPane(popup, refreshBar);
const mentionPane = setupNpcMentionPane(popup, refreshBar);
// Esc 关闭;输入框回车即保存 / 添加(指令内容是多行 textarea:回车载入换行,Ctrl / ⌘ + 回车才保存)
popup.addEventListener('keydown', e => {
if (e.key === 'Escape') { popup.remove(); return; }
if (e.key !== 'Enter') return;
if (e.target.classList.contains('npc-prompt-input') && !e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const pane = e.target.closest('.npc-pane');
if (pane && pane.dataset.pane === 'mentions') mentionPane.add();
else presetPane.save();
});
bindPopupOutsideClose(popup, triggerBtn);
}
// —— 设置弹窗:指令模板页签(行内增删改,删除立即落盘)——
function setupNpcPresetPane(popup, refreshBar) {
const rowsBox = popup.querySelector('.npc-preset-rows');
// 整块重绘(值要转义),删除走一次事件委托,不再逐行绑监听
const presetRowHtml = p => `
`;
function renderPresetRows() {
const list = getNpcPresets();
rowsBox.innerHTML = list.length
? list.map(presetRowHtml).join('')
: '' + NPC_EMPTY_PRESETS_TIP + '
';
}
// 删除立即落盘;剩余行有未填完整的则留给「保存」统一提示
rowsBox.addEventListener('click', e => {
const del = e.target.closest('.npc-row-del');
if (!del) return;
const row = del.closest('.npc-row');
const name = row.querySelector('.npc-label-input').value.trim();
row.remove();
if (!rowsBox.querySelector('.npc-row')) rowsBox.innerHTML = '' + NPC_EMPTY_PRESETS_TIP + '
';
if (persistRowsIfValid()) {
showToast(getNpcPresets().length ? '已删除模板「' + (name || '未命名') + '」' : '已删除全部模板,指令条只保留固定项', 'ok');
} else {
showToast('已删除该行,其余改动请点「保存」', 'ok');
}
});
// 收集当前 DOM 中的模板行(整行留空视为已删除)
function collectPresetRows() {
const rows = [...rowsBox.querySelectorAll('.npc-row')];
const list = [];
let invalid = null;
rows.forEach(row => {
const labelEl = row.querySelector('.npc-label-input');
const promptEl = row.querySelector('.npc-prompt-input');
const label = labelEl.value.trim();
const prompt = promptEl.value.trim();
labelEl.classList.remove('invalid');
promptEl.classList.remove('invalid');
if (!label && !prompt) return; // 整行留空视为删除
if (!label || label.length > NPC_PRESET_LABEL_MAX) {
labelEl.classList.add('invalid');
invalid = invalid || labelEl;
}
if (!prompt) {
promptEl.classList.add('invalid');
invalid = invalid || promptEl;
}
list.push({ label, prompt });
});
return { list, invalid };
}
// 删除行后立即落盘;剩余行有未填完整的返回 false(交给「保存」按钮提示)
function persistRowsIfValid() {
const { list: next, invalid } = collectPresetRows();
if (invalid) return false;
saveNpcPresets(next);
refreshBar();
return true;
}
function savePresets() {
const { list: next, invalid } = collectPresetRows();
if (invalid) {
showToast(`标签(≤${NPC_PRESET_LABEL_MAX} 字)与指令都不能为空`, 'err');
invalid.focus();
return;
}
saveNpcPresets(next);
renderPresetRows();
refreshBar();
showToast(next.length ? '指令模板已保存' : '已清空模板,指令条只保留固定项', 'ok');
}
renderPresetRows();
function addPresetRow() {
rowsBox.querySelector('.npc-empty')?.remove();
rowsBox.insertAdjacentHTML('beforeend', presetRowHtml({ label: '', prompt: '' }));
rowsBox.querySelector('.npc-row:last-child .npc-label-input').focus();
}
popup.querySelector('.npc-add-preset').addEventListener('click', addPresetRow);
popup.querySelector('.npc-restore-presets').addEventListener('click', () => {
resetNpcPresets();
renderPresetRows();
refreshBar();
showToast('已恢复内置默认指令模板', 'ok');
});
popup.querySelector('.npc-save-presets').addEventListener('click', savePresets);
return { save: savePresets, add: addPresetRow };
}
// —— 设置弹窗:NPC 列表页签(增删,改动即时落盘)——
function setupNpcMentionPane(popup, refreshBar) {
const mentionBox = popup.querySelector('.npc-mention-list');
const mentionInput = popup.querySelector('.npc-mention-input');
// 与模板行同样是「整块重绘 + 一次委托」:当前在用的那个打标记,其余只可移除
function renderMentions() {
const current = getSelectedNpc();
mentionBox.innerHTML = getNpcMentions().map(name => `
${escapeHTML(name)}
${name === current ? '使用中' : ''}
`).join('');
}
mentionBox.addEventListener('click', e => {
const chip = e.target.closest('.npc-mention');
if (!chip || e.target.closest('button')?.dataset.act !== 'del') return;
const name = chip.dataset.name;
const wasSelected = getSelectedNpc() === name;
const before = getNpcMentions();
saveNpcMentions(before.filter(item => item !== name));
const after = getNpcMentions();
if (wasSelected) setSelectedNpc(''); // 在用的被移除 → 清掉记忆,回退到列表第一项
renderMentions();
refreshBar();
showToast(after.length === before.length - 1 ? '已移除 ' + name : '列表已空,已恢复默认 NPC', 'ok');
});
function addMention() {
const list = getNpcMentions();
const name = normalizeNpcName(mentionInput.value);
if (!name) {
showToast('格式:CodeBuddy 或 仓库路径(角色名)', 'err');
mentionInput.focus();
return;
}
if (list.indexOf(name) >= 0) {
showToast('该 NPC 已在列表中', 'err');
return;
}
if (list.length >= NPC_MENTIONS_MAX) {
showToast('最多 ' + NPC_MENTIONS_MAX + ' 个,请先移除一个', 'err');
return;
}
list.push(name);
saveNpcMentions(list);
mentionInput.value = '';
renderMentions();
refreshBar();
showToast('已添加 ' + name, 'ok');
}
renderMentions();
popup.querySelector('.npc-add-mention').addEventListener('click', addMention);
popup.querySelector('.npc-restore-mentions').addEventListener('click', () => {
resetNpcMentions();
setSelectedNpc(''); // 恢复默认列表同时清掉选择记忆
renderMentions();
refreshBar();
showToast('已恢复默认 NPC 列表', 'ok');
});
return { add: addMention };
}
// ===== 时间线侧栏开关(插在 issue 侧栏「私密」开关正下方):只看评论 / 自动展开 =====
// 「只看评论」参考 CNBuff(https://cnb.cool/wwvo/chrome-extensions/cnbuff) 的 conversation-activity-filter:
// 命中 html 上的状态类后,隐藏 .issue-pull-activity 里除 .comment / .end 以外的活动项。
// 两个开关都复用原生 TDesign 开关外观,状态走 GM_setValue 本地持久化(刷新 / 重进页面不丢)。
const SIDEBAR_TOGGLES_CLASS = 'cnb-sidebar-toggles';
const ONLY_COMMENTS_CLASS = 'cnb-only-comments'; // 挂在 上(React 重建子树不会丢)
let _onlyCommentsOn = null; // 读缓存:processPage 每次 DOM 变更都会走一遍
function isOnlyCommentsOn() {
if (_onlyCommentsOn === null) _onlyCommentsOn = GM_getValue(KEYS.onlyComments, '0') === '1';
return _onlyCommentsOn;
}
function setOnlyCommentsOn(on) {
_onlyCommentsOn = !!on;
GM_setValue(KEYS.onlyComments, on ? '1' : '0');
}
// 「只看评论」:评论全部保留;活动项里**只保留最后一条 NPC 提及行(npc-mentioned)**,
// 其余活动(私有切换 / 标签 / 状态变更等)全部隐藏 → 给该条打 .cnb-last-activity,CSS 靠它决定显隐
function markLastActivity() {
const items = document.querySelectorAll('.issue-pull-activity.npc-mentioned');
const last = items.length ? items[items.length - 1] : null;
// 换页 / 新增活动后,旧标记要挪走,避免出现两个「保留项」
document.querySelectorAll('.issue-pull-activity.cnb-last-activity').forEach(el => {
if (el !== last) el.classList.remove('cnb-last-activity');
});
if (last) last.classList.add('cnb-last-activity');
}
function clearLastActivityMark() {
document.querySelectorAll('.issue-pull-activity.cnb-last-activity').forEach(el => el.classList.remove('cnb-last-activity'));
}
// 让过滤状态与存储保持一致:即使开关此刻不在页面上(时间线尚未渲染等),刷新后也能恢复
function applyOnlyComments() {
const on = isOnlyCommentsOn();
document.documentElement.classList.toggle(ONLY_COMMENTS_CLASS, on);
// 活动项随分页 / 新事件变化,每次页面处理都重标一次;关闭时清掉标记,避免残留
if (on) markLastActivity();
else clearLastActivityMark();
}
// —— 自动展开:滚到「向下加载更多」折叠块处自动点开 ——
const AUTO_UNFOLD_SEL = TEXT.loadMoreDown.map(t => `[aria-label="${t}"]`).join(','); // 多语言兜底
let _autoUnfoldOn = null;
function isAutoUnfoldOn() {
if (_autoUnfoldOn === null) _autoUnfoldOn = GM_getValue(KEYS.autoUnfold, '0') === '1';
return _autoUnfoldOn;
}
function setAutoUnfoldOn(on) {
_autoUnfoldOn = !!on;
GM_setValue(KEYS.autoUnfold, on ? '1' : '0');
}
let _autoUnfoldObserver = null;
let _autoUnfoldLastClick = 0;
const _autoUnfoldWatched = new WeakSet();
function isInViewport(el) {
const r = el.getBoundingClientRect();
const h = window.innerHeight || document.documentElement.clientHeight;
return r.bottom > 0 && r.top < h;
}
// 连点保护:展开会改动 DOM 触发全局重扫,短时间内只允许点一次,避免连环展开
function unfoldOnce(container) {
if (!isAutoUnfoldOn()) return;
if (Date.now() - _autoUnfoldLastClick < 700) return;
const btn = container.querySelector('button');
if (!btn) return;
_autoUnfoldLastClick = Date.now();
btn.click();
}
function expandFoldsInViewport() {
document.querySelectorAll(AUTO_UNFOLD_SEL).forEach(el => { if (isInViewport(el)) unfoldOnce(el); });
}
function bindAutoUnfoldObserver() {
if (_autoUnfoldObserver) return;
_autoUnfoldObserver = new IntersectionObserver(entries => {
if (!isAutoUnfoldOn()) return;
entries.forEach(entry => { if (entry.isIntersecting) unfoldOnce(entry.target); });
}, { threshold: 0 });
}
// 给页面上每个折叠块挂一次观察;滚进视口后由观察器回调点击「展开 N 条」
function ensureAutoUnfold() {
bindAutoUnfoldObserver();
document.querySelectorAll(AUTO_UNFOLD_SEL).forEach(el => {
if (_autoUnfoldWatched.has(el)) return;
_autoUnfoldWatched.add(el);
_autoUnfoldObserver.observe(el);
});
}
// 原生「私密」开关的定位:私密
function findPrivacySwitch() {
const spans = document.querySelectorAll('span.text-base');
for (const sp of spans) {
const label = sp.textContent.trim();
if (!TEXT.privacy.includes(label)) continue;
const row = sp.parentElement;
const sw = row && row.querySelector('.t-switch');
if (sw) return sw;
}
return null;
}
// 复用页面原生样式:克隆「私密」开关节点(去掉选中 / 禁用态)+ 原生 flex / text-base 排版
function buildSidebarSwitch(cfg, privacySwitch) {
const group = document.createElement('div');
group.className = 'cnb-sidebar-switch';
// 与原生一致:每个开关区块上方都用 TDesign 分割线分隔(原生「私密」块上方也是这条)
const divider = document.createElement('div');
divider.className = 't-divider my-2 t-divider--horizontal';
group.appendChild(divider);
const row = document.createElement('div');
row.className = 'flex items-center';
const label = document.createElement('span');
label.className = 'text-base';
label.textContent = cfg.label;
row.appendChild(label);
const toggle = privacySwitch.cloneNode(true);
toggle.classList.add('ml-2');
toggle.classList.remove('t-is-checked', 't-is-disabled', 't-is-loading');
toggle.removeAttribute('disabled');
toggle.tabIndex = 0;
const sync = () => {
const on = cfg.isOn();
toggle.classList.toggle('t-is-checked', on);
toggle.setAttribute('aria-checked', on ? 'true' : 'false');
toggle.title = on ? cfg.titleOn : cfg.titleOff;
};
sync();
toggle.addEventListener('click', () => {
const on = !cfg.isOn();
cfg.setOn(on);
sync();
showToast(on ? cfg.toastOn : cfg.toastOff, 'ok');
});
row.appendChild(toggle);
group.appendChild(row);
const desc = document.createElement('p');
desc.className = 'mt-2 text-sm text-ter break-all text-justify max-w-[300px]';
desc.textContent = cfg.desc;
group.appendChild(desc);
return group;
}
function buildSidebarToggles(privacySwitch) {
const block = document.createElement('div');
block.className = SIDEBAR_TOGGLES_CLASS;
// 每个开关自成一段,各自带一条分割线(与原生区块的分隔方式一致)
block.appendChild(buildSidebarSwitch({
label: '只看评论',
desc: '开启后评论全部保留,只显示最后一条 NPC 提及行,其它活动记录全部隐藏。',
isOn: isOnlyCommentsOn,
setOn: on => { setOnlyCommentsOn(on); applyOnlyComments(); },
titleOn: '已开启:评论全保留,只留最后一条 NPC 提及行',
titleOff: '已关闭:显示全部内容',
toastOn: '已开启:评论全保留,只留最后一条 NPC 提及行',
toastOff: '已关闭:显示全部内容'
}, privacySwitch));
block.appendChild(buildSidebarSwitch({
label: '自动展开',
desc: '滚动到「展开更多」处时,自动展开折叠的楼层。',
isOn: isAutoUnfoldOn,
setOn: on => { setAutoUnfoldOn(on); if (on) expandFoldsInViewport(); },
titleOn: '已开启:滚到折叠处自动展开',
titleOff: '已关闭:手动点击展开',
toastOn: '已开启:滚到折叠处自动展开',
toastOff: '已关闭:自动展开'
}, privacySwitch));
return block;
}
// —— 侧栏开关吸附:向下滚动、开关块原位置滚出视口后固定到页眉下方,滚回来自动归位 ——
// 用 fixed(而非 sticky)实现:侧栏高度不足或祖先 overflow 时 sticky 会失效,固定定位不受影响。
// 固定期间用一个同高占位节点顶住原位置,避免侧栏内容跳位;位置 / 宽度按占位节点实时对齐(含窗口缩放)。
const TOGGLES_PINNED_CLASS = 'cnb-toggles-pinned';
const TOGGLES_PLACEHOLDER_CLASS = 'cnb-sidebar-toggles-placeholder';
const SIDEBAR_PIN_TOP = 60; // 吸附位置:页眉(约 50px 高)下方
let _togglesPinRaf = null;
let _togglesPinBound = false;
let _togglesPinWanted = false; // 只在会话页维持吸附:其它页面滚动事件直接短路,不做 DOM 查询
function unpinSidebarToggles(block) {
block.classList.remove(TOGGLES_PINNED_CLASS);
block.style.top = block.style.left = block.style.width = '';
const ph = block.previousElementSibling;
if (ph && ph.classList.contains(TOGGLES_PLACEHOLDER_CLASS)) ph.remove();
}
// 清理固定态与占位节点(离开会话页 / 重建开关块前调用,避免残留)
function clearSidebarTogglesPin() {
document.querySelectorAll('.' + TOGGLES_PINNED_CLASS).forEach(el => unpinSidebarToggles(el));
document.querySelectorAll('.' + TOGGLES_PLACEHOLDER_CLASS).forEach(el => el.remove());
}
function syncSidebarTogglesPin() {
const block = document.querySelector('.' + SIDEBAR_TOGGLES_CLASS);
if (!block) { // 开关块被移除(非会话页 / React 重建)→ 顺手清掉孤儿占位节点
document.querySelectorAll('.' + TOGGLES_PLACEHOLDER_CLASS).forEach(el => el.remove());
return;
}
let ph = block.previousElementSibling;
if (!ph || !ph.classList.contains(TOGGLES_PLACEHOLDER_CLASS)) {
// 尚未固定:原位置还在吸附线以下就不动
const rect = block.getBoundingClientRect();
if (rect.top >= SIDEBAR_PIN_TOP) return;
ph = document.createElement('div');
ph.className = TOGGLES_PLACEHOLDER_CLASS;
ph.style.height = rect.height + 'px';
block.before(ph);
block.classList.add(TOGGLES_PINNED_CLASS);
}
// 已固定:占位节点回到吸附线以下(滚回来了)→ 归位;否则对齐位置与宽度
const phRect = ph.getBoundingClientRect();
if (phRect.top >= SIDEBAR_PIN_TOP) { unpinSidebarToggles(block); return; }
block.style.top = SIDEBAR_PIN_TOP + 'px';
block.style.left = phRect.left + 'px';
block.style.width = phRect.width + 'px';
}
function scheduleSidebarTogglesPin() {
if (!_togglesPinWanted || _togglesPinRaf !== null) return;
_togglesPinRaf = requestAnimationFrame(() => {
_togglesPinRaf = null;
syncSidebarTogglesPin();
});
}
// 捕获阶段监听:无论滚的是 window 还是某个滚动容器都能收到
function bindSidebarTogglesPin() {
if (_togglesPinBound) return;
_togglesPinBound = true;
document.addEventListener('scroll', scheduleSidebarTogglesPin, { capture: true, passive: true });
window.addEventListener('resize', scheduleSidebarTogglesPin);
}
// 开关块是否仍紧贴「私密」块之后(固定态下中间会多一个占位节点)
function isTogglesInPlace(block) {
let prev = block.previousElementSibling;
if (prev && prev.classList.contains(TOGGLES_PLACEHOLDER_CLASS)) prev = prev.previousElementSibling;
return !!(prev && prev.querySelector('.t-switch'));
}
// 维持侧栏开关:会话页插到「私密」下方;非会话页或找不到「私密」时移除
function ensureSidebarToggles(force) {
applyOnlyComments();
ensureAutoUnfold();
bindSidebarTogglesPin();
_togglesPinWanted = isCnbDomain() && isNpcConversationPage();
if (!_togglesPinWanted) {
clearSidebarTogglesPin();
document.querySelectorAll('.' + SIDEBAR_TOGGLES_CLASS).forEach(el => el.remove());
return;
}
const existing = document.querySelector('.' + SIDEBAR_TOGGLES_CLASS);
// 已就位(前一兄弟仍是「私密」块,含原生开关)→ 直接返回,避免每轮都全量扫 span.text-base
if (existing && !force && isTogglesInPlace(existing)) {
scheduleSidebarTogglesPin(); // 已滚过吸附线时重建后立刻归位
return;
}
const sw = findPrivacySwitch();
if (!sw) return; // 页面上没有「私密」(无权限 / 非 issue 详情)就不注入
const host = sw.parentElement && sw.parentElement.parentElement; // 「私密」外层 div(含说明)
if (!host || !host.parentNode) return;
if (existing) { clearSidebarTogglesPin(); existing.remove(); }
host.after(buildSidebarToggles(sw));
scheduleSidebarTogglesPin();
}
// ===== 评论楼层号(issue / PR 会话页时间线,按文档顺序编号)=====
// 评论项判据:.issue-pull-activity.comment —— 正文、普通评论、机器人(NPC)评论共用这一套 class。
// 徽章挂在评论头部「作者 / 时间」那一行的末尾:
// 1) 优先 .issue-pull-comment-header-container 里不含操作按钮的那一组(右侧那组是 … 按钮);
// 2) 头部容器类名变化时(或机器人评论结构不同)退化为跟在时间元素后面,保证每条评论都有楼层号。
// 注意别用作者主页链接定位:机器人 / NPC 评论的作者名不是 a[href*="/u/"],按链接找会整条漏掉。
// 楼层号从 0 开始:正文 = #0、第一条评论 = #1,依次类推。
// 分页 / 加载更多会插入新评论,每轮 processPage 按文档顺序重写编号,只改文本不重建节点。
const FLOOR_BADGE_CLASS = 'cnb-floor-badge';
const FLOOR_ITEM_SEL = '.issue-pull-activity.comment';
const FLOOR_HEADER_SEL = '.issue-pull-comment-header-container';
const FLOOR_TIME_SEL = '.t-trigger, .text-sm.text-ter';
// 楼层号载体 = 评论头部「作者 / 时间」那一行
function findFloorHost(item) {
const header = item.querySelector(FLOOR_HEADER_SEL);
if (header) {
return Array.from(header.children).find(el => !el.querySelector('button')) || header.firstElementChild;
}
const time = item.querySelector(FLOOR_TIME_SEL); // 兜底:头部容器缺失时贴着时间走
return time && time.parentElement;
}
function ensureCommentFloors() {
if (!isCnbDomain() || !isNpcConversationPage()) {
document.querySelectorAll('.' + FLOOR_BADGE_CLASS).forEach(el => el.remove());
return;
}
let floor = 0; // 楼层号从 0 开始:正文 = #0、第一条评论 = #1 …
document.querySelectorAll(FLOOR_ITEM_SEL).forEach(item => {
const host = findFloorHost(item);
if (!host) return; // 没有头部也没有时间的条目(占位等)不占楼层号
const no = floor++;
let badge = host.querySelector('.' + FLOOR_BADGE_CLASS);
if (!badge) {
badge = document.createElement('span');
badge.className = FLOOR_BADGE_CLASS;
host.appendChild(badge);
} else if (badge !== host.lastElementChild) {
host.appendChild(badge); // 上游插入新节点后挪回末尾,避免出现两个楼层号
}
const text = '#' + no;
if (badge.textContent !== text) badge.textContent = text;
badge.title = `第 ${no} 楼`;
});
}
// ===== 只看评论:把 NPC 提及行(npc-mentioned)里的「✓ 计数 + token 用量」搬到下方评论的楼层号右边 =====
// 开启只看评论后活动行会被隐藏,但这两块信息有用(跑了几个检查、耗了多少 token),
// 所以克隆到下方那条评论的头部、紧跟楼层号;关闭只看评论 / 离开会话页时清掉克隆。
// npc-mentioned 里只有最后一条被保留(本身可见,不再重复搬),其余隐藏后才把信息搬到评论旁。
// 只复制不动原件:原件照常留在活动行里(关闭只看评论时正常可见),不会「搬走」丢掉。
const MENTION_ITEM_SEL = '.issue-pull-activity.npc-mentioned';
const MENTION_CHECK_SEL = '.ruyi-icon-check'; // ✓ 计数图标
const MENTION_TOKENS_SEL = 'a[href*="/build/logs/"]'; // token 用量链接
const MENTION_COPY_CLASS = 'cnb-mention-info'; // 克隆节点标记(清理 + 幂等判断)
// 活动行之后紧随的评论项(跳过其它活动行 / 收尾装饰)
function nextCommentItem(item) {
let el = item.nextElementSibling;
while (el) {
if (el.classList.contains('comment')) return el;
el = el.nextElementSibling;
}
return null;
}
// 需要搬运的源节点:✓ 计数行(可能多条)+ token 用量链接
function collectMentionSources(item) {
const sources = [];
item.querySelectorAll(MENTION_CHECK_SEL).forEach(icon => {
const row = icon.parentElement; // ✓ 计数那一行(图标 + 数字)
if (row && !sources.includes(row)) sources.push(row);
});
const tokens = item.querySelector(MENTION_TOKENS_SEL)
|| Array.from(item.querySelectorAll('a')).find(a => /token/i.test(a.textContent)); // 多语言兜底
if (tokens) sources.push(tokens);
return sources;
}
// 克隆到 host 里 anchor 之后;内容 / 位置都没变则不动 DOM(否则每轮都触发 MutationObserver)
function syncMentionCopies(host, sources, anchor) {
const existing = Array.from(host.querySelectorAll('.' + MENTION_COPY_CLASS));
const sameText = existing.length === sources.length
&& existing.map(el => el.textContent).join('|') === sources.map(el => el.textContent).join('|');
if (sameText) {
let prev = anchor;
let inPlace = existing.every(el => {
const ok = el.previousElementSibling === prev;
prev = el;
return ok;
});
if (inPlace) return;
}
existing.forEach(el => el.remove());
let prev = anchor;
sources.forEach(src => {
const copy = src.cloneNode(true);
copy.classList.add(MENTION_COPY_CLASS);
copy.removeAttribute('id');
// 源节点里的图标带 id(如 id="check"),克隆后要去掉避免同页重复 id
copy.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
prev.after(copy);
prev = copy;
});
}
function ensureMentionInfoInline() {
const removeCopies = () => document.querySelectorAll('.' + MENTION_COPY_CLASS).forEach(el => el.remove());
if (!isCnbDomain() || !isNpcConversationPage() || !isOnlyCommentsOn()) {
removeCopies();
return;
}
// 目标评论 → 负责写入的活动行;同一评论前有多条提及行时取最后一条,保证只有一方写 DOM
const owners = new Map();
document.querySelectorAll(MENTION_ITEM_SEL).forEach(item => {
const comment = nextCommentItem(item);
if (comment) owners.set(comment, item);
});
owners.forEach((item, comment) => {
const host = findFloorHost(comment);
if (!host) return;
const sources = collectMentionSources(item);
// 该活动行此刻仍可见(只看评论会保留下最后一条活动)→ 信息本来就在页面上,
// 不再搬到下面;同时清掉可能残留的克隆(标记移走后这一行会重新变可见)
if (!sources.length || item.getClientRects().length) {
host.querySelectorAll('.' + MENTION_COPY_CLASS).forEach(el => el.remove());
return;
}
// 挂在楼层号右边;楼层号缺失(异常结构)时退到元信息行末尾
const anchor = host.querySelector('.' + FLOOR_BADGE_CLASS) || host.lastElementChild || host;
syncMentionCopies(host, sources, anchor);
});
}
// CNB 网站功能初始化
function initCnbFeatures() {
if (!isCnbDomain()) return;
// 处理短链接跳转(仅执行一次)
handleCnbGotoPage();
// 添加点击事件委托(仅添加一次)
document.addEventListener('click', cnbGotoClickHandler, true);
// 页面处理:挂到共享调度器(与收藏夹共用同一个 MutationObserver + 节流)
registerPageProcessor(() => {
addCreateRepoButton();
rewriteCnbGotoLinks();
enhanceGridLayout();
addDownloadButtonFeature();
addQuickAccessBar();
ensureNpcBar();
ensureSidebarToggles();
ensureCommentFloors();
ensureMentionInfoInline();
});
ensurePageObserver();
// 立即尝试添加
schedulePageProcess();
// SPA 路由变化 / 页面重新可见时补一次处理
window.addEventListener('popstate', () => setTimeout(schedulePageProcess, 100));
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') setTimeout(schedulePageProcess, 100);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCnbFeatures);
} else {
initCnbFeatures();
}
})();