// ==UserScript== // @name CNB Issue 网页内容收藏工具 // @namespace https://cnb.cool/IIIStudio/Greasemonkey/CNBIssue/ // @version 1.6.3 // @description 在任意网页上选择页面区域,一键将选中内容从 HTML 转为 Markdown,按"页面信息 + 选择的内容"的格式展示,并可直接通过 CNB 接口创建 Issue。支持链接、图片、代码块/行内代码、标题、列表、表格、引用等常见结构的 Markdown 转换。 // @author IIIStudio // @match *://*/* // @noframes // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @connect api.cnb.cool // @connect cnb.cool // @connect api.cnb.build // @connect cnb.build // @connect * // @connect weibo.com // @connect *.weibo.com // @connect sinaimg.cn // @connect *.sinaimg.cn // @connect tvax*.sinaimg.cn // @connect tva*.sinaimg.cn // @connect wx*.sinaimg.cn // @connect hb*.sinaimg.cn // @connect cdn3.ldstatic.com // @connect *.ldstatic.com // @connect ldstatic.com // @license MIT // ==/UserScript== (function() { 'use strict'; // 内存与样式注入防重、窗口/观察者单例 const __CNB_FLAGS = Object.create(null); function addStyleOnce(key, cssText) { try { if (__CNB_FLAGS[key]) return; if (typeof GM_addStyle === 'function') GM_addStyle(cssText); __CNB_FLAGS[key] = 1; } catch (_) {} } let __CNB_CLIP_DIALOG = null; let __CNB_SETTINGS_DIALOG = null, __CNB_SETTINGS_OVERLAY = null; let __CNB_SETTINGS_REFRESH = null; let __CNB_ISSUE_DIALOG = null, __CNB_ISSUE_OVERLAY = null; let __CNB_CREATE_DIALOG = null, __CNB_CREATE_OVERLAY = null; let __CNB_MO = null; let __CNB_UNLOAD_BOUND = false; let __CNB_DOCK_SHOW_TIMER = null; // 筛选标签按钮样式(创建弹窗与 Issue 列表共用,胶囊风格) const CNB_FILTER_BTN_CSS = ` .cnb-issue-dialog .cnb-issue-filter { display:flex !important; flex-wrap:wrap !important; gap:4px !important; } .cnb-issue-dialog .cnb-issue-filter .cnb-issue-filter-btn { display: inline-flex !important; align-items: center !important; gap: 4px !important; margin: 0 !important; padding: 2px 8px !important; border: 1px solid #d0d7de !important; border-radius: 9999px !important; background: #fff !important; color: #24292f !important; font-size: 12px !important; line-height: 1.2 !important; white-space: nowrap !important; vertical-align: middle !important; box-shadow: 0 1px 0 rgba(27,31,36,0.04) !important; transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .02s ease !important; cursor: pointer !important; user-select: none !important; } .cnb-issue-dialog .cnb-issue-filter .cnb-issue-filter-btn:hover { background: #f6f8fa !important; border-color: #afb8c1 !important; box-shadow: 0 1px 0 rgba(27,31,36,0.06) !important; } .cnb-issue-dialog .cnb-issue-filter .cnb-issue-filter-btn.active { background: #0366d6 !important; border-color: #0256b9 !important; color: #fff !important; box-shadow: 0 1px 0 rgba(27,31,36,0.05) !important; } .cnb-issue-dialog .cnb-issue-filter .cnb-issue-filter-btn.pressed { transform: translateY(1px) scale(0.98) !important; box-shadow: 0 1px 0 rgba(27,31,36,0.08) !important; } `; // 配置信息 const CONFIG = { region: 'cn', // 'cn' = 国内, 'abroad' = 国外 apiBase: 'https://api.cnb.cool', // 由当前区域决定 webBase: 'https://cnb.cool', // 由当前区域决定 repoPath: '', accessToken: '', issueEndpoint: '/-/issues', uploadEnabled: true, uploadFilesEnabled: true }; // 服务器区域:国内 / 国外,各自使用独立的 API、站点与仓库/令牌配置 const REGIONS = { cn: { label: '国内', apiBase: 'https://api.cnb.cool', webBase: 'https://cnb.cool', repoKey: 'repoPath', tokenKey: 'accessToken', clipKey: 'cnbClipboardIssue', favKey: 'cnbFavIssue', clipTitlesKey: 'cnbClipboardIssueTitles' }, abroad: { label: '国外', apiBase: 'https://api.cnb.build', webBase: 'https://cnb.build', repoKey: 'repoPathAbroad', tokenKey: 'accessTokenAbroad', clipKey: 'cnbClipboardIssueAbroad', favKey: 'cnbFavIssueAbroad', clipTitlesKey: 'cnbClipboardIssueTitlesAbroad' } }; function currentRegion() { return REGIONS[CONFIG.region] || REGIONS.cn; } function regionLabel() { return currentRegion().label; } // 应用区域:切换 API/站点地址,并读取该区域对应的仓库与令牌 function applyRegion(region, persist) { const key = REGIONS[region] ? region : 'cn'; const r = REGIONS[key]; CONFIG.region = key; CONFIG.apiBase = r.apiBase; CONFIG.webBase = r.webBase; try { if (typeof GM_getValue === 'function') { CONFIG.repoPath = GM_getValue(r.repoKey, '') || ''; CONFIG.accessToken = GM_getValue(r.tokenKey, '') || ''; } } catch (_) {} if (persist && typeof GM_setValue === 'function') { try { GM_setValue('cnbRegion', key); } catch (_) {} } } // 切换区域:国内 <-> 国外 function switchRegion() { const next = (CONFIG.region === 'abroad') ? 'cn' : 'abroad'; applyRegion(next, true); try { const btn = document.getElementById('cnb-btn-region'); if (btn) btn.textContent = regionLabel(); } catch (_) {} // 同步刷新设置弹窗(仓库/令牌/标签/剪贴板/收藏)与 Dock 剪贴板按钮 try { if (__CNB_SETTINGS_REFRESH) __CNB_SETTINGS_REFRESH(); } catch (_) {} try { syncDockClipboardButton(); } catch (_) {} try { notifyUser('已切换到' + regionLabel() + '服务器', 2000); } catch (_) {} return next; } let SAVED_TAGS = []; // 选择模式快捷键(可在设置中修改),规范格式如:Shift+E let START_HOTKEY = 'Shift+E'; let HOTKEY_ENABLED = false; // 添加自定义样式 - 扁平黑白配色 GM_addStyle(` .cnb-issue-dialog { position: fixed !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) !important; background: #fff !important; border: 2px solid #000 !important; border-radius: 0 !important; padding: 10px 12px !important; z-index: 2147483641 !important; box-shadow: 4px 4px 0 #000 !important; min-width: 500px !important; max-width: 90vw !important; max-height: 80vh !important; overflow: auto !important; } .cnb-issue-dialog h3 { margin: 0 0 6px 0; color: #000; font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; } .cnb-issue-dialog textarea { width: 100%; height: 200px; margin: 4px 0; padding: 8px; border: 2px solid #000; border-radius: 0; resize: vertical; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 12px; line-height: 1.4; background: #fff; color: #000; box-shadow: none; } .cnb-issue-dialog textarea:focus { outline: none; border-color: #000; } .cnb-issue-dialog input { width: 100%; margin: 4px 0; padding: 6px 8px; border: 2px solid #000; border-radius: 0; background: #fff; color: #000; box-shadow: none; } .cnb-issue-dialog input:focus { outline: none; border-color: #000; } .cnb-issue-dialog-buttons { display: flex; justify-content: flex-end; gap: 6px; margin-top: 8px; } /* 仅底部操作按钮生效,避免影响设置区的小按钮与"×" */ .cnb-issue-dialog .cnb-issue-dialog-buttons > button { padding: 4px 12px; border: 2px solid #000; border-radius: 0; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.1s ease; background: #fff; color: #000; box-shadow: 2px 2px 0 #000; } .cnb-issue-dialog .cnb-issue-dialog-buttons > button:active { transform: translate(2px, 2px); box-shadow: none; } .cnb-issue-btn-confirm { background: #000; color: #fff; } .cnb-issue-btn-cancel { background: #fff; color: #000; } .cnb-issue-btn-confirm:hover { background: #333; border-color: #000; } .cnb-issue-btn-cancel:hover { background: #f0f0f0; border-color: #000; } /* 创建完成Issue 按钮样式 */ .cnb-issue-btn-done { background: #000; color: #fff; } .cnb-issue-btn-done:hover { background: #333; } .cnb-issue-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 2147483640; } .cnb-issue-loading { display: inline-block; width: 20px; height: 20px; border: 3px solid #e0e0e0; border-top: 3px solid #000; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 10px; } /* 区域选择模式样式 */ .cnb-selection-mode * { cursor: crosshair !important; } .cnb-selection-hover { outline: 3px solid #000 !important; background-color: rgba(0, 0, 0, 0.08) !important; } .cnb-selection-selected { outline: 4px solid #000 !important; background-color: rgba(0, 0, 0, 0.12) !important; } .cnb-selection-tooltip { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); background: #000; color: #fff; padding: 5px 20px; border-radius: 0; border: 2px solid #000; z-index: 2147483643; font-size: 14px; font-weight: 500; box-shadow: 3px 3px 0 rgba(0,0,0,0.5); } .cnb-selection-tooltip button { margin-left: 10px; padding: 1px 12px; background: #fff; color: #000; border: 2px solid #fff; font-weight: 600; cursor: pointer; transition: all 0.1s ease; } .cnb-selection-tooltip button:hover { background: #000; color: #fff; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `); /* 左侧贴边 Dock 控制栏(自动隐藏,鼠标移到左边缘显示) - 扁平黑白配色 */ GM_addStyle(` .cnb-dock { position: fixed !important; left: -200px !important; right: auto !important; top: 40% !important; bottom: auto !important; margin: 0 !important; transform: none !important; float: none !important; display: flex !important; flex-direction: column !important; gap: 4px !important; padding: 4px 4px 4px 8px !important; background: #fff !important; border: 2px solid #000 !important; border-left: none !important; border-radius: 0 !important; box-shadow: 3px 3px 0 #000 !important; z-index: 2147483642 !important; transition: left .15s ease, opacity .15s ease !important; opacity: 0.9; /* 隐藏态:完全不可见、不拦截鼠标事件,避免误触发或被站点样式挤到页面中间 */ visibility: hidden; pointer-events: none; } .cnb-dock:hover, .cnb-dock.cnb-dock--visible { left: 0 !important; opacity: 1; visibility: visible; pointer-events: auto; } .cnb-dock .cnb-dock-btn { position: static !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; min-width: 54px !important; height: 28px !important; margin: 0 !important; padding: 0 8px !important; font-size: 12px !important; font-weight: 600 !important; color: #000 !important; background: #fff !important; border: 2px solid #000 !important; border-radius: 0 !important; box-sizing: border-box !important; cursor: pointer !important; transition: all 0.1s ease !important; } .cnb-dock .cnb-dock-btn:hover { background: #000; color: #fff; } .cnb-dock .cnb-dock-btn:active { transform: translate(1px, 1px); } /* 右下角结果提示(Toast):显示后向右滑出消失 */ .cnb-toast { position: fixed !important; right: 16px !important; bottom: 16px !important; left: auto !important; top: auto !important; z-index: 2147483646 !important; display: block !important; visibility: visible !important; pointer-events: auto !important; max-width: 320px !important; padding: 10px 14px !important; background: #fff !important; color: #000 !important; border: 2px solid #000 !important; border-radius: 0 !important; box-shadow: 4px 4px 0 #000 !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; font-size: 13px !important; font-weight: 400 !important; line-height: 1.5 !important; word-break: break-word !important; cursor: pointer !important; transform: translateX(120%) !important; opacity: 0 !important; transition: transform .28s ease, opacity .28s ease !important; } .cnb-toast.cnb-toast--show { transform: translateX(0) !important; opacity: 1 !important; } .cnb-toast.cnb-toast--hide { transform: translateX(120%) !important; opacity: 0 !important; } .cnb-toast .cnb-toast-title { font-weight: 700 !important; } .cnb-toast a.cnb-toast-link { color: #000 !important; text-decoration: underline !important; cursor: pointer !important; } .cnb-toast a.cnb-toast-link:hover { color: #666 !important; } `); /* 强制隔离并统一控件样式,避免继承站点样式 - 扁平黑白配色 */ GM_addStyle(` /* 对话框内全局样式重置 */ .cnb-issue-dialog, .cnb-issue-dialog * { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; text-align: left !important; letter-spacing: normal !important; word-spacing: normal !important; } /* 对话框内标签统一字号 */ .cnb-issue-dialog label { font-size: 13px !important; } .cnb-issue-dialog input.cnb-control, .cnb-issue-dialog textarea.cnb-control { box-sizing: border-box !important; width: 100% !important; margin: 4px 0 !important; padding: 5px 10px !important; border: 2px solid #000 !important; border-radius: 0 !important; background: #fff !important; color: #000 !important; font: normal 13px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,Helvetica,Arial,"PingFang SC","Microsoft Yahei",sans-serif !important; outline: none !important; appearance: none !important; -webkit-appearance: none !important; -moz-appearance: none !important; transition: all 0.1s ease !important; height: 30px !important; } .cnb-issue-dialog textarea.cnb-control { min-height: 180px !important; resize: vertical !important; font-family: 'Monaco','Menlo','Ubuntu Mono',monospace !important; font-size: 12px !important; line-height: 1.4 !important; } .cnb-issue-dialog input.cnb-control:focus, .cnb-issue-dialog textarea.cnb-control:focus { border-color: #000 !important; } /* 仅底部操作按钮生效,避免影响设置区的小按钮与"×" */ .cnb-issue-dialog .cnb-issue-dialog-buttons > button { padding: 4px 12px !important; border: 2px solid #000 !important; border-radius: 0 !important; cursor: pointer !important; font-size: 13px !important; font-weight: 600 !important; transition: all 0.1s ease !important; background: #fff !important; color: #000 !important; box-shadow: 2px 2px 0 #000 !important; } .cnb-issue-dialog .cnb-issue-dialog-buttons > button:active { transform: translate(2px, 2px) !important; box-shadow: none !important; } .cnb-issue-btn-confirm { background: #000 !important; color: #fff !important; } .cnb-issue-btn-confirm:hover { background: #333 !important; color: #fff !important; } .cnb-issue-btn-cancel { background: #fff !important; color: #000 !important; } .cnb-issue-btn-cancel:hover { background: #f0f0f0 !important; color: #000 !important; } /* 新增:创建完成Issue 按钮(黑色) */ .cnb-issue-btn-done { background: #000 !important; color: #fff !important; } .cnb-issue-btn-done:hover { background: #333 !important; color: #fff !important; } /* 标签选择按钮 - 扁平黑白配色 */ #cnb-issue-tags { margin-top: 2px !important; } .cnb-tag-btn { margin: 2px !important; padding: 0 6px !important; border: 2px solid #000 !important; border-radius: 0 !important; background: #fff !important; color: #000 !important; font-size: 12px !important; font-weight: 500 !important; cursor: pointer !important; transition: all 0.1s ease !important; } .cnb-tag-btn:hover { background: #000 !important; color: #fff !important; } .cnb-tag-btn.active { background: #000 !important; border-color: #000 !important; color: #fff !important; } /* 设置页:标签胶囊与删除按钮 - 扁平黑白配色 */ .cnb-tags-list { margin-top: 4px !important; } .cnb-tag-pill { display: inline-flex !important; align-items: center !important; gap: 4px !important; margin: 2px !important; padding: 0 6px !important; border: 2px solid #000 !important; border-radius: 0 !important; background: #fff !important; color: #000 !important; font-size: 12px !important; font-weight: 500 !important; line-height: 1.2 !important; white-space: nowrap !important; vertical-align: middle !important; box-shadow: none !important; transition: all 0.1s ease !important; user-select: none !important; } .cnb-tag-pill:hover { background: #000 !important; color: #fff !important; } .cnb-tag-delbtn { margin-left: 4px !important; border: none !important; background: transparent !important; cursor: pointer !important; color: #000 !important; font-size: 14px !important; font-weight: 700 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; height: 16px !important; padding: 0 4px !important; line-height: 16px !important; border-radius: 0 !important; box-sizing: border-box !important; white-space: nowrap !important; min-width: 0 !important; } .cnb-tag-pill:hover .cnb-tag-delbtn { color: #fff !important; } /* 设置页:输入与按钮排列 - 扁平黑白配色 */ .cnb-flex { display: flex !important; gap: 6px !important; align-items: center !important; flex-wrap: nowrap !important; } .cnb-tag-addbtn, .cnb-tag-fetchbtn { display: inline-flex !important; align-items: center !important; justify-content: center !important; white-space: nowrap !important; height: 30px !important; padding: 0 12px !important; box-sizing: border-box !important; border-radius: 0 !important; border: 2px solid #000 !important; background: #000 !important; color: #fff !important; cursor: pointer !important; font-size: 13px !important; font-weight: 600 !important; flex: 0 0 auto !important; min-width: max-content !important; transition: all 0.1s ease !important; } .cnb-tag-addbtn:hover, .cnb-tag-fetchbtn:hover { background: #fff !important; color: #000 !important; } .cnb-tag-fetchbtn:disabled { opacity: 0.6; cursor: not-allowed; } /* 让输入框可伸缩并等高 */ .cnb-flex .cnb-control#cnb-setting-newtag { height: 30px !important; flex: 1 1 auto !important; } /* 提示文本 */ .cnb-hint { color: #666 !important; font-size: 12px !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important; font-weight: 400 !important; line-height: 1.4 !important; text-align: left !important; margin: 0 !important; padding: 0 !important; } /* 设置页:标题与项目超链接(并排显示)- 扁平黑白配色 */ .cnb-settings-header { display: flex !important; align-items: center !important; flex-wrap: wrap !important; gap: 8px !important; margin: 0 0 6px 0 !important; padding-right: 24px !important; } .cnb-settings-header h3 { margin: 0 !important; } .cnb-settings-links-row { display: inline-flex !important; flex-wrap: wrap !important; gap: 6px !important; } .cnb-issue-dialog a.cnb-link-btn { display: inline !important; padding: 0 !important; border: none !important; background: transparent !important; color: #000 !important; font-size: 12px !important; font-weight: 600 !important; line-height: 1 !important; text-decoration: none !important; cursor: pointer !important; transition: color 0.1s ease !important; } .cnb-issue-dialog a.cnb-link-btn:hover { color: #666 !important; text-decoration: underline !important; } /* 设置页标题右侧:区域切换开关 */ .cnb-issue-dialog .cnb-region-toggle { display: inline-flex !important; align-items: center !important; justify-content: center !important; height: 22px !important; padding: 0 8px !important; box-sizing: border-box !important; border: 2px solid #000 !important; border-radius: 0 !important; background: #000 !important; color: #fff !important; font-size: 12px !important; font-weight: 600 !important; line-height: 1 !important; cursor: pointer !important; transition: all 0.1s ease !important; } .cnb-issue-dialog .cnb-region-toggle:hover { background: #fff !important; color: #000 !important; } /* 图片上传开关容器 */ .cnb-image-upload-toggle { display: flex !important; align-items: center !important; gap: 6px !important; margin-top: 2px !important; } /* 开关样式 - 扁平黑白配色 */ .cnb-toggle-switch { position: relative !important; display: inline-block !important; width: 40px !important; height: 22px !important; } .cnb-toggle-switch input { opacity: 0 !important; width: 0 !important; height: 0 !important; } .cnb-toggle-slider { position: absolute !important; cursor: pointer !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; background-color: #e0e0e0 !important; transition: .15s !important; border-radius: 0 !important; border: 2px solid #000 !important; } .cnb-toggle-slider:before { position: absolute !important; content: "" !important; height: 12px !important; width: 12px !important; left: 3px !important; bottom: 3px !important; background-color: #000 !important; transition: .15s !important; border-radius: 0 !important; } .cnb-toggle-switch input:checked + .cnb-toggle-slider { background-color: #000 !important; } .cnb-toggle-switch input:checked + .cnb-toggle-slider:before { transform: translateX(18px) !important; background-color: #fff !important; } /* Issue号输入框样式 - 覆盖全局的 width: 100% */ .cnb-issue-dialog input.cnb-issue-number-input { width: 90px !important; margin-left: 8px !important; height: 28px !important; } /* 开关样式(无文字,仅图形) - 扁平黑白配色 */ .cnb-switch { position: relative !important; display: inline-block !important; width: 36px !important; height: 20px !important; vertical-align: middle !important; } .cnb-switch input { opacity: 0 !important; width: 0 !important; height: 0 !important; position: absolute !important; } .cnb-switch-slider { position: absolute !important; inset: 0 !important; background: #e0e0e0 !important; border-radius: 0 !important; border: 2px solid #000 !important; transition: background-color .15s ease !important; box-shadow: none !important; cursor: pointer !important; } .cnb-switch-slider::before { content: '' !important; position: absolute !important; left: 3px !important; top: 3px !important; width: 10px !important; height: 10px !important; background: #000 !important; border-radius: 0 !important; box-shadow: none !important; transition: transform .15s ease !important; } .cnb-switch input:checked + .cnb-switch-slider { background: #000 !important; } .cnb-switch input:checked + .cnb-switch-slider::before { transform: translateX(16px) !important; background: #fff !important; } /* 统一滚动条样式 - 扁平黑白配色 */ .cnb-issue-dialog, .cnb-issue-list, .cnb-clipwin, .cnb-clipwin-content, .cnb-clipwin-body { scrollbar-width: thin; scrollbar-color: #000 #f5f5f5; } .cnb-issue-dialog::-webkit-scrollbar, .cnb-issue-list::-webkit-scrollbar, .cnb-clipwin::-webkit-scrollbar, .cnb-clipwin-content::-webkit-scrollbar, .cnb-clipwin-body::-webkit-scrollbar { width: 8px; height: 8px; } .cnb-issue-dialog::-webkit-scrollbar-track, .cnb-issue-list::-webkit-scrollbar-track, .cnb-clipwin::-webkit-scrollbar-track, .cnb-clipwin-content::-webkit-scrollbar-track, .cnb-clipwin-body::-webkit-scrollbar-track { background: #f5f5f5; } .cnb-issue-dialog::-webkit-scrollbar-thumb, .cnb-issue-list::-webkit-scrollbar-thumb, .cnb-clipwin::-webkit-scrollbar-thumb, .cnb-clipwin-content::-webkit-scrollbar-thumb, .cnb-clipwin-body::-webkit-scrollbar-thumb { background: #000; border-radius: 0; } .cnb-issue-dialog::-webkit-scrollbar-thumb:hover, .cnb-issue-list::-webkit-scrollbar-thumb:hover, .cnb-clipwin::-webkit-scrollbar-thumb:hover, .cnb-clipwin-content::-webkit-scrollbar-thumb:hover, .cnb-clipwin-body::-webkit-scrollbar-thumb:hover { background: #333; } `); let isSelecting = false; let selectedElement = null; // 多选集合与最近一次选择的元素 let selectedElements = new Set(); let lastSelectedElement = null; // HTML转Markdown的转换器 const htmlToMarkdown = { // 图片收集列表 images: [], // 转换入口函数 convert: function(html) { // 重置图片列表 this.images = []; // 创建临时容器 const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // 清理不需要的元素 this.cleanUnwantedElements(tempDiv); // 递归转换 return this.processNode(tempDiv).trim(); }, // 清理不需要的元素 cleanUnwantedElements: function(element) { const unwantedSelectors = [ 'script', 'style', 'noscript', 'link', 'meta', // 广告相关(更安全的选择器,避免误伤 heading/markdown/header 等) '.ads', '.advertisement', '[class*="advert"]', '[id*="advert"]', '[id^="ad-"]', '[id^="ads-"]', // 隐藏元素 '.hidden', '[style*="display:none"]', '[style*="display: none"]' ]; unwantedSelectors.forEach(selector => { const elements = element.querySelectorAll(selector); elements.forEach(el => el.remove()); }); }, // 将相对 URL 解析为绝对 URL(基于原页面地址),避免相对链接在 CNB Issue 中 // 被错误地解析为 CNB 仓库路径(如 /uploads/xxx.zip -> cnb.cool//-/git/raw/HEAD/...) resolveUrl: function(url) { if (!url) return ''; const value = String(url).trim(); if (!value) return value; // 纯锚点保持原样(避免解析成当前页面的绝对地址) if (value.startsWith('#')) return value; // 已带协议(http:, https:, mailto:, javascript:, data:, blob: 等)直接返回 if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) return value; try { return new URL(value, document.baseURI || location.href).href; } catch (_) { return value; } }, // 处理节点 processNode: function(node) { if (node.nodeType === Node.TEXT_NODE) { return this.escapeText(node.textContent || ''); } if (node.nodeType !== Node.ELEMENT_NODE) { return ''; } const tagName = node.tagName.toLowerCase(); const children = Array.from(node.childNodes); const childrenContent = children.map(child => this.processNode(child)).join(''); switch (tagName) { case 'h1': return `# ${childrenContent}\n\n`; case 'h2': return `## ${childrenContent}\n\n`; case 'h3': return `### ${childrenContent}\n\n`; case 'h4': return `#### ${childrenContent}\n\n`; case 'h5': return `##### ${childrenContent}\n\n`; case 'h6': return `###### ${childrenContent}\n\n`; case 'p': return `${childrenContent}\n\n`; case 'br': return '\n'; case 'hr': return '---\n\n'; case 'strong': case 'b': return `**${childrenContent}**`; case 'em': case 'i': return `*${childrenContent}*`; case 'code': if (node.parentElement.tagName.toLowerCase() === 'pre') { return childrenContent; } return `\`${childrenContent}\``; case 'pre': const language = node.querySelector('code')?.className?.replace('language-', '') || ''; const raw = node.textContent || ''; return `\`\`\`${language}\n${raw}\n\`\`\`\n\n`; case 'a': const href = this.resolveUrl(node.getAttribute('href')); // 提取可见文本(去掉空白) const visibleText = (childrenContent || '').replace(/\s+/g, ''); // 规则: // 1) 如果 href 为空:仅返回子内容(可能有内嵌 strong/img 等) // 2) 如果 href 以 '#' 开头且无可见文本(通常是锚点图标/空链接):丢弃该链接,仅返回子内容,避免生成 [](#...) // 3) 其他情况:按 [text](href) 输出。 if (!href) { return childrenContent; } if (href.startsWith('#') && visibleText.length === 0) { return ''; } return `[${childrenContent}](${href})`; case 'img': const alt = node.getAttribute('alt') || ''; // 优先获取父元素 标签的原始图片 URL(不受类名限制) let src = this.resolveUrl(node.getAttribute('src')); const parentHref = (() => { const parent = node.parentElement; if (parent && parent.tagName === 'A') { const href = this.resolveUrl(parent.getAttribute('href')); // 检查 href 是否指向图片 if (/\.(webp|jpe?g|png|gif|svg|bmp|ico)(\?.*)?$/i.test(href)) { return href; } } return null; })(); if (parentHref) { src = parentHref; } // 收集图片信息用于上传 if (src && !src.startsWith('data:')) { this.images.push({ src: src, alt: alt, element: node }); } return `![${alt}](${src})`; case 'ul': return `${childrenContent}\n`; case 'ol': return `${childrenContent}\n`; case 'li': const parentTag = node.parentElement.tagName.toLowerCase(); if (parentTag === 'ol') { const index = Array.from(node.parentElement.children).indexOf(node) + 1; return `${index}. ${childrenContent}\n`; } else { return `- ${childrenContent}\n`; } case 'blockquote': return `> ${childrenContent.split('\n').join('\n> ')}\n\n`; case 'table': const rows = node.querySelectorAll('tr'); let tableContent = ''; // 表头 const headerCells = rows[0]?.querySelectorAll('th, td') || []; if (headerCells.length > 0) { tableContent += '| ' + Array.from(headerCells).map(cell => this.processNode(cell).replace(/\n/g, ' ').trim()).join(' | ') + ' |\n'; tableContent += '| ' + Array.from(headerCells).map(() => '---').join(' | ') + ' |\n'; } // 数据行 for (let i = 1; i < rows.length; i++) { const cells = rows[i].querySelectorAll('td'); if (cells.length > 0) { tableContent += '| ' + Array.from(cells).map(cell => this.processNode(cell).replace(/\n/g, ' ').trim()).join(' | ') + ' |\n'; } } return tableContent + '\n'; case 'div': case 'section': case 'article': case 'main': case 'header': case 'footer': case 'nav': case 'aside': // 当容器中含有标题元素时,确保子内容以换行分隔,避免标题不在行首而无法识别 try { const hasHeading = typeof node.querySelector === 'function' && node.querySelector('h1, h2, h3, h4, h5, h6'); if (hasHeading) { const joined = children.map(child => this.processNode(child)).join('\n'); return `\n${joined}\n`; } } catch (_) {} return `${childrenContent}\n`; default: return childrenContent; } }, // 转义文本 escapeText: function(text) { return text .replace(/\*/g, '\\*') .replace(/_/g, '\\_') .replace(/`/g, '\\`') .replace(/\[/g, '\\[') .replace(/\]/g, '\\]') .replace(/\(/g, '\\(') .replace(/\)/g, '\\)') .replace(/#/g, '\\#') .replace(/\+/g, '\\+') .replace(/!/g, '\\!') .replace(/\|/g, '\\|') .replace(/\n\s*\n/g, '\n\n') .replace(/[ \t]+/g, ' ') .trim(); } }; // 热键工具:规范化与匹配 function normalizeHotkeyString(s) { if (!s) return ''; return s.split('+').map(p => p.trim()).filter(Boolean).map(p => { const up = p.toLowerCase(); if (up === 'ctrl') return 'Control'; if (up === 'control') return 'Control'; if (up === 'meta' || up === 'cmd' || up === 'command') return 'Meta'; if (up === 'alt' || up === 'option') return 'Alt'; if (up === 'shift') return 'Shift'; if (up.length === 1) return up.toUpperCase(); // 常见功能键统一首字母大写 return p[0].toUpperCase() + p.slice(1); }).join('+'); } function toDisplayHotkeyString(s) { if (!s) return ''; return s.replace(/\bControl\b/g, 'Ctrl'); } function eventToHotkeyString(e) { const parts = []; if (e.ctrlKey) parts.push('Control'); if (e.shiftKey) parts.push('Shift'); if (e.altKey) parts.push('Alt'); if (e.metaKey) parts.push('Meta'); let key = e.key; if (!key) return parts.join('+'); // 忽略纯修饰键 if (['Control','Shift','Alt','Meta'].includes(key)) key = ''; // 统一字母为大写,功能键保持名称 if (key && key.length === 1) key = key.toUpperCase(); if (key === ' ') key = 'Space'; if (key === 'Esc') key = 'Escape'; if (key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown') { // 保持不变 } return parts.concat(key ? [key] : []).join('+'); } function matchesHotkey(e, hotkeyStr) { const want = normalizeHotkeyString(hotkeyStr); const got = eventToHotkeyString(e); return want && got === want; } function isEditableTarget(el) { if (!el) return false; const tag = el.tagName ? el.tagName.toLowerCase() : ''; if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; if (el.isContentEditable) return true; return false; } function globalHotkeyHandler(e) { // 避免在输入编辑时触发;对话框/遮罩存在时也不触发 if (!HOTKEY_ENABLED) return; if (isEditableTarget(e.target)) return; if (document.querySelector('.cnb-issue-dialog') || document.querySelector('.cnb-issue-overlay')) return; if (!isSelecting && matchesHotkey(e, START_HOTKEY)) { e.preventDefault(); startAreaSelection(); } } // 根据当前区域配置的“剪贴板位置”同步 Dock 上的“剪贴板”按钮 function syncDockClipboardButton() { const dock = document.querySelector('.cnb-dock'); if (!dock) return; let clipIssue = ''; try { if (typeof GM_getValue === 'function') clipIssue = String(GM_getValue(currentRegion().clipKey, '') || '').trim(); } catch (_) {} let btn = dock.querySelector('#cnb-btn-clipboard'); if (clipIssue) { if (!btn) { btn = document.createElement('button'); btn.id = 'cnb-btn-clipboard'; btn.className = 'cnb-dock-btn'; btn.textContent = '剪贴板'; btn.addEventListener('click', (e) => { e.preventDefault(); if (typeof openClipboardWindow === 'function') openClipboardWindow(); }); dock.appendChild(btn); } } else if (btn) { btn.remove(); } } // 创建左侧 Dock(去除拖动,仅点击) function createFloatingButton() { const dock = document.createElement('div'); dock.className = 'cnb-dock'; const btnSelect = document.createElement('button'); btnSelect.className = 'cnb-dock-btn'; btnSelect.textContent = '选择'; btnSelect.addEventListener('click', (e) => { e.preventDefault(); startAreaSelection(); }); const btnSettings = document.createElement('button'); btnSettings.className = 'cnb-dock-btn'; btnSettings.textContent = '设置'; btnSettings.addEventListener('click', (e) => { e.preventDefault(); openSettingsDialog(); }); dock.appendChild(btnSelect); dock.appendChild(btnSettings); const btnList = document.createElement('button'); btnList.className = 'cnb-dock-btn'; btnList.textContent = '列表'; btnList.addEventListener('click', (e) => { e.preventDefault(); openIssueList(); }); dock.appendChild(btnList); document.body.appendChild(dock); // 剪贴板按钮:根据当前区域的“剪贴板位置”决定是否显示 syncDockClipboardButton(); // 仅当鼠标真正移动到浏览器最左侧极窄边缘(且位于 dock 垂直范围内)时才显示 dock。 // 使用 mousemove 而非给热区绑定 mouseenter:热区只要光标在其上方就会触发, // 导致页面中的小窗口/浮层恰好出现在光标下方时被误触发。 const DOCK_EDGE_PX = 3; const handleEdgeMouseMove = (e) => { // 不在最左边缘:取消尚未触发的延迟显示 if (e.clientX > DOCK_EDGE_PX) { if (__CNB_DOCK_SHOW_TIMER) { clearTimeout(__CNB_DOCK_SHOW_TIMER); __CNB_DOCK_SHOW_TIMER = null; } return; } // 仅在 dock 所在的垂直范围内触发,避免页面其他位置误触 const dockRect = dock.getBoundingClientRect(); if (e.clientY < dockRect.top || e.clientY > dockRect.bottom) return; if (dock.classList.contains('cnb-dock--visible') || __CNB_DOCK_SHOW_TIMER) return; // 延迟300ms后显示,避免过于敏感 __CNB_DOCK_SHOW_TIMER = setTimeout(() => { __CNB_DOCK_SHOW_TIMER = null; dock.classList.add('cnb-dock--visible'); }, 300); }; document.addEventListener('mousemove', handleEdgeMouseMove, true); dock._edgeMouseMoveHandler = handleEdgeMouseMove; // 鼠标进入dock时立即显示(并取消延迟) dock.addEventListener('mouseenter', () => { if (__CNB_DOCK_SHOW_TIMER) { clearTimeout(__CNB_DOCK_SHOW_TIMER); __CNB_DOCK_SHOW_TIMER = null; } dock.classList.add('cnb-dock--visible'); }); // 鼠标离开dock时隐藏 dock.addEventListener('mouseleave', () => { dock.classList.remove('cnb-dock--visible'); }); // 点击页面其他地方时隐藏dock const handleClickOutside = (e) => { // 点击 dock 之外的地方时隐藏 if (!dock.contains(e.target)) { dock.classList.remove('cnb-dock--visible'); } }; // 使用捕获阶段监听,确保能捕获所有点击 document.addEventListener('click', handleClickOutside, true); // 将点击事件监听器绑定到 dock 元素上,以便后续可以移除 dock._clickOutsideHandler = handleClickOutside; return dock; } // 开始区域选择模式 function startAreaSelection() { if (isSelecting) return; isSelecting = true; document.body.classList.add('cnb-selection-mode'); // 创建提示工具条 const tooltip = document.createElement('div'); tooltip.className = 'cnb-selection-tooltip'; tooltip.innerHTML = ` 请点击选择页面区域 (将转换为Markdown格式) `; tooltip.id = 'cnb-selection-tooltip'; document.body.appendChild(tooltip); // 添加事件监听 const confirmBtn = tooltip.querySelector('#cnb-confirm-selection'); const cancelBtn = tooltip.querySelector('#cnb-cancel-selection'); confirmBtn.addEventListener('click', () => { if (selectedElements && selectedElements.size > 0) { showIssueDialog(Array.from(selectedElements)); } else { GM_notification({ text: '请先选择区域(支持 Ctrl+点击多选)', title: 'CNB Issue工具', timeout: 3000 }); } }); cancelBtn.addEventListener('click', stopAreaSelection); // 添加鼠标移动和点击事件(使用捕获阶段,防止页面 stopPropagation 拦截) document.addEventListener('mouseover', handleMouseOver, true); document.addEventListener('mouseout', handleMouseOut, true); document.addEventListener('click', handleElementClick, true); // ESC键取消选择(捕获阶段) document.addEventListener('keydown', handleKeyDown, true); } // 停止区域选择模式 function stopAreaSelection() { isSelecting = false; document.body.classList.remove('cnb-selection-mode'); // 移除提示工具条 const tooltip = document.getElementById('cnb-selection-tooltip'); if (tooltip) { document.body.removeChild(tooltip); } // 移除样式(包含已选与悬停高亮) if (selectedElement) { selectedElement.classList.remove('cnb-selection-selected'); } const toClear = document.querySelectorAll('.cnb-selection-hover, .cnb-selection-selected'); toClear.forEach(el => { el.classList.remove('cnb-selection-hover'); el.classList.remove('cnb-selection-selected'); }); selectedElements = new Set(); lastSelectedElement = null; selectedElement = null; // 移除事件监听(与注册时一致使用捕获阶段) document.removeEventListener('mouseover', handleMouseOver, true); document.removeEventListener('mouseout', handleMouseOut, true); document.removeEventListener('click', handleElementClick, true); document.removeEventListener('keydown', handleKeyDown, true); } // 处理鼠标悬停 function handleMouseOver(e) { if (!isSelecting) return; const element = e.target; if (!selectedElements.has(element) && !element.closest('.cnb-dock')) { // 移除之前的高亮 const previousHighlight = document.querySelector('.cnb-selection-hover'); if (previousHighlight) { previousHighlight.classList.remove('cnb-selection-hover'); } // 高亮当前元素 element.classList.add('cnb-selection-hover'); } } // 处理鼠标移出 function handleMouseOut(e) { if (!isSelecting) return; const element = e.target; if (!selectedElements.has(element) && element.classList.contains('cnb-selection-hover')) { element.classList.remove('cnb-selection-hover'); } } // 处理元素点击 function handleElementClick(e) { if (!isSelecting) return; e.preventDefault(); e.stopPropagation(); const element = e.target; // Ctrl 多选:切换该元素选中状态;否则保持单选 if (e.ctrlKey === true) { element.classList.remove('cnb-selection-hover'); if (selectedElements.has(element)) { element.classList.remove('cnb-selection-selected'); selectedElements.delete(element); } else { element.classList.add('cnb-selection-selected'); selectedElements.add(element); lastSelectedElement = element; } } else { selectedElements.forEach(el => el.classList.remove('cnb-selection-selected')); selectedElements.clear(); selectedElement = element; selectedElement.classList.remove('cnb-selection-hover'); selectedElement.classList.add('cnb-selection-selected'); selectedElements.add(selectedElement); lastSelectedElement = selectedElement; } // 更新提示信息 const tooltip = document.getElementById('cnb-selection-tooltip'); if (tooltip) { const tagName = element.tagName.toLowerCase(); const className = element.className ? ` class="${element.className.split(' ')[0]}"` : ''; tooltip.innerHTML = ` 已选择: <${tagName}${className}> (将转换为Markdown) `; // 重新绑定事件 const confirmBtn = tooltip.querySelector('#cnb-confirm-selection'); const cancelBtn = tooltip.querySelector('#cnb-cancel-selection'); confirmBtn.addEventListener('click', () => { if (selectedElements && selectedElements.size > 0) { showIssueDialog(Array.from(selectedElements)); } else if (typeof GM_notification === 'function') { GM_notification({ text: '请先选择区域(支持 Ctrl+点击多选)', title: 'CNB Issue工具', timeout: 3000 }); } }); cancelBtn.addEventListener('click', stopAreaSelection); } } // 处理按键 function handleKeyDown(e) { if (e.key === 'Escape') { stopAreaSelection(); } else if (e.key === 'Enter' || e.key === 'NumpadEnter') { if (isSelecting && selectedElements && selectedElements.size > 0) { e.preventDefault(); showIssueDialog(Array.from(selectedElements)); } } } // 显示创建Issue的对话框 function showIssueDialog(selected) { stopAreaSelection(); // 先退出选择模式 // 单例:若已有创建弹窗(含微博截图弹窗),先关闭,避免重复打开 try { if (__CNB_CREATE_OVERLAY && __CNB_CREATE_OVERLAY.parentNode) __CNB_CREATE_OVERLAY.remove(); if (__CNB_CREATE_DIALOG && __CNB_CREATE_DIALOG.parentNode) __CNB_CREATE_DIALOG.remove(); } catch (_) {} __CNB_CREATE_OVERLAY = null; __CNB_CREATE_DIALOG = null; // 获取选择的内容并转换为Markdown(支持多选) const elements = Array.isArray(selected) ? selected : (selected ? [selected] : []); const pageTitle = document.title.replace(/\s+-\s+.+$/, '').replace(/\s+/g, ' '); const pageUrl = window.location.href; // 检测是否为微博网站 const isWeibo = location.hostname === 'weibo.com' || location.hostname.endsWith('.weibo.com'); // 微博特殊处理:截图模式 if (isWeibo && elements.length > 0) { handleWeiboSelection(elements, pageUrl, pageTitle); return; } // 创建遮罩层 const overlay = document.createElement('div'); overlay.className = 'cnb-issue-overlay'; // 创建对话框 const dialog = document.createElement('div'); dialog.className = 'cnb-issue-dialog'; // 记录单例,供下次打开前清理 __CNB_CREATE_OVERLAY = overlay; __CNB_CREATE_DIALOG = dialog; // 强化筛选标签按钮样式(避免被站点样式覆盖,统一为胶囊风格) addStyleOnce('issue-filter-btn', CNB_FILTER_BTN_CSS); // 获取选择的内容并转换为Markdown(支持多选) const parts = elements.map(el => (getSelectedContentAsMarkdown(el) || '').trim()).filter(Boolean); const joined = parts.join(` --- `); let selectedContent = (parts.length > 1 ? ` ` : '') + joined; // 收集所有需要上传的图片 const allImages = []; elements.forEach(el => { const tempDiv = document.createElement('div'); tempDiv.innerHTML = el.innerHTML; htmlToMarkdown.convert(tempDiv.innerHTML); allImages.push(...htmlToMarkdown.images); }); // 去重图片 const uniqueImages = []; const seenSrcs = new Set(); allImages.forEach(img => { if (!seenSrcs.has(img.src)) { seenSrcs.add(img.src); uniqueImages.push(img); } }); // 检测选中内容中的附件链接(如 zip/pdf 等) const detectedFiles = extractFileLinksFromMarkdown(selectedContent); dialog.innerHTML = `

