// ==UserScript==
// @name CNB Issue 网页内容收藏工具
// @namespace https://cnb.cool/IIIStudio/Greasemonkey/CNBIssue/
// @version 1.5.6
// @description 在任意网页上选择页面区域,一键将选中内容从 HTML 转为 Markdown,按"页面信息 + 选择的内容"的格式展示,并可直接通过 CNB 接口创建 Issue。支持链接、图片、代码块/行内代码、标题、列表、表格、引用等常见结构的 Markdown 转换。
// @author IIIStudio
// @match *://*/*
// @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 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
// @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_ISSUE_DIALOG = null, __CNB_ISSUE_OVERLAY = null;
let __CNB_MO = null;
let __CNB_UNLOAD_BOUND = false;
let __CNB_DOCK_SHOW_TIMER = null;
// 配置信息
const CONFIG = {
apiBase: 'https://api.cnb.cool',
repoPath: '',
accessToken: '',
issueEndpoint: '/-/issues',
uploadEnabled: true
};
let SAVED_TAGS = [];
// 选择模式快捷键(可在设置中修改),规范格式如:Shift+E
let START_HOTKEY = 'Shift+E';
let HOTKEY_ENABLED = false;
// 添加自定义样式 - 扁平黑白配色
GM_addStyle(`
.cnb-issue-floating-btn {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
background: #000;
color: #fff;
border: 2px solid #000;
border-radius: 0;
width: 50px;
height: 50px;
cursor: pointer;
box-shadow: none;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.cnb-issue-floating-btn:hover {
background: #fff;
color: #000;
transform: none;
}
.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: 16px !important;
z-index: 10001 !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 12px 0;
color: #000;
font-size: 16px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.cnb-issue-dialog textarea {
width: 100%;
height: 300px;
margin: 8px 0;
padding: 10px;
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: 8px 0;
padding: 8px 10px;
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: 10px;
margin-top: 12px;
}
/* 仅底部操作按钮生效,避免影响设置区的小按钮与"×" */
.cnb-issue-dialog .cnb-issue-dialog-buttons > button {
padding: 8px 16px;
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: 10000;
}
.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: 10002;
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;
left: -200px;
top: 40%;
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 6px 6px 10px;
background: #fff;
border: 2px solid #000;
border-left: none;
border-radius: 0 0 0 0;
box-shadow: 3px 3px 0 #000;
z-index: 10002;
transition: left .15s ease, opacity .15s ease;
opacity: 0.9;
}
.cnb-dock:hover,
.cnb-dock.cnb-dock--visible {
left: 0;
opacity: 1;
}
.cnb-dock .cnb-dock-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64px;
height: 32px;
padding: 0 10px;
font-size: 12px;
font-weight: 600;
color: #000;
background: #fff;
border: 2px solid #000;
border-radius: 0;
cursor: pointer;
transition: all 0.1s ease;
}
.cnb-dock .cnb-dock-btn:hover {
background: #000;
color: #fff;
}
.cnb-dock .cnb-dock-btn:active {
transform: translate(1px, 1px);
}
.cnb-dock-trigger {
position: fixed;
left: 0;
top: 40%;
width: 20px;
height: 150px;
z-index: 10001;
}
`);
// 追加设置按钮样式 - 扁平黑白配色
GM_addStyle(`
.cnb-issue-settings-btn {
position: fixed;
z-index: 10000;
background: #000;
color: #fff;
border: 2px solid #000;
border-radius: 0;
width: 44px;
height: 44px;
cursor: pointer;
box-shadow: 2px 2px 0 rgba(0,0,0,0.5);
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s ease;
}
.cnb-issue-settings-btn:hover {
background: #fff;
color: #000;
}
`);
/* 强制隔离并统一控件样式,避免继承站点样式 - 扁平黑白配色 */
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 input.cnb-control,
.cnb-issue-dialog textarea.cnb-control {
box-sizing: border-box !important;
width: 100% !important;
margin: 8px 0 !important;
padding: 10px 12px !important;
border: 2px solid #000 !important;
border-radius: 0 !important;
background: #fff !important;
color: #000 !important;
font: normal 14px/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: 36px !important;
}
.cnb-issue-dialog textarea.cnb-control {
min-height: 300px !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: 5px 15x !important;
border: 2px solid #000 !important;
border-radius: 0 !important;
cursor: pointer !important;
font-size: 14px !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: 6px !important; }
.cnb-tag-btn {
margin: 4px !important;
padding: 1px 8px !important;
border: 2px solid #000 !important;
border-radius: 0 !important;
background: #fff !important;
color: #000 !important;
font-size: 13px !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: 8px !important; }
.cnb-tag-pill {
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
margin: 4px !important;
padding: 1px 8px !important;
border: 2px solid #000 !important;
border-radius: 0 !important;
background: #fff !important;
color: #000 !important;
font-size: 13px !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: 18px !important;
font-weight: 700 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
height: 20px !important;
padding: 0 4px !important;
line-height: 20px !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: 8px !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: 36px !important;
padding: 0 16px !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: 14px !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: 36px !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-image-upload-toggle {
display: flex !important;
align-items: center !important;
gap: 8px !important;
margin-top: 5px !important;
}
/* 开关样式 - 扁平黑白配色 */
.cnb-toggle-switch {
position: relative !important;
display: inline-block !important;
width: 48px !important;
height: 26px !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: 16px !important;
width: 16px !important;
left: 4px !important;
bottom: 4px !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(22px) !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: 44px !important;
height: 24px !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: 4px !important;
top: 4px !important;
width: 14px !important;
height: 14px !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(20px) !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());
});
},
// 处理节点
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 = 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 src = node.getAttribute('src') || '';
const alt = node.getAttribute('alt') || '';
// 收集图片信息用于上传
if (src && !src.startsWith('data:')) {
this.images.push({
src: src,
alt: alt,
element: node
});
}
return ``;
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 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);
// 剪贴板(根据设置的"剪贴板位置"是否为空来决定是否显示)
let __cnbClipCfg = '';
try { if (typeof GM_getValue === 'function') { const v = GM_getValue('cnbClipboardIssue', ''); __cnbClipCfg = String(v || '').trim(); } } catch (_) {}
if (__cnbClipCfg) {
const btnClipboard = document.createElement('button');
btnClipboard.id = 'cnb-btn-clipboard';
btnClipboard.className = 'cnb-dock-btn';
btnClipboard.textContent = '剪贴板';
btnClipboard.addEventListener('click', (e) => {
e.preventDefault();
if (typeof openClipboardWindow === 'function') {
openClipboardWindow();
}
});
dock.appendChild(btnClipboard);
}
document.body.appendChild(dock);
// 创建左边缘触发区域
const trigger = document.createElement('div');
trigger.className = 'cnb-dock-trigger';
document.body.appendChild(trigger);
// 等待dock完全渲染后,设置触发区域的尺寸
setTimeout(() => {
const dockRect = dock.getBoundingClientRect();
trigger.style.top = dockRect.top + 'px';
trigger.style.height = dockRect.height + 'px';
}, 0);
// 鼠标移到触发区域时显示dock(延迟显示,避免过于敏感)
trigger.addEventListener('mouseenter', () => {
// 清除之前的定时器
if (__CNB_DOCK_SHOW_TIMER) {
clearTimeout(__CNB_DOCK_SHOW_TIMER);
}
// 延迟300ms后显示
__CNB_DOCK_SHOW_TIMER = setTimeout(() => {
dock.classList.add('cnb-dock--visible');
}, 300);
});
// 鼠标离开触发区域时取消显示
trigger.addEventListener('mouseleave', () => {
if (__CNB_DOCK_SHOW_TIMER) {
clearTimeout(__CNB_DOCK_SHOW_TIMER);
__CNB_DOCK_SHOW_TIMER = null;
}
});
// 鼠标进入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) && !trigger.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);
// 添加鼠标移动和点击事件
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('mouseout', handleMouseOut);
document.addEventListener('click', handleElementClick);
// ESC键取消选择
document.addEventListener('keydown', handleKeyDown);
}
// 停止区域选择模式
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);
document.removeEventListener('mouseout', handleMouseOut);
document.removeEventListener('click', handleElementClick);
document.removeEventListener('keydown', handleKeyDown);
}
// 处理鼠标悬停
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(); // 先退出选择模式
// 获取选择的内容并转换为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';
// 强化筛选标签按钮样式(避免被站点样式覆盖,统一为胶囊风格)
GM_addStyle(`
.cnb-issue-dialog .cnb-issue-filter { display:flex !important; flex-wrap:wrap !important; gap:5px !important; }
.cnb-issue-dialog .cnb-issue-filter .cnb-issue-filter-btn {
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
padding: 4px 10px !important;
border: 1px solid #d0d7de !important;
border-radius: 9999px !important;
background: #fff !important;
color: #24292f !important;
font-size: 13px !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;
}
`);
// 获取选择的内容并转换为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);
}
});
dialog.innerHTML = `
创建 CNB Issue (Markdown格式)
${uniqueImages.length > 0 ? `
` : ''}
`;
// 添加事件监听
// 先定义常用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('cnbFavIssue', '') || '';
}
} 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);
}
});
}
// 监听编辑开关变化,显示/隐藏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);
};
overlay.addEventListener('click', closeDialog);
cancelBtn.addEventListener('click', closeDialog);
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 imagesInContent = extractImagesFromMarkdown(content);
// 处理图片上传和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';
}
});
}
};
// 如果开启了上传且有图片,先上传图片
if (shouldUpload && 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 = '图片上传完成';
}
handleContentReady(updatedContent);
});
} else {
// 不上传图片或没有图片,直接处理Issue操作
handleContentReady(content);
}
});
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 imagesInContent = extractImagesFromMarkdown(content);
// 如果开启了上传且有图片,先上传图片
if (shouldUpload && 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 = '图片上传完成';
}
handleContentReady(updatedContent);
});
} else {
handleContentReady(content);
}
});
}
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, '');
// 删除引用块中的空行(> 后面只有空格或空行的)
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, '');
// 删除引用块中的空行(> 后面只有空格或空行的)
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, '
');
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 '' + items.map(i=>`- ${i}
`).join('') + '
';
});
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=>`- ${i}
`).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';
dialog.innerHTML = `
创建 CNB 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('cnbFavIssue', '') || '';
}
} 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);
};
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' + ``;
// 创建并完成 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' + ``;
// 创建 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('cnbClipboardIssue', '');
currentClipIssue = (v == null) ? '' : String(v);
const f = GM_getValue('cnbFavIssue', '');
currentFavIssue = (f == null) ? '' : String(f);
}
} catch (_) {}
dialog.innerHTML = `
CNB 设置
`;
// 渲染与管理标签
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');
// 评论收藏即时保存
if (favIssueInput) {
favIssueInput.addEventListener('input', () => {
const favIssue = favIssueInput.value.trim();
if (typeof GM_setValue === 'function') GM_setValue('cnbFavIssue', favIssue);
});
}
// 仓库路径即时保存
if (repoInput) {
repoInput.addEventListener('input', () => {
const repo = repoInput.value.trim();
if (repo) {
CONFIG.repoPath = repo;
if (typeof GM_setValue === 'function') GM_setValue('repoPath', repo);
}
});
}
// 访问令牌即时保存
if (tokenInput) {
tokenInput.addEventListener('input', () => {
const token = tokenInput.value.trim();
if (token) {
CONFIG.accessToken = token;
if (typeof GM_setValue === 'function') GM_setValue('accessToken', token);
}
});
}
// 剪贴板位置即时保存并生效
if (clipIssueInput) {
const updateClipIssue = () => {
const clipIssue = clipIssueInput.value.trim();
if (typeof GM_setValue === 'function') GM_setValue('cnbClipboardIssue', clipIssue);
// 即时生效:根据是否有值来动态增删"剪贴板"按钮
const dock = document.querySelector('.cnb-dock');
if (dock) {
let btn = dock.querySelector('#cnb-btn-clipboard');
if (clipIssue) {
if (!btn) {
const btnClipboard = document.createElement('button');
btnClipboard.id = 'cnb-btn-clipboard';
btnClipboard.className = 'cnb-dock-btn';
btnClipboard.textContent = '剪贴板';
btnClipboard.addEventListener('click', (e) => {
e.preventDefault();
if (typeof openClipboardWindow === 'function') {
openClipboardWindow();
}
});
dock.appendChild(btnClipboard);
}
} else {
if (btn) btn.remove();
}
}
};
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) {
const deleteLabelUrl = `${CONFIG.apiBase}/${repo}/-/labels/${encodeURIComponent(tag)}`;
try {
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'DELETE',
url: deleteLabelUrl,
headers: {
'Accept': 'application/vnd.cnb.api+json',
'Authorization': token
},
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
resolve();
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: function() {
reject(new Error('网络错误'));
}
});
});
} 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;
}
// 创建仓库标签
const createLabelUrl = `${CONFIG.apiBase}/${repo}/-/labels`;
try {
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: createLabelUrl,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/vnd.cnb.api+json',
'Authorization': token
},
data: JSON.stringify({ name: t }),
responseType: 'json',
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
resolve();
} else {
let errorMsg = `HTTP ${response.status}`;
try {
const err = typeof response.response === 'string'
? JSON.parse(response.response) : response.response;
if (err?.message) errorMsg = err.message;
} catch (e) {}
reject(new Error(errorMsg));
}
},
onerror: function() {
reject(new Error('网络错误'));
}
});
});
// 添加到本地列表
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) => {
const labelsUrl = `${CONFIG.apiBase}/${repo}/-/labels?page=${page}&page_size=${pageSize}`;
GM_xmlhttpRequest({
method: 'GET',
url: labelsUrl,
headers: {
'Accept': 'application/vnd.cnb.api+json',
'Authorization': token
},
responseType: 'json',
onload: function(response) {
if (response.status === 200) {
let data = null;
try {
data = typeof response.response === 'object' && response.response !== null
? response.response
: JSON.parse(response.responseText || '{}');
} catch (e) {
data = null;
}
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);
} else {
let errorMsg = `HTTP ${response.status}`;
try {
const err = typeof response.response === 'string'
? JSON.parse(response.response) : response.response;
if (err?.message) errorMsg = err.message;
} catch (e) {}
reject(new Error(errorMsg));
}
},
onerror: function() {
reject(new Error('网络错误'));
}
});
});
}
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);
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;
};
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';
// 补充:筛选按钮按压态样式 - 扁平黑白配色
GM_addStyle(`
.cnb-issue-filter-btn.pressed {
transform: translate(1px, 1px);
box-shadow: none;
}
.cnb-issue-filter-btn {
transition: all 0.1s ease;
}
`);
const listEl = dialog.querySelector('#cnb-issue-list');
const closeBtn = dialog.querySelector('.cnb-dialog-close');
// 行内标签(Issue 列表中的 labels)胶囊样式 - 扁平黑白配色
GM_addStyle(`
.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 = `加载中...
`;
// 分页相关变量
let currentPage = 1;
const pageSize = 50;
let allItems = [];
let currentFilterLabel = null;
function loadIssues(page) {
listEl.innerHTML = `加载中...
`;
const url = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}?page=${page}&page_size=${pageSize}&state=closed`;
GM_xmlhttpRequest({
method: 'GET',
url: url.replace(/&/g, '&'),
headers: {
'accept': 'application/json',
'Authorization': `${CONFIG.accessToken}`
},
responseType: 'json',
onload: function(res) {
try {
const data = typeof res.response === 'object' && res.response !== null
? res.response
: JSON.parse(res.responseText || '[]');
const items = Array.isArray(data) ? data : (Array.isArray(data.items) ? data.items : []);
if (!items.length) {
listEl.innerHTML = `暂无数据
`;
return;
}
allItems = Array.isArray(items) ? items : [];
// 根据返回的数量判断是否有下一页
// 如果返回的数量等于 pageSize,说明可能还有下一页
// 否则就是最后一页
const hasMore = items.length === pageSize;
currentPage = page;
renderList(currentFilterLabel);
renderPagination(hasMore);
} catch (e) {
listEl.innerHTML = `加载失败
`;
}
},
onerror: function() {
listEl.innerHTML = `网络请求失败
`;
}
});
}
function renderList(filterLabel) {
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:8px 10px;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 = `https://cnb.cool/${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 = '复制到剪贴板';
btnCopy.style.cssText = 'margin-left:6px;display:inline-flex;align-items:center;justify-content:center;padding:3px 6px;border:2px solid #000;background:#fff;color:#000;font-size:11px;font-weight:600;cursor:pointer;line-height:1;transition:all 0.1s ease;';
btnCopy.addEventListener('mouseover', () => { btnCopy.style.background = '#000'; btnCopy.style.color = '#fff'; });
btnCopy.addEventListener('mouseout', () => { btnCopy.style.background = '#fff'; btnCopy.style.color = '#000'; });
btnCopy.addEventListener('click', () => {
if (btnCopy.disabled) return;
btnCopy.disabled = true;
const oldText = btnCopy.textContent;
btnCopy.textContent = '…';
const urlPatch = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}/${number}`;
GM_xmlhttpRequest({
method: 'PATCH',
url: urlPatch,
headers: {
'Content-Type': 'application/json',
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/json'
},
data: JSON.stringify({ state: 'closed', state_reason: 'completed' }),
responseType: 'json',
onload: function(res) {
try {
if (res.status >= 200 && res.status < 300) {
let obj = null;
try {
obj = (typeof res.response === 'object' && res.response !== null)
? res.response
: JSON.parse(res.responseText || '{}');
} catch(_) {}
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');
}
if (typeof GM_notification === 'function') {
GM_notification({ text: '已关闭并复制到剪贴板', title: 'CNB Issue工具', timeout: 3000 });
}
} else {
if (typeof GM_notification === 'function') {
GM_notification({ text: '操作失败: HTTP ' + res.status, title: 'CNB Issue工具', timeout: 5000 });
}
}
} finally {
btnCopy.disabled = false;
btnCopy.textContent = oldText;
}
},
onerror: function() {
if (typeof GM_notification === 'function') {
GM_notification({ text: '网络请求失败', title: 'CNB Issue工具', timeout: 5000 });
}
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);
}
function renderPagination(hasMore) {
const paginationEl = dialog.querySelector('#cnb-issue-pagination');
if (!paginationEl) return;
paginationEl.innerHTML = '';
// 第一页不显示"上一页"按钮
if (currentPage > 1) {
const prevBtn = document.createElement('button');
prevBtn.textContent = '上一页';
prevBtn.style.cssText = 'padding:4px 8px;font-size:12px;border:2px solid #000;background:#fff;color:#000;font-weight:600;cursor:pointer;';
prevBtn.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
loadIssues(currentPage);
}
});
paginationEl.appendChild(prevBtn);
}
// 最后一页不显示"下一页"按钮
if (hasMore) {
const nextBtn = document.createElement('button');
nextBtn.textContent = '下一页';
nextBtn.style.cssText = 'padding:4px 8px;font-size:12px;border:2px solid #000;background:#fff;color:#000;font-weight:600;cursor:pointer;';
nextBtn.addEventListener('click', () => {
currentPage++;
loadIssues(currentPage);
});
paginationEl.appendChild(nextBtn);
}
}
// 渲染筛选按钮
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 = '全部';
applyFilterButtonStyles(allBtn);
applyFilterButtonActive(allBtn);
addPressEffect(allBtn);
allBtn.addEventListener('click', () => {
setActive(allBtn);
currentFilterLabel = null;
renderList(null);
});
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;
applyFilterButtonStyles(b);
addPressEffect(b);
b.addEventListener('click', () => {
setActive(b);
currentFilterLabel = tag;
renderList(tag);
});
filterEl.appendChild(b);
});
function setActive(btn) {
const buttons = filterEl.querySelectorAll('button');
buttons.forEach(x => {
x.classList.remove('active');
applyFilterButtonDefault(x);
});
btn.classList.add('active');
applyFilterButtonActive(btn);
}
// 行内样式(带 !important)确保胶囊风格不被站点覆盖
function applyFilterButtonStyles(btn) {
const s = btn.style;
s.setProperty('display', 'inline-flex', 'important');
s.setProperty('align-items', 'center', 'important');
s.setProperty('gap', '6px', 'important');
s.setProperty('padding', '4px 10px', 'important');
s.setProperty('border', '1px solid #d0d7de', 'important');
s.setProperty('border-radius', '9999px', 'important');
s.setProperty('background', '#fff', 'important');
s.setProperty('color', '#24292f', 'important');
s.setProperty('font-size', '13px', 'important');
s.setProperty('line-height', '1.2', 'important');
s.setProperty('white-space', 'nowrap', 'important');
s.setProperty('vertical-align', 'middle', 'important');
s.setProperty('box-shadow', '0 1px 0 rgba(27,31,36,0.04)', 'important');
s.setProperty('transition', 'background-color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .02s ease', 'important');
s.setProperty('cursor', 'pointer', 'important');
s.setProperty('user-select', 'none', 'important');
// 关键:移除按钮自身外边距,确保由容器 gap 控制间距
s.setProperty('margin', '0', 'important');
}
function applyFilterButtonDefault(btn) {
const s = btn.style;
s.setProperty('background', '#fff', 'important');
s.setProperty('border-color', '#d0d7de', 'important');
s.setProperty('color', '#24292f', 'important');
s.setProperty('box-shadow', '0 1px 0 rgba(27,31,36,0.04)', 'important');
}
function applyFilterButtonActive(btn) {
const s = btn.style;
s.setProperty('background', '#0366d6', 'important');
s.setProperty('border-color', '#0256b9', 'important');
s.setProperty('color', '#fff', 'important');
s.setProperty('box-shadow', '0 1px 0 rgba(27,31,36,0.05)', 'important');
}
// 为筛选按钮添加按压反馈
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'));
}
}
// 首次加载第一页
loadIssues(1);
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 #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: 10010;
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;
pointer-events: auto;
}
.cnb-clipwin-title a {
color: #fff;
text-decoration: none;
}
.cnb-clipwin-title a:hover {
text-decoration: underline;
}
text-transform: uppercase;
}
.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: 8px;
overflow: auto;
}
.cnb-clipwin-tabs {
position: fixed;
display: flex;
flex-direction: column;
z-index: 10011;
opacity: 1;
pointer-events: auto;
transition: opacity 0.3s ease;
}
.cnb-clipwin-tab {
margin: 2px 0;
padding: 5px 12px;
padding-left: 12px;
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-body {
margin: 0;
padding: 8px;
background: #fff;
border: 2px solid #000;
border-radius: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
font-size: 11px;
line-height: 1.4;
max-height: 60vh;
overflow: auto;
}
.cnb-clipwin-actions {
border-top: 2px solid #000;
padding: 8px 12px;
display: flex; gap: 6px; justify-content: flex-end;
background: #fff;
}
.cnb-clipwin-btn {
display: inline-flex; align-items: center; justify-content: center;
height: 28px; padding: 0 12px; 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: 8px;
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 {
GM_addStyle(`
/* 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('cnbClipboardIssue', '');
__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) {
const url = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}/${encodeURIComponent(issueNum)}`;
bodyEl.textContent = '加载中…';
// 如果已缓存,直接使用
if (issueDataCache[index]) {
renderIssueContent(issueDataCache[index], issueNum);
return;
}
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
'Accept': 'application/json',
'Authorization': `${CONFIG.accessToken}`
},
responseType: 'json',
onload: function(res) {
try {
if (res.status >= 200 && res.status < 300) {
let data = null;
try {
data = (typeof res.response === 'object' && res.response !== null)
? res.response
: JSON.parse(res.responseText || '{}');
} catch (_) {}
// 缓存数据
issueDataCache[index] = data;
renderIssueContent(data, issueNum);
} else {
bodyEl.textContent = `加载失败 (HTTP ${res.status})`;
}
} catch (e) {
bodyEl.textContent = '加载出错';
}
},
onerror: function() {
bodyEl.textContent = '网络错误';
}
});
}
// 函数:渲染 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('cnbClipboardIssueTitles', '{}');
const titleMap = typeof titles === 'object' ? titles : {};
titleMap[String(issueNum)] = t;
GM_setValue('cnbClipboardIssueTitles', 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 = `https://cnb.cool/${CONFIG.repoPath}/-/issues/${data.iid}`;
} else if (issueNum) {
issueUrl = `https://cnb.cool/${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') {
GM_addStyle(`
.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: 4px 10px;
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('cnbClipboardIssueTitles', '{}');
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();
}
// 创建Issue
function createIssue(title, content, labels = [], callback) {
if (!CONFIG.repoPath || !CONFIG.accessToken) {
if (typeof GM_notification === 'function') {
GM_notification({ text: '请先在设置中配置仓库路径与访问令牌', title: 'CNB Issue工具', timeout: 3000 });
}
if (typeof openSettingsDialog === 'function') openSettingsDialog();
if (typeof callback === 'function') callback(false);
return;
}
const issueData = {
repoId: CONFIG.repoPath,
title: title,
body: content,
labels: labels,
assignees: []
};
const apiUrl = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}`;
GM_xmlhttpRequest({
method: 'POST',
url: apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/json'
},
data: JSON.stringify(issueData),
responseType: 'json',
onload: function(response) {
if (response.status === 200 || response.status === 201) {
// 解析返回,取 issueId(兼容不同字段)
let respObj = null;
try {
respObj = typeof response.response === 'object' && response.response !== null
? response.response
: JSON.parse(response.responseText || '{}');
} catch (_) {
respObj = null;
}
const issueId = respObj?.id ?? respObj?.number ?? respObj?.iid ?? respObj?.issue_id;
const notifySuccess = () => {
GM_notification({
text: `Issue创建成功!`,
title: 'CNB Issue工具',
timeout: 3000
});
if (callback) callback(true, issueId);
};
// 若有标签,则单独 PUT 标签
if (Array.isArray(labels) && labels.length > 0 && issueId != null) {
const labelsUrl = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}/${issueId}/labels`;
GM_xmlhttpRequest({
method: 'PUT',
url: labelsUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/json'
},
data: JSON.stringify({ labels }),
responseType: 'json',
onload: function(res2) {
if (res2.status >= 200 && res2.status < 300) {
notifySuccess();
} else {
let msg = `HTTP ${res2.status}`;
try {
const err = typeof res2.response === 'string'
? JSON.parse(res2.response) : res2.response;
if (err?.message) msg = err.message;
} catch (_) {}
GM_notification({
text: `Issue已创建,但设置标签失败:${msg}`,
title: 'CNB Issue工具',
timeout: 5000
});
if (callback) callback(true, issueId);
}
},
onerror: function() {
GM_notification({
text: `Issue已创建,但设置标签时网络错误`,
title: 'CNB Issue工具',
timeout: 5000
});
if (callback) callback(true, issueId);
}
});
} else {
// 无标签或无法解析 issueId,直接成功
notifySuccess();
}
} else {
let errorMsg = `HTTP ${response.status}`;
try {
const errorData = typeof response.response === 'string' ?
JSON.parse(response.response) : response.response;
if (errorData && errorData.message) {
errorMsg = errorData.message;
}
} catch (e) {}
GM_notification({
text: `创建失败: ${errorMsg}`,
title: 'CNB Issue工具',
timeout: 5000
});
if (callback) callback(false);
}
},
onerror: function(error) {
GM_notification({
text: `网络请求失败`,
title: 'CNB Issue工具',
timeout: 5000
});
if (callback) callback(false);
}
});
}
// 关闭Issue(设置为 closed,state_reason=completed)
function closeIssue(issueId, stateReason = 'completed', callback) {
if (issueId == null) {
if (typeof callback === 'function') callback(false);
return;
}
const url = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}/${issueId}`;
GM_xmlhttpRequest({
method: 'PATCH',
url,
headers: {
'Content-Type': 'application/json',
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/json'
},
data: JSON.stringify({
state: 'closed',
state_reason: stateReason
}),
responseType: 'json',
onload: function(res) {
if (res.status >= 200 && res.status < 300) {
if (typeof callback === 'function') callback(true);
} else {
let msg = `HTTP ${res.status}`;
try {
const err = typeof res.response === 'string' ? JSON.parse(res.response) : res.response;
if (err?.message) msg = err.message;
} catch (_) {}
if (typeof GM_notification === 'function') {
GM_notification({
text: `标记完成失败:${msg}`,
title: 'CNB Issue工具',
timeout: 5000
});
}
if (typeof callback === 'function') callback(false);
}
},
onerror: function() {
if (typeof GM_notification === 'function') {
GM_notification({
text: `网络请求失败(关闭Issue)`,
title: 'CNB Issue工具',
timeout: 5000
});
}
if (typeof callback === 'function') callback(false);
}
});
}
// 更新Issue
function updateIssue(issueNumber, data, labels = [], callback) {
if (!CONFIG.repoPath || !CONFIG.accessToken) {
if (typeof GM_notification === 'function') {
GM_notification({ text: '请先在设置中配置仓库路径与访问令牌', title: 'CNB Issue工具', timeout: 3000 });
}
if (typeof callback === 'function') callback(false);
return;
}
const url = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}/${issueNumber}`;
const labelsUrl = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}/${issueNumber}/labels`;
// 处理标签的函数
const handleLabels = (afterUpdateCallback) => {
// 判断是否选择了标签,如果没有选择标签,则跳过标签处理
if (!Array.isArray(labels) || labels.length === 0) {
afterUpdateCallback(true);
return;
}
// 先删除所有标签(如果有)
const deleteLabels = () => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'DELETE',
url: labelsUrl,
headers: {
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/vnd.cnb.api+json'
},
responseType: 'json',
onload: function(res) {
// 无论删除成功与否,都继续添加标签(可能本来就没有标签)
resolve();
},
onerror: function() {
// 即使删除失败也继续
resolve();
}
});
});
};
// 添加新标签
const addLabels = () => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'PUT',
url: labelsUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/vnd.cnb.api+json'
},
data: JSON.stringify({ labels }),
responseType: 'json',
onload: function(res) {
if (res.status >= 200 && res.status < 300) {
resolve();
} else {
reject(`HTTP ${res.status}`);
}
},
onerror: function() {
reject('网络错误');
}
});
});
};
// 执行删除和添加标签
deleteLabels()
.then(() => addLabels())
.then(() => afterUpdateCallback(true))
.catch((err) => {
if (typeof GM_notification === 'function') {
GM_notification({
text: `更新标签失败:${err}`,
title: 'CNB Issue工具',
timeout: 5000
});
}
// 标签更新失败,但Issue已更新,仍然返回成功
afterUpdateCallback(true);
});
};
// 先更新Issue的title和body
GM_xmlhttpRequest({
method: 'PATCH',
url,
headers: {
'Content-Type': 'application/json',
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/vnd.cnb.api+json'
},
data: JSON.stringify(data),
responseType: 'json',
onload: function(res) {
if (res.status >= 200 && res.status < 300) {
// Issue更新成功,处理标签
handleLabels((labelSuccess) => {
if (typeof GM_notification === 'function') {
GM_notification({
text: `Issue #${issueNumber} 更新成功!`,
title: 'CNB Issue工具',
timeout: 3000
});
}
if (typeof callback === 'function') callback(true);
});
} else {
let msg = `HTTP ${res.status}`;
try {
const err = typeof res.response === 'string' ? JSON.parse(res.response) : res.response;
if (err?.message) msg = err.message;
} catch (_) {}
if (typeof GM_notification === 'function') {
GM_notification({
text: `更新失败:${msg}`,
title: 'CNB Issue工具',
timeout: 5000
});
}
if (typeof callback === 'function') callback(false);
}
},
onerror: function() {
if (typeof GM_notification === 'function') {
GM_notification({
text: `网络请求失败(更新Issue)`,
title: 'CNB Issue工具',
timeout: 5000
});
}
if (typeof callback === 'function') callback(false);
}
});
}
// 获取Issue的标签
function fetchIssueLabels(issueNumber, callback) {
if (!CONFIG.repoPath || !CONFIG.accessToken) {
if (typeof callback === 'function') callback([], '请先配置仓库路径与访问令牌');
return;
}
const url = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}/${issueNumber}/labels`;
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/vnd.cnb.api+json'
},
responseType: 'json',
onload: function(res) {
if (res.status >= 200 && res.status < 300) {
let labels = [];
try {
const data = typeof res.response === 'object' && res.response !== null
? res.response
: JSON.parse(res.responseText || '{}');
// 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);
} else {
let msg = `HTTP ${res.status}`;
try {
const err = typeof res.response === 'string' ? JSON.parse(res.response) : res.response;
if (err?.message) msg = err.message;
} catch (_) {}
if (typeof callback === 'function') callback([], msg);
}
},
onerror: function() {
if (typeof callback === 'function') callback([], '网络错误');
}
});
}
// 添加评论到Issue
function addCommentToIssue(issueNumber, body, callback) {
if (!CONFIG.repoPath || !CONFIG.accessToken) {
if (typeof GM_notification === 'function') {
GM_notification({ text: '请先在设置中配置仓库路径与访问令牌', title: 'CNB Issue工具', timeout: 3000 });
}
if (typeof callback === 'function') callback(false);
return;
}
const url = `${CONFIG.apiBase}/${CONFIG.repoPath}${CONFIG.issueEndpoint}/${issueNumber}/comments`;
GM_xmlhttpRequest({
method: 'POST',
url,
headers: {
'Content-Type': 'application/json',
'Authorization': `${CONFIG.accessToken}`,
'Accept': 'application/vnd.cnb.api+json'
},
data: JSON.stringify({ body }),
responseType: 'json',
onload: function(res) {
if (res.status >= 200 && res.status < 300) {
if (typeof GM_notification === 'function') {
GM_notification({
text: `评论添加成功!`,
title: 'CNB Issue工具',
timeout: 3000
});
}
if (typeof callback === 'function') callback(true);
} else {
let msg = `HTTP ${res.status}`;
try {
const err = typeof res.response === 'string' ? JSON.parse(res.response) : res.response;
if (err?.message) msg = err.message;
} catch (_) {}
if (typeof GM_notification === 'function') {
GM_notification({
text: `添加评论失败:${msg}`,
title: 'CNB Issue工具',
timeout: 5000
});
}
if (typeof callback === 'function') callback(false);
}
},
onerror: function() {
if (typeof GM_notification === 'function') {
GM_notification({
text: `网络请求失败(添加评论)`,
title: 'CNB Issue工具',
timeout: 5000
});
}
if (typeof callback === 'function') callback(false);
}
});
}
// 1. 获取上传凭证
function requestUploadToken(fileName, fileSize, callback) {
if (!CONFIG.repoPath || !CONFIG.accessToken) {
if (typeof callback === 'function') callback(null, '请先配置仓库路径和访问令牌');
return;
}
const uploadUrl = `${CONFIG.apiBase}/${CONFIG.repoPath}/-/upload/imgs`;
GM_xmlhttpRequest({
method: 'POST',
url: uploadUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': CONFIG.accessToken,
'Accept': 'application/json'
},
data: JSON.stringify({ name: fileName, size: fileSize, ext: {} }),
responseType: 'json',
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const resp = response.response || JSON.parse(response.responseText || '{}');
if (callback) callback(resp, null);
return;
}
let errorMsg = `HTTP ${response.status}`;
const err = typeof response.response === 'string'
? JSON.parse(response.response || '{}') : response.response;
if (err?.message) errorMsg = err.message;
// 特殊处理权限错误 (errcode: 7)
if (err?.errcode === 7 && err?.errmsg?.includes('票据授权范围')) {
errorMsg = '访问令牌缺少 repo-contents:rw 权限';
if (typeof GM_notification === 'function') {
GM_notification({
text: '图片上传失败:访问令牌缺少 repo-contents:rw 权限',
title: 'CNB Issue工具',
timeout: 5000
});
}
}
if (callback) callback(null, errorMsg);
},
onerror: function() {
if (callback) callback(null, '网络请求失败');
}
});
}
// 2. 上传图片到 OSS
function uploadImageToOss(uploadInfo, fileData, callback) {
if (!uploadInfo?.upload_url) {
if (callback) callback(null, '上传凭证无效');
return;
}
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadInfo.upload_url);
xhr.setRequestHeader('Content-Type', fileData.type || 'application/octet-stream');
// 添加额外的表单参数作为请求头
if (uploadInfo.form) {
Object.entries(uploadInfo.form).forEach(([key, value]) => {
if (key.toLowerCase() !== 'file') {
xhr.setRequestHeader(key, value);
}
});
}
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
const relativePath = uploadInfo.assets?.path || '';
const fullUrl = relativePath.includes(CONFIG.repoPath)
? `https://cnb.cool${relativePath}`
: `https://cnb.cool/${CONFIG.repoPath}${relativePath}`;
if (callback) callback(fullUrl, null);
return;
}
let errorMsg = `HTTP ${xhr.status}`;
try {
if (xhr.responseText) {
const err = JSON.parse(xhr.responseText);
if (err?.message) errorMsg = err.message;
}
} catch (_) {}
if (callback) callback(null, errorMsg);
};
xhr.onerror = function() {
if (callback) callback(null, '图片上传失败');
};
xhr.send(fileData);
}
// 3. 获取图片数据(从 URL 或 base64)
function fetchImageData(imageUrl, callback) {
// 如果是 base64 数据
if (imageUrl.startsWith('data:')) {
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
if (matches) {
const mimeType = matches[1];
const base64Data = matches[2];
try {
const byteString = atob(base64Data);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeType });
if (callback) callback(blob, null);
return;
} catch (e) {
if (callback) callback(null, 'base64 解析失败');
return;
}
}
}
// 通过 URL 获取图片 - 使用 fetch
fetch(imageUrl, {
method: 'GET',
mode: 'cors',
credentials: 'omit'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.blob();
})
.then(blob => {
if (callback) callback(blob, null);
})
.catch(error => {
// 如果 fetch 失败,尝试使用 GM_xmlhttpRequest
GM_xmlhttpRequest({
method: 'GET',
url: imageUrl,
responseType: 'arraybuffer',
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const arrayBuffer = response.response;
if (arrayBuffer) {
const blob = new Blob([arrayBuffer]);
if (callback) callback(blob, null);
} else {
if (callback) callback(null, '获取图片数据失败');
}
} else {
if (callback) callback(null, `HTTP ${response.status}`);
}
},
onerror: function() {
if (callback) callback(null, '获取图片失败');
}
});
});
}
// 4. 从Markdown内容中提取图片链接
function extractImagesFromMarkdown(markdown) {
const images = [];
// 匹配 Markdown 图片语法: 
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'),
``
);
}
});
if (callback) callback(updatedMarkdown, errors);
});
}
// 检查是否为 cnb.cool 域名
function isCnbDomain() {
return /\b(^|\.)cnb\.cool$/i.test(location.hostname);
}
function isCnbHomepage() {
return location.hostname === 'cnb.cool' && (location.pathname === '/' || location.pathname === '');
}
// 直达目标解码:获取 cnb.cool /数字?url= 的目标地址
function getCnbGotoTarget(urlLike) {
try {
const u = new URL(urlLike, location.href);
const raw = u.searchParams.get('url') || '';
if (!raw) return '';
// 解码 1-2 次,兼容已编码/双重编码
let t = decodeURIComponent(raw);
if (/%[0-9A-Fa-f]{2}/.test(t)) {
try { t = decodeURIComponent(t); } catch (_) {}
}
// 只允许 http/https
return /^https?:\/\//i.test(t) ? t : '';
} catch (_) {
return '';
}
}
// 若当前位于 cnb.cool 的数字跳转页,立即重定向到真实目标
function handleCnbGotoPage() {
if (!isCnbDomain()) return;
if (!location.pathname.match(/^\/(\d+)$/)) return;
if (!location.search.includes('url=')) return;
const target = getCnbGotoTarget(location.href);
if (target) location.replace(target);
}
// 将页面内所有 数字?url= 链接批量改写为直链
function rewriteCnbGotoLinks(root = document) {
if (!isCnbDomain()) return;
root.querySelectorAll('a[href*="?url="]').forEach(a => {
try {
const href = a.getAttribute('href');
if (!href) return;
const absUrl = new URL(href, location.href).href;
if (!absUrl.includes('cnb.cool/') || !/\/(\d+)\?url=/i.test(absUrl)) return;
const target = getCnbGotoTarget(absUrl);
if (target) a.href = target;
} catch (_) {}
});
}
// 事件委托兜底:拦截点击数字跳转链接并直接打开目标
function cnbGotoClickHandler(e) {
if (!isCnbDomain()) return;
// 查找被点击的 元素
let el = e.target;
while (el && el !== document && !(el instanceof HTMLAnchorElement)) {
el = el.parentElement;
}
if (!(el instanceof HTMLAnchorElement)) return;
const href = el.getAttribute('href');
if (!href) return;
const absUrl = new URL(href, location.href).href;
if (!/\/(\d+)\?url=/i.test(absUrl)) return;
const target = getCnbGotoTarget(absUrl);
if (!target) return;
e.preventDefault();
e.stopPropagation();
// 兼容中键或带修饰键的新开方式
const newTab = e.button === 1 || e.ctrlKey || e.metaKey;
if (newTab) {
window.open(target, '_blank', 'noopener');
} else {
location.href = target;
}
}
// 清理资源
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;
const repo = GM_getValue('repoPath', CONFIG.repoPath);
const token = GM_getValue('accessToken', CONFIG.accessToken);
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);
if (repo) CONFIG.repoPath = repo;
if (token) CONFIG.accessToken = token;
if (Array.isArray(tags)) SAVED_TAGS = tags;
if (hk) START_HOTKEY = normalizeHotkeyString(hk);
HOTKEY_ENABLED = !!hkEnabled;
CONFIG.uploadEnabled = !!uploadEnabled;
} catch (_) {}
}
// 隐藏"暂无展示仓库"的空状态容器
function hideEmptyRepoContainer() {
const emptyText = document.querySelector('.text-base.text-ter.mt-4');
if (emptyText && emptyText.textContent === '暂无展示仓库') {
const container = emptyText.closest('.w-full.h-\\[328px\\]');
if (container) {
container.style.setProperty('display', 'none', 'important');
}
}
}
// 增强 CNB 页面网格布局
function enhanceGridLayout() {
if (!isCnbDomain()) return;
// 添加单栏/双栏切换按钮样式
GM_addStyle(`
.cnb-grid-toggle-btn {
padding: 4px !important;
}
.cnb-grid-toggle-active {
background-color: #3d3d3d !important;
color: #fff !important;
}
.cnb-grid-toggle-inactive {
background-color: #f3f4f6 !important;
color: #6b7280 !important;
}
.cnb-grid-toggle-inactive:hover {
background-color: #e5e7eb !important;
}
`);
// 添加单栏/双栏切换开关
addGridLayoutToggle();
// 应用布局
applyGridLayout();
// "执行"按钮鼠标悬停时自动打开弹窗
enhanceExecuteButtonHover();
// 添加"与我有关"标签
addMineTab();
}
// 应用网格布局
function applyGridLayout() {
const isDoubleColumn = localStorage.getItem('cnbGridLayout') === 'double';
// 只修改 class="grid grid-cols-1 gap-4 mt-4 mb-8"("最近更新"下面的网格)
document.querySelectorAll('.grid.grid-cols-1.gap-4.mt-4.mb-8').forEach(el => {
if (isDoubleColumn) {
if (!el.classList.contains('xl:grid-cols-2')) {
el.classList.add('xl:grid-cols-2');
}
} else {
el.classList.remove('xl:grid-cols-2');
}
});
}
// 添加单栏/双栏切换开关
function addGridLayoutToggle() {
// 查找包含"最近更新"文本的元素
const elements = document.querySelectorAll('div');
let targetDiv = null;
for (const el of elements) {
const text = el.textContent.trim();
// 精确匹配"最近更新"文本
if (text === '最近更新' &&
el.classList.contains('font-semibold') &&
el.classList.contains('text-l') &&
el.classList.contains('mb-4') &&
el.classList.contains('mt-4')) {
targetDiv = el;
break;
}
}
if (!targetDiv) return;
// 检查是否已经添加过
if (targetDiv.querySelector('.cnb-grid-layout-toggle')) return;
// 获取当前布局状态
const isDoubleColumn = localStorage.getItem('cnbGridLayout') === 'double';
// 给目标div添加flex布局,让内容两端对齐
targetDiv.classList.add('flex', 'items-center', 'justify-between');
// 保存"最近更新"文本
const textContent = targetDiv.textContent.trim();
targetDiv.textContent = '';
// 创建左边的内容容器
const leftContent = document.createElement('span');
leftContent.textContent = textContent;
// 创建开关容器
const toggleContainer = document.createElement('div');
toggleContainer.className = 'cnb-grid-layout-toggle flex items-center gap-2';
// 创建单栏按钮(图标)
const singleBtn = document.createElement('button');
singleBtn.className = `cnb-grid-toggle-btn rounded-lg transition-colors ${!isDoubleColumn ? 'cnb-grid-toggle-active' : 'cnb-grid-toggle-inactive'}`;
singleBtn.innerHTML = `
`;
singleBtn.title = '单栏';
singleBtn.addEventListener('click', () => {
localStorage.setItem('cnbGridLayout', 'single');
updateToggleButtons();
applyGridLayout();
});
// 创建双栏按钮(图标)
const doubleBtn = document.createElement('button');
doubleBtn.className = `cnb-grid-toggle-btn rounded-lg transition-colors ${isDoubleColumn ? 'cnb-grid-toggle-active' : 'cnb-grid-toggle-inactive'}`;
doubleBtn.innerHTML = `
`;
doubleBtn.title = '双栏';
doubleBtn.addEventListener('click', () => {
localStorage.setItem('cnbGridLayout', 'double');
updateToggleButtons();
applyGridLayout();
});
// 更新按钮状态的函数
function updateToggleButtons() {
const isDoubleColumn = localStorage.getItem('cnbGridLayout') === 'double';
if (isDoubleColumn) {
singleBtn.className = 'cnb-grid-toggle-btn rounded-lg transition-colors cnb-grid-toggle-inactive';
doubleBtn.className = 'cnb-grid-toggle-btn rounded-lg transition-colors cnb-grid-toggle-active';
} else {
singleBtn.className = 'cnb-grid-toggle-btn rounded-lg transition-colors cnb-grid-toggle-active';
doubleBtn.className = 'cnb-grid-toggle-btn rounded-lg transition-colors cnb-grid-toggle-inactive';
}
}
toggleContainer.appendChild(singleBtn);
toggleContainer.appendChild(doubleBtn);
// 将左边内容和开关添加到div中
targetDiv.appendChild(leftContent);
targetDiv.appendChild(toggleContainer);
}
// 增强"执行"按钮:鼠标悬停时自动打开弹窗
function enhanceExecuteButtonHover() {
if (!isCnbDomain()) return;
// 查找包含"执行"文本的按钮
document.querySelectorAll('.t-button--theme-default.t-button--variant-outline').forEach(btn => {
const buttonText = btn.textContent || '';
if (buttonText.includes('执行')) {
// 标记已处理,避免重复绑定
if (btn.dataset.enhanced) return;
btn.dataset.enhanced = 'true';
// 鼠标进入时模拟点击打开弹窗
btn.addEventListener('mouseenter', () => {
// 先添加 class
btn.classList.add('t-popup-open');
// 模拟点击事件触发弹窗
setTimeout(() => {
btn.click();
}, 10);
}, { passive: true });
// 鼠标离开时移除 class(不关闭弹窗,让用户可以交互)
btn.addEventListener('mouseleave', () => {
btn.classList.remove('t-popup-open');
}, { passive: true });
}
});
}
// 添加"与我有关"标签
function addMineTab() {
if (!isCnbHomepage()) return;
// 查找标签导航容器
const navWrap = document.querySelector('.t-tabs__nav-wrap');
if (!navWrap) return;
// 检查是否已经添加过
if (navWrap.querySelector('.cnb-mine-tab')) return;
// 创建新的标签项
const newTabItem = document.createElement('div');
newTabItem.className = 't-tabs__nav-item t-size-m t-is-top cnb-mine-tab';
newTabItem.innerHTML = `
`;
// 添加到标签导航中
navWrap.appendChild(newTabItem);
}
// CNB 网站功能:添加"创建仓库"按钮
function initCnbFeatures() {
// 检查是否在 cnb.cool 网站
if (!location.hostname.includes('cnb.cool')) return;
// 处理短链接跳转(仅执行一次)
handleCnbGotoPage();
// 添加点击事件委托(仅添加一次)
document.addEventListener('click', cnbGotoClickHandler, true);
// 统一的处理函数
const processPage = () => {
addCreateRepoButton();
rewriteCnbGotoLinks();
hideEmptyRepoContainer();
enhanceGridLayout();
};
// 等待页面加载完成
const observer = new MutationObserver(() => {
processPage();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// 立即尝试添加
processPage();
}
function addCreateRepoButton() {
// 查找目标元素:包含"仓库墙"标题的父容器
const targetDiv = document.querySelector('h1.font-semibold.text-l');
if (!targetDiv) return;
const parentDiv = targetDiv.closest('.flex.items-center.justify-between');
if (!parentDiv) return;
// 检查是否已经添加过按钮
if (parentDiv.querySelector('.cnb-create-repo-btn')) return;
// 获取当前路径
const pathParts = location.pathname.split('/').filter(Boolean);
const groupPath = pathParts.join('/');
// 创建按钮容器
const buttonContainer = document.createElement('div');
buttonContainer.className = 'flex items-center gap-3';
// 移动原有的"更新仓库墙"按钮到新容器中
const existingButton = parentDiv.querySelector('button');
if (existingButton) {
buttonContainer.appendChild(existingButton);
}
// 创建"创建仓库"按钮
const createRepoBtn = document.createElement('button');
createRepoBtn.className = 't-button t-button--theme-primary t-button--variant-base cnb-create-repo-btn';
createRepoBtn.innerHTML = `
创建仓库
`;
// 设置链接
const createRepoUrl = groupPath ? `https://cnb.cool/new/repos?group=${groupPath}` : 'https://cnb.cool/new/repos';
createRepoBtn.addEventListener('click', () => {
window.location.href = createRepoUrl;
});
buttonContainer.appendChild(createRepoBtn);
// 清空原容器并添加新容器
parentDiv.innerHTML = '';
parentDiv.appendChild(targetDiv);
parentDiv.appendChild(buttonContainer);
}
// 初始化
function init() {
loadPersistedConfig();
createFloatingButton();
initCnbFeatures();
document.addEventListener('keydown', globalHotkeyHandler, true);
}
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();