// ==UserScript== // @name CNB Cool 增强 // @namespace https://cnb.cool/IIIStudio/Code/Greasemonkey/CNB // @version 1.16.0 // @description CNB.Cool 综合增强工具:直达链接解码、网格布局切换、收藏夹标签管理(含 AI 自动打标签)、与我有关标签、创建仓库按钮、快捷导航栏(支持拖拽排序/右键编辑/导入导出)、云原生开发模式(点击头像启动)、NPC 快捷指令(指令模板/NPC 列表/角色提示词)、AI 润色、时间线只看评论/自动展开/评论楼层号、引用自动折叠等功能 // @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 列表 / 角色提示词 / AI 润色)与评论「引用」自动折叠 * 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: flex; gap: 8px; align-items: center; } .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; } /* 页眉这块只剩 AI 打标签按钮,且只在收藏夹视图出现(导入 / 导出已挪到快捷导航设置弹窗) */ .cnb-backup-btn-group .cnb-fav-ai-btn { display: none; } body.cnb-fav-active .cnb-backup-btn-group .cnb-fav-ai-btn { display: inline-flex; } /* AI 打标签按钮:只要图标,不要边框 / 底色(悬停给个淡灰底,和指令条那个图标按钮一致) */ .cnb-backup-btn-group .cnb-fav-ai-btn { border: none; background: transparent; padding: 4px; border-radius: 4px; line-height: 0; align-self: center; align-items: center; justify-content: center; } .cnb-backup-btn-group .cnb-fav-ai-btn:hover { border: none; background: rgba(78,89,105,0.08); } .cnb-backup-btn-group .cnb-fav-ai-btn:focus-visible { outline: 2px solid rgba(78,89,105,0.4); outline-offset: 2px; } .cnb-backup-btn-group .cnb-fav-ai-btn:disabled { opacity: 0.6; cursor: progress; } .cnb-backup-btn-group .cnb-fav-ai-btn.busy svg { animation: cnb-ai-spin 1.1s linear infinite; } /* 快捷导航栏样式 */ .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-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; } /* 备份 / 还原(导出 / 导入):和 COS 云同步放在同一个弹窗里 */ .cnb-qa-cos-popup .cos-backup-actions { margin-top:12px;border-top:1px solid #eee;padding-top:12px; } .dark .cnb-qa-cos-popup .cos-backup-actions { border-top-color:#333; } .cnb-qa-cos-popup .cos-backup-label { font-size:11px;color:#999; } .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 的二次元粉 / 品红基调(粉紫高饱和 + 柔光)。 注意:类名必须带独立前缀 —— 兄弟脚本 CNBIssue 也用了 .cnb-toast, 且带 !important 的隐藏态(opacity:0 / translateX(120%)),会把我们的提示条直接按没。 */ .cnb-enh-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-enh-toast.cnb-enh-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-enh-toast.cnb-enh-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-enh-toast.cnb-enh-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; } /* 条尾靠右的一组:AI 润色按钮 + 设置齿轮(润色在齿轮左边,由润色按钮撑开左侧空白) */ .cnb-npc-bar .cnb-npc-bar-gear { margin-left: 0; } /* 润色:站点官方 AI 图标按钮(图标自带配色,底色 / 悬停与齿轮同款灰) */ .cnb-npc-bar .cnb-npc-polish { margin-left: auto; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border: none; background: transparent; padding: 3px 4px; border-radius: 4px; line-height: 0; } .cnb-npc-bar .cnb-npc-polish:hover { background: rgba(78,89,105,0.08); } .dark .cnb-npc-bar .cnb-npc-polish:hover { background: rgba(201,205,212,0.12); } .cnb-npc-bar .cnb-npc-polish:focus-visible { outline: 2px solid rgba(78,89,105,0.4); outline-offset: 2px; } .cnb-npc-bar .cnb-npc-polish.busy { cursor: progress; } .cnb-npc-bar .cnb-npc-polish.busy svg { animation: cnb-ai-spin 1.1s linear infinite; } @keyframes cnb-ai-spin { to { transform: rotate(360deg); } } /* 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-expand-btn { border: none; background: transparent; color: #bbb; cursor: pointer; padding: 3px 4px; border-radius: 4px; flex-shrink: 0; line-height: 0; } .cnb-npc-popup .npc-expand-btn:hover { color: #4e5969; background: rgba(78,89,105,0.08); } .cnb-npc-popup .npc-expand-btn.active { color: #4e5969; background: rgba(78,89,105,0.1); } .cnb-npc-popup .npc-row.npc-row-open { align-items: flex-start; } .cnb-npc-popup .npc-row .npc-prompt-input.expanded { height: 140px; min-height: 140px; max-height: none; resize: vertical; overflow-y: auto; } .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-role-radio { flex-shrink: 0; margin: 0; cursor: pointer; } .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', npcRoles: 'cnb_npc_roles', aiBase: 'cnb_ai_base', // 最近一次成功的 AI 调用所用的仓库路径(收藏页借它调接口) onlyComments: 'cnb_only_comments', autoUnfold: 'cnb_auto_unfold', gridLayout: 'cnbGridLayout' // 历史遗留:网格布局偏好一直存在 localStorage }; const COS_FILE_PATH = 'CNB/CNB.json'; // COS 同步对象路径(非 GM 存储键) // 站点官方的 AI 图标(Issue 名称右侧「AI 自动生成」同款):AI 润色按钮 / 收藏页 AI 打标签共用; // 去掉原节点的 id 避免与站点自己的重复 const NPC_AI_ICON = ''; // 站点选择器(集中管理,上游改版时只改这里) 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', repoCardDesc: '.mt-1.text-sm.font-normal.text-sec.line-clamp-2' // 收藏卡片描述行(AI 打标签时读它) }; // 文案(数组 = 多语言兜底,按顺序匹配) 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); } // 写盘 + 立刻重绘这张卡片的标签区(整体视图刷新由调用方决定时机:AI 打标签要等一批跑完) function setCardTags(card, href, tags) { setRepoTags(href, tags); const box = card.querySelector('.cnb-tags-container'); if (box) renderTags(box, card, href); } 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(); refreshFavView(true); showToast('已全部清理', 'ok'); } else { popup.querySelector('.orphan-title').textContent = `被删除的仓库标签(共 ${remaining.length} 项)`; const btnDelAll = popup.querySelector('.btn-del-all'); if (btnDelAll) btnDelAll.textContent = `删除全部(${remaining.length} 项)`; refreshFavView(true); } } }); }); // 删除全部 popup.querySelector('.btn-del-all').addEventListener('click', async () => { if (await showConfirm(`确定删除全部 ${orphans.length} 项被删除仓库的标签数据吗?`)) { getOrphanedEntries().forEach(o => deleteOrphanedEntry(o.href)); popup.remove(); refreshFavView(true); showToast(`已清理 ${orphans.length} 项孤立数据`, 'ok'); } }); // 关闭 popup.querySelector('.btn-close').addEventListener('click', () => popup.remove()); bindPopupOutsideClose(popup, triggerEl); } // 「被删除的」按钮的显示前提:必须确认仓库列表已**全部加载完**。 // 卡片是滚动 / 分页异步加载的(首屏往往只有 10 个),只靠「数量稳定」会把还没加载出来的收藏误报成「被删除」。 // 判据:卡片数 ≥ 页签上的仓库总数(页签形如「代码仓库(41)」),再静置 FAV_GRID_SETTLE_MS 收尾;读不到总数就不显示。 const FAV_GRID_SETTLE_MS = 1500; let _favGridPk = ''; let _favGridCount = -1; let _favGridChangedAt = 0; let _orphanRefreshTimer = null; // 从页签文案里读仓库总数(跳过脚本自己注入的「收藏夹」页签),页签形如「代码仓库(41)」。 // 取各页签里的最大值:偏大只会让我们更保守(宁可不显示,也不能报错数字)。 function favGridTotal() { let total = 0; document.querySelectorAll('.t-radio-button.issue-list-switcher').forEach(lb => { if (lb.classList.contains('cnb-fav-tab')) return; const text = (lb.textContent || '').replace(/\s+/g, ''); const m = text.match(/[((](\d+)[))]/); if (m) { total = Math.max(total, Number(m[1])); return; } // 兜底:数量单独放在一个 span 里、且没有括号 const span = lb.querySelector('.ml-1, .text-ter'); const digits = span ? String(span.textContent || '').match(/\d+/) : null; if (digits) total = Math.max(total, Number(digits[0])); }); return total; } 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(); } if (!n) return false; // 卡片是分批异步加载的(首屏往往只有 10 个),没加载全就统计「被删除的」必然误报。 // 所以这里必须确认已全部加载完:卡片数达到页签总数才算;拿不到总数时一律不显示。 const total = favGridTotal(); if (total <= 0 || n < total) return false; return (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 备份与还原 ===== // 备份内容概览:导出成功时给个明确反馈(顺便能看出都包含了哪些数据) function backupSummary() { const data = getData(); // 标签按页面 URL 分 key 存储,同步/导出包含全量数据,汇总也应统计所有 key, // 否则在其它页面触发导出时会因为当前 pageKey 下没有标签而显示「收藏标签 0 条」。 let tagged = 0; Object.values(data).forEach(page => { if (!page) return; Object.values(page).forEach(it => { if (it && it.tags && it.tags.length) tagged++; }); }); const npc = getNpcBackup(); return '收藏标签 ' + tagged + ' 条 / 快捷导航 ' + getQALinks().length + ' 个' + ' / 指令模板 ' + npc.presets.length + ' 条 / NPC ' + npc.mentions.length + ' 个' + ' / 角色提示词 ' + npc.roles.length + ' 条'; } async function exportJSON() { const data = getData(); const qaLinks = getQALinks(); const allData = { favorites: data, quickAccess: qaLinks, npc: getNpcBackup() }; const jsonStr = JSON.stringify(allData, null, 2); const summary = backupSummary(); if (isCosEnabled()) { try { await cosUpload(jsonStr); showToast('已同步到 COS 云端(' + COS_FILE_PATH + ')|' + summary, 'ok'); } catch (e) { showToast('COS 上传失败:' + e.message + ',已改为本地下载|' + summary, 'err'); doLocalExport(); } } else { doLocalExport(); showToast('已导出备份文件|' + summary, 'ok'); } function doLocalExport() { // 用 Blob 而不是 data: URL:数据量大时更稳(也不受 URL 长度限制) const blob = new Blob([jsonStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.setAttribute('href', url); anchor.setAttribute('download', `cnb_cool_backup_${new Date().getTime()}.json`); document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } } 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); }; showToast('请在弹出的窗口里选择备份 JSON 文件', 'ok'); input.click(); } } async function applyImport(parsedData) { const scope = ['标签、收藏与导航栏数据']; if (parsedData.npc) scope.push('NPC 指令设置(指令模板 / 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(); } } // 导入 / 导出 / AI 打标签:用事件委托只绑一次。 // 站点会重渲染页眉,节点一旦被换掉,直接绑在按钮上的监听就失效(表现就是「点了没反应」);委托不受影响。 let _backupClickBound = false; function bindBackupClicks() { if (_backupClickBound) return; _backupClickBound = true; document.addEventListener('click', e => { const btn = e.target && e.target.closest ? e.target.closest('.cnb-backup-btn-group button, #cos-btn-export, #cos-btn-import, #qa-btn-export, #qa-btn-import') : null; if (!btn) return; e.preventDefault(); e.stopPropagation(); // 这几个都是 async:失败时不接住会变成静默的 unhandled rejection(看着就像「点了没反应」) const run = fn => { try { const p = fn(); if (p && typeof p.catch === 'function') p.catch(err => showToast('操作失败:' + ((err && err.message) || err), 'err')); } catch (err) { showToast('操作失败:' + ((err && err.message) || err), 'err'); } }; // 三个入口共用同一套导出 / 导入:快捷导航「编辑」弹窗、COS 设置弹窗、收藏页 AI 按钮 if (btn.id === 'cos-btn-export' || btn.id === 'qa-btn-export') run(exportJSON); else if (btn.id === 'cos-btn-import' || btn.id === 'qa-btn-import') run(importJSON); else if (btn.classList.contains('cnb-fav-ai-btn')) run(() => aiTagFavorites(btn)); }, true); } function addBackupButtons() { bindBackupClicks(); 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 existing = document.querySelector('.cnb-backup-btn-group'); if (existing && existing.querySelector('.cnb-fav-ai-btn')) return; // 已是新版 if (existing) existing.remove(); // 旧版本残留(里面没有 AI 按钮)→ 重建 const btnGroup = document.createElement('div'); btnGroup.className = 'cnb-backup-btn-group'; btnGroup.innerHTML = ` `; // 点击统一走 bindBackupClicks() 的事件委托,这里不再逐个绑监听 const rightWrap = targetHeader.lastElementChild; if (rightWrap && rightWrap.tagName === 'DIV' && rightWrap.classList.contains('flex')) { rightWrap.appendChild(btnGroup); } else { targetHeader.appendChild(btnGroup); } } // ===== AI 打标签(收藏页 导出 旁边的 AI 图标)===== // 规则:只从「已有标签」里挑,绝不允许 AI 造新标签;卡片描述为空就不打标签。 // 每个仓库一条请求:system 负责约束,user 给出「可用标签库 + 仓库标题 + 仓库描述」, // 返回纯文本,多个标签用英文逗号分隔(参考既有实现的写法)。 const FAV_AI_TAG_SYSTEM = [ '你是一个智能仓库分类助手。你的任务是从【可用标签库】中挑选出适合给定【仓库标题与描述】的标签。', '限制条件:', '1. 只能从【可用标签库】中选择,绝对不能创造新标签!', '2. 一个仓库可能同时适合多个标签,请把合适的都列出来(一般 1~3 个,宁缺毋滥)。', '3. 输出格式必须是纯文本,如果有多个标签请用英文逗号(,)分隔,不要在标签之间或首尾加任何其它字符。', '4. 不要输出任何解释说明、前缀或后缀内容。如果没有任何合适的标签,直接输出空字符串。' ].join('\n'); const FAV_AI_TAG_CONCURRENCY = 3; // 同时进行的请求数(避免一次轰太多) const FAV_AI_TAG_TIMEOUT_MS = 90000; // 整批超时(到点直接中止,不再发新请求) let _favAiTagBusy = false; // AI 接口挂在某个仓库下;收藏页本身不是仓库路径 → 借一个仓库来调。 // 优先用「最近一次调用成功的仓库」(可能是你在别的仓库页点过润色), // 否则退化为页面上第一张带合法路径的卡片(异步渲染时前几张可能还是骨架占位,要跳过)。 function favAiBasePath(grid) { const remembered = normRepoPath(GM_getValue(KEYS.aiBase, '')); if (remembered && remembered.split('/').length >= 3) return remembered; let base = ''; grid.querySelectorAll(':scope > div').forEach(card => { if (base) return; const p = normRepoPath(getRepoHref(card)); if (p && p.split('/').length >= 3) base = p; }); if (!base) throw new Error('页面上找不到可用的仓库路径,无法调用 AI 接口'); return base; } // 卡片是否已全部加载完(页签上有总数时用它判断),返回 'x/y' 文案或空串 function favLoadProgress(grid) { const total = favGridTotal(); const loaded = grid.querySelectorAll(':scope > div').length; return total > 0 && loaded < total ? loaded + '/' + total : ''; } // 滚到底(列表是滚到底才继续加载的):优先网格自己的可滚动祖先,找不到就滚页面 function favScrollBottom() { const grid = document.querySelector(SEL.repoGrid); let node = grid ? grid.parentElement : null; while (node && node !== document.body) { if (node.scrollHeight > node.clientHeight + 20) { node.scrollTop = node.scrollHeight; return; } node = node.parentElement; } window.scrollTo(0, (document.scrollingElement || document.documentElement).scrollHeight); } // 自动把收藏卡片加载全(最多 10 轮 × 0.5s;连续两轮数量没涨就认为加载不动了) async function loadAllFavCards(onTick) { let last = -1; let stall = 0; for (let i = 0; i < 10; i++) { const grid = document.querySelector(SEL.repoGrid); const total = favGridTotal(); const n = grid ? grid.querySelectorAll(':scope > div').length : 0; if (total > 0 && n >= total) return; if (n === last) { if (++stall >= 2) return; } else { stall = 0; last = n; } if (onTick) onTick(n, total); favScrollBottom(); await new Promise(r => setTimeout(r, 500)); } } // AI 返回的纯文本 → 切开后只保留标签库里真实存在的标签(大小写不同也归一到库里的写法)。 // 大小写 / 首尾引号与标点都不影响命中,但最终必须能在标签库里找到,否则丢弃(绝不让 AI 造标签)。 function pickPoolTags(answer, pool) { const out = []; String(answer || '').split(/[,\uFF0C、;;\n\r]/).forEach(raw => { const name = raw.trim() .replace(/^["'「『【\[((]+/, '') .replace(/["'」』】\]))。..,,;;::!!??]+$/, '') .trim(); if (!name) return; const hit = pool.filter(t => t === name)[0] || pool.filter(t => t.toLowerCase() === name.toLowerCase())[0]; if (hit && out.indexOf(hit) < 0) out.push(hit); }); return out; } async function aiTagFavorites(btn) { if (_favAiTagBusy) return; if (!document.querySelector(SEL.repoGrid)) { showToast('没找到收藏列表', 'err'); return; } const pool = getAllTags(); if (!pool.length) { showToast('还没有任何标签:先在卡片上手动打一个标签', 'err'); return; } // 一进来就进忙碌态:既要挡重复点击,也要把「自动加载列表」这段时间展示出来 _favAiTagBusy = true; const idleTitle = btn.title; btn.classList.add('busy'); btn.disabled = true; let controller = null; let timer = null; try { // 卡片是滚到底才继续加载的 → 先自动滚到底把列表加载全,免得只处理已渲染的那几张 btn.title = '加载收藏列表…'; await loadAllFavCards((n, total) => { btn.title = '加载收藏列表… ' + n + (total ? '/' + total : ''); }); const grid = document.querySelector(SEL.repoGrid); if (!grid) { showToast('没找到收藏列表', 'err'); return; } const loadTip = favLoadProgress(grid); // 自动滚完仍没加载全 → 只能处理已渲染的部分 // 只处理「没有标签 且 有描述」的卡片:已有标签的不动,描述为空的不打标签 const items = []; grid.querySelectorAll(':scope > div').forEach(card => { const href = getRepoHref(card); if (!href || getRepoTags(href).length) return; const descEl = card.querySelector(SEL.repoCardDesc); const desc = descEl ? descEl.textContent.replace(/\s+/g, ' ').trim() : ''; if (!desc) return; const titleEl = card.querySelector('a[href*="/"][target="_self"]'); const title = titleEl ? titleEl.textContent.replace(/\s+/g, ' ').trim() : ''; items.push({ href, card, desc, title: title || href.replace(/^\//, '') }); }); if (!items.length) { showToast('没有需要打标签的收藏(已有标签 / 描述为空的已跳过)', 'ok'); return; } const base = favAiBasePath(grid); controller = typeof AbortController === 'function' ? new AbortController() : null; timer = setTimeout(() => { if (controller) controller.abort(); }, FAV_AI_TAG_TIMEOUT_MS); let done = 0; let applied = 0; let addedTags = 0; let failed = 0; let firstErr = ''; const progress = () => { btn.title = 'AI 正在分类… ' + done + '/' + items.length; }; progress(); const runOne = async item => { // 标签库按行列出(分隔符不用逗号,避免和「输出用逗号分隔」互相干扰) const user = '可用标签库:\n' + pool.map(t => '- ' + t).join('\n') + '\n\n仓库标题:' + item.title + '\n仓库描述:' + item.desc; const answer = await askCnbAi(user, FAV_AI_TAG_SYSTEM, null, controller ? controller.signal : undefined, base); const picked = pickPoolTags(answer, pool); // 返回里可能有多个,全部保留 if (!picked.length) return; const tags = getRepoTags(item.href); const add = picked.filter(t => tags.indexOf(t) < 0); // 只加库里已有的、且这个仓库还没有的 if (!add.length) return; setCardTags(item.card, item.href, tags.concat(add)); applied++; addedTags += add.length; }; // 各仓库独立请求:单个失败(超时 / 限流)不影响其它仓库 const queue = items.slice(); const worker = async () => { for (;;) { const item = queue.shift(); if (!item) return; try { await runOne(item); } catch (e) { failed++; // 记住第一条错误原因并带进 toast(否则只能看到「N 个请求失败」却不知道为什么) if (!firstErr) firstErr = (e && e.message) || String(e); } done++; progress(); } }; await Promise.all(Array.from({ length: Math.min(FAV_AI_TAG_CONCURRENCY, items.length) }, worker)); refreshFavView(); // 结果文案:全失败 / 部分失败 / 没匹配到都说清原因,且尽量短(只报第一条错误) let msg; if (applied) msg = 'AI 已给 ' + applied + ' 个收藏打上 ' + addedTags + ' 个标签'; else if (failed === items.length) msg = 'AI 打标签失败'; else msg = 'AI 没找到合适的已有标签,未改动'; if (failed) msg += ':' + (firstErr || '请求失败') + (failed > 1 ? '(另 ' + (failed - 1) + ' 个也失败)' : ''); if (loadTip) msg += '(列表只加载了 ' + loadTip + ')'; showToast(msg, failed ? 'err' : 'ok'); } finally { if (timer) clearTimeout(timer); _favAiTagBusy = false; btn.classList.remove('busy'); btn.disabled = false; btn.title = idleTitle; } } // ===== 状态 ===== 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); uncheckFavTab(); 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); } // 把 TDesign 单选组的滑动底色对齐到指定项(收藏夹页签 / 筛选栏标签通用) 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'; } // 取消「收藏夹」页签的选中样式(切回普通视图时用) function uncheckFavTab() { const favTab = document.querySelector('.cnb-fav-tab'); if (!favTab) return; favTab.classList.remove('t-is-checked'); const text = favTab.querySelector('.font-bold-placeholder'); if (text) { text.classList.remove('text-pri', 'font-semibold'); text.classList.add('text-sec'); } } // ===== 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; updateBgBlock(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'; }); } // 标签数据变了以后统一刷新:页签计数 / 筛选栏标签 / 当前筛选结果(withOrphans 时顺带重算「被删除的」) function refreshFavView(withOrphans) { updateFavCount(); syncFilterTags(); if (_favActive) { const act = document.querySelector('.cnb-fav-filter-bar .t-is-checked'); filterFavCards(act ? act.getAttribute('data-tag') || 'all' : 'all'); } if (withOrphans) updateOrphanedBtnCount(); } // ===== 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 = '
'; html += ''; for (const tag of allTags) { const escapedTag = escapeHTML(tag); html += ``; } 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; updateBgBlock(this); filterFavCards(this.getAttribute('data-tag') || 'all'); }); }); const first = bar.querySelector('.t-radio-button'); if (first) updateBgBlock(first); } // ===== 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; setCardTags(card, rh, getRepoTags(rh).filter(x => x !== t)); refreshFavView(); }); }); } 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; } const tags = getRepoTags(href); setCardTags(card, href, tags.includes(val) ? tags : tags.concat(val)); popup.remove(); refreshFavView(); } 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; updateBgBlock(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); } } // 按钮组独立维护:站点重渲染页眉把它吃掉、或旧版本残留时补回来 if (!document.querySelector('.cnb-backup-btn-group')) addBackupButtons(); 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 秒后向右滑出消失) // 类名用 cnb-enh-toast:与 CNBIssue 等兄弟脚本的 .cnb-toast 隔离(它会用 !important 把同名元素隐藏) function showToast(msg, type) { document.querySelectorAll('.cnb-enh-toast').forEach(el => el.remove()); const toast = document.createElement('div'); toast.className = 'cnb-enh-toast cnb-enh-toast-' + (type || 'ok'); toast.textContent = msg; document.body.appendChild(toast); setTimeout(() => { toast.classList.add('cnb-enh-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 = `
${msg}
`; 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 = `
腾讯云 COS 设置(导入导出云端同步)
格式:bucketname-appid,如 mybucket-1250000000
数据备份 / 还原
`; 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; } // 表单 → 配置对象(测试 / 保存共用,避免两处抄同一段取值) const readForm = () => ({ 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() }); // 测试连接 popup.querySelector('#cos-btn-test').addEventListener('click', async () => { setStatus('正在测试连接...', ''); const cfg2 = readForm(); if (!cfg2.SecretId || !cfg2.SecretKey || !cfg2.Bucket || !cfg2.Region) { setStatus('请填写完整配置信息', 'err'); return; } try { setCosConfig(cfg2); await cosRequest('HEAD', '', null, cfg2); setStatus('连接成功!COS 可用', 'ok'); } catch (e) { setStatus('连接失败: ' + e.message, 'err'); } }); // 保存 popup.querySelector('#cos-btn-save').addEventListener('click', () => { setCosConfig(readForm()); 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 = `
CNB ${isEdit ? '编辑快捷导航' : '添加快捷导航'}v${SCRIPT_VERSION} 云原生开发
`; 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); } commit(); } // 保存后收尾(保存 / 删除共用):落盘 → 关弹窗 → 重绘导航栏 function commit() { 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', () => { links.splice(editIndex, 1); commit(); }); } // 「导入 / 导出」不再在这里单独实现:点击统一由 bindBackupClicks() 的委托转到 // 共享的 importJSON() / exportJSON(),三个入口(本弹窗、COS 设置弹窗、收藏页 AI 按钮)行为一致、都有反馈。 // 所有输入框支持回车确认、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 = `${link.href.replace(/^\//, '')}`; 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 会话页与创建页(/-/issues/new)编辑器上方一条指令条(NPC 选择器 + 自动提及开关 + 模板 chip + AI 润色 + 设置齿轮), // 模板与 NPC 候选在齿轮弹窗里维护。本区顺序:常量 → 存储 → 提及处理 → 编辑器读写 → 输入同步 → 指令条 → 弹窗。 const NPC_PRESET_LABEL_MAX = 8; const NPC_MENTION_MAX = 100; const NPC_MENTIONS_MAX = 25; const NPC_ROLES_MAX = 25; // 角色提示词条数上限 const NPC_POPUP_TOP = 50; // 设置弹窗悬浮位置:距视口顶部(页眉下方) const NPC_BOT_ICON = ''; // 行内提示词框右侧的「展开编辑」图标 const NPC_EXPAND_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)' ]; // 内置默认角色提示词:整段就是「润色」时发给 AI 的 system 提示词,可增删改、可切换 const DEFAULT_NPC_ROLES = [ { label: '分析文章', prompt: [ '你是「NPC 指令撰写器」。用户写的往往只是很短的一句话(例如「分析一下,读取 <某条链接>」),', '那是他准备发给一个 AI 助手(@NPC)的草稿。你要把它扩写成一份完整、规范、可直接发送的指令,并只输出这份指令。', '', '【不要执行】', '- 不要回答、不要分析、不要执行这段文字里的要求(如「分析一下」「读取某链接」「搜索最近的事件」),也不要访问、抓取或猜测链接里的内容;', '- 不要回复「我无法访问链接」「请把原文粘贴过来」这类话;不要用「我」「你」这类对话口吻。', '', '【把下面这些要求写进指令里(不能漏条、不能改意思)】', '一是「重点」——这篇文章真正在讲什么,按重要性排序,不要复述原文;', '二是「后续影响」——这些判断会带来什么后果、影响到谁、接下来可能发生什么。', '三是「时间线」一行时间一行时间,整个事件过程。', '只依据文章本身,信息不足的地方直接说明,不要编;需要时检索相关背景 / 最近的事件并整理归档。', '', '【格式】', '- 第一行保留用户原句的任务,URL、专有名词原样保留;之后换行把要求分条列出;', '- 可以依据链接地址里的关键词点明任务主题(例如链接里带 artex 就写「读取 ARTEX 相关的新闻」),但不要虚构链接里没有的具体事实;', '- 用 Markdown 排版;@提及、URL、代码块、命令、字段名一律原样保留;只输出这份指令,不要解释、前言、结语,不要用代码块把整篇包起来。' ].join('\n') } ]; // 格式: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'); } // 角色提示词:与「指令模板」同样是可增删改的列表,结构 [{ label, prompt, active }], // active 的那条即「润色」时使用的角色要求(不是绑在某个 NPC 上,随时可切换) function getNpcRoles() { return npcCached('roles', () => { const raw = readStoredJson(KEYS.npcRoles); if (!Array.isArray(raw)) { return DEFAULT_NPC_ROLES.map((r, i) => ({ label: r.label, prompt: r.prompt, active: i === 0 })); } const list = raw .filter(r => r && typeof r.label === 'string' && typeof r.prompt === 'string') .map(r => ({ label: r.label, prompt: r.prompt, active: !!r.active })) .slice(0, NPC_ROLES_MAX); if (list.length && !list.some(r => r.active)) list[0].active = true; // 至少有一条在用 return list; }).map(r => ({ label: r.label, prompt: r.prompt, active: r.active })); // 调用方会就地改,返回副本 } function saveNpcRoles(list) { GM_setValue(KEYS.npcRoles, JSON.stringify(list.map(r => ({ label: String(r.label).trim(), prompt: String(r.prompt).trim(), active: !!r.active })))); dropNpcCache('roles'); } function resetNpcRoles() { GM_setValue(KEYS.npcRoles, 'null'); dropNpcCache('roles'); } // 当前用于「润色」的角色提示词(active 的那条;没有 active 就用第一条) function getActiveNpcRole() { const list = getNpcRoles(); const hit = list.filter(r => r.active)[0] || list[0]; return hit ? hit.prompt : ''; } 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(), roles: getNpcRoles(), 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')); // 旧备份没有 roles 字段 → 保留现有角色提示词;是数组则整体覆盖 if (Array.isArray(npc.roles)) { const valid = npc.roles.filter(r => r && typeof r.label === 'string' && typeof r.prompt === 'string'); // 空数组是合法状态(清空角色提示词);整段脏数据则跳过,别把现有配置冲掉 if (valid.length || npc.roles.length === 0) { saveNpcRoles(valid.map(r => ({ label: r.label, prompt: r.prompt, active: !!r.active }))); } } 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 + '\n' + text; } /** 替换草稿开头的提及(切换 NPC 时用):提及同样单独占一行 */ function replaceLeadingMention(value, npc) { const rest = String(value).replace(NPC_LEAD_MENTION_RE, '').replace(/^\s+/, ''); return '@' + npc + '\n' + 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); } // 创建页:/…/-/issues/new、/…/-/pulls/new(编辑器结构与会话页一致,但没有时间线) function isNpcCreatePage() { return /\/-\/(issues|pulls)\/new(?:\/|$)/.test(location.pathname); } // 指令条 / 草稿补 NPC 的生效页面:会话页 + 创建页。 // 时间线相关功能(只看评论 / 楼层号 / 侧栏开关等)仍然只看 isNpcConversationPage。 function isNpcEditorPage() { return isNpcConversationPage() || isNpcCreatePage(); } // 主评论编辑器 = 文档顺序最后一个 .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; } // 是否是「当前页面的主编辑框」:只在会话页 / 创建页、且是底部那个编辑器才算,inline 编辑框不算 function isNpcMainEditor(el) { if (!isNpcEditorPage()) return false; if (!el || el.tagName !== 'TEXTAREA') return false; const editor = findNpcEditor(); return !!editor && editor.contains(el); } /** * 「指令模板」插入:模板正文写进光标处,`@提及` 只在草稿**开头**补一次、且**单独占一行**。 * 开头已经有 @提及(自己 @ 的人或别的 NPC)就原样不动,避免点几次叠出好几个 @NPC。 */ function insertNpcPreset(preset) { const textarea = getNpcEditorTextarea(); if (!textarea) { showToast('未找到评论输入框', 'err'); return; } if (!insertNpcText(textarea, preset.prompt)) { showToast('插入失败,请手动输入', 'err'); return; } // 插入后兜底:草稿开头没有提及才补当前 NPC(已有提及则保持不动,光标由 writeLeadingNpc 顺移) const next = withLeadingNpc(textarea.value, getSelectedNpc()); if (next !== textarea.value) writeLeadingNpc(textarea, next); showToast('已插入指令模板', 'ok'); } // 改写整段草稿,光标按长度增量偏移(补/换提及都发生在开头) 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 + '\u0004' + SCRIPT_VERSION; 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; } // ===== AI 润色:复用当前站点自身的 AI 接口,沿用浏览器登录态 ===== // 站点自己的请求形如 POST /{分组}/{仓库}/-/ai/chat/completions(同源、带 cookie + csrftoken),返回 SSE 流。 const AI_CHAT_MODEL = 'deepseek-v4.1-flash'; // 站点 AI 接口使用的模型 const AI_POLISH_TIMEOUT_MS = 60000; // 提示词只有一处:设置弹窗「角色提示词」里当前选中的那条(脚本不再内置任何固定提示词)。 // 没选 / 没配置时直接提示,不会偷偷替用户用别的提示词。 // 当前仓库根路径:/分组/仓库(即 /-/ 之前的那一段) function repoBasePath() { const i = location.pathname.indexOf('/-/'); return i > 0 ? location.pathname.slice(0, i) : ''; } // 站点的写请求都带 csrftoken,但这个值只存在于站点内存里 —— 偷看它自己的请求头,抓到后当次会话复用 let _npcCsrfToken = ''; let _npcCsrfHooked = false; function stashCsrfToken(value) { if (typeof value !== 'string' || !value) return; _npcCsrfToken = value; } function pickCsrfFromHeaders(headers) { if (!headers) return; try { if (typeof headers.forEach === 'function') { headers.forEach((value, name) => { if (String(name).toLowerCase() === 'csrftoken') stashCsrfToken(value); }); } else if (Array.isArray(headers)) { headers.forEach(pair => { if (Array.isArray(pair) && String(pair[0]).toLowerCase() === 'csrftoken') stashCsrfToken(pair[1]); }); } else if (typeof headers === 'object') { Object.keys(headers).forEach(name => { if (name.toLowerCase() === 'csrftoken') stashCsrfToken(headers[name]); }); } } catch (e) { /* 忽略异常参数形态 */ } } function hookCsrfCapture() { if (_npcCsrfHooked) return; _npcCsrfHooked = true; const origFetch = window.fetch; if (typeof origFetch === 'function') { window.fetch = function (input, init) { try { pickCsrfFromHeaders(init && init.headers); if (typeof input !== 'string') pickCsrfFromHeaders(input && input.headers); } catch (e) { /* 忽略 */ } return origFetch.apply(this, arguments); }; } const origSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function (name, value) { try { if (String(name).toLowerCase() === 'csrftoken') stashCsrfToken(value); } catch (e) { /* 忽略 */ } return origSetHeader.apply(this, arguments); }; } // 从任意文本里抠 csrftoken(meta 标签 / 内联脚本里的赋值都长这样) function csrfFromText(text) { const src = String(text || ''); const m = src.match(/name=["']csrf[-_]?token["'][^>]*content=["']([^"']{8,})["']/i) || src.match(/csrf[-_]?token["']?\s*[:=]\s*["']([^"']{8,})["']/i); return m ? m[1] : ''; } // 依次尝试:抓到的请求头(最可信)→ meta → cookie → 本地存储 → 内联脚本 → window 全局。 // 这些来源都是「猜」,可能过期或不对 → 调用方遇到 401/403 会清掉缓存重新找一次。 function readCsrfToken() { if (_npcCsrfToken) return _npcCsrfToken; const meta = document.querySelector('meta[name="csrf-token"], meta[name="csrftoken"], meta[name="csrfToken"]'); if (meta) stashCsrfToken(meta.getAttribute('content') || ''); if (_npcCsrfToken) return _npcCsrfToken; try { // cookie(HttpOnly 的看不到,能看到就直接用) const parts = String(document.cookie || '').split(';'); for (const part of parts) { const i = part.indexOf('='); if (i < 0 || !/csrf/i.test(part.slice(0, i))) continue; const v = part.slice(i + 1).trim(); if (v.length >= 16) { stashCsrfToken(v); return _npcCsrfToken; } } } catch (e) { /* 忽略 */ } for (const store of [window.localStorage, window.sessionStorage]) { try { for (let i = 0; i < store.length; i++) { const key = store.key(i) || ''; if (!/csrf/i.test(key)) continue; const m = String(store.getItem(key) || '').match(/[0-9a-f]{16,64}/i); if (m) { stashCsrfToken(m[0]); return _npcCsrfToken; } } } catch (e) { /* 隐私模式下访问可能抛错 */ } } try { // SPA 常把 token 写在内联脚本里() const scripts = document.querySelectorAll('script:not([src])'); for (const s of scripts) { const hit = csrfFromText(s.textContent); if (hit) { stashCsrfToken(hit); return _npcCsrfToken; } } } catch (e) { /* 忽略 */ } ['csrfToken', 'csrf', '_csrf', '__csrf', 'csrftoken'].forEach(name => { if (_npcCsrfToken) return; const v = window[name]; if (typeof v === 'string' && v.length >= 16) stashCsrfToken(v); }); return _npcCsrfToken; } // 兜底:拉一次当前页的 HTML 壳(同源 GET,无副作用),从里面抠 csrftoken。 // 收藏页这类「整页没有一个写请求」的页面,靠偷看请求头是抓不到 token 的。 async function fetchCsrfFromShell() { try { const res = await fetch(location.pathname + location.search, { credentials: 'same-origin' }); if (res.ok) stashCsrfToken(csrfFromText(await res.text())); } catch (e) { /* 忽略 */ } return _npcCsrfToken; } // HTTP 错误 → 短文案:CNB 返回 {"errcode":16,"errmsg":"…"},只取 errmsg,别把整段 JSON 塞进 toast function aiErrorText(status, detail, noToken) { const raw = String(detail || '').replace(/\s+/g, ' ').trim(); let msg = ''; try { const json = JSON.parse(raw); msg = (json && (json.errmsg || json.message || json.error)) || ''; } catch (e) { /* 不是 JSON,按纯文本截断 */ } if (!msg) msg = raw.slice(0, 80); const csrf = /csrf/i.test(msg) || status === 401 || status === 403; if (csrf && noToken) msg = '拿不到 csrftoken —— 先在页面里做一次写操作(例如切换一次标签 / 勾一下收藏)再点' + (msg ? '(' + msg + ')' : ''); else if (msg) msg = 'HTTP ' + status + ':' + msg; else msg = 'HTTP ' + status; return msg; } // 解析一行 SSE:`data: {...}` → 增量文本 function parseAiSseLine(line) { const text = String(line || '').trim(); if (text.indexOf('data:') !== 0) return ''; const payload = text.slice(5).trim(); if (!payload || payload === '[DONE]') return ''; try { const json = JSON.parse(payload); const choice = json && json.choices && json.choices[0]; if (!choice) return ''; if (choice.delta && typeof choice.delta.content === 'string') return choice.delta.content; if (choice.message && typeof choice.message.content === 'string') return choice.message.content; if (typeof choice.text === 'string') return choice.text; } catch (e) { /* 非 JSON 行(心跳等)直接跳过 */ } return ''; } // 非流式响应的整包文本兜底 function parseAiWholeBody(body) { const raw = String(body || ''); try { const json = JSON.parse(raw); const choice = json && json.choices && json.choices[0]; const text = choice && ((choice.message && choice.message.content) || (choice.delta && choice.delta.content)); if (typeof text === 'string') return text; } catch (e) { /* 是 SSE 文本,逐行取 */ } return raw.split('\n').map(parseAiSseLine).join(''); } /** * 调用站点自身 AI 接口(同源 fetch,浏览器自动带登录 cookie)。 * @param {string} text 待润色正文 * @param {string} system system 提示词(就是「角色提示词」里选中的那条,脚本不做任何拼接) * @param {(acc: string) => void} [onDelta] 流式增量回调(累计文本);不需要流式时传 null * @param {AbortSignal} [signal] * @param {string} [basePath] 接口挂在哪个仓库路径下;默认取当前页面的仓库路径 */ async function askCnbAi(text, system, onDelta, signal, basePath) { const base = basePath || repoBasePath(); if (!base) throw new Error('当前页面不在仓库内,找不到 AI 接口'); const payload = JSON.stringify({ model: AI_CHAT_MODEL, stream: true, messages: [ { role: 'system', content: system }, { role: 'user', content: text } ] }); const post = token => { const headers = { 'content-type': 'application/json', accept: 'application/json' }; if (token) headers.csrftoken = token; // 站点写请求的原始形态 return fetch(base + '/-/ai/chat/completions', { method: 'POST', credentials: 'same-origin', headers, signal, body: payload }); }; let token = readCsrfToken() || await fetchCsrfFromShell(); let res = await post(token); // 401 / 403(CNB 会回 {"errcode":16,"errmsg":"CSRF 校验失败…"})→ 丢掉这个猜出来的 token, // 重新找一次再试;找到的还是同一个值就说明页面上确实没有,直接报错 if (!res.ok && (res.status === 401 || res.status === 403)) { _npcCsrfToken = ''; const fresh = readCsrfToken() || await fetchCsrfFromShell(); if (fresh && fresh !== token) { res = await post(fresh); token = fresh; } else if (!fresh && token) { res = await post(''); token = ''; } } if (!res.ok) { let detail = ''; try { detail = (await res.text()).slice(0, 200); } catch (e) { /* 忽略 */ } throw new Error(aiErrorText(res.status, detail, !token)); } GM_setValue(KEYS.aiBase, base); // 记下这个能用的仓库,收藏页下次直接借它 const type = res.headers.get('content-type') || ''; if (!/event-stream/i.test(type) || !res.body || typeof res.body.getReader !== 'function') { return parseAiWholeBody(await res.text()); } const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let acc = ''; const consume = line => { const piece = parseAiSseLine(line); if (!piece) return; acc += piece; if (onDelta) onDelta(acc); }; for (;;) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // 末段可能被截断,留到下一轮再拼 lines.forEach(consume); } if (buffer) consume(buffer); return acc; } // 草稿开头的 @提及单独摘出来,只把正文送润色,最后原样拼回(不破坏 @NPC 自动补全那套) // 复用上面的 NPC_LEAD_MENTION_RE,别再抄一份同样的正则 function splitLeadingMention(value) { const text = String(value == null ? '' : value); const m = text.match(NPC_LEAD_MENTION_RE); if (!m) return { mention: '', body: text }; return { mention: m[0].trim(), body: text.slice(m[0].length).replace(/^\s+/, '') }; } let _npcPolishBusy = false; // 「润色」按钮:把输入框里的正文交给站点 AI 改写,流式写回 async function polishEditorDraft(btn) { if (_npcPolishBusy) return; const textarea = getNpcEditorTextarea(); if (!textarea) { showToast('未找到输入框', 'err'); return; } const original = textarea.value; const parts = splitLeadingMention(original); const body = parts.body.trim(); if (!body) { showToast('输入框里还没有正文,先写点内容再润色', 'err'); return; } // 提示词就用「角色提示词」里当前选中的那条(没有就不硬编,直接提示) const system = getActiveNpcRole(); if (!system.trim()) { showToast('还没角色提示词:点指令条尾部齿轮 →「角色提示词」新增或选一条', 'err'); return; } _npcPolishBusy = true; const idleTitle = btn.title; btn.classList.add('busy'); btn.disabled = true; btn.title = '润色中…(AI 正在扩写,请稍候)'; const controller = typeof AbortController === 'function' ? new AbortController() : null; const timer = setTimeout(() => { if (controller) controller.abort(); }, AI_POLISH_TIMEOUT_MS); const compose = text => (parts.mention ? parts.mention + ' ' + text : text); let frame = 0; let latest = ''; const flush = () => { frame = 0; writeNpcTextarea(textarea, compose(latest), compose(latest).length, false); }; const onDelta = acc => { latest = acc.replace(/^\s+/, ''); if (!frame) frame = requestAnimationFrame(flush); }; try { const result = (await askCnbAi(body, system, onDelta, controller ? controller.signal : undefined) || '').trim(); if (!result) throw new Error('AI 没有返回内容'); if (frame) { cancelAnimationFrame(frame); frame = 0; } writeNpcTextarea(textarea, compose(result), compose(result).length, false); showToast('已润色输入框内容', 'ok'); } catch (err) { if (frame) { cancelAnimationFrame(frame); frame = 0; } writeNpcTextarea(textarea, original, original.length, false); // 失败原样还原 const msg = err && err.name === 'AbortError' ? '润色超时或已取消' : '润色失败:' + ((err && err.message) || err); showToast(msg, 'err'); } finally { clearTimeout(timer); _npcPolishBusy = false; btn.classList.remove('busy'); btn.disabled = false; btn.title = idleTitle; } } // 渲染指令条(NPC 选择器 + 模板 chip + AI 润色 + 设置齿轮) 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) => '插入:' + preset.prompt + '\n(@' + name + ' 只在草稿开头补一次,开头已有提及则不再插)\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(); insertNpcPreset(preset); }); 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); }); // AI 润色:站点官方 AI 图标按钮,放在设置齿轮左边(悬停显示说明) const polish = document.createElement('button'); polish.type = 'button'; polish.className = 'cnb-npc-polish'; polish.innerHTML = NPC_AI_ICON; polish.title = 'AI 润色:把输入框这句话扩写成完整、可直接发给 NPC 的指令(保留 @提及 与链接,不代你执行)'; polish.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); polishEditorDraft(polish); }); bar.appendChild(polish); 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; } // 指令条落位:创建页的编辑器父级是 flex 行(t-form__controls-content),编辑器本身是 w-full 的 flex 项, // 此时把指令条当兄弟节点插进去会被排到编辑器左边,改为插进编辑器内部顶部(视觉上仍在工具栏上方); // 会话页父级是块级容器,照旧插在编辑器前面。 function npcBarSlot(editor) { const parent = editor.parentNode; const display = parent ? getComputedStyle(parent).display : ''; return display.indexOf('flex') >= 0 ? { host: editor, inside: true } : { host: parent, inside: false }; } function placeNpcBar(bar, editor) { const slot = npcBarSlot(editor); if (slot.inside) slot.host.insertBefore(bar, slot.host.firstChild); else slot.host.insertBefore(bar, editor); } // 指令条是否已落在应在的位置(React 重建 / 切页后父容器可能变,需要重新落位) function npcBarSettled(bar, editor) { const slot = npcBarSlot(editor); if (!slot.host || slot.host !== bar.parentNode) return false; return slot.inside ? editor.firstElementChild === bar : bar.nextElementSibling === editor; } // 维持指令条:页面/配置变化时重建,非会话页 / 创建页或模板清空时移除 function ensureNpcBar(force) { if (!isCnbDomain()) return; bindNpcPrefixSync(); // 「发送自动带 NPC」的输入监听只绑定一次 bindEditorImgWidthFix(); // 上传图片宽度改百分比的输入监听只绑定一次 bindQuoteDetails(); // 「引用」写入后自动包
的点击监听只绑定一次 if (!isNpcEditorPage()) { 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); // 除了指纹与落位,还要求结构完整(例如旧版本建的条子没有「润色」按钮)→ 缺件也重建 const intact = !!existing && !!existing.querySelector('.cnb-npc-polish'); if (!force && intact && existing.dataset.signature === signature && npcBarSettled(existing, editor)) { return; // 已就位且配置未变 } if (existing) existing.remove(); placeNpcBar(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} 个。
与「指令模板」一样:可以维护多份角色提示词,最左侧单选哪一条用于「润色」。 点「润色」时会把这条要求交给 AI,让它写进给 NPC 的指令里(不会替你去执行)。
`; 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 rolePane = setupNpcRolePane(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 if (pane && pane.dataset.pane === 'roles') rolePane.save(); else presetPane.save(); }); bindPopupOutsideClose(popup, triggerBtn); } // 行内「展开编辑」:点提示词框最右边的图标把该行撑高(再点收起),方便改多行内容 function bindRowExpand(rowsBox) { rowsBox.addEventListener('click', e => { const btn = e.target.closest('.npc-expand-btn'); if (!btn) return; const row = btn.closest('.npc-row'); const box = row && row.querySelector('.npc-prompt-input'); if (!box) return; const open = !box.classList.contains('expanded'); box.classList.toggle('expanded', open); row.classList.toggle('npc-row-open', open); btn.classList.toggle('active', open); btn.title = open ? '收起' : '展开编辑'; if (open) box.focus(); }); } // —— 「指令模板」与「角色提示词」两个页签共用同一套行操作(避免同一段逻辑抄两遍)—— const npcEmptyHtml = tip => '
' + tip + '
'; // 收集当前 DOM 里的行并校验;整行留空视为删除。withRadio 时顺带读「使用中」单选框 function collectNpcRows(rowsBox, withRadio) { const list = []; let invalid = null; rowsBox.querySelectorAll('.npc-row').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; } const item = { label, prompt }; if (withRadio) item.active = !!row.querySelector('.npc-role-radio')?.checked; list.push(item); }); // 单选框同组只可能有一个 checked;万一都没选,把第一条设为使用中 if (withRadio && list.length && !list.some(r => r.active)) list[0].active = true; return { list, invalid }; } // 行内「×」删除:删完 DOM 把剩下的处理(空列表提示 / 落盘 / 文案)交给调用方 function bindNpcRowDelete(rowsBox, onRemoved) { 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(); onRemoved(name || '未命名'); }); } // 追加一行空行并聚焦标签框 function appendNpcRow(rowsBox, rowHtml) { rowsBox.querySelector('.npc-empty')?.remove(); rowsBox.insertAdjacentHTML('beforeend', rowHtml); rowsBox.querySelector('.npc-row:last-child .npc-label-input').focus(); } // —— 设置弹窗:指令模板页签(行内增删改,删除立即落盘)—— function setupNpcPresetPane(popup, refreshBar) { const rowsBox = popup.querySelector('.npc-preset-rows'); bindRowExpand(rowsBox); // 整块重绘(值要转义),删除走一次事件委托,不再逐行绑监听 const rowHtml = p => `
`; const render = () => { const list = getNpcPresets(); rowsBox.innerHTML = list.length ? list.map(rowHtml).join('') : npcEmptyHtml(NPC_EMPTY_PRESETS_TIP); }; // 删除行后立即落盘;剩余行有未填完整的返回 false(交给「保存」按钮提示) const persistIfValid = () => { const { list, invalid } = collectNpcRows(rowsBox, false); if (invalid) return false; saveNpcPresets(list); refreshBar(); return true; }; const save = () => { const { list, invalid } = collectNpcRows(rowsBox, false); if (invalid) { showToast(`标签(≤${NPC_PRESET_LABEL_MAX} 字)与指令都不能为空`, 'err'); invalid.focus(); return; } saveNpcPresets(list); render(); refreshBar(); showToast(list.length ? '指令模板已保存' : '已清空模板,指令条只保留固定项', 'ok'); }; const add = () => appendNpcRow(rowsBox, rowHtml({ label: '', prompt: '' })); bindNpcRowDelete(rowsBox, name => { if (!rowsBox.querySelector('.npc-row')) rowsBox.innerHTML = npcEmptyHtml(NPC_EMPTY_PRESETS_TIP); if (persistIfValid()) { showToast(getNpcPresets().length ? '已删除模板「' + name + '」' : '已删除全部模板,指令条只保留固定项', 'ok'); } else { showToast('已删除该行,其余改动请点「保存」', 'ok'); } }); render(); popup.querySelector('.npc-add-preset').addEventListener('click', add); popup.querySelector('.npc-restore-presets').addEventListener('click', () => { resetNpcPresets(); render(); refreshBar(); showToast('已恢复内置默认指令模板', 'ok'); }); popup.querySelector('.npc-save-presets').addEventListener('click', save); return { save, add }; } // —— 设置弹窗: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 }; } // —— 设置弹窗:角色提示词页签(与指令模板同一套路:增删改 + 选一条「使用中」)—— function setupNpcRolePane(popup, refreshBar) { const rowsBox = popup.querySelector('.npc-role-rows'); bindRowExpand(rowsBox); const EMPTY_TIP = '暂无角色提示词:点「+ 新增角色」添加,或「恢复默认」找回内置的「分析文章」'; const rowHtml = r => `
`; const render = () => { const list = getNpcRoles(); rowsBox.innerHTML = list.length ? list.map(rowHtml).join('') : npcEmptyHtml(EMPTY_TIP); }; const persistIfValid = () => { const { list, invalid } = collectNpcRows(rowsBox, true); if (invalid) return false; saveNpcRoles(list); refreshBar(); return true; }; const save = () => { const { list, invalid } = collectNpcRows(rowsBox, true); if (invalid) { showToast(`标签(≤${NPC_PRESET_LABEL_MAX} 字)与角色提示词都不能为空`, 'err'); invalid.focus(); return; } if (list.length > NPC_ROLES_MAX) { showToast('最多 ' + NPC_ROLES_MAX + ' 条角色提示词', 'err'); return; } saveNpcRoles(list); render(); refreshBar(); const active = list.filter(r => r.active)[0]; showToast(list.length ? '角色提示词已保存,当前使用「' + active.label + '」' : '已清空角色提示词', 'ok'); }; bindNpcRowDelete(rowsBox, name => { if (!rowsBox.querySelector('.npc-row')) rowsBox.innerHTML = npcEmptyHtml(EMPTY_TIP); if (persistIfValid()) { render(); showToast(getNpcRoles().length ? '已删除角色「' + name + '」' : '已清空角色提示词', 'ok'); } else { showToast('已删除该行,其余改动请点「保存」', 'ok'); } }); render(); popup.querySelector('.npc-add-role').addEventListener('click', () => { if (rowsBox.querySelectorAll('.npc-row').length >= NPC_ROLES_MAX) { showToast('最多 ' + NPC_ROLES_MAX + ' 条角色提示词', 'err'); return; } appendNpcRow(rowsBox, rowHtml({ label: '', prompt: '', active: !rowsBox.querySelector('.npc-role-radio:checked') })); }); popup.querySelector('.npc-restore-roles').addEventListener('click', () => { resetNpcRoles(); render(); refreshBar(); showToast('已恢复内置默认角色提示词', 'ok'); }); popup.querySelector('.npc-save-roles').addEventListener('click', save); return { save }; } // ===== 时间线侧栏开关(插在 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; // 偷看站点写请求里的 csrftoken,供 AI 润色接口复用(仅挂一次) hookCsrfCapture(); // 导入 / 导出 / AI 打标签的点击委托:只在 CNB 页挂一次(快捷导航设置弹窗在任何页面都能打开) bindBackupClicks(); // 处理短链接跳转(仅执行一次) 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(); } })();