创建 CNB Issue (Markdown格式)

${uniqueImages.length > 0 ? `
检测到 ${uniqueImages.length} 张图片,点击创建时将自动上传
` : ''}
可添加本地文件(如 zip),创建 Issue / 评论时自动上传并插入链接
${detectedFiles.length > 0 ? `
检测到 ${detectedFiles.length} 个附件链接,点击创建时将自动上传
` : ''}
修改Issue
添加评论
`; // 添加事件监听 // 先定义常用DOM元素 const editToggle = dialog.querySelector('#cnb-edit-toggle'); const commentToggle = dialog.querySelector('#cnb-comment-toggle'); const issueNumberInput = dialog.querySelector('#cnb-issue-number'); const confirmBtn = dialog.querySelector('.cnb-issue-btn-confirm'); const doneBtn = dialog.querySelector('.cnb-issue-btn-done'); // 更新按钮文本的函数 function updateButtonText() { const isEdit = editToggle ? editToggle.checked : false; const isComment = commentToggle ? commentToggle.checked : false; if (isComment) { confirmBtn.textContent = '添加评论'; } else if (isEdit) { confirmBtn.textContent = '修改Issue'; } else { confirmBtn.textContent = '创建Issue'; } if (doneBtn) { if (isComment) { doneBtn.textContent = '添加评论并完成'; } else if (isEdit) { doneBtn.textContent = '修改并完成'; } else { doneBtn.textContent = '创建完成Issue'; } } } // 渲染收藏按钮 const favContainer = dialog.querySelector('#cnb-issue-fav'); if (favContainer) { favContainer.innerHTML = ''; let favConfig = ''; try { if (typeof GM_getValue === 'function') { favConfig = GM_getValue(currentRegion().favKey, '') || ''; } } catch (_) {} // 解析收藏配置: 1|收藏,5|阅读 或 1|收藏,5|阅读(支持中文逗号) const favItems = []; if (favConfig) { const parts = favConfig.split(/[,,]/).map(s => s.trim()).filter(Boolean); parts.forEach(part => { const match = part.match(/^(\d+)\|(.+)$/); if (match) { favItems.push({ issueNumber: match[1], name: match[2].trim() }); } }); } // 如果收藏为空,隐藏整个收藏区域 if (favItems.length === 0) { const favSection = favContainer.parentElement; if (favSection) { favSection.style.display = 'none'; } } else { favItems.forEach(item => { const btnFav = document.createElement('button'); btnFav.type = 'button'; btnFav.className = 'cnb-tag-btn'; btnFav.textContent = item.name; btnFav.addEventListener('click', () => { // 检查是否已经选中 if (btnFav.classList.contains('active')) { // 如果已选中,取消选中 btnFav.classList.remove('active'); // 关闭编辑开关和评论开关 if (editToggle) { editToggle.checked = false; // 手动隐藏Issue号输入框 if (issueNumberInput) { issueNumberInput.style.display = 'none'; } } if (commentToggle) { commentToggle.checked = false; } // 清空所有标签选择 selectedTags = []; const tagBtns = tagsContainer.querySelectorAll('.cnb-tag-btn'); tagBtns.forEach(btn => btn.classList.remove('active')); // 更新按钮文本 updateButtonText(); } else { // 先移除所有收藏按钮的active状态 const allFavBtns = favContainer.querySelectorAll('.cnb-tag-btn'); allFavBtns.forEach(btn => btn.classList.remove('active')); // 为当前点击的按钮添加active状态 btnFav.classList.add('active'); // 自动打开编辑开关和评论开关 if (editToggle) { editToggle.checked = true; if (issueNumberInput) { issueNumberInput.style.display = 'inline-block'; issueNumberInput.value = item.issueNumber; } } if (commentToggle) { commentToggle.checked = true; } // 获取Issue的标签 fetchIssueLabels(item.issueNumber, (labels, error) => { if (error) { console.error('获取标签失败:', error); return; } // 提取标签名称 const labelNames = labels.map(l => l.name || l); // 清空当前选择 selectedTags = []; const tagBtns = tagsContainer.querySelectorAll('.cnb-tag-btn'); tagBtns.forEach(btn => { const tagName = btn.textContent; // 如果标签在Issue的标签列表中,则勾选 if (labelNames.includes(tagName)) { btn.classList.add('active'); selectedTags.push(tagName); } else { btn.classList.remove('active'); } }); }); // 更新按钮文本 updateButtonText(); } }); favContainer.appendChild(btnFav); }); } } // 渲染标签为可选按钮 const tagsContainer = dialog.querySelector('#cnb-issue-tags'); let selectedTags = []; if (tagsContainer) { tagsContainer.innerHTML = ''; const tags = Array.isArray(SAVED_TAGS) ? SAVED_TAGS : []; if (tags.length === 0) { const hint = document.createElement('div'); hint.className = 'cnb-hint'; hint.textContent = '在设置中添加标签后可在此选择'; tagsContainer.appendChild(hint); } else { tags.forEach(tag => { const btnTag = document.createElement('button'); btnTag.type = 'button'; btnTag.className = 'cnb-tag-btn'; btnTag.textContent = tag; btnTag.addEventListener('click', () => { const idx = selectedTags.indexOf(tag); if (idx >= 0) { selectedTags.splice(idx, 1); btnTag.classList.remove('active'); } else { selectedTags.push(tag); btnTag.classList.add('active'); } }); tagsContainer.appendChild(btnTag); }); } } const cancelBtn = dialog.querySelector('.cnb-issue-btn-cancel'); const uploadToggle = dialog.querySelector('#cnb-upload-toggle'); // 监听上传开关变化,保存状态 if (uploadToggle) { uploadToggle.addEventListener('change', () => { CONFIG.uploadEnabled = !!uploadToggle.checked; if (typeof GM_setValue === 'function') { GM_setValue('cnbUploadEnabled', CONFIG.uploadEnabled); } }); } // ===== 附件(文件)上传 ===== const attachPickBtn = dialog.querySelector('#cnb-attach-pick'); const attachInput = dialog.querySelector('#cnb-attach-input'); const attachListEl = dialog.querySelector('#cnb-attach-list'); const attachFileToggle = dialog.querySelector('#cnb-file-upload-toggle'); let localAttachments = []; if (attachFileToggle) { attachFileToggle.addEventListener('change', () => { CONFIG.uploadFilesEnabled = !!attachFileToggle.checked; if (typeof GM_setValue === 'function') { GM_setValue('cnbUploadFilesEnabled', CONFIG.uploadFilesEnabled); } }); } const renderAttachList = () => { if (!attachListEl) return; attachListEl.innerHTML = ''; localAttachments.forEach((f, idx) => { const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:12px;margin:2px 0;'; const nameSpan = document.createElement('span'); nameSpan.textContent = `${f.name} (${formatFileSize(f.blob.size)})`; const rmBtn = document.createElement('button'); rmBtn.type = 'button'; rmBtn.className = 'cnb-tag-btn'; rmBtn.textContent = '移除'; rmBtn.addEventListener('click', () => { localAttachments.splice(idx, 1); renderAttachList(); }); row.appendChild(nameSpan); row.appendChild(rmBtn); attachListEl.appendChild(row); }); }; if (attachPickBtn && attachInput) { attachPickBtn.addEventListener('click', () => attachInput.click()); attachInput.addEventListener('change', () => { Array.from(attachInput.files || []).forEach(file => { localAttachments.push({ name: file.name, blob: file }); }); attachInput.value = ''; renderAttachList(); }); } // 监听编辑开关变化,显示/隐藏Issue号输入框 if (editToggle && issueNumberInput) { editToggle.addEventListener('change', () => { issueNumberInput.style.display = editToggle.checked ? 'inline-block' : 'none'; // 如果关闭编辑开关,则同时关闭评论开关 if (!editToggle.checked && commentToggle.checked) { commentToggle.checked = false; } updateButtonText(); }); // 监听Issue号输入框失去焦点时,获取并勾选对应的标签 issueNumberInput.addEventListener('blur', () => { const issueNum = issueNumberInput.value.trim(); if (!issueNum) { // 清空Issue号时,取消所有标签勾选 selectedTags = []; const tagBtns = tagsContainer.querySelectorAll('.cnb-tag-btn'); tagBtns.forEach(btn => btn.classList.remove('active')); return; } // 获取Issue的标签 fetchIssueLabels(issueNum, (labels, error) => { if (error) { console.error('获取标签失败:', error); return; } // 提取标签名称 const labelNames = labels.map(l => l.name || l); // 清空当前选择 selectedTags = []; const tagBtns = tagsContainer.querySelectorAll('.cnb-tag-btn'); tagBtns.forEach(btn => { const tagName = btn.textContent; // 如果标签在Issue的标签列表中,则勾选 if (labelNames.includes(tagName)) { btn.classList.add('active'); selectedTags.push(tagName); } else { btn.classList.remove('active'); } }); }); }); } // 监听评论开关变化 if (commentToggle && editToggle) { commentToggle.addEventListener('change', () => { if (commentToggle.checked) { // 打开评论开关,必须同时打开编辑开关 if (!editToggle.checked) { editToggle.checked = true; if (issueNumberInput) { issueNumberInput.style.display = 'inline-block'; } } } updateButtonText(); }); } // 监听编辑开关变化 if (editToggle) { editToggle.addEventListener('change', updateButtonText); } const closeDialog = () => { if (document.body.contains(overlay)) document.body.removeChild(overlay); if (document.body.contains(dialog)) document.body.removeChild(dialog); if (__CNB_CREATE_OVERLAY === overlay) __CNB_CREATE_OVERLAY = null; if (__CNB_CREATE_DIALOG === dialog) __CNB_CREATE_DIALOG = null; }; overlay.addEventListener('click', closeDialog); cancelBtn.addEventListener('click', closeDialog); // 统一的图片/附件上传流程:先上传图片,再上传附件(检测到的链接 + 本地文件) const processUploads = (content, shouldUploadImages, shouldUploadFiles, finalCallback) => { const afterImages = (cb) => { const imagesInContent = extractImagesFromMarkdown(content); if (shouldUploadImages && imagesInContent.length > 0) { const statusEl = dialog.querySelector('#cnb-image-upload-status'); if (statusEl) statusEl.textContent = '正在上传图片...'; uploadImagesAndReplace(content, imagesInContent, (updatedContent, errors) => { if (errors && errors.length > 0) { const failedCount = errors.filter(e => e.error).length; const successCount = errors.length - failedCount; if (statusEl) statusEl.textContent = `图片上传完成:成功 ${successCount} 张,失败 ${failedCount} 张`; if (failedCount > 0) console.warn('部分图片上传失败:', errors.filter(e => e.error)); } else if (statusEl) { statusEl.textContent = '图片上传完成'; } cb(updatedContent); }); } else { cb(content); } }; afterImages((afterImageContent) => { const detectedFiles = shouldUploadFiles ? extractFileLinksFromMarkdown(afterImageContent) : []; const entries = detectedFiles .map(f => ({ name: f.name, href: f.url })) .concat(localAttachments.map(f => ({ name: f.name, blob: f.blob }))); if (entries.length === 0) { finalCallback(afterImageContent); return; } const statusEl = dialog.querySelector('#cnb-file-upload-status'); if (statusEl) statusEl.textContent = '正在上传附件...'; uploadFilesAndReplace(afterImageContent, entries, (updatedContent, errors) => { const failedCount = (errors || []).filter(e => e.error).length; const successCount = (errors || []).length - failedCount; if (statusEl) { statusEl.textContent = failedCount > 0 ? `附件上传完成:成功 ${successCount} 个,失败 ${failedCount} 个` : '附件上传完成'; } if (failedCount > 0) { const failedList = (errors || []).filter(e => e.error); console.error('部分附件上传失败:', failedList); if (typeof GM_notification === 'function') { GM_notification({ text: '附件上传失败:' + (failedList[0]?.error || '未知错误'), title: 'CNB Issue工具', timeout: 8000 }); } } finalCallback(updatedContent); }); }); }; confirmBtn.addEventListener('click', () => { const title = dialog.querySelector('#cnb-issue-title').value; const content = dialog.querySelector('#cnb-issue-content').value; const uploadToggle = dialog.querySelector('#cnb-upload-toggle'); const editToggle = dialog.querySelector('#cnb-edit-toggle'); const commentToggle = dialog.querySelector('#cnb-comment-toggle'); const issueNumberInput = dialog.querySelector('#cnb-issue-number'); const shouldUpload = uploadToggle ? uploadToggle.checked : true; const shouldEdit = editToggle ? editToggle.checked : false; const shouldComment = commentToggle ? commentToggle.checked : false; const issueNumber = issueNumberInput ? issueNumberInput.value.trim() : ''; const labels = Array.isArray(selectedTags) ? selectedTags.slice() : []; // 验证:如果要修改或评论Issue,必须输入Issue号 if (shouldEdit || shouldComment) { if (!issueNumber) { if (typeof GM_notification === 'function') { GM_notification({ text: '请输入Issue号', title: 'CNB Issue工具', timeout: 3000 }); } return; } } // 禁用按钮并显示加载状态 confirmBtn.disabled = true; confirmBtn.innerHTML = '
' + (shouldComment ? '添加评论中...' : (shouldEdit ? '修改中...' : '创建中...')); const fileUploadToggle = dialog.querySelector('#cnb-file-upload-toggle'); const shouldUploadFiles = fileUploadToggle ? fileUploadToggle.checked : true; // 处理图片/附件上传和Issue操作的逻辑 const handleContentReady = (updatedContent) => { if (shouldComment) { // 只添加评论,不修改Issue addCommentToIssue(issueNumber, updatedContent, (commentSuccess) => { closeDialog(); }); } else if (shouldEdit) { // 修改现有Issue const updateData = { body: updatedContent, title: title }; updateIssue(issueNumber, updateData, labels, (success) => { if (success) { closeDialog(); } else { confirmBtn.disabled = false; confirmBtn.innerHTML = '修改Issue'; } }); } else { // 创建新Issue createIssue(title, updatedContent, labels, (success) => { if (success) { closeDialog(); } else { confirmBtn.disabled = false; confirmBtn.innerHTML = '创建Issue'; } }); } }; // 处理图片与附件上传,然后创建/修改/评论 processUploads(content, shouldUpload, shouldUploadFiles, handleContentReady); }); if (doneBtn) { doneBtn.addEventListener('click', () => { const title = dialog.querySelector('#cnb-issue-title').value; const content = dialog.querySelector('#cnb-issue-content').value; const uploadToggle = dialog.querySelector('#cnb-upload-toggle'); const editToggle = dialog.querySelector('#cnb-edit-toggle'); const commentToggle = dialog.querySelector('#cnb-comment-toggle'); const issueNumberInput = dialog.querySelector('#cnb-issue-number'); const shouldUpload = uploadToggle ? uploadToggle.checked : true; const shouldEdit = editToggle ? editToggle.checked : false; const shouldComment = commentToggle ? commentToggle.checked : false; const issueNumber = issueNumberInput ? issueNumberInput.value.trim() : ''; const labels = Array.isArray(selectedTags) ? selectedTags.slice() : []; // 验证:如果要修改或评论Issue,必须输入Issue号 if (shouldEdit || shouldComment) { if (!issueNumber) { if (typeof GM_notification === 'function') { GM_notification({ text: '请输入Issue号', title: 'CNB Issue工具', timeout: 3000 }); } return; } } doneBtn.disabled = true; confirmBtn.disabled = true; doneBtn.innerHTML = '
处理中...'; const handleContentReady = (updatedContent) => { if (shouldComment) { // 只添加评论,然后关闭Issue addCommentToIssue(issueNumber, updatedContent, (commentSuccess) => { if (commentSuccess) { closeIssue(issueNumber, 'completed', (ok) => { if (typeof GM_notification === 'function') { GM_notification({ text: '评论已添加,Issue已标记为已完成', title: 'CNB Issue工具', timeout: 3000 }); } if (document.body.contains(overlay)) document.body.removeChild(overlay); if (document.body.contains(dialog)) document.body.removeChild(dialog); }); } else { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '添加评论并完成'; } }); } else if (shouldEdit) { // 修改现有Issue并完成 const updateData = { body: updatedContent, title: title, state: 'closed', state_reason: 'completed' }; updateIssue(issueNumber, updateData, labels, (success) => { if (success) { if (typeof GM_notification === 'function') { GM_notification({ text: 'Issue已修改并完成', title: 'CNB Issue工具', timeout: 3000 }); } if (document.body.contains(overlay)) document.body.removeChild(overlay); if (document.body.contains(dialog)) document.body.removeChild(dialog); } else { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '修改并完成'; } }); } else { // 创建新Issue并完成 createIssue(title, updatedContent, labels, (success, issueId) => { if (success && issueId != null) { closeIssue(issueId, 'completed', (ok) => { if (!ok) { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '创建完成Issue'; return; } if (typeof GM_notification === 'function') { GM_notification({ text: 'Issue已标记为已完成(closed: completed)', title: 'CNB Issue工具', timeout: 3000 }); } if (document.body.contains(overlay)) document.body.removeChild(overlay); if (document.body.contains(dialog)) document.body.removeChild(dialog); }); } else { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '创建完成Issue'; } }); } }; const fileUploadToggle = dialog.querySelector('#cnb-file-upload-toggle'); const shouldUploadFiles = fileUploadToggle ? fileUploadToggle.checked : true; // 处理图片与附件上传,然后创建/修改/评论 processUploads(content, shouldUpload, shouldUploadFiles, handleContentReady); }); } document.body.appendChild(overlay); document.body.appendChild(dialog); // 自动聚焦到标题输入框 dialog.querySelector('#cnb-issue-title').focus(); dialog.querySelector('#cnb-issue-title').select(); } // 获取选择区域的内容并转换为Markdown function getSelectedContentAsMarkdown(element) { if (!element) return ''; try { // 获取元素的HTML内容 const htmlContent = element.innerHTML; // 转换为Markdown const markdownContent = htmlToMarkdown.convert(htmlContent); // 清理和格式化 return cleanMarkdownContent(markdownContent); } catch (error) { console.error('转换Markdown失败:', error); // 如果转换失败,回退到纯文本 return element.textContent || element.innerText || ''; } } // 清理Markdown内容(用于显示) function cleanMarkdownContent(markdown) { // 删除表情图片(以 ![:grimacing:] 格式) markdown = markdown.replace(/!\[:[^\]]+\]\([^)]+\)/g, ''); // 将复杂图片链接格式转换为纯图片格式 markdown = markdown.replace(/\[!\[([^\]]*)\]\(([^)]+)\)[^\]]*\]\([^)]+\)/g, '![$1]($2)'); // 删除引用块中的空行(> 后面只有空格或空行的) markdown = markdown.replace(/^>\s*$/gm, ''); // 删除代码块结束标记前的空行(只处理 \n``` 这种出现在代码块结束前的情况) markdown = markdown.replace(/\n{2,}```/g, '\n```'); // 多个空行合并为两个 markdown = markdown.replace(/\n{3,}/g, '\n\n'); // 去除首尾空白 markdown = markdown.replace(/^\s+|\s+$/g, ''); return markdown; } // 清理Markdown内容(用于复制,更激进的换行处理) function cleanMarkdownContentForCopy(markdown) { // 删除表情图片(以 ![:grimacing:] 格式) markdown = markdown.replace(/!\[:[^\]]+\]\([^)]+\)/g, ''); // 将复杂图片链接格式转换为纯图片格式 markdown = markdown.replace(/\[!\[([^\]]*)\]\(([^)]+)\)[^\]]*\]\([^)]+\)/g, '![$1]($2)'); // 删除引用块中的空行(> 后面只有空格或空行的) markdown = markdown.replace(/^>\s*$/gm, ''); // 删除代码块结束标记前的空行(只处理 \n``` 这种出现在代码块结束前的情况) markdown = markdown.replace(/\n{2,}```/g, '\n```'); // 多个空行合并为两个 markdown = markdown.replace(/\n{3,}/g, '\n\n'); // 去除首尾空白 markdown = markdown.replace(/^\s+|\s+$/g, ''); return markdown; } // 轻量 Markdown 转 HTML(基础语法) function markdownToHtml(md) { if (!md) return ''; let placeholders = []; // 保护代码块 ```lang\n...\n``` md = md.replace(/```(\w+)?\n([\s\S]*?)```/g, function(_, lang, code) { const idx = placeholders.length; const esc = (s)=>String(s).replace(/&/g,'&').replace(//g,'>'); placeholders.push(`
${esc(code)}
`); return `\u0000BLOCK${idx}\u0000`; }); // 保护行内代码 `code` md = md.replace(/`([^`\n]+)`/g, function(_, code){ const idx = placeholders.length; const esc = (s)=>String(s).replace(/&/g,'&').replace(//g,'>'); placeholders.push(`${esc(code)}`); return `\u0000INLINE${idx}\u0000`; }); // 先整体转义,避免 HTML 注入 md = md.replace(/&/g,'&').replace(//g,'>'); // 图片与链接 md = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); md = md.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
$1'); // 粗体/斜体 md = md.replace(/\*\*([^*]+)\*\*/g, '$1'); md = md.replace(/\*([^*]+)\*/g, '$1'); // 标题 md = md.replace(/^(#{6})\s+(.+)$/gm, '
$2
') .replace(/^(#{5})\s+(.+)$/gm, '
$2
') .replace(/^(#{4})\s+(.+)$/gm, '

$2

') .replace(/^(#{3})\s+(.+)$/gm, '

$2

') .replace(/^(#{2})\s+(.+)$/gm, '

$2

') .replace(/^(#{1})\s+(.+)$/gm, '

$2

'); // 水平线 md = md.replace(/^\s*[-*_]{3,}\s*$/gm, '
'); // 引用 md = md.replace(/^(?:>\s?(.*))$/gm, '

$1

'); // 列表(连续项聚合) md = md.replace(/(?:^(?:\s*-\s+.+)\n?)+/gm, function(block){ const items = block.trim().split(/\n/).map(l => l.replace(/^\s*-\s+/, '').trim()); return ''; }); md = md.replace(/(?:^(?:\s*\d+\.\s+.+)\n?)+/gm, function(block){ const items = block.trim().split(/\n/).map(l => l.replace(/^\s*\d+\.\s+/, '').trim()); return '
    ' + items.map(i=>`
  1. ${i}
  2. `).join('') + '
'; }); // 段落:使用换行分段,避免已是块级元素再次包裹 const blocks = md.split('\n') .filter(seg => seg.trim().length > 0) // 移除空行 .map(seg=>{ if (/^\s*<(h\d|ul|ol|li|pre|blockquote|hr)/i.test(seg)) return seg; return '

' + seg + '

'; }); let html = blocks.join(''); // 还原占位 html = html.replace(/\u0000(INLINE|BLOCK)(\d+)\u0000/g, (_, type, i) => placeholders[Number(i)] || ''); return html; } // HTML转义 function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // 处理微博选择:截图并创建Issue function handleWeiboSelection(elements, pageUrl, pageTitle) { // 加载 html2canvas 库(如果未加载) if (typeof html2canvas === 'undefined') { loadHtml2CanvasLibrary(() => { processWeiboCapture(elements, pageUrl, pageTitle); }); } else { processWeiboCapture(elements, pageUrl, pageTitle); } } // 加载 html2canvas 库 function loadHtml2CanvasLibrary(callback) { const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'; script.onload = callback; script.onerror = () => { if (typeof GM_notification === 'function') { GM_notification({ text: 'html2canvas 库加载失败', title: 'CNB Issue工具', timeout: 3000 }); } }; document.head.appendChild(script); } // 处理微博截图 function processWeiboCapture(elements, pageUrl, pageTitle) { // 提取出处信息(从选择区域中查找时间链接) let sourceUrl = pageUrl; let sourceTime = new Date().toLocaleString(); // 遍历选择区域,查找时间链接 for (const element of elements) { const timeLink = element.querySelector('a[class*="_time"]'); if (timeLink && timeLink.href) { sourceUrl = timeLink.href; // 从 title 属性中提取时间 if (timeLink.title) { sourceTime = timeLink.title; } break; } } // 提取标题(从选择区域中查找微博正文) let weiboTitle = pageTitle.replace(/\s*-\s*.+$/, '').replace(/\s+/g, ' '); const wbtextElement = elements[0].querySelector('[class*="_wbtext"]'); if (wbtextElement) { const textContent = wbtextElement.textContent.trim(); // 限制标题长度为100字符 weiboTitle = textContent.length > 100 ? textContent.substring(0, 100) + '...' : textContent; } // 创建遮罩层和对话框 const overlay = document.createElement('div'); overlay.className = 'cnb-issue-overlay'; const dialog = document.createElement('div'); dialog.className = 'cnb-issue-dialog'; // 记录单例,供下次打开前清理 __CNB_CREATE_OVERLAY = overlay; __CNB_CREATE_DIALOG = dialog; dialog.innerHTML = `

创建 CNB Issue (微博截图)

点击创建时将自动生成并上传截图
修改Issue
添加评论
`; // 先定义常用DOM元素 const editToggle = dialog.querySelector('#cnb-edit-toggle'); const commentToggle = dialog.querySelector('#cnb-comment-toggle'); const issueNumberInput = dialog.querySelector('#cnb-issue-number'); const confirmBtn = dialog.querySelector('.cnb-issue-btn-confirm'); const doneBtn = dialog.querySelector('.cnb-issue-btn-done'); // 更新按钮文本的函数 function updateButtonText() { const isEdit = editToggle ? editToggle.checked : false; const isComment = commentToggle ? commentToggle.checked : false; if (isComment) { confirmBtn.textContent = '添加评论'; } else if (isEdit) { confirmBtn.textContent = '修改Issue'; } else { confirmBtn.textContent = '创建Issue'; } if (doneBtn) { if (isComment) { doneBtn.textContent = '添加评论并完成'; } else if (isEdit) { doneBtn.textContent = '修改并完成'; } else { doneBtn.textContent = '创建完成Issue'; } } } // 渲染收藏按钮 const favContainer = dialog.querySelector('#cnb-issue-fav'); if (favContainer) { favContainer.innerHTML = ''; let favConfig = ''; try { if (typeof GM_getValue === 'function') { favConfig = GM_getValue(currentRegion().favKey, '') || ''; } } catch (_) {} // 解析收藏配置: 1|收藏,5|阅读 或 1|收藏,5|阅读(支持中文逗号) const favItems = []; if (favConfig) { const parts = favConfig.split(/[,,]/).map(s => s.trim()).filter(Boolean); parts.forEach(part => { const match = part.match(/^(\d+)\|(.+)$/); if (match) { favItems.push({ issueNumber: match[1], name: match[2].trim() }); } }); } // 如果收藏为空,隐藏整个收藏区域 if (favItems.length === 0) { const favSection = favContainer.parentElement; if (favSection) { favSection.style.display = 'none'; } } else { favItems.forEach(item => { const btnFav = document.createElement('button'); btnFav.type = 'button'; btnFav.className = 'cnb-tag-btn'; btnFav.textContent = item.name; btnFav.addEventListener('click', () => { // 检查是否已经选中 if (btnFav.classList.contains('active')) { // 如果已选中,取消选中 btnFav.classList.remove('active'); // 关闭编辑开关和评论开关 if (editToggle) { editToggle.checked = false; // 手动隐藏Issue号输入框 if (issueNumberInput) { issueNumberInput.style.display = 'none'; } } if (commentToggle) { commentToggle.checked = false; } // 清空所有标签选择 selectedTags = []; const tagBtns = tagsContainer.querySelectorAll('.cnb-tag-btn'); tagBtns.forEach(btn => btn.classList.remove('active')); // 更新按钮文本 updateButtonText(); } else { // 先移除所有收藏按钮的active状态 const allFavBtns = favContainer.querySelectorAll('.cnb-tag-btn'); allFavBtns.forEach(btn => btn.classList.remove('active')); // 为当前点击的按钮添加active状态 btnFav.classList.add('active'); // 自动打开编辑开关和评论开关 if (editToggle) { editToggle.checked = true; if (issueNumberInput) { issueNumberInput.style.display = 'inline-block'; issueNumberInput.value = item.issueNumber; } } if (commentToggle) { commentToggle.checked = true; } // 获取Issue的标签 fetchIssueLabels(item.issueNumber, (labels, error) => { if (error) { console.error('获取标签失败:', error); return; } // 提取标签名称 const labelNames = labels.map(l => l.name || l); // 清空当前选择 selectedTags = []; const tagBtns = tagsContainer.querySelectorAll('.cnb-tag-btn'); tagBtns.forEach(btn => { const tagName = btn.textContent; // 如果标签在Issue的标签列表中,则勾选 if (labelNames.includes(tagName)) { btn.classList.add('active'); selectedTags.push(tagName); } else { btn.classList.remove('active'); } }); }); // 更新按钮文本 updateButtonText(); } }); favContainer.appendChild(btnFav); }); } } // 渲染标签选择 const tagsContainer = dialog.querySelector('#cnb-issue-tags'); let selectedTags = []; if (tagsContainer) { tagsContainer.innerHTML = ''; const tags = Array.isArray(SAVED_TAGS) ? SAVED_TAGS : []; if (tags.length === 0) { const hint = document.createElement('div'); hint.className = 'cnb-hint'; hint.textContent = '在设置中添加标签后可在此选择'; tagsContainer.appendChild(hint); } else { tags.forEach(tag => { const btnTag = document.createElement('button'); btnTag.type = 'button'; btnTag.className = 'cnb-tag-btn'; btnTag.textContent = tag; btnTag.addEventListener('click', () => { const idx = selectedTags.indexOf(tag); if (idx >= 0) { selectedTags.splice(idx, 1); btnTag.classList.remove('active'); } else { selectedTags.push(tag); btnTag.classList.add('active'); } }); tagsContainer.appendChild(btnTag); }); } } const cancelBtn = dialog.querySelector('.cnb-issue-btn-cancel'); // 监听编辑开关变化,显示/隐藏Issue号输入框 if (editToggle && issueNumberInput) { editToggle.addEventListener('change', () => { issueNumberInput.style.display = editToggle.checked ? 'inline-block' : 'none'; // 如果关闭编辑开关,则同时关闭评论开关 if (!editToggle.checked && commentToggle.checked) { commentToggle.checked = false; } updateButtonText(); }); // 监听Issue号输入框失去焦点时,获取并勾选对应的标签 issueNumberInput.addEventListener('blur', () => { const issueNum = issueNumberInput.value.trim(); if (!issueNum) { // 清空Issue号时,取消所有标签勾选 selectedTags = []; const tagBtns = tagsContainer.querySelectorAll('.cnb-tag-btn'); tagBtns.forEach(btn => btn.classList.remove('active')); return; } // 获取Issue的标签 fetchIssueLabels(issueNum, (labels, error) => { if (error) { console.error('获取标签失败:', error); return; } // 提取标签名称 const labelNames = labels.map(l => l.name || l); // 清空当前选择 selectedTags = []; const tagBtns = tagsContainer.querySelectorAll('.cnb-tag-btn'); tagBtns.forEach(btn => { const tagName = btn.textContent; // 如果标签在Issue的标签列表中,则勾选 if (labelNames.includes(tagName)) { btn.classList.add('active'); selectedTags.push(tagName); } else { btn.classList.remove('active'); } }); }); }); } // 监听评论开关变化 if (commentToggle && editToggle) { commentToggle.addEventListener('change', () => { if (commentToggle.checked) { // 打开评论开关,必须同时打开编辑开关 if (!editToggle.checked) { editToggle.checked = true; if (issueNumberInput) { issueNumberInput.style.display = 'inline-block'; } } } updateButtonText(); }); } const closeDialog = () => { if (document.body.contains(overlay)) document.body.removeChild(overlay); if (document.body.contains(dialog)) document.body.removeChild(dialog); if (__CNB_CREATE_OVERLAY === overlay) __CNB_CREATE_OVERLAY = null; if (__CNB_CREATE_DIALOG === dialog) __CNB_CREATE_DIALOG = null; }; overlay.addEventListener('click', closeDialog); cancelBtn.addEventListener('click', closeDialog); // 创建并完成 Issue doneBtn.addEventListener('click', async () => { const title = dialog.querySelector('#cnb-issue-title').value; const content = dialog.querySelector('#cnb-issue-content').value; const editToggle = dialog.querySelector('#cnb-edit-toggle'); const commentToggle = dialog.querySelector('#cnb-comment-toggle'); const issueNumberInput = dialog.querySelector('#cnb-issue-number'); const shouldEdit = editToggle ? editToggle.checked : false; const shouldComment = commentToggle ? commentToggle.checked : false; const issueNumber = issueNumberInput ? issueNumberInput.value.trim() : ''; const labels = Array.isArray(selectedTags) ? selectedTags.slice() : []; // 验证:如果要修改或评论Issue,必须输入Issue号 if (shouldEdit || shouldComment) { if (!issueNumber) { if (typeof GM_notification === 'function') { GM_notification({ text: '请输入Issue号', title: 'CNB Issue工具', timeout: 3000 }); } return; } } doneBtn.disabled = true; confirmBtn.disabled = true; doneBtn.innerHTML = '
处理中...'; // 生成截图 const statusEl = dialog.querySelector('#cnb-capture-status'); if (statusEl) statusEl.textContent = '正在生成截图...'; try { // 先计算截图区域(在修改样式之前获取准确坐标) const bounds = elements.map(el => el.getBoundingClientRect()); const minX = Math.min(...bounds.map(b => b.left)) - 10; const minY = Math.min(...bounds.map(b => b.top)) - 10; const maxX = Math.max(...bounds.map(b => b.right)) + 10; const maxY = Math.max(...bounds.map(b => b.bottom)) + 10; // 临时保存原始样式 const originalStyles = []; elements.forEach(el => { originalStyles.push({ el: el, outline: el.style.outline, boxShadow: el.style.boxShadow, zIndex: el.style.zIndex }); // 移除选择样式 el.style.outline = 'none'; el.style.boxShadow = 'none'; // 不要修改 zIndex,避免影响布局 // el.style.zIndex = '999999'; }); // 预加载选中区域内的所有图片,解决跨域图片显示空白的问题 // 使用 GM_xmlhttpRequest 来获取图片数据,绕过跨域限制 const imagePromises = []; elements.forEach(el => { const imgs = el.querySelectorAll('img'); imgs.forEach(img => { if (img.src && !img.src.startsWith('data:')) { const promise = new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: img.src, responseType: 'blob', headers: { 'Referer': window.location.href }, onload: (response) => { try { if (response.response && response.response instanceof Blob) { const reader = new FileReader(); reader.onload = () => { img.src = reader.result; console.log('Converted image with GM_xmlhttpRequest:', img.src.substring(0, 50) + '...'); resolve(); }; reader.onerror = () => { console.warn('Failed to read blob'); resolve(); }; reader.readAsDataURL(response.response); } else { resolve(); } } catch (e) { console.warn('Failed to convert image:', e); resolve(); } }, onerror: () => { console.warn('Failed to fetch image with GM_xmlhttpRequest:', img.src); resolve(); } }); }); imagePromises.push(promise); } }); }); // 等待所有图片加载或转换完成 await Promise.all(imagePromises); // 等待元素重新渲染 await new Promise(resolve => setTimeout(resolve, 500)); // 使用 html2canvas 生成截图 const canvas = await html2canvas(document.body, { backgroundColor: '#ffffff', scale: 2, useCORS: true, allowTaint: true, logging: false, x: minX, y: minY, width: maxX - minX, height: maxY - minY, ignoreElements: (element) => { // 忽略对话框 return element.classList.contains('cnb-issue-dialog') || element.classList.contains('cnb-issue-overlay') || element.closest('.cnb-issue-dialog') || element.closest('.cnb-issue-overlay'); } }); // 恢复原始样式 originalStyles.forEach(item => { item.el.style.outline = item.outline; item.el.style.boxShadow = item.boxShadow; item.el.style.zIndex = item.zIndex; }); // 转换为 blob const blob = await new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) { resolve(blob); } else { reject(new Error('Canvas toBlob failed')); } }, 'image/png'); }); if (!blob) { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '创建完成Issue'; if (typeof GM_notification === 'function') { GM_notification({ text: '截图生成失败', title: 'CNB Issue工具', timeout: 3000 }); } return; } if (statusEl) statusEl.textContent = '正在上传截图...'; doneBtn.innerHTML = '
上传中...'; // 上传截图 const fileName = `weibo_${Date.now()}.png`; requestUploadToken(fileName, blob.size, (uploadInfo, tokenError) => { if (tokenError || !uploadInfo) { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '创建完成Issue'; if (typeof GM_notification === 'function') { GM_notification({ text: '获取上传凭证失败: ' + tokenError, title: 'CNB Issue工具', timeout: 5000 }); } return; } uploadImageToOss(uploadInfo, blob, (imageUrl, uploadError) => { if (uploadError || !imageUrl) { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '创建完成Issue'; if (typeof GM_notification === 'function') { GM_notification({ text: '截图上传失败: ' + uploadError, title: 'CNB Issue工具', timeout: 5000 }); } return; } // 更新内容为图片 const updatedContent = content + '\n\n' + `![微博截图](${imageUrl})`; // 创建并完成 Issue 或修改 Issue 或添加评论 if (statusEl) { if (shouldComment) { statusEl.textContent = '正在添加评论...'; } else if (shouldEdit) { statusEl.textContent = '正在修改Issue...'; } else { statusEl.textContent = '正在创建Issue...'; } } doneBtn.innerHTML = '
' + (shouldComment ? '添加评论中...' : (shouldEdit ? '修改中...' : '创建中...')); const handleIssueOperation = () => { if (shouldComment) { // 只添加评论,然后关闭Issue addCommentToIssue(issueNumber, updatedContent, (commentSuccess) => { if (commentSuccess) { closeIssue(issueNumber, 'completed', (ok) => { if (typeof GM_notification === 'function') { GM_notification({ text: '评论已添加,Issue已标记为已完成', title: 'CNB Issue工具', timeout: 3000 }); } closeDialog(); }); } else { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '添加评论并完成'; } }); } else if (shouldEdit) { // 修改现有Issue const updateData = { body: updatedContent, title: title, state: 'closed', state_reason: 'completed' }; updateIssue(issueNumber, updateData, labels, (success) => { if (success) { if (typeof GM_notification === 'function') { GM_notification({ text: 'Issue已修改并完成', title: 'CNB Issue工具', timeout: 3000 }); } closeDialog(); } else { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '修改并完成'; } }); } else { // 创建新Issue createIssue(title, updatedContent, labels, (success, issueId) => { if (success && issueId != null) { closeIssue(issueId, 'completed', (ok) => { if (!ok) { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '创建完成Issue'; return; } if (typeof GM_notification === 'function') { GM_notification({ text: 'Issue已标记为已完成(closed: completed)', title: 'CNB Issue工具', timeout: 3000 }); } closeDialog(); }); } else { doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '创建完成Issue'; } }); } }; handleIssueOperation(); }); }); } catch (error) { console.error('html2canvas error:', error); doneBtn.disabled = false; confirmBtn.disabled = false; doneBtn.innerHTML = '创建完成Issue'; if (typeof GM_notification === 'function') { GM_notification({ text: '截图生成失败: ' + error.message, title: 'CNB Issue工具', timeout: 5000 }); } } }); // 创建 Issue confirmBtn.addEventListener('click', async () => { const title = dialog.querySelector('#cnb-issue-title').value; const content = dialog.querySelector('#cnb-issue-content').value; const editToggle = dialog.querySelector('#cnb-edit-toggle'); const commentToggle = dialog.querySelector('#cnb-comment-toggle'); const issueNumberInput = dialog.querySelector('#cnb-issue-number'); const shouldEdit = editToggle ? editToggle.checked : false; const shouldComment = commentToggle ? commentToggle.checked : false; const issueNumber = issueNumberInput ? issueNumberInput.value.trim() : ''; const labels = Array.isArray(selectedTags) ? selectedTags.slice() : []; // 验证:如果要修改或评论Issue,必须输入Issue号 if (shouldEdit || shouldComment) { if (!issueNumber) { if (typeof GM_notification === 'function') { GM_notification({ text: '请输入Issue号', title: 'CNB Issue工具', timeout: 3000 }); } return; } } confirmBtn.disabled = true; confirmBtn.innerHTML = '
生成截图中...'; // 生成截图 const statusEl = dialog.querySelector('#cnb-capture-status'); if (statusEl) statusEl.textContent = '正在生成截图...'; try { // 先计算截图区域(在修改样式之前获取准确坐标) const bounds = elements.map(el => el.getBoundingClientRect()); const minX = Math.min(...bounds.map(b => b.left)) - 10; const minY = Math.min(...bounds.map(b => b.top)) - 10; const maxX = Math.max(...bounds.map(b => b.right)) + 10; const maxY = Math.max(...bounds.map(b => b.bottom)) + 10; // 使用原始元素直接截图,不克隆(避免丢失动态加载的内容) // 临时保存原始样式 const originalStyles = []; elements.forEach(el => { originalStyles.push({ el: el, outline: el.style.outline, boxShadow: el.style.boxShadow, zIndex: el.style.zIndex }); // 移除选择样式 el.style.outline = 'none'; el.style.boxShadow = 'none'; // 不要修改 zIndex,避免影响布局 // el.style.zIndex = '999999'; }); // 预加载选中区域内的所有图片,解决跨域图片显示空白的问题 // 使用 GM_xmlhttpRequest 来获取图片数据,绕过跨域限制 const imagePromises = []; elements.forEach(el => { const imgs = el.querySelectorAll('img'); imgs.forEach(img => { if (img.src && !img.src.startsWith('data:')) { const promise = new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: img.src, responseType: 'blob', headers: { 'Referer': window.location.href }, onload: (response) => { try { if (response.response && response.response instanceof Blob) { const reader = new FileReader(); reader.onload = () => { img.src = reader.result; console.log('Converted image with GM_xmlhttpRequest:', img.src.substring(0, 50) + '...'); resolve(); }; reader.onerror = () => { console.warn('Failed to read blob'); resolve(); }; reader.readAsDataURL(response.response); } else { resolve(); } } catch (e) { console.warn('Failed to convert image:', e); resolve(); } }, onerror: () => { console.warn('Failed to fetch image with GM_xmlhttpRequest:', img.src); resolve(); } }); }); imagePromises.push(promise); } }); }); // 等待所有图片加载或转换完成 await Promise.all(imagePromises); // 等待元素重新渲染 await new Promise(resolve => setTimeout(resolve, 500)); // 使用 html2canvas 生成截图(使用之前计算的坐标) const canvas = await html2canvas(document.body, { backgroundColor: '#ffffff', scale: 2, useCORS: true, allowTaint: true, logging: false, x: minX, y: minY, width: maxX - minX, height: maxY - minY, ignoreElements: (element) => { // 忽略对话框 return element.classList.contains('cnb-issue-dialog') || element.classList.contains('cnb-issue-overlay'); } }); // 恢复原始样式 originalStyles.forEach(item => { item.el.style.outline = item.outline; item.el.style.boxShadow = item.boxShadow; item.el.style.zIndex = item.zIndex; }); // 转换为 blob const blob = await new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) { resolve(blob); } else { reject(new Error('Canvas toBlob failed')); } }, 'image/png'); }); if (!blob) { confirmBtn.disabled = false; confirmBtn.innerHTML = '创建Issue'; if (typeof GM_notification === 'function') { GM_notification({ text: '截图生成失败', title: 'CNB Issue工具', timeout: 3000 }); } return; } if (statusEl) statusEl.textContent = '正在上传截图...'; confirmBtn.innerHTML = '
上传中...'; // 上传截图 const fileName = `weibo_${Date.now()}.png`; requestUploadToken(fileName, blob.size, (uploadInfo, tokenError) => { if (tokenError || !uploadInfo) { confirmBtn.disabled = false; confirmBtn.innerHTML = '创建Issue'; if (typeof GM_notification === 'function') { GM_notification({ text: '获取上传凭证失败: ' + tokenError, title: 'CNB Issue工具', timeout: 5000 }); } return; } uploadImageToOss(uploadInfo, blob, (imageUrl, uploadError) => { if (uploadError || !imageUrl) { confirmBtn.disabled = false; confirmBtn.innerHTML = '创建Issue'; if (typeof GM_notification === 'function') { GM_notification({ text: '截图上传失败: ' + uploadError, title: 'CNB Issue工具', timeout: 5000 }); } return; } // 更新内容为图片 const updatedContent = content + '\n\n' + `![微博截图](${imageUrl})`; // 创建 Issue 或修改 Issue 或添加评论 if (statusEl) { if (shouldComment) { statusEl.textContent = '正在添加评论...'; } else if (shouldEdit) { statusEl.textContent = '正在修改Issue...'; } else { statusEl.textContent = '正在创建Issue...'; } } confirmBtn.innerHTML = '
' + (shouldComment ? '添加评论中...' : (shouldEdit ? '修改中...' : '创建中...')); const handleIssueOperation = () => { if (shouldComment) { // 只添加评论,不修改Issue addCommentToIssue(issueNumber, updatedContent, (commentSuccess) => { closeDialog(); }); } else if (shouldEdit) { // 修改现有Issue const updateData = { body: updatedContent, title: title }; updateIssue(issueNumber, updateData, labels, (success) => { if (success) { closeDialog(); } else { confirmBtn.disabled = false; confirmBtn.innerHTML = '修改Issue'; } }); } else { // 创建新Issue createIssue(title, updatedContent, labels, (success) => { if (success) { closeDialog(); } else { confirmBtn.disabled = false; confirmBtn.innerHTML = '创建Issue'; } }); } }; handleIssueOperation(); }); }); } catch (error) { console.error('html2canvas error:', error); confirmBtn.disabled = false; confirmBtn.innerHTML = '创建Issue'; if (typeof GM_notification === 'function') { GM_notification({ text: '截图生成失败: ' + error.message, title: 'CNB Issue工具', timeout: 5000 }); } } }); document.body.appendChild(overlay); document.body.appendChild(dialog); // 自动聚焦到标题输入框 dialog.querySelector('#cnb-issue-title').focus(); dialog.querySelector('#cnb-issue-title').select(); } // 设置弹窗 function openSettingsDialog() { // 单例:若已存在,先移除旧实例 try { if (__CNB_SETTINGS_OVERLAY && __CNB_SETTINGS_OVERLAY.parentNode) __CNB_SETTINGS_OVERLAY.remove(); if (__CNB_SETTINGS_DIALOG && __CNB_SETTINGS_DIALOG.parentNode) __CNB_SETTINGS_DIALOG.remove(); } catch (_) {} const overlay = document.createElement('div'); overlay.className = 'cnb-issue-overlay'; const dialog = document.createElement('div'); dialog.className = 'cnb-issue-dialog'; __CNB_SETTINGS_OVERLAY = overlay; __CNB_SETTINGS_DIALOG = dialog; const currentRepo = CONFIG.repoPath || ''; const currentToken = CONFIG.accessToken || ''; const currentHotkey = START_HOTKEY || ''; const currentHotkeyEnabled = !!HOTKEY_ENABLED; let currentClipIssue = ''; let currentFavIssue = ''; try { if (typeof GM_getValue === 'function') { const v = GM_getValue(currentRegion().clipKey, ''); currentClipIssue = (v == null) ? '' : String(v); const f = GM_getValue(currentRegion().favKey, ''); currentFavIssue = (f == null) ? '' : String(f); } } catch (_) {} dialog.innerHTML = `

CNB 设置

注意:上传图片需要访问令牌具有 repo-code:rw 权限
格式: Issue号|名称,多个用逗号分隔(如: 1|收藏,5|阅读)
`; // 渲染与管理标签 const tagsList = dialog.querySelector('#cnb-setting-tags-list'); const newTagInput = dialog.querySelector('#cnb-setting-newtag'); const addTagBtn = dialog.querySelector('#cnb-setting-addtag'); const hotkeyInput = dialog.querySelector('#cnb-setting-hotkey'); const hotkeyEnabledInput = dialog.querySelector('#cnb-setting-hotkey-enabled'); const repoInput = dialog.querySelector('#cnb-setting-repo'); const tokenInput = dialog.querySelector('#cnb-setting-token'); const clipIssueInput = dialog.querySelector('#cnb-setting-clip-issue'); const favIssueInput = dialog.querySelector('#cnb-setting-fav-issue'); // 标题右侧:区域切换开关(国内 / 国外) const regionToggle = dialog.querySelector('#cnb-btn-region'); if (regionToggle) { regionToggle.addEventListener('click', (e) => { e.preventDefault(); switchRegion(); }); } // 评论收藏即时保存 if (favIssueInput) { favIssueInput.addEventListener('input', () => { const favIssue = favIssueInput.value.trim(); if (typeof GM_setValue === 'function') GM_setValue(currentRegion().favKey, favIssue); }); } // 仓库路径即时保存 if (repoInput) { repoInput.addEventListener('input', () => { const repo = repoInput.value.trim(); if (repo) { CONFIG.repoPath = repo; if (typeof GM_setValue === 'function') GM_setValue(currentRegion().repoKey, repo); } }); } // 访问令牌即时保存 if (tokenInput) { tokenInput.addEventListener('input', () => { const token = tokenInput.value.trim(); if (token) { CONFIG.accessToken = token; if (typeof GM_setValue === 'function') GM_setValue(currentRegion().tokenKey, token); } }); } // 剪贴板位置即时保存并生效 if (clipIssueInput) { const updateClipIssue = () => { const clipIssue = clipIssueInput.value.trim(); if (typeof GM_setValue === 'function') GM_setValue(currentRegion().clipKey, clipIssue); // 即时生效:根据是否有值来动态增删"剪贴板"按钮 syncDockClipboardButton(); }; clipIssueInput.addEventListener('input', updateClipIssue); } if (hotkeyEnabledInput) { hotkeyEnabledInput.addEventListener('change', () => { HOTKEY_ENABLED = !!hotkeyEnabledInput.checked; if (typeof GM_setValue === 'function') GM_setValue('cnbHotkeyEnabled', HOTKEY_ENABLED); }); } // 录制快捷键:在输入框中按组合键即生成规范字符串 if (hotkeyInput) { hotkeyInput.addEventListener('keydown', (e) => { e.preventDefault(); const str = eventToHotkeyString(e); hotkeyInput.value = toDisplayHotkeyString(normalizeHotkeyString(str)); // 快捷键即时保存 START_HOTKEY = normalizeHotkeyString(str); if (typeof GM_setValue === 'function') GM_setValue('cnbHotkey', START_HOTKEY); }); } // 回车键添加标签 newTagInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTagBtn.click(); } }); function renderTagsList() { tagsList.innerHTML = ''; const tags = Array.isArray(SAVED_TAGS) ? SAVED_TAGS : []; if (tags.length === 0) { const empty = document.createElement('div'); empty.className = 'cnb-hint'; empty.textContent = '暂无标签'; tagsList.appendChild(empty); return; } tags.forEach((tag, idx) => { const item = document.createElement('span'); item.textContent = tag; item.className = 'cnb-tag-pill'; const del = document.createElement('button'); del.type = 'button'; del.textContent = '×'; del.title = '删除'; del.className = 'cnb-tag-delbtn'; del.addEventListener('click', async () => { const repo = dialog.querySelector('#cnb-setting-repo').value.trim(); const token = dialog.querySelector('#cnb-setting-token').value.trim(); // 如果配置了仓库路径和访问令牌,则调用 API 删除标签 if (repo && token) { try { await new Promise((resolve, reject) => { apiRequest({ method: 'DELETE', repo: repo, token: token, path: `/-/labels/${encodeURIComponent(tag)}` }, function(err) { if (err) reject(err); else resolve(); }); }); } catch (error) { // API 删除失败,只从本地删除 console.warn('从仓库删除标签失败:', error); } } // 从本地删除 SAVED_TAGS.splice(idx, 1); if (typeof GM_setValue === 'function') GM_setValue('cnbTags', SAVED_TAGS); renderTagsList(); }); item.appendChild(del); tagsList.appendChild(item); }); } renderTagsList(); addTagBtn.addEventListener('click', async () => { const t = (newTagInput.value || '').trim(); if (!t) return; const repo = dialog.querySelector('#cnb-setting-repo').value.trim(); const token = dialog.querySelector('#cnb-setting-token').value.trim(); if (!repo || !token) { if (typeof GM_notification === 'function') { GM_notification({ text: '请先配置仓库路径和访问令牌', title: 'CNB Issue工具', timeout: 3000 }); } return; } // 创建仓库标签 try { await new Promise((resolve, reject) => { apiRequest({ method: 'POST', repo: repo, token: token, path: '/-/labels', data: { name: t } }, function(err) { if (err) reject(err); else resolve(); }); }); // 添加到本地列表 if (!Array.isArray(SAVED_TAGS)) SAVED_TAGS = []; if (!SAVED_TAGS.includes(t)) { SAVED_TAGS.push(t); if (typeof GM_setValue === 'function') GM_setValue('cnbTags', SAVED_TAGS); renderTagsList(); newTagInput.value = ''; if (typeof GM_notification === 'function') { GM_notification({ text: '标签已添加', title: 'CNB Issue工具', timeout: 1500 }); } } } catch (error) { if (typeof GM_notification === 'function') { GM_notification({ text: `添加标签失败: ${error.message}`, title: 'CNB Issue工具', timeout: 3000 }); } } }); // 自动获取标签(打开设置对话框时自动调用) async function fetchRepoTags(repo, token) { if (!repo || !token) { return; } // 分页获取所有标签 let allLabels = []; let page = 1; const pageSize = 50; let hasMore = true; async function fetchLabelsPage() { return new Promise((resolve, reject) => { apiRequest({ method: 'GET', repo: repo, token: token, path: `/-/labels?page=${page}&page_size=${pageSize}` }, function(err, data) { if (err) { reject(err); return; } const labels = Array.isArray(data) ? data : (Array.isArray(data?.data) ? data.data : []); allLabels = allLabels.concat(labels); // 检查是否还有更多数据 const total = data?.total_count ?? data?.total ?? data?.totalCount ?? 0; hasMore = labels.length === pageSize && allLabels.length < total; resolve(labels); }); }); } try { while (hasMore) { await fetchLabelsPage(); page++; } const labelNames = allLabels.map(l => l.name || l.title || l).filter(Boolean); // 直接替换标签列表 SAVED_TAGS = labelNames; if (typeof GM_setValue === 'function') GM_setValue('cnbTags', SAVED_TAGS); renderTagsList(); if (labelNames.length > 0) { if (typeof GM_notification === 'function') { GM_notification({ text: `已获取 ${labelNames.length} 个标签`, title: 'CNB Issue工具', timeout: 2000 }); } } else { if (typeof GM_notification === 'function') { GM_notification({ text: '仓库暂无标签', title: 'CNB Issue工具', timeout: 2000 }); } } } catch (error) { // 静默失败,不影响对话框打开 console.warn('获取标签失败:', error); } } // 打开对话框时自动获取标签 const repo = dialog.querySelector('#cnb-setting-repo').value.trim(); const token = dialog.querySelector('#cnb-setting-token').value.trim(); fetchRepoTags(repo, token); // 供区域切换调用:同步该区域的仓库/令牌/剪贴板/收藏,并按新仓库重新拉取标签 __CNB_SETTINGS_REFRESH = () => { if (repoInput) repoInput.value = CONFIG.repoPath || ''; if (tokenInput) tokenInput.value = CONFIG.accessToken || ''; if (clipIssueInput) { clipIssueInput.value = (typeof GM_getValue === 'function') ? String(GM_getValue(currentRegion().clipKey, '') || '') : ''; } if (favIssueInput) { favIssueInput.value = (typeof GM_getValue === 'function') ? String(GM_getValue(currentRegion().favKey, '') || '') : ''; } if (CONFIG.repoPath && CONFIG.accessToken) { fetchRepoTags(CONFIG.repoPath, CONFIG.accessToken); } else { SAVED_TAGS = []; renderTagsList(); } }; const close = () => { if (document.body.contains(overlay)) document.body.removeChild(overlay); if (document.body.contains(dialog)) document.body.removeChild(dialog); __CNB_SETTINGS_OVERLAY = null; __CNB_SETTINGS_DIALOG = null; __CNB_SETTINGS_REFRESH = null; }; const closeBtn = dialog.querySelector('.cnb-dialog-close'); if (closeBtn) { closeBtn.addEventListener('click', close); } overlay.addEventListener('click', close); // ESC 关闭 const onSettingsEsc = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); document.removeEventListener('keydown', onSettingsEsc, true); } }; document.addEventListener('keydown', onSettingsEsc, true); document.body.appendChild(overlay); document.body.appendChild(dialog); console.log('[CNB Issue] overlay added:', document.body.contains(overlay)); console.log('[CNB Issue] dialog added:', document.body.contains(dialog)); console.log('[CNB Issue] dialog.className:', dialog.className); } // Issue 列表弹窗 function openIssueList() { // 单例:若已存在,先移除旧实例 try { if (__CNB_ISSUE_OVERLAY && __CNB_ISSUE_OVERLAY.parentNode) __CNB_ISSUE_OVERLAY.remove(); if (__CNB_ISSUE_DIALOG && __CNB_ISSUE_DIALOG.parentNode) __CNB_ISSUE_DIALOG.remove(); } catch (_) {} if (!CONFIG.repoPath || !CONFIG.accessToken) { if (typeof GM_notification === 'function') { GM_notification({ text: '请先在设置中配置仓库路径与访问令牌', title: 'CNB Issue工具', timeout: 3000 }); } if (typeof openSettingsDialog === 'function') openSettingsDialog(); return; } // 如果已存在旧窗口,先清理 try { if (__CNB_CLIP_DIALOG) __CNB_CLIP_DIALOG.remove(); } catch (_) {} // 移除所有旧的标签容器 try { const oldTabsList = document.querySelectorAll('.cnb-clipwin-tabs'); oldTabsList.forEach(tab => tab.remove()); } catch (_) {} __CNB_CLIP_DIALOG = null; const overlay = document.createElement('div'); overlay.className = 'cnb-issue-overlay'; __CNB_ISSUE_OVERLAY = overlay; const dialog = document.createElement('div'); dialog.className = 'cnb-issue-dialog'; __CNB_ISSUE_DIALOG = dialog; dialog.innerHTML = `

Issue 列表

`; // 固定对话框尺寸,防止点击筛选按钮时窗口抖动 dialog.style.width = '840px'; dialog.style.maxWidth = '840px'; // 筛选按钮样式(与创建弹窗共用,防重复注入) addStyleOnce('issue-filter-btn', CNB_FILTER_BTN_CSS); const listEl = dialog.querySelector('#cnb-issue-list'); const closeBtn = dialog.querySelector('.cnb-dialog-close'); // 行内标签(Issue 列表中的 labels)胶囊样式 - 扁平黑白配色 addStyleOnce('issue-chip', ` .cnb-issue-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border: 2px solid #000; border-radius: 0; background: #fff; color: #000; font-size: 11px; font-weight: 500; line-height: 1.1; white-space: nowrap; vertical-align: middle; box-shadow: none; transition: all 0.1s ease; user-select: none; } .cnb-issue-chip:hover { background: #000; color: #fff; } `); const close = () => { if (document.body.contains(overlay)) document.body.removeChild(overlay); if (document.body.contains(dialog)) document.body.removeChild(dialog); document.removeEventListener('keydown', onEsc, true); __CNB_ISSUE_OVERLAY = null; __CNB_ISSUE_DIALOG = null; }; overlay.addEventListener('click', close); if (closeBtn) closeBtn.addEventListener('click', close); // ESC 关闭 const onEsc = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; document.addEventListener('keydown', onEsc, true); // 初始加载中 listEl.innerHTML = `
加载中...
`; // 无限滚动相关状态 const pageSize = 50; let nextPage = 1; // 下一个待加载页码 let hasMore = true; // 是否还有更多数据 let loading = false; // 是否正在加载 let allItems = []; // 已加载的全部 Issue let currentFilterLabel = null; // 加载第 page 页并追加到已加载列表 function loadPage(page) { if (loading || !hasMore) return; loading = true; const isFirst = page === 1; if (isFirst) listEl.innerHTML = `
加载中...
`; apiRequest({ method: 'GET', path: `${CONFIG.issueEndpoint}?page=${page}&page_size=${pageSize}&state=closed`, accept: 'application/json' }, function(err, data) { loading = false; if (err) { if (isFirst) { listEl.innerHTML = `
加载失败
`; } else { notifyUser('加载下一页失败:' + err.message, 4000); } return; } const items = Array.isArray(data) ? data : (Array.isArray(data && data.items) ? data.items : []); allItems = isFirst ? items : allItems.concat(items); hasMore = items.length === pageSize; // 满一页说明可能还有下一页 nextPage = page + 1; if (!allItems.length) { listEl.innerHTML = `
暂无数据
`; return; } renderList(currentFilterLabel, true); maybeAutoLoad(); }); } // 内容不足一屏且还有更多时自动继续加载,保证标签筛选也能一次展示完整 function maybeAutoLoad() { if (hasMore && !loading && listEl.scrollHeight <= listEl.clientHeight + 4) { loadPage(nextPage); } } // 滚动到底部附近时加载下一页 listEl.addEventListener('scroll', () => { if (!hasMore || loading) return; if (listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 40) { loadPage(nextPage); } }); function renderList(filterLabel, keepScroll) { const prevScrollTop = listEl.scrollTop; const filterEl = dialog.querySelector('#cnb-issue-filter'); // 行内样式强制为 flex 并设置 4px 间距,避免被站点覆盖 if (filterEl) { const s = filterEl.style; s.setProperty('display', 'flex', 'important'); s.setProperty('flex-wrap', 'wrap', 'important'); s.setProperty('gap', '4px', 'important'); } const frag = document.createDocumentFragment(); const filtered = !filterLabel ? allItems : allItems.filter(it => { const names = Array.isArray(it.labels) ? it.labels.map(l => l.name) : []; return names.includes(filterLabel); }); filtered.forEach(it => { const number = it.number ?? it.id ?? it.iid ?? ''; const title = it.title ?? ''; const createdAt = it.created_at ?? ''; const labelNames = Array.isArray(it.labels) ? it.labels.map(l => l.name) : []; const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:4px 8px;border-bottom:2px solid #000;'; const left = document.createElement('div'); left.style.cssText = 'min-width:0;flex:1;font-size:13px;color:#000;display:flex;gap:6px !important;align-items:center;'; const prefix = document.createElement('span'); prefix.textContent = `#${number}`; prefix.style.fontWeight = '700'; const a = document.createElement('a'); a.href = `${CONFIG.webBase}/${CONFIG.repoPath}/-/issues/${number}`; a.target = '_blank'; a.rel = 'noopener noreferrer'; const fullTitle = String(title || ''); const truncated = fullTitle.length > 30 ? fullTitle.slice(0, 30) + '…' : fullTitle; a.textContent = truncated; a.title = fullTitle; a.style.cssText = 'color:#000;text-decoration:none;word-break:break-all;font-weight:500;'; a.addEventListener('mouseover', () => a.style.textDecoration = 'underline'); a.addEventListener('mouseout', () => a.style.textDecoration = 'none'); left.appendChild(prefix); left.appendChild(a); // 复制按钮:关闭 Issue(完成) 并复制 title + body(清理为Markdown) 到剪贴板 const btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = '复制'; btnCopy.title = '关闭 Issue 并复制到剪贴板'; btnCopy.style.cssText = 'margin-left:6px;display:inline-flex;align-items:center;justify-content:center;height:20px;padding:0 8px;border:1px solid #d0d7de;border-radius:4px;background:#f6f8fa;color:#57606a;font-size:11px;font-weight:500;cursor:pointer;line-height:1;transition:all 0.1s ease;'; btnCopy.addEventListener('mouseover', () => { btnCopy.style.background = '#eaeef2'; btnCopy.style.color = '#24292f'; btnCopy.style.borderColor = '#afb8c1'; }); btnCopy.addEventListener('mouseout', () => { btnCopy.style.background = '#f6f8fa'; btnCopy.style.color = '#57606a'; btnCopy.style.borderColor = '#d0d7de'; }); btnCopy.addEventListener('click', () => { if (btnCopy.disabled) return; btnCopy.disabled = true; const oldText = btnCopy.textContent; btnCopy.textContent = '…'; apiRequest({ method: 'PATCH', path: `${CONFIG.issueEndpoint}/${number}`, accept: 'application/json', data: { state: 'closed', state_reason: 'completed' } }, function(err, obj) { try { if (err) { notifyUser(`操作失败: ${err.message}`, 5000); return; } const t = (obj && obj.title) ? obj.title : title; const b = (obj && typeof obj.body === 'string') ? obj.body : ''; const md = cleanMarkdownContent(String(b || '')); if (typeof GM_setClipboard === 'function') { GM_setClipboard(`${t} ${md}`, 'text'); } notifyUser('已关闭并复制到剪贴板'); } finally { btnCopy.disabled = false; btnCopy.textContent = oldText; } }); }); left.appendChild(btnCopy); const right = document.createElement('div'); right.style.cssText = 'flex:0 0 auto;color:#57606a;font-size:11px;text-align:right;display:flex;align-items:center;gap:6px;flex-wrap:wrap;justify-content:flex-end;'; // 标签胶囊容器 const labelsWrap = document.createElement('div'); labelsWrap.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end;'; const labelObjs = Array.isArray(it.labels) ? it.labels : []; labelObjs.forEach(l => { const chip = document.createElement('span'); chip.className = 'cnb-issue-chip'; chip.textContent = l?.name ?? ''; // 若有颜色,应用为背景,并适当设置边框与前景色 const color = (l && typeof l.color === 'string' && l.color) ? l.color : ''; if (color) { chip.style.background = color; // 根据背景亮度调整文字与边框(简单阈值) try { const hex = color.replace('#',''); const r = parseInt(hex.substring(0,2),16); const g = parseInt(hex.substring(2,4),16); const b = parseInt(hex.substring(4,6),16); const lum = 0.2126*r + 0.7152*g + 0.0722*b; chip.style.color = lum < 140 ? '#fff' : '#24292f'; chip.style.borderColor = lum < 140 ? 'rgba(255,255,255,0.35)' : '#d0d7de'; } catch(_) {} } labelsWrap.appendChild(chip); }); const dateSpan = document.createElement('span'); dateSpan.textContent = createdAt; dateSpan.style.cssText = 'color:#57606a;'; right.appendChild(labelsWrap); right.appendChild(dateSpan); row.appendChild(left); row.appendChild(right); frag.appendChild(row); }); listEl.innerHTML = ''; listEl.appendChild(frag); listEl.scrollTop = keepScroll ? prevScrollTop : 0; } // 渲染筛选按钮 const filterEl = dialog.querySelector('#cnb-issue-filter'); if (filterEl) { filterEl.innerHTML = ''; const allBtn = document.createElement('button'); allBtn.className = 'cnb-issue-filter-btn active'; allBtn.textContent = '全部'; addPressEffect(allBtn); allBtn.addEventListener('click', () => { setActive(allBtn); currentFilterLabel = null; renderList(null, false); maybeAutoLoad(); }); filterEl.appendChild(allBtn); const tagList = Array.isArray(SAVED_TAGS) ? SAVED_TAGS : []; tagList.forEach(tag => { const b = document.createElement('button'); b.className = 'cnb-issue-filter-btn'; b.textContent = tag; addPressEffect(b); b.addEventListener('click', () => { setActive(b); currentFilterLabel = tag; renderList(tag, false); maybeAutoLoad(); }); filterEl.appendChild(b); }); function setActive(btn) { const buttons = filterEl.querySelectorAll('button'); buttons.forEach(x => x.classList.remove('active')); btn.classList.add('active'); } // 为筛选按钮添加按压反馈 function addPressEffect(btn) { btn.addEventListener('mousedown', () => btn.classList.add('pressed')); btn.addEventListener('mouseup', () => btn.classList.remove('pressed')); btn.addEventListener('mouseleave', () => btn.classList.remove('pressed')); btn.addEventListener('touchstart', () => btn.classList.add('pressed'), { passive: true }); btn.addEventListener('touchend', () => btn.classList.remove('pressed')); btn.addEventListener('touchcancel', () => btn.classList.remove('pressed')); btn.addEventListener('blur', () => btn.classList.remove('pressed')); } } // 首次加载第一页 loadPage(1); document.body.appendChild(overlay); document.body.appendChild(dialog); } // 剪贴板弹窗(独立样式),展示 Issue #25 function openClipboardWindow() { // 单例:若已存在旧窗口或标签,先移除 try { if (__CNB_CLIP_DIALOG && __CNB_CLIP_DIALOG.parentNode) __CNB_CLIP_DIALOG.remove(); } catch (_) {} try { const existingTabs = document.getElementById('cnb-clipwin-tabs'); if (existingTabs) existingTabs.remove(); } catch (_) {} __CNB_CLIP_DIALOG = null; try { // 注入独立样式(不复用 .cnb-issue-dialog),无遮罩,默认居中,可拖动 addStyleOnce('clipwin-base', ` .cnb-clipwin { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(320px, 92vw); max-height: 80vh; display: flex; flex-direction: column; background: #ffffff; border: 2px solid #000; border-radius: 0; box-shadow: 4px 4px 0 #000; z-index: 2147483644; overflow: hidden; font: 13px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,Helvetica,Arial,"PingFang SC","Microsoft Yahei",sans-serif; color: #000; opacity: 1; pointer-events: auto; transition: opacity 0.3s ease; } .cnb-clipwin-header { position: relative; height: 28px; border-bottom: 2px solid #000; background: #000; cursor: move; /* 拖动条 */ } .cnb-clipwin-close { position: absolute; right: 6px; top: 3px; border: none; background: transparent; color: #fff; font-size: 18px; line-height: 1; cursor: pointer; padding: 0 4px; } .cnb-clipwin-pin { position: absolute; right: 30px; top: 5px; border: none; background: transparent; color: #fff; line-height: 1; cursor: pointer; padding: 2px; } /* 左上角标题 */ .cnb-clipwin-title { position: absolute; left: 6px; top: 6px; border: none; background: transparent; color: #fff; line-height: 1; font-size: 11px; font-weight: 600; text-transform: uppercase; pointer-events: auto; } .cnb-clipwin-title a { color: #fff; text-decoration: none; } .cnb-clipwin-title a:hover { text-decoration: underline; } .cnb-clipwin-close:hover, .cnb-clipwin-pin:hover { color: #ccc; } /* 固定按钮图标样式 */ .cnb-clipwin-pin svg { fill: rgba(255, 255, 255, 0.5); transition: fill 0.15s ease; } .cnb-clipwin-pin:hover svg { fill: rgba(255, 255, 255, 0.8); } .menuBar-Btn_Icon-pin.isActive { fill: #2ea043 !important; } .cnb-clipwin-content { padding: 5px; overflow: auto; } .cnb-clipwin-tabs { position: fixed; display: flex; flex-direction: column; z-index: 2147483645; opacity: 1; pointer-events: auto; transition: opacity 0.3s ease; } .cnb-clipwin-tab { margin: 1px 0; padding: 3px 10px; padding-left: 10px; background: #f5f5f5; border: 2px solid #000; border-right: none; cursor: pointer; font-size: 12px; font-weight: 500; color: #000; text-align: right; width: auto; flex-shrink: 0; align-self: flex-end; } .cnb-clipwin-tab:hover { background: #e0e0e0; } .cnb-clipwin-tab.active { background: #fff; color: #000; font-weight: 600; } .cnb-clipwin-tab.active::after { content: ''; display: block; margin-right: -1px; height: 100%; width: 1px; background: #fff; } .cnb-clipwin-actions { border-top: 2px solid #000; padding: 5px 8px; display: flex; gap: 4px; justify-content: flex-end; background: #fff; } .cnb-clipwin-btn { display: inline-flex; align-items: center; justify-content: center; height: 24px; padding: 0 10px; border-radius: 0; border: 2px solid #000; background: #fff; color: #000; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.1s ease; box-shadow: 2px 2px 0 #000; } .cnb-clipwin-btn:hover { background: #000; color: #fff; } .cnb-clipwin-btn:active { transform: translate(2px, 2px); box-shadow: none; } `); } catch (_) {} // 覆盖剪贴板窗口内容样式,适配 HTML 渲染 - 扁平黑白配色 try { addStyleOnce('clipwin-body', ` .cnb-clipwin-body { margin: 0; padding: 5px; background: #ffffff; border: 2px solid #000; border-radius: 0; word-break: break-word; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,Helvetica,Arial,"PingFang SC","Microsoft Yahei",sans-serif; font-size: 12px; line-height: 1.4; color: #000; max-height: 65vh; overflow: auto; } .cnb-clipwin-body * { margin-top: 0 !important; margin-bottom: 0 !important; } .cnb-clipwin-body h1 { font-size: 1.5em; margin: .3em 0 .2em 0 !important; font-weight: 700; color: #000; } .cnb-clipwin-body h2 { font-size: 1.3em; margin: .3em 0 .2em 0 !important; font-weight: 700; color: #000; } .cnb-clipwin-body h3 { font-size: 1.15em; margin: .3em 0 .2em 0 !important; font-weight: 700; color: #000; } .cnb-clipwin-body p { margin: .2em 0 !important; color: #000; } .cnb-clipwin-body ul, .cnb-clipwin-body ol { padding-left: 1.3em; margin: .2em 0 !important; color: #000; } .cnb-clipwin-body blockquote { margin: .2em 0 !important; padding: .3em .6em; color:#000; background:#f5f5f5; border-left: 3px solid #000; border-radius: 0; } .cnb-clipwin-body hr { border: none; border-top: 2px solid #000; margin: .3em 0 !important; } .cnb-clipwin-body code { background: #000; border: none; border-radius: 0; padding: .08em .3em; font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: .88em; color: #fff; } .cnb-clipwin-body pre { background: #000; color: #fff; border-radius: 0; padding: 8px; margin: .2em 0 .3em 0 !important; overflow-x: hidden; overflow-y: auto; } .cnb-clipwin-body pre code { background: transparent; border: none; padding: 0; color: inherit; font-size: .92em; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; } .cnb-clipwin-body a { color: #000; text-decoration: underline; font-weight: 500; } .cnb-clipwin-body a:hover { text-decoration: underline; color: #666; } `); } catch (_) {} /* 剪贴板窗口滚动条样式:扁平黑白(仅作用于剪贴板窗口) */ try { addStyleOnce('clipwin-scrollbar', ` /* Firefox */ .cnb-clipwin, .cnb-clipwin-content, .cnb-clipwin-body, .cnb-clipwin-body pre { scrollbar-width: thin; scrollbar-color: #000 #f5f5f5; } /* WebKit */ .cnb-clipwin::-webkit-scrollbar, .cnb-clipwin-content::-webkit-scrollbar, .cnb-clipwin-body::-webkit-scrollbar, .cnb-clipwin-body pre::-webkit-scrollbar { width: 8px; height: 8px; } .cnb-clipwin::-webkit-scrollbar-track, .cnb-clipwin-content::-webkit-scrollbar-track, .cnb-clipwin-body::-webkit-scrollbar-track, .cnb-clipwin-body pre::-webkit-scrollbar-track { background: #f5f5f5; } .cnb-clipwin::-webkit-scrollbar-thumb, .cnb-clipwin-content::-webkit-scrollbar-thumb, .cnb-clipwin-body::-webkit-scrollbar-thumb, .cnb-clipwin-body pre::-webkit-scrollbar-thumb { background: #000; border-radius: 0; } .cnb-clipwin::-webkit-scrollbar-thumb:hover, .cnb-clipwin-content::-webkit-scrollbar-thumb:hover, .cnb-clipwin-body::-webkit-scrollbar-thumb:hover, .cnb-clipwin-body pre::-webkit-scrollbar-thumb:hover { background: #333; } `); } catch (_) {} if (!CONFIG.repoPath || !CONFIG.accessToken) { if (typeof GM_notification === 'function') { GM_notification({ text: '请先在设置中配置仓库路径与访问令牌', title: 'CNB Issue工具', timeout: 3000 }); } if (typeof openSettingsDialog === 'function') openSettingsDialog(); return; } // 如果已存在旧窗口,先清理 try { if (__CNB_CLIP_DIALOG) __CNB_CLIP_DIALOG.remove(); } catch (_) {} // 移除所有旧的标签容器 try { const oldTabsList = document.querySelectorAll('.cnb-clipwin-tabs'); oldTabsList.forEach(tab => tab.remove()); } catch (_) {} __CNB_CLIP_DIALOG = null; // 仅创建窗口(无遮罩) const dialog = document.createElement('div'); dialog.className = 'cnb-clipwin'; __CNB_CLIP_DIALOG = dialog; dialog.innerHTML = `
剪贴板
加载中…
`; // 读取存储的固定状态,默认为true(固定) let pinned = true; try { if (typeof GM_getValue === 'function') { const savedPinned = GM_getValue('cnbClipboardPinned', null); if (savedPinned !== null) { pinned = Boolean(savedPinned); } } } catch (_) {} // 隐藏剪贴板的定时器 let hideClipboardTimer = null; // 标志位:防止初始化时触发隐藏 let isInitialized = false; function cleanup() { document.removeEventListener('mousedown', onDocDown, true); document.removeEventListener('mouseup', onDocUp, true); document.removeEventListener('mousemove', onDocMove, true); document.removeEventListener('click', onOutsideClick, true); if (typeof onEsc === 'function') document.removeEventListener('keydown', onEsc, true); // 清理隐藏定时器 if (hideClipboardTimer) { clearTimeout(hideClipboardTimer); hideClipboardTimer = null; } } function close() { cleanup(); try { dialog.remove(); } catch (_) {} try { tabsContainer.remove(); } catch (_) {} try { if (__CNB_CLIP_DIALOG === dialog) __CNB_CLIP_DIALOG = null; } catch (_) {} } const btnPin = dialog.querySelector('.cnb-clipwin-pin'); // 初始化按钮状态 if (btnPin) { const path = btnPin.querySelector('.menuBar-Btn_Icon-pin'); if (path) { if (pinned) path.classList.add('isActive'); else path.classList.remove('isActive'); } btnPin.addEventListener('click', () => { pinned = !pinned; if (path) { if (pinned) path.classList.add('isActive'); else path.classList.remove('isActive'); } // 保存固定状态 try { if (typeof GM_setValue === 'function') { GM_setValue('cnbClipboardPinned', pinned); } } catch (_) {} }); } // 关闭按钮事件 const btnClose = dialog.querySelector('.cnb-clipwin-close'); if (btnClose) btnClose.addEventListener('click', close); // 复制按钮:复制窗口内容(优先原始 Markdown) const btnCopy = dialog.querySelector('.cnb-clipwin-copy'); if (btnCopy) btnCopy.addEventListener('click', async () => { try { const body = dialog.querySelector('#cnb-clipwin-body'); const rawText = (body && body.dataset && body.dataset.mdraw) ? body.dataset.mdraw : (body ? body.textContent : ''); // 使用更激进的清理函数移除空行 const text = typeof cleanMarkdownContentForCopy === 'function' ? cleanMarkdownContentForCopy(String(rawText || '')) : String(rawText || ''); if (typeof GM_setClipboard === 'function') { GM_setClipboard(String(text || ''), 'text'); } else if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(String(text || '')); } if (typeof GM_notification === 'function') { GM_notification({ text: '已复制到剪贴板', title: 'CNB 剪贴板', timeout: 2000 }); } } catch (_) {} }); // 点击窗口外关闭(未固定时) function onOutsideClick(e) { if (pinned) return; if (!dialog.contains(e.target) && !tabsContainer.contains(e.target)) close(); } document.addEventListener('click', onOutsideClick, true); // ESC 关闭 function onEsc(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } } document.addEventListener('keydown', onEsc, true); // 拖动逻辑(拖拽 header) const header = dialog.querySelector('.cnb-clipwin-header'); let dragging = false; let startX = 0, startY = 0, boxX = 0, boxY = 0; function onDocDown(e) { if (header && header.contains(e.target)) { dragging = true; const rect = dialog.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; boxX = rect.left; boxY = rect.top; // 拖动时改为绝对定位并去掉居中 transform dialog.style.left = rect.left + 'px'; dialog.style.top = rect.top + 'px'; dialog.style.transform = 'none'; // 标签在窗口左侧,向下偏移 29px const tabWidth = tabsContainer.offsetWidth || 80; tabsContainer.style.left = (rect.left - tabWidth) + 'px'; tabsContainer.style.top = (rect.top + 29) + 'px'; tabsContainer.style.transform = 'none'; } } function onDocUp() { if (dragging) { dragging = false; // 保存窗口位置 try { if (typeof GM_setValue === 'function') { const rect = dialog.getBoundingClientRect(); GM_setValue('cnbClipboardWindowPos', JSON.stringify({ left: rect.left, top: rect.top })); } } catch (_) {} } } function onDocMove(e) { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; const tabWidth = tabsContainer.offsetWidth || 80; dialog.style.left = (boxX + dx) + 'px'; dialog.style.top = (boxY + dy) + 'px'; // 标签跟随窗口移动,在窗口左侧,向下偏移 29px tabsContainer.style.left = (boxX + dx - tabWidth) + 'px'; tabsContainer.style.top = (boxY + dy + 29) + 'px'; } document.addEventListener('mousedown', onDocDown, true); document.addEventListener('mouseup', onDocUp, true); document.addEventListener('mousemove', onDocMove, true); // 创建外部标签容器(在窗口左侧) // 先确保没有旧的标签容器 const existingTabs = document.getElementById('cnb-clipwin-tabs'); if (existingTabs) existingTabs.remove(); const tabsContainer = document.createElement('div'); tabsContainer.className = 'cnb-clipwin-tabs'; tabsContainer.id = 'cnb-clipwin-tabs'; // 初始隐藏,等标签创建后再显示 tabsContainer.style.display = 'none'; // 将标签容器插入到 dialog 之前(作为兄弟元素) document.body.appendChild(tabsContainer); document.body.appendChild(dialog); const bodyEl = dialog.querySelector('#cnb-clipwin-body'); const tabsEl = tabsContainer; // 显示剪贴板窗口 function showClipboard() { if (dialog) { dialog.style.opacity = '1'; dialog.style.display = 'flex'; if (tabsContainer) { tabsContainer.style.opacity = '1'; // 只有在有多个 Issue 时才显示标签容器 if (hasMultipleIssues) { tabsContainer.style.display = 'flex'; } } } } // 隐藏剪贴板窗口 function hideClipboard() { if (dialog) { dialog.style.opacity = '0'; if (tabsContainer) { tabsContainer.style.opacity = '0'; } } } // 固定状态下的鼠标交互逻辑 function onClipboardEnter() { // 清除隐藏定时器 if (hideClipboardTimer) { clearTimeout(hideClipboardTimer); hideClipboardTimer = null; } // 显示剪贴板 showClipboard(); } function onClipboardLeave() { // 仅在固定状态下启用自动隐藏,且在初始化完成后才生效 if (!pinned || !isInitialized) return; // 清除之前的定时器 if (hideClipboardTimer) { clearTimeout(hideClipboardTimer); } // 2秒后隐藏 hideClipboardTimer = setTimeout(() => { hideClipboard(); }, 2000); } // 绑定鼠标进入/离开事件 dialog.addEventListener('mouseenter', onClipboardEnter); dialog.addEventListener('mouseleave', onClipboardLeave); tabsContainer.addEventListener('mouseenter', onClipboardEnter); tabsContainer.addEventListener('mouseleave', onClipboardLeave); // 延迟标记初始化完成,避免创建时触发mouseleave setTimeout(() => { isInitialized = true; }, 500); // 读取剪贴板位置(Issue编号和窗口位置) let __clipIssueNum = ''; let __clipWinPos = { left: null, top: null }; try { if (typeof GM_getValue === 'function') { const v = GM_getValue(currentRegion().clipKey, ''); __clipIssueNum = String(v || '').trim(); const pos = GM_getValue('cnbClipboardWindowPos', ''); if (pos) { try { __clipWinPos = JSON.parse(pos); } catch (_) {} } } } catch (_) {} // 解析多个 Issue 编号(支持逗号分隔,如 "2,3,4") const issueList = __clipIssueNum.split(/[,,]/).map(s => s.trim()).filter(s => s); const hasMultipleIssues = issueList.length > 1; // 显示标签栏(仅在多个 Issue 时) if (hasMultipleIssues) { tabsEl.style.display = 'flex'; } else { tabsEl.style.display = 'none'; } // 当前激活的 Issue 索引(默认第一个) let currentIssueIndex = 0; let issueDataCache = {}; // 缓存已加载的 Issue 数据 // 函数:加载指定 Issue 的内容 function loadIssue(issueNum, index) { bodyEl.textContent = '加载中…'; // 如果已缓存,直接使用 if (issueDataCache[index]) { renderIssueContent(issueDataCache[index], issueNum); return; } apiRequest({ method: 'GET', path: `${CONFIG.issueEndpoint}/${encodeURIComponent(issueNum)}`, accept: 'application/json' }, function(err, data) { if (err) { bodyEl.textContent = `加载失败 (${err.message})`; return; } // 缓存数据 issueDataCache[index] = data; renderIssueContent(data, issueNum); }); } // 函数:渲染 Issue 内容 function renderIssueContent(data, issueNum) { const rawBody = typeof data?.body === 'string' ? data.body : ''; const md = typeof cleanMarkdownContent === 'function' ? cleanMarkdownContent(String(rawBody || '')) : String(rawBody || ''); if (bodyEl) { bodyEl.dataset.mdraw = md; bodyEl.innerHTML = (typeof markdownToHtml === 'function') ? markdownToHtml(md) : md; // 移除所有
,避免用换行标签作为分隔 try { bodyEl.querySelectorAll('br').forEach(br => br.remove()); } catch (_) {} } // 设置标题文本和链接(优先 Issue 标题) const titleEl = dialog.querySelector('.cnb-clipwin-title'); const t = (data && typeof data.title === 'string' && data.title) ? data.title : '剪贴板'; // 保存 Issue 标题到本地 try { if (typeof GM_getValue === 'function' && typeof GM_setValue === 'function') { const titles = GM_getValue(currentRegion().clipTitlesKey, '{}'); const titleMap = typeof titles === 'object' ? titles : {}; titleMap[String(issueNum)] = t; GM_setValue(currentRegion().clipTitlesKey, titleMap); } } catch (_) {} let issueUrl = ''; if (data && data.web_url) { issueUrl = data.web_url; } else if (data && data.html_url) { issueUrl = data.html_url; } else if (data && data.iid) { // 如果 API 没有返回 URL,则手动构建 issueUrl = `${CONFIG.webBase}/${CONFIG.repoPath}/-/issues/${data.iid}`; } else if (issueNum) { issueUrl = `${CONFIG.webBase}/${CONFIG.repoPath}/-/issues/${issueNum}`; } if (titleEl) { if (issueUrl) { titleEl.innerHTML = `${t}`; } else { titleEl.textContent = t; } } // 更新对应标签的标题 // 通过 issueNum 找到对应的索引 const tabIndex = issueList.indexOf(String(issueNum)); if (tabIndex >= 0) { updateTabTitle(tabIndex, t); } // 为每个代码块注入右上角复制按钮,并设置布局 const pres = bodyEl.querySelectorAll('pre'); pres.forEach(pre => { try { pre.style.marginTop = '5px'; pre.style.marginBottom = '5px'; pre.style.cursor = 'pointer'; pre.style.transition = 'background-color 0.15s ease'; // 点击代码块复制 pre.addEventListener('click', async (e) => { const codeEl = pre.querySelector('code'); const text = codeEl ? codeEl.textContent : pre.textContent; const cleanText = String(text || '').trim(); try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(cleanText, 'text'); } else if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(cleanText); } // 视觉反馈:短暂闪烁绿色背景 const prevBg = pre.style.backgroundColor; pre.style.backgroundColor = 'rgba(46, 160, 67, 0.3)'; setTimeout(() => { pre.style.backgroundColor = prevBg; }, 200); if (typeof GM_notification === 'function') { GM_notification({ text: '代码已复制', title: 'CNB 剪贴板', timeout: 1500 }); } } catch (_) {} }); } catch (_) {} }); // 行为增强:为每个代码块提供"两行预览 + 展开/收起",复制仍复制全文 try { if (typeof GM_addStyle === 'function') { addStyleOnce('clipwin-pre-controls', ` .cnb-pre-collapsed { max-height: 3.2em; /* 约两行 */ overflow: hidden; position: relative; } .cnb-pre-collapsed::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 28px; background: linear-gradient(to bottom, rgba(11,16,33,0), rgba(11,16,33,0.85)); pointer-events: none; } /* 顶部右侧控制容器:右为复制,左为展开 */ .cnb-code-controls { position: absolute; top: 4px; right: 6px; display: inline-flex; align-items: center; gap: 6px; } .cnb-code-controls .cnb-codecopy-inline, .cnb-code-controls .cnb-code-toggle { border: 2px solid #fff; background: rgba(255,255,255,0.15); color: #fff; padding: 4px 6px; border-radius: 0; cursor: pointer; line-height: 1; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; transition: all 0.1s ease; } .cnb-code-controls .cnb-codecopy-inline:hover, .cnb-code-controls .cnb-code-toggle:hover { background: #fff; color: #000; } `); } } catch (_) {} try { const pres2 = bodyEl ? bodyEl.querySelectorAll('pre') : []; pres2.forEach(pre => { try { const codeEl = pre.querySelector('code') || pre; const text = codeEl ? (codeEl.textContent || '') : (pre.textContent || ''); const lines = String(text || '').split('\n'); if (lines.length <= 2) return; // 初始折叠为两行 pre.classList.add('cnb-pre-collapsed'); // 若不存在展开按钮则添加 /* 将展开按钮插入到右上角控制容器中,位于复制按钮左侧 */ // 确保控制容器存在 let __controls = pre.querySelector('.cnb-code-controls'); if (!__controls) { __controls = document.createElement('div'); __controls.className = 'cnb-code-controls'; pre.appendChild(__controls); } if (!pre.querySelector('.cnb-code-toggle')) { const tbtn = document.createElement('button'); tbtn.type = 'button'; tbtn.className = 'cnb-code-toggle'; tbtn.textContent = '展开'; tbtn.title = '展开/收起'; tbtn.addEventListener('mouseenter', () => { tbtn.style.background = '#fff'; tbtn.style.color = '#000'; }); tbtn.addEventListener('mouseleave', () => { tbtn.style.background = 'rgba(255,255,255,0.15)'; tbtn.style.color = '#fff'; }); tbtn.addEventListener('click', (e) => { e.stopPropagation(); const collapsed = pre.classList.toggle('cnb-pre-collapsed'); tbtn.textContent = collapsed ? '展开' : '收起'; }); __controls.appendChild(tbtn); } } catch (_) {} }); } catch (_) {} // 渲染后隐藏 h2,并收紧 pre 间距与清理多余换行 try { if (bodyEl) { // 隐藏所有 h2,去除占位 bodyEl.querySelectorAll('h2').forEach(h => { h.style.display = 'none'; h.style.margin = '0'; h.style.padding = '0'; }); // 移除所有
与空段落,避免形成额外间隙 bodyEl.querySelectorAll('br').forEach(br => br.remove()); bodyEl.querySelectorAll('p').forEach(p => { const txt = (p.textContent || '').trim(); const onlyBr = p.children.length === 1 && p.firstElementChild && p.firstElementChild.tagName.toLowerCase() === 'br'; if (!txt || onlyBr) p.remove(); }); // 统一设置代码块上下间距与内边距 bodyEl.querySelectorAll('pre').forEach(pre => { pre.style.marginTop = '0'; pre.style.marginBottom = '0'; }); // 额外收紧:取消段落与分段容器的默认间距 bodyEl.querySelectorAll('p').forEach(p => { p.style.marginTop = '0'; p.style.marginBottom = '0'; }); bodyEl.querySelectorAll('.cnb-sec').forEach(sec => { sec.style.margin = '0'; sec.style.padding = '0'; }); } } catch (_) {} // 基于 h2 构建章节按键并切换显示 try { const contentDiv = dialog.querySelector('.cnb-clipwin-content'); const h2s = (bodyEl && bodyEl.querySelectorAll) ? bodyEl.querySelectorAll('h2') : []; if (contentDiv && h2s.length > 1) { const sections = []; for (let i = 0; i < h2s.length; i++) { const h = h2s[i]; const sec = document.createElement('div'); sec.className = 'cnb-sec'; // 将 h2 及其之后内容(直到下一个 h2)打包进 section h.parentNode.insertBefore(sec, h); sec.appendChild(h); let next = sec.nextSibling; while (next && !(next.nodeType === 1 && next.tagName && next.tagName.toLowerCase() === 'h2')) { const move = next; next = next.nextSibling; sec.appendChild(move); } sections.push(sec); } // 在正文之前插入 tabs 容器(注入样式 - 扁平黑白配色) try { if (typeof GM_addStyle === 'function') { addStyleOnce('clipwin-inline-tabs', ` .cnb-clipwin-tabs-inline { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 6px; } .cnb-tab-inline { appearance: none; border: 2px solid #000; border-radius: 0; padding: 2px 8px; background: #fff; color: #000; cursor: pointer; font-size: 11px; font-weight: 500; line-height: 1.1; transition: all 0.1s ease; } .cnb-tab-inline:hover { background: #000; color: #fff; } .cnb-tab-inline.active { background: #000; color: #fff; } .cnb-tab-inline:focus { outline: none; } `); } } catch (_) {} let tabsInline = contentDiv.querySelector('.cnb-clipwin-tabs-inline'); if (!tabsInline) { tabsInline = document.createElement('div'); tabsInline.className = 'cnb-clipwin-tabs-inline'; contentDiv.insertBefore(tabsInline, bodyEl); } else { tabsInline.innerHTML = ''; } h2s.forEach((h, idx) => { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = (h.textContent || '').trim() || ('Section ' + (idx + 1)); btn.className = 'cnb-tab-inline'; btn.addEventListener('click', () => { // 确保窗口使用固定定位而不是transform居中 if (dialog.style.transform && dialog.style.transform !== 'none') { const rect = dialog.getBoundingClientRect(); dialog.style.left = rect.left + 'px'; dialog.style.top = rect.top + 'px'; dialog.style.transform = 'none'; } sections.forEach((s, j) => { s.style.display = (j === idx) ? '' : 'none'; }); Array.from(tabsInline.children).forEach((b, i) => { b.classList.toggle('active', i === idx); }); }); tabsInline.appendChild(btn); }); // 默认激活第一个 if (tabsInline.firstElementChild) { tabsInline.firstElementChild.click(); } // 内容首次渲染后,固定窗口位置(避免后续切换tab时从居中动画) if (dialog.style.transform && dialog.style.transform !== 'none') { const rect = dialog.getBoundingClientRect(); dialog.style.left = rect.left + 'px'; dialog.style.top = rect.top + 'px'; dialog.style.transform = 'none'; } } } catch (_) {} } // 函数:创建 Issue 标签按钮(显示标题而不是编号) function createIssueTabs() { tabsEl.innerHTML = ''; // 读取保存的 Issue 标题 let issueTitles = {}; try { if (typeof GM_getValue === 'function') { const titles = GM_getValue(currentRegion().clipTitlesKey, '{}'); issueTitles = typeof titles === 'object' ? titles : {}; } } catch (_) {} issueList.forEach((issueNum, index) => { const tab = document.createElement('button'); tab.type = 'button'; tab.className = 'cnb-clipwin-tab'; // 优先显示保存的标题,否则显示编号 tab.textContent = issueTitles[String(issueNum)] || issueNum; tab.dataset.issueNum = issueNum; tab.dataset.index = index; if (index === currentIssueIndex) { tab.classList.add('active'); } tab.addEventListener('click', () => { // 更新激活状态 currentIssueIndex = index; Array.from(tabsEl.children).forEach((b, i) => { b.classList.toggle('active', i === index); }); // 加载对应 Issue loadIssue(issueNum, index); }); tabsEl.appendChild(tab); }); } // 函数:更新标签文本为 Issue 标题 function updateTabTitle(index, title) { const tab = tabsEl.children[index]; if (tab && title) { tab.textContent = title; } } // 创建标签按钮 createIssueTabs(); // 如果有保存的窗口位置,应用它 if (__clipWinPos.left !== null && __clipWinPos.top !== null) { dialog.style.left = __clipWinPos.left + 'px'; dialog.style.top = __clipWinPos.top + 'px'; dialog.style.transform = 'none'; } if (issueList.length === 0) { if (bodyEl) bodyEl.textContent = '未配置剪贴板位置'; return; } // 只在多个 Issue 时显示并初始化标签位置 if (issueList.length > 1) { // 初始化标签位置(在窗口左侧,等标签创建完成后) tabsContainer.style.display = 'flex'; // 强制重排确保 DOM 完全渲染 void tabsContainer.offsetHeight; const rect = dialog.getBoundingClientRect(); const tabWidth = tabsContainer.offsetWidth || 80; // 将标签显示在对话框左侧 tabsContainer.style.left = (rect.left - tabWidth) + 'px'; tabsContainer.style.top = (rect.top + 29) + 'px'; tabsContainer.style.transform = 'none'; // 根据标签高度设置剪贴板窗口的最小高度 const tabsHeight = tabsContainer.offsetHeight || 0; dialog.style.minHeight = (tabsHeight + 50) + 'px'; } else { // 只有一个 Issue 时隐藏标签容器 tabsContainer.style.display = 'none'; } // 加载默认 Issue(第一个) loadIssue(issueList[0], 0); // 确保窗口在创建后是显示状态 showClipboard(); } // ===================== 统一 API 请求层 ===================== // 统一处理:URL 拼接、鉴权头、JSON 解析、错误信息提取、失败通知 function notifyUser(text, timeout) { if (typeof GM_notification === 'function') { GM_notification({ text: text, title: 'CNB Issue工具', timeout: timeout || 3000 }); } } // 右下角结果提示:显示 10 秒后向右滑出消失 // opts = { success, title, url, message } // - success 为 true 时显示「完成!」+ 标题(超链接,最多显示 15 字) // - 否则显示「失败」+ 错误信息 function showResultToast(opts) { const o = opts || {}; try { console.log('[CNB Issue] 显示结果提示:', o.success ? '成功' : '失败', o.title || o.message || ''); } catch (_) {} try { const old = document.querySelector('.cnb-toast'); if (old) old.remove(); } catch (_) {} const el = document.createElement('div'); el.className = 'cnb-toast'; const prefix = document.createElement('span'); prefix.className = 'cnb-toast-title'; prefix.textContent = o.success ? '完成!' : '失败'; el.appendChild(prefix); if (o.success) { const t = String(o.title || '').trim(); if (t) { const shown = t.length > 15 ? t.slice(0, 15) + '…' : t; if (o.url) { const a = document.createElement('a'); a.className = 'cnb-toast-link'; a.textContent = ' ' + shown; a.href = o.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; el.appendChild(a); } else { const span = document.createElement('span'); span.textContent = ' ' + shown; el.appendChild(span); } } } else if (o.message) { const span = document.createElement('span'); span.textContent = ':' + String(o.message); el.appendChild(span); } document.body.appendChild(el); try { requestAnimationFrame(() => el.classList.add('cnb-toast--show')); } catch (_) { el.classList.add('cnb-toast--show'); } let removed = false; const dismiss = () => { if (removed) return; removed = true; el.classList.remove('cnb-toast--show'); el.classList.add('cnb-toast--hide'); setTimeout(() => { try { el.remove(); } catch (_) {} }, 320); }; const timer = setTimeout(dismiss, 10000); el.addEventListener('click', (e) => { if (e.target && e.target.tagName === 'A') return; // 点击链接正常跳转 clearTimeout(timer); dismiss(); }); } // 解析响应体(json / blob / text) function parseApiResponse(response, responseType) { if (responseType === 'blob' || responseType === 'arraybuffer') return response.response; if (response.response && typeof response.response === 'object') return response.response; try { return JSON.parse(response.responseText || '{}'); } catch (_) { return response.response !== undefined ? response.response : (response.responseText || null); } } // 从响应/错误体中提取可读错误信息 function apiErrorMessage(parsed, response) { const msg = `HTTP ${response.status}`; let body = (parsed === undefined || parsed === null || parsed === '') ? response.responseText : parsed; if (typeof body === 'string') { try { const j = JSON.parse(body); if (j && j.message) return j.message; } catch (_) {} if (body && body.length < 300 && body[0] !== '{' && body[0] !== '<') return `${msg}: ${body}`; return msg; } if (body && typeof body === 'object' && body.message) return body.message; return msg; } /** * 统一请求。 * @param {Object} options * - method {string} 默认 GET * - path {string} 相对仓库的路径,如 '/-/issues/1'(与 url 二选一) * - url {string} 绝对 URL(与 path 二选一) * - repo {string} 覆盖默认仓库(默认 CONFIG.repoPath) * - token {string} 覆盖默认令牌(默认 CONFIG.accessToken) * - accept {string} Accept 头,默认 application/vnd.cnb.api+json * - data {any} 请求体,非 rawBody 时自动 JSON.stringify * - rawBody {boolean} data 是否已序列化 * - headers {Object} 额外请求头 * - responseType {string} 默认 json * - binary {boolean} 二进制上传 * - notifyError {string} 失败时自动通知的前缀 * - notifyTimeout {number} * @param {(err, data, response) => void} callback */ function apiRequest(options, callback) { const opt = options || {}; const cb = (typeof callback === 'function') ? callback : null; const method = (opt.method || 'GET').toUpperCase(); const accept = opt.accept || 'application/vnd.cnb.api+json'; const token = opt.token !== undefined ? opt.token : CONFIG.accessToken; const repo = opt.repo !== undefined ? opt.repo : CONFIG.repoPath; let url = opt.url; if (!url) { const base = `${CONFIG.apiBase}/${repo}`; const p = opt.path || ''; url = p ? `${base}${p.charAt(0) === '/' ? p : '/' + p}` : base; } const headers = Object.assign({ 'Accept': accept, 'Authorization': token }, opt.headers || {}); const responseType = opt.responseType || 'json'; const hasBody = opt.data !== undefined && opt.data !== null; if (hasBody && !opt.rawBody) { headers['Content-Type'] = headers['Content-Type'] || 'application/json'; } const request = { method, url, headers, responseType, onload: function(response) { const parsed = parseApiResponse(response, responseType); if (response.status >= 200 && response.status < 300) { if (cb) cb(null, parsed, response); } else { const msg = apiErrorMessage(parsed, response); if (opt.notifyError) notifyUser(opt.notifyError + msg, opt.notifyTimeout || 5000); if (cb) cb(new Error(msg), parsed, response); } }, onerror: function() { const msg = '网络请求失败'; if (opt.notifyError) notifyUser(opt.notifyError + msg, opt.notifyTimeout || 5000); if (cb) cb(new Error(msg), null, null); } }; if (hasBody) request.data = opt.rawBody ? opt.data : JSON.stringify(opt.data); if (opt.binary) request.binary = true; if (opt.timeout) request.timeout = opt.timeout; GM_xmlhttpRequest(request); } // 创建Issue function createIssue(title, content, labels = [], callback) { if (!CONFIG.repoPath || !CONFIG.accessToken) { notifyUser('请先在设置中配置仓库路径与访问令牌'); if (typeof openSettingsDialog === 'function') openSettingsDialog(); if (typeof callback === 'function') callback(false); return; } const issueData = { repoId: CONFIG.repoPath, title: title, body: content, labels: labels, assignees: [] }; apiRequest({ method: 'POST', path: CONFIG.issueEndpoint, accept: 'application/json', data: issueData }, function(err, respObj) { if (err) { notifyUser(`创建失败: ${err.message}`, 5000); showResultToast({ success: false, message: err.message }); if (typeof callback === 'function') callback(false); return; } // 解析返回,取 issueId(兼容不同字段) const issueId = respObj?.id ?? respObj?.number ?? respObj?.iid ?? respObj?.issue_id; // Issue 已创建:立即提示(即使后续设置标签失败也照常提示) notifyUser('Issue创建成功!'); showResultToast({ success: true, title: title, url: (issueId != null) ? `${CONFIG.webBase}/${CONFIG.repoPath}/-/issues/${issueId}` : '' }); const notifySuccess = () => { if (callback) callback(true, issueId); }; // 若有标签,则单独 PUT 标签 if (Array.isArray(labels) && labels.length > 0 && issueId != null) { apiRequest({ method: 'PUT', path: `${CONFIG.issueEndpoint}/${issueId}/labels`, accept: 'application/json', data: { labels } }, function(labelErr) { if (labelErr) { notifyUser(`Issue已创建,但设置标签失败:${labelErr.message}`, 5000); if (callback) callback(true, issueId); } else { notifySuccess(); } }); } else { // 无标签或无法解析 issueId,直接成功 notifySuccess(); } }); } // 关闭Issue(设置为 closed,state_reason=completed) function closeIssue(issueId, stateReason = 'completed', callback) { if (issueId == null) { if (typeof callback === 'function') callback(false); return; } apiRequest({ method: 'PATCH', path: `${CONFIG.issueEndpoint}/${issueId}`, accept: 'application/json', notifyError: '标记完成失败:', data: { state: 'closed', state_reason: stateReason } }, function(err) { if (typeof callback === 'function') callback(!err); }); } // 更新Issue function updateIssue(issueNumber, data, labels = [], callback) { if (!CONFIG.repoPath || !CONFIG.accessToken) { notifyUser('请先在设置中配置仓库路径与访问令牌'); if (typeof callback === 'function') callback(false); return; } const labelsPath = `${CONFIG.issueEndpoint}/${issueNumber}/labels`; // 比较两个标签数组是否相同 const labelsEqual = (arr1, arr2) => { if (arr1.length !== arr2.length) return false; const sorted1 = [...arr1].sort(); const sorted2 = [...arr2].sort(); return sorted1.every((val, index) => val === sorted2[index]); }; // 处理标签的函数 const handleLabels = (afterUpdateCallback) => { // 判断是否选择了标签,如果没有选择标签,则跳过标签处理 if (!Array.isArray(labels) || labels.length === 0) { afterUpdateCallback(true); return; } // 先获取当前Issue的标签 apiRequest({ method: 'GET', path: `${CONFIG.issueEndpoint}/${issueNumber}` }, function(err, issueData) { const currentLabels = (!err && issueData && issueData.labels) || []; const currentNames = currentLabels.map(l => l.name || l); // 标签相同则不做任何操作 if (!err && labelsEqual(currentNames, labels)) { afterUpdateCallback(true); return; } // 标签有变化(或获取失败):先删除再添加 apiRequest({ method: 'DELETE', path: labelsPath }, function() { apiRequest({ method: 'PUT', path: labelsPath, data: { labels } }, function(labelErr) { if (labelErr) { notifyUser(`更新标签失败:${labelErr.message}`, 5000); } // 标签更新失败,但Issue已更新,仍然返回成功 afterUpdateCallback(true); }); }); }); }; // 先更新Issue的title和body apiRequest({ method: 'PATCH', path: `${CONFIG.issueEndpoint}/${issueNumber}`, data: data }, function(err) { if (err) { notifyUser(`更新失败:${err.message}`, 5000); showResultToast({ success: false, message: err.message }); if (typeof callback === 'function') callback(false); return; } // Issue更新成功,处理标签 handleLabels(() => { notifyUser(`Issue #${issueNumber} 更新成功!`); showResultToast({ success: true, title: (data && data.title) ? data.title : ('#' + issueNumber), url: (issueNumber != null) ? `${CONFIG.webBase}/${CONFIG.repoPath}/-/issues/${issueNumber}` : '' }); if (typeof callback === 'function') callback(true); }); }); } // 获取Issue的标签 function fetchIssueLabels(issueNumber, callback) { if (!CONFIG.repoPath || !CONFIG.accessToken) { if (typeof callback === 'function') callback([], '请先配置仓库路径与访问令牌'); return; } apiRequest({ method: 'GET', path: `${CONFIG.issueEndpoint}/${issueNumber}/labels` }, function(err, data) { if (err) { if (typeof callback === 'function') callback([], err.message); return; } let labels = []; try { // CNB API 返回的标签可能是数组 labels = Array.isArray(data) ? data : (Array.isArray(data?.data) ? data.data : []); } catch (e) { console.error('解析标签数据失败:', e); } if (typeof callback === 'function') callback(labels, null); }); } // 添加评论到Issue function addCommentToIssue(issueNumber, body, callback) { if (!CONFIG.repoPath || !CONFIG.accessToken) { notifyUser('请先在设置中配置仓库路径与访问令牌'); if (typeof callback === 'function') callback(false); return; } apiRequest({ method: 'POST', path: `${CONFIG.issueEndpoint}/${issueNumber}/comments`, data: { body } }, function(err) { if (err) { notifyUser(`添加评论失败:${err.message}`, 5000); showResultToast({ success: false, message: err.message }); if (typeof callback === 'function') callback(false); } else { notifyUser('评论添加成功!'); showResultToast({ success: true, title: '#' + issueNumber, url: (issueNumber != null) ? `${CONFIG.webBase}/${CONFIG.repoPath}/-/issues/${issueNumber}` : '' }); if (typeof callback === 'function') callback(true); } }); } // ===== 上传层(图片 / 附件共用) ===== // 上传凭证接口与所需权限 const ASSET_UPLOAD_KINDS = { imgs: { path: '/-/upload/imgs', permission: 'repo-code:rw', label: '图片' }, files: { path: '/-/upload/files', permission: 'repo-notes:rw', label: '附件' } }; // 1. 获取上传凭证(kind: 'imgs' | 'files') function requestAssetUploadToken(kind, fileName, fileSize, callback) { const conf = ASSET_UPLOAD_KINDS[kind] || ASSET_UPLOAD_KINDS.imgs; if (!CONFIG.repoPath || !CONFIG.accessToken) { if (typeof callback === 'function') callback(null, '请先配置仓库路径和访问令牌'); return; } const body = (kind === 'files') ? { name: fileName, size: fileSize } : { name: fileName, size: fileSize, ext: {} }; apiRequest({ method: 'POST', path: conf.path, accept: 'application/json', data: body }, function(err, resp, response) { if (err) { let errorMsg = err.message; // 特殊处理权限错误 (errcode: 7) if (resp && resp.errcode === 7 && String(resp.errmsg || '').includes('票据授权范围')) { errorMsg = `访问令牌缺少 ${conf.permission} 权限`; notifyUser(`${conf.label}上传失败:访问令牌缺少 ${conf.permission} 权限`, 5000); } console.error(`[CNB Issue] ${conf.label}上传凭证失败:`, response ? response.status : '', resp || errorMsg); if (callback) callback(null, errorMsg); return; } console.log(`[CNB Issue] ${conf.label}上传凭证响应:`, resp); if (callback) callback(resp, null); }); } // 兼容旧调用 function requestUploadToken(fileName, fileSize, callback) { requestAssetUploadToken('imgs', fileName, fileSize, callback); } function requestFileUploadToken(fileName, fileSize, callback) { requestAssetUploadToken('files', fileName, fileSize, callback); } // 2. 上传二进制到 OSS(label 仅用于日志/错误文案) function uploadBlobToOss(uploadInfo, fileData, label, callback) { if (!uploadInfo?.upload_url) { if (callback) callback(null, '上传凭证无效'); return; } // 构建请求头,并附加凭证里的表单参数 const headers = { 'Content-Type': (fileData && fileData.type) || 'application/octet-stream' }; if (uploadInfo.form) { Object.entries(uploadInfo.form).forEach(([key, value]) => { if (key.toLowerCase() !== 'file') { headers[key] = value; } }); } GM_xmlhttpRequest({ method: 'PUT', url: uploadInfo.upload_url, headers: headers, data: fileData, binary: (fileData instanceof Blob), onload: function(response) { if (response.status >= 200 && response.status < 300) { const finalUrl = buildAssetUrl(uploadInfo); console.log(`[CNB Issue] ${label} PUT 上传成功,解析链接:`, finalUrl); if (callback) callback(finalUrl, finalUrl ? null : `上传成功但未获取到${label}链接(凭证响应:${JSON.stringify(uploadInfo).slice(0, 300)})`); return; } let errorMsg = `HTTP ${response.status}`; let detail = ''; try { detail = response.responseText || ''; if (detail) { const err = JSON.parse(detail); if (err?.message) errorMsg = err.message; } } catch (_) {} if (detail) errorMsg = `${errorMsg}(${String(detail).slice(0, 300)})`; console.error(`[CNB Issue] ${label} PUT 上传失败:`, response.status, detail); if (callback) callback(null, errorMsg); }, onerror: function() { if (callback) callback(null, `${label}上传失败`); } }); } // 兼容旧调用 function uploadImageToOss(uploadInfo, fileData, callback) { uploadBlobToOss(uploadInfo, fileData, '图片', callback); } function uploadFileToOss(uploadInfo, fileData, callback) { uploadBlobToOss(uploadInfo, fileData, '附件', callback); } // 3. 解析 base64 数据为 Blob(返回 null 表示不是 base64 或解析失败) function decodeBase64Blob(dataUrl) { const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!matches) return null; try { const byteString = atob(matches[2]); const ab = new ArrayBuffer(byteString.length); const ia = new Uint8Array(ab); for (let i = 0; i < byteString.length; i++) { ia[i] = byteString.charCodeAt(i); } return new Blob([ab], { type: matches[1] }); } catch (_) { return null; } } // 计算下载时的 Referer:使用目标资源自身的源,兼容防盗链站点 function refererForUrl(url) { try { return new URL(url, location.href).origin + '/'; } catch (_) { return location.href; } } // 3.1 下载远程二进制(图片 / 附件通用) // - 用 GM_xmlhttpRequest 绕过 CORS,并自动携带目标域 Cookie(保证登录态) // - 部分站点(如 linux.do 的 /uploads/short-url)会 302 跳到 CDN 直链, // 而 Tampermonkey 在跨域跳转后会丢弃自定义 Referer,触发 CDN 防盗链返回 403。 // 这里对跳转后的最终地址带 Referer 直连重试一次,避免因丢失登录/来源信息而失败。 function downloadRemoteBinary(url, callback) { if (!url) { if (callback) callback(null, '无效的下载地址'); return; } const referer = refererForUrl(url); const request = (target, isRetry) => { GM_xmlhttpRequest({ method: 'GET', url: target, headers: { 'Referer': referer }, responseType: 'blob', anonymous: false, // 显式携带 Cookie,确保登录态可用 onload: function(response) { if (response.status >= 200 && response.status < 300 && response.response) { if (callback) callback(response.response, null); return; } const finalUrl = response.finalUrl; // 跨域跳转导致 Referer 丢失:直连最终地址并补齐 Referer 重试一次 if (!isRetry && finalUrl && finalUrl !== target) { console.warn('[CNB Issue] 下载被拦截(HTTP ' + response.status + '),尝试直连跳转地址重试:', finalUrl); request(finalUrl, true); return; } let msg = `HTTP ${response.status}`; if (response.status === 401 || response.status === 403) { msg += '(下载需要登录,或资源存在防盗链保护)'; } if (callback) callback(null, msg); }, onerror: function() { if (callback) callback(null, '下载失败(网络错误)'); } }); }; request(url, false); } // 3.2 获取图片数据(从 URL 或 base64) function fetchImageData(imageUrl, callback) { if (imageUrl.startsWith('data:')) { const blob = decodeBase64Blob(imageUrl); if (callback) callback(blob, blob ? null : 'base64 解析失败'); return; } downloadRemoteBinary(imageUrl, callback); } // 3.3 下载远程文件(附件)数据:同源优先用 fetch 携带登录 Cookie,失败再回退 GM_xmlhttpRequest function fetchRemoteFileData(fileUrl, callback) { if (!fileUrl) { if (callback) callback(null, '无效的文件地址'); return; } if (fileUrl.startsWith('data:')) { const blob = decodeBase64Blob(fileUrl); if (callback) callback(blob, blob ? null : 'base64 解析失败'); return; } let sameOrigin = false; try { sameOrigin = new URL(fileUrl, location.href).origin === location.origin; } catch (_) {} // 同源请求用 credentials: 'same-origin': // - 初始同源请求(如 linux.do 的 /uploads/short-url)会携带登录 Cookie,满足“需登录”的下载 // - 若该请求 302 跳到跨域 CDN 直链,跨域这一跳不再带凭证, // 可避免 CDN 返回空 Access-Control-Allow-Credentials 时与 credentials: 'include' 冲突被 CORS 拦截 if (sameOrigin && typeof fetch === 'function') { fetch(fileUrl, { credentials: 'same-origin', redirect: 'follow' }) .then(res => { if (!res.ok) throw new Error('HTTP ' + res.status); return res.blob(); }) .then(blob => { if (blob && blob.size > 0) { if (callback) callback(blob, null); } else { downloadRemoteBinary(fileUrl, callback); } }) .catch(() => { downloadRemoteBinary(fileUrl, callback); }); return; } downloadRemoteBinary(fileUrl, callback); } // 4. 从Markdown内容中提取图片链接 function extractImagesFromMarkdown(markdown) { const images = []; // 匹配 Markdown 图片语法: ![alt](url) const imgRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; let match; const seenSrcs = new Set(); while ((match = imgRegex.exec(markdown)) !== null) { const src = match[2]; if (!seenSrcs.has(src)) { seenSrcs.add(src); images.push({ src: src, alt: match[1] }); } } return images; } // 5. 批量上传图片并替换 Markdown 中的 URL function uploadImagesAndReplace(markdown, images, callback) { if (!images || images.length === 0) { if (callback) callback(markdown, []); return; } const uploadPromises = []; const errors = []; // 处理每个图片 images.forEach((imgInfo, index) => { const promise = new Promise((resolve) => { // 从 URL 获取文件名 let fileName = 'image.png'; try { const urlParts = new URL(imgInfo.src, location.href); const pathname = urlParts.pathname; const filenameMatch = pathname.match(/([^\/]+)\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i); if (filenameMatch) { fileName = filenameMatch[1].substring(0, 50) + '.' + filenameMatch[2]; } } catch (_) {} fetchImageData(imgInfo.src, (blob, error) => { if (error || !blob) { errors.push({ src: imgInfo.src, error: error || '获取图片失败' }); resolve({ oldSrc: imgInfo.src, newSrc: null }); return; } const fileSize = blob.size; // 请求上传凭证 requestUploadToken(fileName, fileSize, (uploadInfo, tokenError) => { if (tokenError || !uploadInfo) { errors.push({ src: imgInfo.src, error: tokenError || '获取上传凭证失败' }); resolve({ oldSrc: imgInfo.src, newSrc: null }); return; } // 上传到 OSS uploadImageToOss(uploadInfo, blob, (newPath, uploadError) => { if (uploadError || !newPath) { errors.push({ src: imgInfo.src, error: uploadError || '上传图片失败' }); resolve({ oldSrc: imgInfo.src, newSrc: null }); } else { resolve({ oldSrc: imgInfo.src, newSrc: newPath }); } }); }); }); }); uploadPromises.push(promise); }); // 等待所有上传完成 Promise.all(uploadPromises).then((results) => { // 替换 Markdown 中的图片 URL let updatedMarkdown = markdown; results.forEach(result => { if (result.newSrc) { // 替换 Markdown 中的图片引用 const escapedOldSrc = result.oldSrc.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); updatedMarkdown = updatedMarkdown.replace( new RegExp(`!\\[([^\\]]*)\\]\\(${escapedOldSrc}\\)`, 'g'), `![$1](${result.newSrc})` ); } }); if (callback) callback(updatedMarkdown, errors); }); } // 判断链接是否指向可下载的附件文件 function isDownloadableFileUrl(url) { if (!url) return false; const clean = String(url).split('#')[0].split('?')[0].toLowerCase(); return /\.(zip|rar|7z|tar|gz|tgz|bz2|xz|pdf|doc|docx|xls|xlsx|ppt|pptx|csv|txt|md|json|xml|apk|exe|msi|dmg|iso|mp3|mp4|mov|avi|mkv|wav|flac|log|torrent)$/.test(clean); } // 从 Markdown 中提取附件链接([名称](url),排除图片 ![]()) function extractFileLinksFromMarkdown(markdown) { const files = []; if (!markdown) return files; const seen = new Set(); const regex = /(!?)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; let match; while ((match = regex.exec(markdown)) !== null) { if (match[1] === '!') continue; // 图片链接,跳过 const url = match[3]; if (!isDownloadableFileUrl(url)) continue; if (seen.has(url)) continue; seen.add(url); let name = (match[2] || '').trim(); if (!name) { try { name = decodeURIComponent(url.split('?')[0].split('/').pop() || ''); } catch (_) { name = url; } } files.push({ url, name }); } return files; } // 格式化文件大小 function formatFileSize(bytes) { if (bytes === null || bytes === undefined) return ''; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / 1024 / 1024).toFixed(2) + ' MB'; } // 从上传凭证响应中解析最终资源链接(图片 / 附件通用) function buildAssetUrl(uploadInfo) { if (!uploadInfo) return null; const direct = uploadInfo.asset_link || uploadInfo.assets?.link || uploadInfo.assets?.url || uploadInfo.link; if (direct && /^https?:\/\//.test(direct)) return direct; const p = uploadInfo.assets?.path || uploadInfo.path; if (p) { if (/^https?:\/\//.test(p)) return p; return p.includes(CONFIG.repoPath) ? `${CONFIG.webBase}${p}` : `${CONFIG.webBase}/${CONFIG.repoPath}${p}`; } return null; } // 8. 批量上传附件并替换/追加 Markdown 中的链接 // files: [{ name, href }] 远程链接 或 [{ name, blob }] 本地文件 function uploadFilesAndReplace(markdown, files, callback) { if (!files || files.length === 0) { if (callback) callback(markdown, []); return; } const errors = []; const results = new Array(files.length); let remaining = files.length; const done = () => { remaining--; if (remaining > 0) return; let updated = markdown; const appended = []; files.forEach((f, i) => { const r = results[i]; if (!r || !r.newUrl) return; if (r.isLocal) { appended.push(`[${r.name}](${r.newUrl})`); } else if (f.href) { updated = updated.split(`](${f.href})`).join(`](${r.newUrl})`); } }); if (appended.length > 0) { updated = updated.replace(/\s+$/, '') + '\n\n' + appended.join('\n') + '\n'; } errors.forEach(e => console.error('[CNB Issue] 附件处理失败:', e.src, '->', e.error)); if (callback) callback(updated, errors); }; const handleBlob = (index, f, blob) => { const isLocal = !!f.blob; const fileName = f.name || 'file'; if (!blob) { errors.push({ src: f.href || fileName, error: '获取文件失败' }); results[index] = { name: fileName, isLocal, newUrl: null }; done(); return; } requestFileUploadToken(fileName, blob.size, (uploadInfo, tokenError) => { if (tokenError || !uploadInfo) { errors.push({ src: f.href || fileName, error: tokenError || '获取上传凭证失败' }); results[index] = { name: fileName, isLocal, newUrl: null }; done(); return; } uploadFileToOss(uploadInfo, blob, (newUrl, uploadError) => { if (uploadError || !newUrl) { errors.push({ src: f.href || fileName, error: uploadError || '上传文件失败' }); results[index] = { name: fileName, isLocal, newUrl: null }; } else { results[index] = { name: fileName, isLocal, newUrl }; } done(); }); }); }; files.forEach((f, index) => { if (f.blob) { handleBlob(index, f, f.blob); } else if (f.href) { fetchRemoteFileData(f.href, (blob, error) => { if (error || !blob) { errors.push({ src: f.href, error: error || '获取文件失败' }); results[index] = { name: f.name || f.href, isLocal: false, newUrl: null }; done(); return; } handleBlob(index, f, blob); }); } else { errors.push({ src: f.name || '', error: '无效的附件' }); results[index] = { name: f.name || '', isLocal: false, newUrl: null }; done(); } }); } // 清理资源 function cleanup() { try { if (__CNB_MO) { __CNB_MO.disconnect(); __CNB_MO = null; } } catch (_) {} try { if (__CNB_CLIP_DIALOG?.parentNode) { __CNB_CLIP_DIALOG.remove(); __CNB_CLIP_DIALOG = null; } } catch (_) {} } // 读取持久化配置 function loadPersistedConfig() { try { if (typeof GM_getValue !== 'function') return; // 先恢复已保存的区域,并按该区域读取对应的仓库路径与访问令牌 applyRegion(GM_getValue('cnbRegion', CONFIG.region || 'cn'), false); const tags = GM_getValue('cnbTags', []); const hk = GM_getValue('cnbHotkey', START_HOTKEY); const hkEnabled = GM_getValue('cnbHotkeyEnabled', HOTKEY_ENABLED); const uploadEnabled = GM_getValue('cnbUploadEnabled', true); const uploadFilesEnabled = GM_getValue('cnbUploadFilesEnabled', true); if (Array.isArray(tags)) SAVED_TAGS = tags; if (hk) START_HOTKEY = normalizeHotkeyString(hk); HOTKEY_ENABLED = !!hkEnabled; CONFIG.uploadEnabled = !!uploadEnabled; CONFIG.uploadFilesEnabled = !!uploadFilesEnabled; } catch (_) {} } // 初始化 function init() { // 仅在顶层文档注入 UI:@match 匹配所有页面,若不做限制,页面内的每个 // iframe/webview 都会各自生成一个贴边 Dock,从 iframe 自身左边缘滑出, // 视觉上就像"按钮组出现在页面中间"。(同时脚本已加 @noframes 双保险) let isTopFrame = true; try { isTopFrame = (window.top === window.self); } catch (_) { isTopFrame = false; } if (!isTopFrame) return; loadPersistedConfig(); createFloatingButton(); document.addEventListener('keydown', globalHotkeyHandler, true); } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();