// ==UserScript==
// @name Notion AI 快速保存助手
// @namespace http://tampermonkey.net/
// @version 1.2
// @description 一个由 AI 驱动的用户脚本(UserScript),支持 OpenAI 和 Gemini,可以快速将网页保存并智能分类到您的 Notion 数据库中。它拥有一个设计优雅、可拖动的悬浮 UI、一个功能全面的设置面板,并为兼容现代网站而精心设计。
// @author tsdw & 大G老师 (Gemini)
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @license MIT
// @connect *
// ==/UserScript==
(function() {
'use strict';
// ===================================================================
// ===================== 初始化锁 (双重保险) =========================
// ===================================================================
if (window.self !== window.top) { return; }
if (window.NQS_SCRIPT_RUNNING) { return; }
window.NQS_SCRIPT_RUNNING = true;
// ===================================================================
// ===================== 全局容器与样式隔离 ========================
// ===================================================================
const globalContainer = document.createElement('div');
globalContainer.id = 'NQS_GLOBAL_CONTAINER';
document.body.appendChild(globalContainer);
// ===================================================================
// ===================== 默认配置与分类管理 ========================
// ===================================================================
const DEFAULT_CATEGORIES = [ "前端开发", "后端开发", "人工智能", "运维安全", "生活服务", "美食烹饪", "旅行", "健康医疗", "影视", "音乐", "游戏", "动漫","综艺", "学术研究", "教育学习", "金融理财", "二手交易", "求职招聘", "办公工具", "职业规划", "热点与政策", "社区互动", "其他" ];
const SETTINGS_DEFAULTS = {
theme: 'auto',
notion_api_key: '',
database_id: '',
ai_provider: 'openai',
ai_api_key: '',
ai_api_url: 'https://api.openai.com/v1/chat/completions',
ai_model: 'gpt-3.5-turbo',
ai_enabled: true,
ai_include_body: false,
ai_timeout: 20000,
ai_model_fetch_timeout: 10000, // 新增: 独立的模型列表获取超时
proxy_enabled: false,
proxy_url: '',
user_categories: JSON.stringify(DEFAULT_CATEGORIES),
prop_name_title: '名称',
prop_name_url: '链接',
prop_name_category: '类型',
read_later_enabled: true,
read_later_category: '稍后读',
ai_prompt: `你是一个专精于网页内容分类的AI模型。你的唯一任务是遵循下述协议,分析网页信息,并从一个预设的分类列表中,选择唯一且最匹配的一个分类。
# 分析层级与协议 (Analysis Hierarchy & Protocol)
你必须严格按照以下优先级顺序获取和分析信息。高优先级方法成功后,低优先级信息仅用作辅助验证。
1. **最高优先级:实时网页分析 (Live Webpage Analysis)**
* **行动指令:** 如果你的能力允许,首先尝试直接访问并完整分析 Page URL 指向的实时网页内容。
* **基本原理:** 实时网页是最权威、最准确的信息源。它的内容、结构和互动元素能最完整地反映页面的真实用途。
2. **次高优先级:静态元数据分析 (Static Metadata Analysis)**
* **触发条件:** 当且仅当你无法访问实时网页(例如,技术限制、访问错误、防火墙)时,启用此层级。
* **行动指令:** 详细分析提供的网页标题 (Page Title) 和 元描述 (Meta Description)。这两个字段是网站所有者设定的核心摘要。
3. **最低优先级:辅助正文参考 (Supplementary Body Text)**
* **触发条件:** 仅在前序层级(实时网页或静态元数据)分析后,分类结果依然高度模糊、难以抉择时,才可使用此信息。
* **行动指令:** 将提供的{page_body_text}作为最后的补充线索。
* **注意:** 此文本可能是不完整或过时的快照,其权重远低于实时网页内容和核心元数据。
# 分类决策原则
在获得信息后,运用以下原则进行最终决策:
- **选择最具体的:** 如果页面符合一个宽泛分类(如“社区互动”)和一个具体分类(如“前端开发”),优先选择更具体的那个。
- **识别核心用途:** 对于多主题页面(如门户网站首页),判断其最核心、最主要的功能或主题进行分类。
- **识别平台/服务核心价值:** 对于像 GitHub, YouTube, Amazon 这样的平台型网站,应根据其核心服务来分类(例如,GitHub -> "后端开发" 或 "办公工具",YouTube -> "影视",Amazon -> "生活服务"),而不是其首页上可能出现的具体内容。
- **选择最佳匹配:** 必须从下方列表中选择。即使没有完美的选项,也要选择最接近的一个。
# 预设分类列表
{categories}
# 输出格式规定
- **【绝对必须】** 只输出最终的分类名称,且该名称必须完整存在于“预设分类列表”中。
- **【绝对禁止】** 禁止添加任何形式的解释、说明、标点符号(如 "", 「」)、Markdown标记(如 *, \`\`)或其他任何多余字符。
# 任务开始
根据下面提供的信息,并严格遵守上述所有协议、原则和规则,给出你的分类结果。
## 网页信息:
{page_metadata}{optional_body_section}`
};
const LOG_LIMIT = 100;
// ===================================================================
// ================= CSP TrustedHTML 处理器 ==========================
// ===================================================================
let nqsPolicy;
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
nqsPolicy = window.trustedTypes.createPolicy('nqs-policy', { createHTML: string => string });
} catch (e) {
nqsPolicy = window.trustedTypes.defaultPolicy || window.trustedTypes.policies.get("nqs-policy");
}
}
function createSafeHTML(htmlString) { return nqsPolicy ? nqsPolicy.createHTML(htmlString) : htmlString; }
function setSafeInnerHTML(element, htmlString) { element.innerHTML = createSafeHTML(htmlString); }
// ===================================================================
// ====================== 核心功能与辅助函数 =======================
// ===================================================================
function getHighSignalPageData(doc) { const url = doc.location.href; const title = doc.title; const description = (doc.querySelector('meta[name="description"]') || {}).content || '无'; const keywords = (doc.querySelector('meta[name="keywords"]') || {}).content || '无'; return `Page URL: ${url}\nPage Title: ${title}\nMeta Description: ${description.trim()}\nMeta Keywords: ${keywords.trim()}`; }
function extractMainContent(doc) { try { const main = doc.querySelector('main'); if (main) return main.innerText; const articles = doc.querySelectorAll('article'); if (articles.length > 0) return Array.from(articles).map(el => el.innerText).join('\n\n'); const clonedBody = doc.body.cloneNode(true); clonedBody.querySelectorAll('nav, footer, header, aside, .sidebar, #sidebar, [role="navigation"], [role="banner"], [role="contentinfo"], .ad, #ad, .advertisement').forEach(el => el.remove()); const cleanedText = clonedBody.innerText; return (cleanedText && cleanedText.trim().length > 100) ? cleanedText : doc.body.innerText; } catch (error) { console.error("NQS - 智能内容提取失败:", error); return doc.body.innerText; } }
async function addLog(level, message, details = {}) { let logs = []; try { logs = JSON.parse(await GM_getValue('nqs_logs', '[]') || '[]'); } catch (e) { console.error("NQS - 解析本地日志失败:", e); logs = []; } logs.unshift({ timestamp: Date.now(), level, message, details }); if (logs.length > LOG_LIMIT) logs.splice(LOG_LIMIT); await GM_setValue('nqs_logs', JSON.stringify(logs)); }
async function loadAllSettings() { const settings = {}; for (const key of Object.keys(SETTINGS_DEFAULTS)) { settings[key] = await GM_getValue(key, SETTINGS_DEFAULTS[key]); } return settings; }
function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }
async function applyTheme() { const theme = await GM_getValue('theme', SETTINGS_DEFAULTS.theme); const container = document.getElementById('NQS_GLOBAL_CONTAINER'); if(container) container.dataset.theme = theme; }
// ===================================================================
// ===================== UI创建与管理 (CSP-Ready) =====================
// ===================================================================
function closeAllNQSPopups() { const container = document.getElementById('NQS_GLOBAL_CONTAINER'); if (container) container.querySelectorAll('.nqs-overlay').forEach(el => el.remove()); }
function createBasePanel(title, subtitle = '', options = {}) {
injectStyles();
const container = document.getElementById('NQS_GLOBAL_CONTAINER');
const overlay = document.createElement('div');
overlay.className = 'nqs-overlay';
if (options.panelId) overlay.id = options.panelId;
if (options.isNested) {
const existingOverlays = container.querySelectorAll('.nqs-overlay');
let topZ = 100000;
if (existingOverlays.length > 0) {
const zIndexes = Array.from(existingOverlays).map(el => parseInt(window.getComputedStyle(el).zIndex, 10));
const maxZ = Math.max(...zIndexes.filter(z => !isNaN(z)));
if (isFinite(maxZ)) topZ = maxZ;
}
overlay.style.zIndex = topZ + 1;
}
const panel = document.createElement('div');
panel.className = `nqs-panel ${options.panelClass || ''}`;
if (options.maxWidth) panel.style.maxWidth = options.maxWidth;
const header = document.createElement('div');
header.className = 'nqs-header';
const h1 = document.createElement('h1');
h1.textContent = title;
header.appendChild(h1);
if (subtitle) {
const p = document.createElement('p');
p.textContent = subtitle;
header.appendChild(p);
}
const body = document.createElement('div');
body.className = 'nqs-body';
const footer = document.createElement('div');
footer.className = 'nqs-footer';
panel.appendChild(header);
panel.appendChild(body);
panel.appendChild(footer);
overlay.appendChild(panel);
container.appendChild(overlay);
const close = () => { overlay.classList.remove('visible'); setTimeout(() => overlay.remove(), 300); };
panel.addEventListener('click', e => e.stopPropagation());
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
setTimeout(() => overlay.classList.add('visible'), 10);
return { overlay, body, footer, close };
}
function highlightJson(jsonString) { if (typeof jsonString !== 'string') jsonString = JSON.stringify(jsonString, undefined, 2); jsonString = jsonString.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); return jsonString.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { let cls = 'json-number'; if (/^"/.test(match)) { if (/:$/.test(match)) { cls = 'json-key'; } else { cls = 'json-string'; } } else if (/true|false/.test(match)) { cls = 'json-boolean'; } else if (/null/.test(match)) { cls = 'json-null'; } return '<span class="' + cls + '">' + match + '</span>'; });}
function showAlertModal(title, message) { return new Promise((resolve) => { const { body, footer, close } = createBasePanel(title, '', { maxWidth: '480px', isNested: true }); setSafeInnerHTML(body, `<p class="nqs-p">${message}</p>`); setSafeInnerHTML(footer, `<div style="flex-grow: 1;"></div><button id="nqs-alert-ok" class="nqs-button nqs-button-primary">确定</button>`); footer.querySelector('#nqs-alert-ok').onclick = () => { close(); resolve(); }; });}
function showConfirmationModal(title, message, options = {}) { return new Promise((resolve) => { const { body, footer, close } = createBasePanel(title, '', { maxWidth: '480px', isNested: true }); const { danger = false, confirmText = '确认', cancelText = '取消' } = options; setSafeInnerHTML(body, `<p class="nqs-p">${message}</p>`); const confirmClass = danger ? 'nqs-button-danger' : 'nqs-button-primary'; setSafeInnerHTML(footer, `<button id="nqs-confirm-cancel" class="nqs-button nqs-button-secondary">${cancelText}</button><div style="flex-grow: 1;"></div><button id="nqs-confirm-ok" class="nqs-button ${confirmClass}">${confirmText}</button>`); footer.querySelector('#nqs-confirm-ok').onclick = () => { close(); resolve(true); }; footer.querySelector('#nqs-confirm-cancel').onclick = () => { close(); resolve(false); }; });}
function showDetailsModal(title, detailsObject) { const { body, footer, close } = createBasePanel(title, '上下文详细信息', { maxWidth: '800px', isNested: true }); body.classList.add('nqs-json-viewer'); const pre = document.createElement('pre'); pre.style.whiteSpace = 'pre-wrap'; pre.style.wordWrap = 'break-word'; pre.style.margin = '0'; setSafeInnerHTML(pre, highlightJson(detailsObject)); body.appendChild(pre); setSafeInnerHTML(footer, `<div style="flex-grow: 1;"></div><button class="nqs-button nqs-button-primary">关闭</button>`); footer.querySelector('button').addEventListener('click', close);}
async function openSettingsPanel() {
closeAllNQSPopups();
const { body, footer, close } = createBasePanel('脚本设置', '在这里配置您的 Notion AI Quick Saver,实现高效网页收藏');
const elements = {};
const createSection = (title) => {
const section = document.createElement('div');
section.className = 'nqs-section';
const h2 = document.createElement('h2');
h2.className = 'nqs-section-title';
h2.textContent = title;
section.appendChild(h2);
body.appendChild(section);
return section;
};
const createField = (parent, id, labelText, descText, fullWidth = false) => {
const field = document.createElement('div');
field.className = 'nqs-field' + (fullWidth ? ' nqs-field-full' : '');
const labelGroup = document.createElement('div');
labelGroup.className = 'nqs-label-group';
const label = document.createElement('label');
label.htmlFor = 'nqs-' + id;
label.textContent = labelText;
labelGroup.appendChild(label);
if (descText) {
const p = document.createElement('p');
p.className = 'nqs-p';
p.innerHTML = createSafeHTML(descText); // Use innerHTML for potential links
labelGroup.appendChild(p);
}
field.appendChild(labelGroup);
parent.appendChild(field);
return field;
};
const createInput = (id, type) => {
const input = document.createElement('input');
input.id = 'nqs-' + id;
input.className = 'nqs-input';
input.type = type;
elements[id] = input;
return input;
};
const createToggle = (id) => {
const wrapper = document.createElement('div');
wrapper.className = 'nqs-toggle-switch';
const label = document.createElement('label');
label.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.id = 'nqs-' + id;
elements[id] = input;
const span = document.createElement('span');
span.className = 'slider';
label.appendChild(input);
label.appendChild(span);
wrapper.appendChild(label);
return wrapper;
};
// 1. 外观设置
const appearanceSection = createSection('外观设置');
const themeField = createField(appearanceSection, 'theme', '主题模式', '选择UI的显示主题,"自动"将跟随系统设置。');
const themeSelect = document.createElement('select');
themeSelect.id = 'nqs-theme';
themeSelect.className = 'nqs-input';
['auto', 'light', 'dark'].forEach(val => { const opt = document.createElement('option'); opt.value = val; opt.textContent = {auto: '自动', light: '明亮', dark: '黑暗'}[val]; themeSelect.appendChild(opt); });
elements['theme'] = themeSelect;
themeField.appendChild(themeSelect);
// 2. 核心设置
const coreSection = createSection('核心设置');
const aiEnabledField = createField(coreSection, 'ai_enabled', 'AI 自动分类', '开启后将自动判断分类,关闭则需要手动选择');
aiEnabledField.appendChild(createToggle('ai_enabled'));
// 3. 模块功能
const moduleSection = createSection('模块功能');
const readLaterField = createField(moduleSection, 'read_later_enabled', '“稍后读”功能', '开启后将显示一个独立的“稍后读”快捷按钮');
readLaterField.appendChild(createToggle('read_later_enabled'));
const readLaterCatField = createField(moduleSection, 'read_later_category', '“稍后读”分类名', '指定用于“稍后读”功能的分类名称');
readLaterCatField.appendChild(createInput('read_later_category', 'text'));
// 4. 分类管理
const categorySection = createSection('分类管理');
const newCatField = createField(categorySection, 'new-category', '添加新分类', '此列表将同时用于手动选择和AI判断', true);
const catManager = document.createElement('div');
catManager.className = 'nqs-category-manager';
const newCatInput = document.createElement('input');
newCatInput.id = 'nqs-new-category'; newCatInput.className = 'nqs-input'; newCatInput.placeholder = '输入新分类名称...';
const addCatBtn = document.createElement('button');
addCatBtn.id = 'nqs-add-category'; addCatBtn.className = 'nqs-button nqs-button-primary'; addCatBtn.textContent = '添加';
catManager.appendChild(newCatInput);
catManager.appendChild(addCatBtn);
newCatField.appendChild(catManager);
const categoryListEl = document.createElement('ul');
categoryListEl.id = 'nqs-category-list';
categorySection.appendChild(categoryListEl);
// 5. Notion 配置
const notionSection = createSection('Notion 配置 (重要)');
const notionKeyField = createField(notionSection, 'notion_api_key', 'Notion API Key', '请填入您的Notion Internal Integration Token');
notionKeyField.appendChild(createInput('notion_api_key', 'password'));
const dbIdField = createField(notionSection, 'database_id', '数据库ID', '请填入您要保存到的Notion数据库ID');
dbIdField.appendChild(createInput('database_id', 'text'));
const subTitle = document.createElement('h3');
subTitle.className = 'nqs-subsection-title'; subTitle.textContent = '数据库字段名称'; notionSection.appendChild(subTitle);
const subP = document.createElement('p');
subP.className = 'nqs-subsection-p'; subP.textContent = '请确保以下名称与您Notion数据库中的属性列名完全一致。'; notionSection.appendChild(subP);
const titlePropField = createField(notionSection, 'prop_name_title', '标题属性名', '');
titlePropField.appendChild(createInput('prop_name_title', 'text'));
const urlPropField = createField(notionSection, 'prop_name_url', '链接属性名', '');
urlPropField.appendChild(createInput('prop_name_url', 'text'));
const catPropField = createField(notionSection, 'prop_name_category', '分类属性名', '');
catPropField.appendChild(createInput('prop_name_category', 'text'));
// 6. AI 配置
const aiSection = createSection('AI 配置');
aiSection.id = 'nqs-ai-section';
const aiIncludeBodyField = createField(aiSection, 'ai_include_body', '附加网页正文', '开启后会提取并发送部分正文,可能增加成本但有助于分析复杂页面');
aiIncludeBodyField.appendChild(createToggle('ai_include_body'));
const aiTimeoutField = createField(aiSection, 'ai_timeout', 'AI分析超时(ms)', 'AI进行内容分析请求的等待上限');
aiTimeoutField.appendChild(createInput('ai_timeout', 'number'));
const aiModelFetchTimeoutField = createField(aiSection, 'ai_model_fetch_timeout', '获取模型超时(ms)', '获取可用模型列表的等待上限');
aiModelFetchTimeoutField.appendChild(createInput('ai_model_fetch_timeout', 'number'));
const aiProviderField = createField(aiSection, 'ai_provider', 'AI 提供商', '选择用于内容分类的 AI 服务');
const providerSelect = document.createElement('select');
providerSelect.id = 'nqs-ai_provider'; providerSelect.className = 'nqs-input';
[{v:'openai', t:'OpenAI'}, {v:'gemini', t:'Google Gemini'}].forEach(p => { const opt = document.createElement('option'); opt.value = p.v; opt.textContent = p.t; providerSelect.appendChild(opt); });
elements['ai_provider'] = providerSelect;
aiProviderField.appendChild(providerSelect);
const aiAPIKeyField = createField(aiSection, 'ai_api_key', 'AI API Key', '填入所选提供商的 API Key');
aiAPIKeyField.appendChild(createInput('ai_api_key', 'password'));
const aiApiUrlField = createField(aiSection, 'ai_api_url', 'AI API Endpoint', '兼容 OpenAI 格式的 API 地址,Gemini 可留空');
aiApiUrlField.appendChild(createInput('ai_api_url', 'text'));
const aiModelField = createField(aiSection, 'ai_model', 'AI 模型名称', '点击右侧刷新图标获取可用模型列表');
const modelSelectorWrapper = document.createElement('div');
modelSelectorWrapper.className = 'nqs-model-selector-wrapper';
const aiModelInput = createInput('ai_model', 'text');
aiModelInput.autocomplete = 'off'; modelSelectorWrapper.appendChild(aiModelInput);
const customDropdown = document.createElement('div');
customDropdown.className = 'nqs-custom-dropdown'; modelSelectorWrapper.appendChild(customDropdown);
const fetchModelsBtn = document.createElement('button');
fetchModelsBtn.id = 'nqs-fetch-models-btn'; fetchModelsBtn.className = 'nqs-icon-button'; fetchModelsBtn.title = '获取可用模型列表';
setSafeInnerHTML(fetchModelsBtn, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm-1.293-2.707a1 1 0 0 1-1.414-1.414l.001-.001 2.122-2.121a1 1 0 0 1 1.414 0l2.121 2.121a1 1 0 0 1-1.414 1.414L13 16.414V20a1 1 0 1 1-2 0v-3.586l-.293.293zM13 4a1 1 0 1 1 2 0v3.586l.293-.293a1 1 0 1 1 1.414 1.414l-2.121 2.121a1 1 0 0 1-1.414 0L10.05 8.707a1 1 0 0 1 1.414-1.414L11.76 7.586 11 7.586V4h2z"></path></svg>`);
modelSelectorWrapper.appendChild(fetchModelsBtn);
aiModelField.appendChild(modelSelectorWrapper);
const aiPromptField = createField(aiSection, 'ai_prompt', 'AI System Prompt', '高级用户可自定义指令模板', true);
const promptHeader = document.createElement('div');
promptHeader.style.textAlign = 'right'; promptHeader.style.marginTop = '0.5rem';
const resetPromptBtn = document.createElement('button');
resetPromptBtn.id = 'nqs-reset-prompt'; resetPromptBtn.className = 'nqs-button nqs-button-text'; resetPromptBtn.textContent = '恢复默认';
promptHeader.appendChild(resetPromptBtn); aiPromptField.appendChild(promptHeader);
const aiPromptTextarea = document.createElement('textarea');
aiPromptTextarea.id = 'nqs-ai_prompt'; aiPromptTextarea.className = 'nqs-textarea';
elements['ai_prompt'] = aiPromptTextarea;
aiPromptField.appendChild(aiPromptTextarea);
// 7. 自定义网络端点 (重命名并优化说明)
const networkSection = createSection('自定义网络端点/代理 (高级)');
const proxyEnabledField = createField(networkSection, 'proxy_enabled', '启用自定义端点', '<b>系统代理无需配置:</b>如果已设置系统或浏览器代理,脚本请求会自动通过,无需开启此项。<br><b>启用场景:</b>当需要使用第三方中继服务器或反代地址来访问AI服务时,请开启此项。');
proxyEnabledField.appendChild(createToggle('proxy_enabled'));
const proxyUrlField = createField(networkSection, 'proxy_url', '端点/代理服务器地址', '一个兼容目标AI提供商API格式的请求中继地址。脚本会将请求完整转发到此地址。', true);
proxyUrlField.appendChild(createInput('proxy_url', 'text'));
proxyUrlField.id = 'nqs-proxy-url-field';
// 8. Footer Buttons
const cancelButton = document.createElement('button');
cancelButton.id = 'nqs-close'; cancelButton.className = 'nqs-button nqs-button-secondary'; cancelButton.textContent = '取消';
const spacer = document.createElement('div');
spacer.style.flexGrow = '1';
const saveButton = document.createElement('button');
saveButton.id = 'nqs-save'; saveButton.className = 'nqs-button nqs-button-primary'; saveButton.textContent = '保存设置';
footer.append(cancelButton, spacer, saveButton);
// --- 逻辑与事件绑定 ---
let currentCategories = JSON.parse(await GM_getValue('user_categories', SETTINGS_DEFAULTS.user_categories));
const toggleAISectionVisibility = () => { aiSection.style.display = elements['ai_enabled'].checked ? 'block' : 'none'; networkSection.style.display = elements['ai_enabled'].checked ? 'block' : 'none'; };
elements['ai_enabled'].addEventListener('change', toggleAISectionVisibility);
const toggleProxyUrlVisibility = () => { proxyUrlField.style.display = elements['proxy_enabled'].checked ? 'grid' : 'none'; };
elements['proxy_enabled'].addEventListener('change', toggleProxyUrlVisibility);
const updateProviderSpecificUI = () => {
const provider = providerSelect.value;
if (provider === 'gemini') {
elements.ai_api_url.placeholder = '通常留空,会自动使用谷歌官方地址';
elements.ai_model.placeholder = '例如: gemini-1.5-flash-latest';
if (elements.ai_api_url.value.includes('openai.com')) { elements.ai_api_url.value = ''; }
} else { // openai
elements.ai_api_url.placeholder = '例如: https://api.openai.com/v1/chat/completions';
elements.ai_model.placeholder = '例如: gpt-3.5-turbo';
if (!elements.ai_api_url.value) { elements.ai_api_url.value = SETTINGS_DEFAULTS.ai_api_url; }
}
};
providerSelect.addEventListener('change', updateProviderSpecificUI);
const renderCategories = () => {
categoryListEl.innerHTML = '';
currentCategories.forEach(cat => { const li = document.createElement('li'); li.textContent = cat; const deleteBtn = document.createElement('span'); deleteBtn.className = 'delete-cat'; deleteBtn.textContent = '×'; deleteBtn.onclick = () => { currentCategories = currentCategories.filter(c => c !== cat); renderCategories(); }; li.appendChild(deleteBtn); categoryListEl.appendChild(li); });
};
const addCategoryAction = () => { const newCat = newCatInput.value.trim(); if (newCat && !currentCategories.includes(newCat)) { currentCategories.unshift(newCat); renderCategories(); newCatInput.value = ''; } };
addCatBtn.addEventListener('click', addCategoryAction);
newCatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addCategoryAction(); } });
resetPromptBtn.addEventListener('click', async () => { if (await showConfirmationModal('恢复默认Prompt', '您当前输入的内容将被覆盖。确定要恢复吗?')) { aiPromptTextarea.value = SETTINGS_DEFAULTS.ai_prompt; } });
let availableModels = [], activeOptionIndex = -1;
const renderDropdown = (filter = '') => {
const filteredModels = availableModels.filter(model => model.toLowerCase().includes(filter.toLowerCase()));
customDropdown.innerHTML = '';
if (filteredModels.length === 0) { customDropdown.classList.remove('is-visible'); return; }
filteredModels.forEach((model, index) => { const item = document.createElement('div'); item.className = 'nqs-dropdown-item'; item.textContent = model; item.dataset.index = index; item.addEventListener('click', () => { aiModelInput.value = model; customDropdown.classList.remove('is-visible'); }); customDropdown.appendChild(item); });
customDropdown.classList.add('is-visible');
activeOptionIndex = -1;
};
const updateActiveOption = (newIndex) => {
const items = customDropdown.querySelectorAll('.nqs-dropdown-item');
if (activeOptionIndex >= 0 && items[activeOptionIndex]) { items[activeOptionIndex].classList.remove('is-active'); }
if (newIndex >= 0 && items[newIndex]) { items[newIndex].classList.add('is-active'); items[newIndex].scrollIntoView({ block: 'nearest' }); activeOptionIndex = newIndex; }
};
fetchModelsBtn.addEventListener('click', async () => {
const provider = elements.ai_provider.value;
const baseUrl = elements.ai_api_url.value;
const apiKey = elements.ai_api_key.value;
const fetchTimeout = elements.ai_model_fetch_timeout.value;
const proxySettings = { enabled: elements.proxy_enabled.checked, url: elements.proxy_url.value };
if (!apiKey) { showAlertModal('操作失败', '请先填写 AI API Key。'); return; }
fetchModelsBtn.classList.add('is-loading'); fetchModelsBtn.disabled = true;
try {
availableModels = await fetchAvailableModels(provider, baseUrl, apiKey, fetchTimeout, proxySettings);
renderDropdown(aiModelInput.value);
await showAlertModal('操作成功', `成功获取 ${availableModels.length} 个可用模型!`);
} catch (error) {
await showAlertModal('操作失败', `无法获取模型列表,请检查您的网络、配置和 API Key 是否正确。\n\n错误详情: ${error.message}`);
} finally {
fetchModelsBtn.classList.remove('is-loading'); fetchModelsBtn.disabled = false;
}
});
aiModelInput.addEventListener('focus', () => { if (availableModels.length > 0) renderDropdown(aiModelInput.value); });
aiModelInput.addEventListener('input', () => { renderDropdown(aiModelInput.value); });
aiModelInput.addEventListener('keydown', (e) => {
const items = customDropdown.querySelectorAll('.nqs-dropdown-item'); if (!customDropdown.classList.contains('is-visible') || items.length === 0) return;
switch(e.key) {
case 'ArrowDown': e.preventDefault(); updateActiveOption(activeOptionIndex < items.length - 1 ? activeOptionIndex + 1 : 0); break;
case 'ArrowUp': e.preventDefault(); updateActiveOption(activeOptionIndex > 0 ? activeOptionIndex - 1 : items.length - 1); break;
case 'Enter': e.preventDefault(); if (activeOptionIndex >= 0 && items[activeOptionIndex]) items[activeOptionIndex].click(); break;
case 'Escape': customDropdown.classList.remove('is-visible'); break;
}
});
document.addEventListener('click', (e) => { if (!modelSelectorWrapper.contains(e.target)) customDropdown.classList.remove('is-visible'); });
for (const key of Object.keys(SETTINGS_DEFAULTS)) { const element = elements[key]; if (element) { const value = await GM_getValue(key, SETTINGS_DEFAULTS[key]); if (element.type === 'checkbox') element.checked = value; else element.value = value; } }
renderCategories(); toggleAISectionVisibility(); toggleProxyUrlVisibility(); updateProviderSpecificUI();
saveButton.addEventListener('click', async () => {
for (const key of Object.keys(SETTINGS_DEFAULTS)) { const element = elements[key]; if (element) { const value = element.type === 'checkbox' ? element.checked : element.value; if (key !== 'user_categories') await GM_setValue(key, value); } }
await GM_setValue('user_categories', JSON.stringify(currentCategories));
await applyTheme();
await showAlertModal('保存成功', '您的设置已成功保存并应用。');
close();
initFloatingButtons();
});
cancelButton.addEventListener('click', close);
}
async function openLogViewerPanel() {
closeAllNQSPopups();
const allLogs = JSON.parse(await GM_getValue('nqs_logs', '[]'));
const { body, footer, close } = createBasePanel('操作日志', `最近 ${allLogs.length} 条保存记录 (上限 ${LOG_LIMIT} 条)`, { maxWidth: '1200px', panelClass: 'nqs-panel--log-viewer' });
const filterContainer = document.createElement('div');
filterContainer.className = 'nqs-log-filter-bar';
setSafeInnerHTML(filterContainer, `<div class="nqs-filter-group"><button class="nqs-filter-btn active" data-filter="all">All</button><button class="nqs-filter-btn" data-filter="info">Info</button><button class="nqs-filter-btn" data-filter="error">Error</button></div><div class="nqs-label-group" style="flex-direction: row; align-items: center; gap: 0.5rem; padding-top:0;"><label for="nqs-show-debug">显示Debug</label><div class="nqs-toggle-switch"><label class="switch"><input type="checkbox" id="nqs-show-debug"><span class="slider"></span></label></div></div>`);
body.appendChild(filterContainer);
const tableContainer = document.createElement('div');
tableContainer.className = 'nqs-table-container';
body.appendChild(tableContainer);
let currentFilter = 'all', showDebug = false;
const rerenderTable = () => {
const filteredLogs = allLogs.filter(log => { const level = (log.level || 'info').toLowerCase(); if (level === 'debug') return showDebug; return currentFilter === 'all' || level === currentFilter || (!log.level && currentFilter === 'info'); });
if (filteredLogs.length === 0) { setSafeInnerHTML(tableContainer, `<p class="nqs-p" style="text-align:center; color:var(--nqs-text-secondary); padding: 2rem 0;">没有符合条件的日志记录</p>`); return; }
setSafeInnerHTML(tableContainer, `<table class="nqs-log-table"><thead><tr><th>时间</th><th>级别</th><th>消息</th><th>上下文</th></tr></thead><tbody>${filteredLogs.map((log) => { const originalIndex = allLogs.indexOf(log); if (log.level) { return `<tr class="nqs-log-row--${log.level}"><td class="nqs-log-cell-time">${new Date(log.timestamp).toLocaleString()}</td><td><span class="nqs-log-tag ${log.level}">${log.level.toUpperCase()}</span></td><td class="nqs-log-cell-message">${log.message}</td><td><button class="nqs-button nqs-button-text" data-log-index="${originalIndex}">查看</button></td></tr>`; } else { return `<tr class="nqs-log-row--info"><td class="nqs-log-cell-time">${new Date(log.timestamp).toLocaleString()}</td><td><span class="nqs-log-tag legacy">LEGACY</span></td><td class="nqs-log-cell-message">[Legacy] Saved '${log.title}' as '${log.result || 'N/A'}'</td><td><button class="nqs-button nqs-button-text" data-log-index="${originalIndex}">查看</button></td></tr>`; } }).join('')}</tbody></table>`);
};
tableContainer.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON' && e.target.dataset.logIndex) { const log = allLogs[e.target.dataset.logIndex]; if (!log) return; showDetailsModal(log.level ? `日志详情 (级别: ${log.level.toUpperCase()})` : `日志详情 (Legacy)`, log.details || log); } });
filterContainer.querySelector('#nqs-show-debug').addEventListener('change', (e) => { showDebug = e.target.checked; rerenderTable(); });
filterContainer.querySelector('.nqs-filter-group').addEventListener('click', (e) => { if (e.target.tagName !== 'BUTTON') return; filterContainer.querySelectorAll('.nqs-filter-btn').forEach(btn => btn.classList.remove('active')); e.target.classList.add('active'); currentFilter = e.target.dataset.filter; rerenderTable(); });
rerenderTable();
setSafeInnerHTML(footer, `<button id="nqs-clear-logs" class="nqs-button nqs-button-danger">清空日志</button><div style="flex-grow: 1;"></div><button id="nqs-close-logs" class="nqs-button nqs-button-secondary">关闭</button>`);
footer.querySelector('#nqs-close-logs').addEventListener('click', close);
footer.querySelector('#nqs-clear-logs').addEventListener('click', async () => { if (await showConfirmationModal('确认清空日志', '此操作不可撤销,您确定要删除所有日志记录吗?', { danger: true, confirmText: '确认清空' })) { await GM_setValue('nqs_logs', '[]'); close(); openLogViewerPanel(); } });
}
async function openCategorySelectorPanel(pageTitle, pageUrl, uiContext) {
closeAllNQSPopups();
const settings = await loadAllSettings();
let categories = JSON.parse(settings.user_categories);
if (settings.read_later_enabled && settings.read_later_category) { categories = categories.filter(c => c !== settings.read_later_category); }
const { body, close } = createBasePanel('选择或创建分类', '', { maxWidth: '560px' });
setSafeInnerHTML(body, `<div id="nqs-selector-list">${categories.map(cat => `<button class="nqs-button nqs-button-secondary" data-cat="${cat}">${cat}</button>`).join('')}</div><div class="nqs-category-manager"><input type="text" class="nqs-input" id="nqs-new-cat-input" placeholder="或输入新分类..."><button id="nqs-save-new-cat" class="nqs-button nqs-button-primary">添加并保存</button></div>`);
const doSave = async (category) => {
if (!category) return; close();
const saveButton = document.getElementById('nqs-save-button'); const originalText = '➤ Notion';
if (uiContext.source === 'fab' && saveButton) showNotification(saveButton, `🚀 保存为: ${category}`, true, originalText); else console.log(`NQS: [Menu] 🚀 即将保存为: ${category}`);
try {
const notionResponse = await saveToNotion(settings.notion_api_key, settings.database_id, pageTitle, pageUrl, category, settings);
await addLog('info', `页面已手动保存 (${uiContext.source})`, { title: pageTitle, url: pageUrl, result: category });
if (uiContext.source === 'fab' && saveButton) showNotification(saveButton, notionResponse, false, originalText); else console.log(`NQS: [Menu] ${notionResponse}`);
} catch (error) {
if (uiContext.source === 'fab' && saveButton) showNotification(saveButton, error.message, false, originalText); else console.error(`NQS: [Menu] 保存失败!错误信息: ${error.message}`);
await addLog('error', '手动保存至Notion失败', { title: pageTitle, url: pageUrl, category: category, error: error.message, stack: error.stack });
}
};
body.querySelector('#nqs-selector-list').addEventListener('click', e => { if (e.target.tagName === 'BUTTON' && e.target.dataset.cat) doSave(e.target.dataset.cat); });
const newCatInputAction = async () => { const newCat = body.querySelector('#nqs-new-cat-input').value.trim(); if (newCat && !categories.includes(newCat)) { const allCategories = JSON.parse(await GM_getValue('user_categories', SETTINGS_DEFAULTS.user_categories)); allCategories.unshift(newCat); await GM_setValue('user_categories', JSON.stringify(allCategories)); } doSave(newCat); };
body.querySelector('#nqs-save-new-cat').addEventListener('click', newCatInputAction);
body.querySelector('#nqs-new-cat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); newCatInputAction(); } });
}
// ===================================================================
// ====================== API 调用与主逻辑 =========================
// ===================================================================
function determineCategoryAI(settings, pageMetadata, pageBodyText) {
return new Promise((resolve, reject) => {
const { ai_provider, ai_api_key, ai_api_url, ai_model, ai_prompt, user_categories, ai_include_body, ai_timeout, read_later_enabled, read_later_category, proxy_enabled, proxy_url } = settings;
if (!ai_model) return reject(new Error("AI Model 未在设置中指定"));
if (!ai_api_key) return reject(new Error("AI API Key 未在设置中指定"));
let categories = JSON.parse(user_categories);
if (read_later_enabled && read_later_category) { categories = categories.filter(c => c !== read_later_category); }
const bodySectionTemplate = `\n\n## (可选) 辅助正文快照:\n"""\n{page_body_text}\n"""`;
const optionalBodySection = ai_include_body ? bodySectionTemplate.replace('{page_body_text}', pageBodyText) : "N/A";
const finalPrompt = ai_prompt.replace('{categories}', JSON.stringify(categories)).replace('{page_metadata}', pageMetadata).replace('{optional_body_section}', optionalBodySection);
addLog('debug', '发送请求至AI', { provider: ai_provider, model: ai_model, prompt: finalPrompt });
let requestDetails = { method: 'POST', url: '', headers: { 'Content-Type': 'application/json' }, data: '', timeout: parseInt(ai_timeout, 10) || 20000, ontimeout: () => reject(new Error("AI分析超时")), onload: null, onerror: () => reject(new Error('AI网络请求失败')) };
if (ai_provider === 'gemini') {
const geminiUrl = `https://generativelanguage.googleapis.com/v1/models/${ai_model}:generateContent?key=${ai_api_key}`;
requestDetails.url = (proxy_enabled && proxy_url) ? proxy_url : geminiUrl;
if (proxy_enabled && proxy_url) { requestDetails.headers['Authorization'] = `Bearer ${ai_api_key}`; }
requestDetails.data = JSON.stringify({ contents: [{ parts: [{ text: finalPrompt }] }], generationConfig: { temperature: 0.0, maxOutputTokens: 50 }, safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" },{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE" },{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE" }] });
requestDetails.onload = (response) => {
if (response.status >= 200 && response.status < 300) {
try {
const result = JSON.parse(response.responseText);
if (!result.candidates || result.candidates.length === 0) { if (result.promptFeedback && result.promptFeedback.blockReason) { throw new Error(`AI请求被拒绝: ${result.promptFeedback.blockReason}`); } throw new Error("AI响应中缺少 'candidates' 字段"); }
const rawResponse = (result.candidates[0]?.content?.parts[0]?.text || "").trim().replace(/["'「」`*]/g, '');
if (!rawResponse) throw new Error("AI返回了空响应。");
const finalCategory = categories.includes(rawResponse) ? rawResponse : "其他";
if (finalCategory === "其他") console.warn("NQS - AI返回意外分类,回退至'其他'. Raw:", rawResponse);
resolve({ category: finalCategory, rawResponse, fullApiResponse: result });
} catch (e) { reject(new Error(`解析AI响应失败: ${e.message}`)); }
} else { reject(new Error(`AI接口错误: ${response.status} ${response.statusText}. 响应: ${response.responseText}`)); }
};
} else { // OpenAI
requestDetails.url = (proxy_enabled && proxy_url) ? proxy_url : ai_api_url;
requestDetails.headers['Authorization'] = `Bearer ${ai_api_key}`;
requestDetails.data = JSON.stringify({ model: ai_model, messages: [{ role: 'user', content: finalPrompt }], temperature: 0.0, max_tokens: 30 });
requestDetails.onload = (response) => {
if (response.status >= 200 && response.status < 300) {
try {
const result = JSON.parse(response.responseText);
if (!result.choices || result.choices.length === 0) throw new Error("AI响应中缺少 'choices' 字段");
const rawResponse = (result.choices[0].message.content || "").trim().replace(/["'「」]/g, '');
if (!rawResponse) throw new Error("AI返回了空响应。");
const finalCategory = categories.includes(rawResponse) ? rawResponse : "其他";
if (finalCategory === "其他") console.warn("NQS - AI返回意外分类,回退至'其他'. Raw:", rawResponse);
resolve({ category: finalCategory, rawResponse, fullApiResponse: result });
} catch (e) { reject(new Error(`解析AI响应失败: ${e.message}`)); }
} else { reject(new Error(`AI接口错误: ${response.status} ${response.statusText}. 响应: ${response.responseText}`)); }
};
}
GM_xmlhttpRequest(requestDetails);
});
}
function saveToNotion(notionKey, dbId, title, url, category, settings) { return new Promise((resolve, reject) => { if (!notionKey || !dbId) return reject(new Error("Notion Key或DB ID未在设置中指定")); const properties = { [settings.prop_name_title]: { title: [{ text: { content: title } }] }, [settings.prop_name_url]: { url: url }, [settings.prop_name_category]: { select: { name: category } } }; GM_xmlhttpRequest({ method: 'POST', url: 'https://api.notion.com/v1/pages', headers: { 'Authorization': `Bearer ${notionKey}`, 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28' }, data: JSON.stringify({ parent: { database_id: dbId }, properties }), onload: (response) => { if (response.status >= 200 && response.status < 300) { resolve('✅ 成功保存到 Notion!'); } else { try { const error = JSON.parse(response.responseText); console.error('NQS - Notion API Error:', error); reject(new Error(`Notion保存失败: ${error.message}`)); } catch (e) { reject(new Error(`Notion保存失败: ${response.status} ${response.statusText}. 响应: ${response.responseText}`)); } } }, onerror: () => reject(new Error('❌ Notion网络请求失败!')) }); }); }
function fetchAvailableModels(provider, baseUrl, apiKey, timeout, proxySettings) {
return new Promise((resolve, reject) => {
let finalUrl, headers = { 'Content-Type': 'application/json' };
if (proxySettings.enabled && proxySettings.url) {
if (provider === 'gemini') {
finalUrl = `${proxySettings.url}`;
if (!finalUrl.endsWith('/models')) finalUrl += (finalUrl.endsWith('/') ? 'v1beta/models' : '/v1beta/models');
} else { // openai
finalUrl = `${proxySettings.url}`;
if (!finalUrl.endsWith('/models')) finalUrl += (finalUrl.endsWith('/') ? 'models' : '/models');
}
headers['Authorization'] = `Bearer ${apiKey}`;
} else {
if (provider === 'gemini') {
finalUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`;
} else { // openai
if (!baseUrl) return reject(new Error("未提供 OpenAI API Endpoint"));
try { const tempUrl = new URL(baseUrl); const pathParts = tempUrl.pathname.split('/').filter(Boolean); const v1Index = pathParts.indexOf('v1'); if (v1Index === -1) { finalUrl = `${tempUrl.origin}/v1/models`; } else { const basePath = pathParts.slice(0, v1Index + 1).join('/'); finalUrl = `${tempUrl.origin}/${basePath}/models`; }
} catch (e) { return reject(new Error(`无效的 Endpoint URL: ${e.message}`)); }
headers['Authorization'] = `Bearer ${apiKey}`;
}
}
GM_xmlhttpRequest({
method: 'GET', url: finalUrl, headers: headers, timeout: parseInt(timeout, 10) || 10000,
ontimeout: () => reject(new Error('获取模型列表超时')),
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
try {
const result = JSON.parse(response.responseText); let modelIds;
if (provider === 'gemini') { if (!result.models || !Array.isArray(result.models)) throw new Error("API响应格式不正确,缺少 'models' 数组。"); modelIds = result.models.filter(m => m.supportedGenerationMethods.includes('generateContent')).map(m => m.name.replace('models/', '')).sort();
} else { if (!result.data || !Array.isArray(result.data)) throw new Error("API响应格式不正确,缺少 'data' 数组。"); modelIds = result.data.map(model => model.id).sort(); }
resolve(modelIds);
} catch (e) { reject(new Error(`解析模型列表响应失败: ${e.message}`)); }
} else { reject(new Error(`API错误: ${response.status} ${response.statusText}. 响应: ${response.responseText}`)); }
},
onerror: (e) => reject(new Error('网络请求失败,请检查网络或浏览器控制台。'))
});
});
}
// ===================================================================
// ====================== 统一的保存逻辑 (Refactored) ===============
// ===================================================================
async function runAiSave(settings, pageTitle, pageUrl, uiContext) { const saveButton = uiContext.buttonElement; const originalText = '➤ Notion'; if (uiContext.source === 'fab') showNotification(saveButton, '🧠 AI 分类中...', true, originalText); else console.log('NQS: [Menu] 🧠 AI 分类中...'); const pageMetadata = getHighSignalPageData(document); const pageBodyText = settings.ai_include_body ? extractMainContent(document).substring(0, 4000) : "N/A"; try { const { category } = await determineCategoryAI(settings, pageMetadata, pageBodyText); if (uiContext.source === 'fab') showNotification(saveButton, `🚀 保存为: ${category}`, true, originalText); else console.log(`NQS: [Menu] 🚀 即将保存为: ${category}`); const notionResponse = await saveToNotion(settings.notion_api_key, settings.database_id, pageTitle, pageUrl, category, settings); await addLog('info', `页面已通过AI保存 (${uiContext.source})`, { title: pageTitle, url: pageUrl, result: category, provider: settings.ai_provider }); if (uiContext.source === 'fab') showNotification(saveButton, notionResponse, false, originalText); else console.log(`NQS: [Menu] ${notionResponse}`); } catch(error) { throw error; } }
async function startSaveProcess(uiContext) { const pageTitle = document.title; const pageUrl = window.location.href; const saveButton = uiContext.buttonElement; try { if (uiContext.source === 'fab') showNotification(saveButton, '⚙️ 读取中...', true, '➤ Notion'); else console.log('NQS: [Menu] 开始处理 "保存"...'); const settings = await loadAllSettings(); if (!settings.notion_api_key || !settings.database_id) { throw new Error('❌ 配置不完整!请在“设置”中填写 Notion API Key 和数据库ID。'); } if (settings.ai_enabled) { await runAiSave(settings, pageTitle, pageUrl, uiContext); } else { if (uiContext.source === 'fab') showNotification(saveButton, '📂 手动选择', false, '➤ Notion'); else console.log('NQS: [Menu] AI已关闭,正在打开分类选择器...'); await openCategorySelectorPanel(pageTitle, pageUrl, uiContext); } } catch (error) { if (uiContext.source === 'fab') showNotification(saveButton, error.message, false, '➤ Notion'); else console.error(`NQS: [Menu] 保存失败!错误信息: ${error.message}`); await addLog('error', `保存操作失败 (${uiContext.source}): ${error.message}`, { title: pageTitle, url: pageUrl, error: error.message, stack: error.stack }); if (error.message.includes('配置')) setTimeout(openSettingsPanel, 1000); } }
async function startReadLaterSave(uiContext) { const pageTitle = document.title; const pageUrl = window.location.href; const readLaterButton = uiContext.buttonElement; const originalText = '◷ 稍后读'; try { if (uiContext.source === 'fab') showNotification(readLaterButton, '⚙️ 读取中...', true, originalText); else console.log('NQS: [Menu] 开始处理 "保存为稍后读"...'); const settings = await loadAllSettings(); if (!settings.notion_api_key || !settings.database_id) throw new Error('❌ 配置不完整!'); if (!settings.read_later_enabled) throw new Error('💡 “稍后读”功能未开启。'); if (!settings.read_later_category) throw new Error('❌ 未设置“稍后读”分类名称。'); const category = settings.read_later_category; if (uiContext.source === 'fab') showNotification(readLaterButton, `🚀 保存为: ${category}`, true, originalText); else console.log(`NQS: [Menu] 🚀 即将保存为: ${category}`); const notionResponse = await saveToNotion(settings.notion_api_key, settings.database_id, pageTitle, pageUrl, category, settings); await addLog('info', `页面已存为稍后读 (${uiContext.source})`, { title: pageTitle, url: pageUrl, result: category }); if (uiContext.source === 'fab') showNotification(readLaterButton, notionResponse, false, originalText); else console.log(`NQS: [Menu] ${notionResponse}`); } catch (error) { if (uiContext.source === 'fab') showNotification(readLaterButton, error.message, false, originalText); else console.error(`NQS: [Menu] “稍后读”保存失败!错误信息: ${error.message}`); await addLog('error', `“稍后读”失败 (${uiContext.source}): ${error.message}`, { title: pageTitle, url: pageUrl, error: error.message, stack: error.stack }); if (error.message.includes('配置')) setTimeout(openSettingsPanel, 1000); } }
// ===================================================================
// ====================== 脚本初始化与执行 =======================
// ===================================================================
class FabPositionManager { constructor() { this.position = null; this.storageKey = 'nqs_fab_pos'; } async loadPosition() { try { const rawPos = await GM_getValue(this.storageKey, null); if (typeof rawPos === 'string') { this.position = JSON.parse(rawPos); } else if (rawPos && typeof rawPos === 'object') { this.position = rawPos; } return this.position; } catch (error) { console.warn('Failed to load fab position:', error); return null; } } async savePosition(position) { this.position = position; try { await GM_setValue(this.storageKey, JSON.stringify(position)); } catch (error) { console.error('Failed to save fab position:', error); } } updatePosition(fabElement) { if (!fabElement) return; const rect = fabElement.getBoundingClientRect(); this.position = { topPercent: rect.top / window.innerHeight, leftPercent: rect.left / window.innerWidth, snapped: false }; } }
class FabDragHandler { constructor(fabElement, positionManager) { this.fabElement = fabElement; this.positionManager = positionManager; this.isDragging = false; this.wasDragged = false; this.offset = { x: 0, y: 0 }; this.bindEvents(); } bindEvents() { const trigger = this.fabElement.querySelector('#nqs-fab-trigger'); if (!trigger) return; trigger.addEventListener('mousedown', this.handleDragStart.bind(this)); document.addEventListener('mousemove', this.handleDragMove.bind(this)); document.addEventListener('mouseup', this.handleDragEnd.bind(this)); trigger.addEventListener('click', (e) => { if (this.wasDragged) { e.preventDefault(); e.stopPropagation(); } }); } handleDragStart(e) { this.fabElement.classList.remove('is-expanded'); this.isDragging = true; this.wasDragged = false; this.fabElement.classList.add('is-dragging'); const rect = this.fabElement.getBoundingClientRect(); this.offset.x = e.clientX - rect.left; this.offset.y = e.clientY - rect.top; document.body.style.userSelect = 'none'; } handleDragMove(e) { if (!this.isDragging) return; e.preventDefault(); this.wasDragged = true; const newPosition = this.calculateNewPosition(e); this.applyPosition(newPosition); this.removeSnapState(); fabInstance.updateAlignmentClass(); } calculateNewPosition(e) { let newX = e.clientX - this.offset.x; let newY = e.clientY - this.offset.y; const fabRect = this.fabElement.getBoundingClientRect(); const maxX = window.innerWidth - fabRect.width; const maxY = window.innerHeight - fabRect.height; return { x: Math.max(0, Math.min(newX, maxX)), y: Math.max(0, Math.min(newY, maxY)) }; } applyPosition({ x, y }) { Object.assign(this.fabElement.style, { left: `${x}px`, top: `${y}px`, right: 'auto', bottom: 'auto' }); } removeSnapState() { this.fabElement.classList.remove('snapped-right', 'snapped-left'); } async handleDragEnd() { if (!this.isDragging) return; this.isDragging = false; this.fabElement.classList.remove('is-dragging'); document.body.style.userSelect = 'auto'; setTimeout(() => { this.wasDragged = false; }, 0); const position = this.calculateFinalPosition(); fabInstance.applyPosition(position); await this.positionManager.savePosition(position); } calculateFinalPosition() { const rect = this.fabElement.getBoundingClientRect(); const snapThresholdRight = window.innerWidth * 0.98; const snapThresholdLeft = window.innerWidth * 0.02; if (rect.right > snapThresholdRight) { return { topPercent: rect.top / window.innerHeight, snapped: 'right' }; } else if (rect.left < snapThresholdLeft) { return { topPercent: rect.top / window.innerHeight, snapped: 'left' }; } return { topPercent: rect.top / window.innerHeight, leftPercent: rect.left / window.innerWidth, snapped: false }; } }
class FloatingActionButton { constructor() { this.positionManager = new FabPositionManager(); this.dragHandler = null; this.container = null; this.resizeHandler = debounce(this.handleResize.bind(this), 150); this.leaveTimeoutId = null; } async init() { await this.cleanup(); injectStyles(); await this.createContainer(); await this.setupPosition(); this.setupInteractions(); this.bindResizeHandler(); } async cleanup() { const existingContainer = document.querySelector('#nqs-fab-container'); if (existingContainer) existingContainer.remove(); } async createContainer() { const globalContainer = document.getElementById('NQS_GLOBAL_CONTAINER'); this.container = document.createElement('div'); this.container.id = 'nqs-fab-container'; await this.createButtons(); globalContainer.appendChild(this.container); } async createButtons() { const settings = await loadAllSettings(); const optionsWrapper = document.createElement('div'); optionsWrapper.className = 'nqs-fab-options'; const saveButton = this.createActionButton('nqs-save-button', '➤ Notion'); saveButton.addEventListener('click', (e) => { e.stopPropagation(); this.handleButtonClick(e, 'save', saveButton); }); optionsWrapper.appendChild(saveButton); if (settings.read_later_enabled) { const readLaterButton = this.createActionButton('nqs-read-later-button', '◷ 稍后读'); readLaterButton.addEventListener('click', (e) => { e.stopPropagation(); this.handleButtonClick(e, 'readLater', readLaterButton); }); optionsWrapper.appendChild(readLaterButton); } const triggerButton = document.createElement('div'); triggerButton.id = 'nqs-fab-trigger'; setSafeInnerHTML(triggerButton, '➤'); this.container.appendChild(triggerButton); this.container.appendChild(optionsWrapper); } createActionButton(id, text) { const button = document.createElement('div'); button.id = id; button.className = 'nqs-fab-action-btn'; button.textContent = text; return button; } handleButtonClick(e, action, buttonElement) { (action === 'save' ? startSaveProcess : startReadLaterSave)({ source: 'fab', buttonElement }); } async setupPosition() { const savedPos = await this.positionManager.loadPosition(); if (savedPos) this.applyPosition(savedPos); else this.setDefaultPosition(); } applyPosition(position) { this.container.classList.remove('snapped-right', 'snapped-left'); if (position.snapped === 'right') { Object.assign(this.container.style, { top: `${position.topPercent * window.innerHeight}px`, right: '0px', left: 'auto', bottom: 'auto' }); this.container.classList.add('snapped-right'); } else if (position.snapped === 'left') { Object.assign(this.container.style, { top: `${position.topPercent * window.innerHeight}px`, left: '0px', right: 'auto', bottom: 'auto' }); this.container.classList.add('snapped-left'); } else { Object.assign(this.container.style, { top: `${position.topPercent * window.innerHeight}px`, left: `${position.leftPercent * window.innerWidth}px`, right: 'auto', bottom: 'auto' }); } this.updateAlignmentClass(); } setDefaultPosition() { Object.assign(this.container.style, { right: '30px', bottom: '30px' }); setTimeout(() => { this.positionManager.updatePosition(this.container); this.updateAlignmentClass(); }, 0); } updateAlignmentClass() { if (!this.container) return; const rect = this.container.getBoundingClientRect(); const viewportCenterX = window.innerWidth / 2; const fabCenterX = rect.left + rect.width / 2; if (fabCenterX < viewportCenterX) this.container.classList.add('align-left'); else this.container.classList.remove('align-left'); }
setupInteractions() { this.dragHandler = new FabDragHandler(this.container, this.positionManager); this.container.addEventListener('mouseenter', (event) => { if (this.leaveTimeoutId) { clearTimeout(this.leaveTimeoutId); this.leaveTimeoutId = null; } if (this.dragHandler.isDragging || event.buttons === 1) return; this.container.classList.add('is-expanded'); }); this.container.addEventListener('mouseleave', () => { this.leaveTimeoutId = setTimeout(() => { this.container.classList.remove('is-expanded'); }, 300); }); }
handleResize() { const position = this.positionManager.position; if (!this.container || !position || this.dragHandler?.isDragging) return; if (!position.snapped) { const newX = position.leftPercent * window.innerWidth; this.container.style.left = `${newX}px`; } const newY = position.topPercent * window.innerHeight; this.container.style.top = `${newY}px`; this.updateAlignmentClass(); } bindResizeHandler() { window.addEventListener('resize', this.resizeHandler); } destroy() { window.removeEventListener('resize', this.resizeHandler); this.container?.remove(); }
}
let fabInstance = null;
function injectStyles() { const styleId = 'nqs-custom-ui-styles'; const container = document.getElementById('NQS_GLOBAL_CONTAINER'); if (container.querySelector(`#${styleId}`)) return; const css = `
#NQS_GLOBAL_CONTAINER { all: initial; --nqs-bg: #ffffff; --nqs-bg-subtle: #f8f9fa; --nqs-border: #e9ecef; --nqs-text-primary: #212529; --nqs-text-secondary: #6c757d; --nqs-accent: #007bff; --nqs-danger: #dc3545; --nqs-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
#NQS_GLOBAL_CONTAINER[data-theme='dark'] { --nqs-bg: #1a202c; --nqs-bg-subtle: #2d3748; --nqs-border: #4a5568; --nqs-text-primary: #f7fafc; --nqs-text-secondary: #a0aec0; --nqs-accent: #3182ce; --nqs-danger: #e53e3e; }
@media (prefers-color-scheme: dark) { #NQS_GLOBAL_CONTAINER[data-theme='auto'] { --nqs-bg: #1a202c; --nqs-bg-subtle: #2d3748; --nqs-border: #4a5568; --nqs-text-primary: #f7fafc; --nqs-text-secondary: #a0aec0; --nqs-accent: #3182ce; --nqs-danger: #e53e3e; } }
#NQS_GLOBAL_CONTAINER * { box-sizing: border-box; font-family: var(--nqs-font-family); }
@keyframes nqs-spin { from { transform: translateY(-50%) rotate(0deg); } to { transform: translateY(-50%) rotate(360deg); } }
#NQS_GLOBAL_CONTAINER .nqs-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(30,41,59,.6);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);display:flex;justify-content:center;align-items:center;opacity:0;transition:opacity .3s ease;z-index: 100000;}
#NQS_GLOBAL_CONTAINER .nqs-overlay.visible{opacity:1}
#NQS_GLOBAL_CONTAINER .nqs-panel{width:100%;max-width:720px;background:var(--nqs-bg);border-radius:20px;box-shadow:0 10px 30px rgba(0,0,0,.1);display:flex;flex-direction:column;max-height:90vh;transform:scale(.95);transition:transform .3s ease}
#NQS_GLOBAL_CONTAINER .nqs-overlay.visible .nqs-panel{transform:scale(1)}
#NQS_GLOBAL_CONTAINER .nqs-header{padding:1.5rem 2rem;border-bottom:1px solid var(--nqs-border);flex-shrink:0}
#NQS_GLOBAL_CONTAINER .nqs-header h1{font-size:1.5rem;margin:0;color:var(--nqs-text-primary); font-weight: 600;}
#NQS_GLOBAL_CONTAINER .nqs-header p{font-size:.9rem;margin:.25rem 0 0 0;color:var(--nqs-text-secondary)}
#NQS_GLOBAL_CONTAINER .nqs-body{padding:1.5rem 2rem;overflow-y:auto;flex-grow:1;min-height:0;}
#NQS_GLOBAL_CONTAINER .nqs-panel--log-viewer .nqs-body{padding:0;display:flex;flex-direction:column;}
#NQS_GLOBAL_CONTAINER .nqs-table-container{overflow-y:auto;flex-grow:1;padding: 0 1rem;}
#NQS_GLOBAL_CONTAINER .nqs-log-filter-bar{display:flex;justify-content:space-between;align-items:center;padding:1rem 2rem;flex-shrink:0;border-bottom: 1px solid var(--nqs-border);}
#NQS_GLOBAL_CONTAINER .nqs-filter-group{display:flex;gap:0.5rem;}
#NQS_GLOBAL_CONTAINER .nqs-filter-btn{background-color:#e9ecef;border:1px solid #dee2e6;color:#495057;padding:0.4rem 0.8rem;border-radius:8px;cursor:pointer;font-size:0.85rem;font-weight:600;transition: all 0.2s ease;}
#NQS_GLOBAL_CONTAINER[data-theme='dark'] .nqs-filter-btn { background-color: var(--nqs-bg-subtle); border-color: var(--nqs-border); color: var(--nqs-text-primary); }
#NQS_GLOBAL_CONTAINER .nqs-filter-btn:hover{background-color:#dee2e6;}
#NQS_GLOBAL_CONTAINER[data-theme='dark'] .nqs-filter-btn:hover { filter: brightness(1.2); }
#NQS_GLOBAL_CONTAINER .nqs-filter-btn.active{background-color:var(--nqs-accent);color:white;border-color:var(--nqs-accent);}
#NQS_GLOBAL_CONTAINER .nqs-footer{padding:1rem 2rem;border-top:1px solid var(--nqs-border);display:flex;align-items:center;gap:1rem;background:var(--nqs-bg-subtle);border-bottom-left-radius:20px;border-bottom-right-radius:20px;flex-shrink:0;}
#NQS_GLOBAL_CONTAINER .nqs-button{display: inline-block; text-align: center; border-radius:8px;padding:.65rem 1.25rem;font-size:.95rem;font-weight:600;cursor:pointer;border:1px solid transparent;transition:all .2s ease}
#NQS_GLOBAL_CONTAINER .nqs-button:hover{transform:translateY(-1px);filter:brightness(1.05);}
#NQS_GLOBAL_CONTAINER .nqs-button-primary{background:var(--nqs-accent);color:#fff;border-color:var(--nqs-accent)}
#NQS_GLOBAL_CONTAINER .nqs-button-secondary{background:var(--nqs-bg-subtle);border-color:var(--nqs-border);color:var(--nqs-text-primary)}
#NQS_GLOBAL_CONTAINER[data-theme='light'] .nqs-button-secondary { background: var(--nqs-bg-subtle); border-color: var(--nqs-border); color: var(--nqs-text-primary); }
#NQS_GLOBAL_CONTAINER .nqs-button-danger{background:var(--nqs-danger);color:#fff;border-color:var(--nqs-danger)}
#NQS_GLOBAL_CONTAINER .nqs-log-table{width:100%;border-collapse:collapse;font-size:.9rem}
#NQS_GLOBAL_CONTAINER .nqs-log-table th, #NQS_GLOBAL_CONTAINER .nqs-log-table td {text-align:left; padding:.85rem 1rem;}
#NQS_GLOBAL_CONTAINER .nqs-log-table th{font-weight:600;color:var(--nqs-text-secondary);border-bottom:2px solid var(--nqs-border);background:var(--nqs-bg);position:sticky;top:0;z-index:5;}
#NQS_GLOBAL_CONTAINER .nqs-log-table td{border-bottom:1px solid var(--nqs-border);vertical-align:middle;color: var(--nqs-text-primary);}
#NQS_GLOBAL_CONTAINER .nqs-log-cell-message{word-break:break-word;white-space:normal;}
#NQS_GLOBAL_CONTAINER .nqs-log-cell-time{white-space:nowrap;color:var(--nqs-text-secondary);}
#NQS_GLOBAL_CONTAINER .nqs-log-row--error{background-color:rgba(220,53,69,0.05);}
#NQS_GLOBAL_CONTAINER[data-theme='dark'] .nqs-log-row--error { background-color: rgba(229, 62, 62, 0.1); }
#NQS_GLOBAL_CONTAINER .nqs-log-row--debug{background-color:rgba(108,117,125,0.05);}
#NQS_GLOBAL_CONTAINER[data-theme='dark'] .nqs-log-row--debug { background-color: rgba(160, 174, 192, 0.1); }
#NQS_GLOBAL_CONTAINER .nqs-log-tag{display: inline-block; padding:.25em .6em;border-radius:.25rem;font-weight:700;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em; line-height: 1;}
#NQS_GLOBAL_CONTAINER .nqs-log-tag.info{background-color:#007bff;color:white;}
#NQS_GLOBAL_CONTAINER[data-theme='dark'] .nqs-log-tag.info { background-color: var(--nqs-accent); }
#NQS_GLOBAL_CONTAINER .nqs-log-tag.error{background-color:#dc3545;color:white;}
#NQS_GLOBAL_CONTAINER[data-theme='dark'] .nqs-log-tag.error { background-color: var(--nqs-danger); }
#NQS_GLOBAL_CONTAINER .nqs-log-tag.debug{background-color:#6c757d;color:white;}
#NQS_GLOBAL_CONTAINER[data-theme='dark'] .nqs-log-tag.debug { background-color: var(--nqs-text-secondary); }
#NQS_GLOBAL_CONTAINER .nqs-log-tag.legacy{background-color:#ffc107;color:#212529;}
#NQS_GLOBAL_CONTAINER .nqs-json-viewer { background-color: var(--nqs-bg-subtle); color: var(--nqs-text-primary); font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 14px; padding: 1.5rem; }
#NQS_GLOBAL_CONTAINER .json-key { color: #9cdcfe; } #NQS_GLOBAL_CONTAINER .json-string { color: #ce9178; } #NQS_GLOBAL_CONTAINER .json-number { color: #b5cea8; } #NQS_GLOBAL_CONTAINER .json-boolean { color: #569cd6; } #NQS_GLOBAL_CONTAINER .json-null { color: #c586c0; }
#NQS_GLOBAL_CONTAINER .nqs-label-group{padding-top:.5rem;display:flex;flex-direction:column;}
#NQS_GLOBAL_CONTAINER .nqs-label-group label{display:block;font-weight:500;color:var(--nqs-text-primary); margin: 0; padding: 0;}
#NQS_GLOBAL_CONTAINER p.nqs-p, #NQS_GLOBAL_CONTAINER .nqs-label-group p {font-size:.85rem;color:var(--nqs-text-secondary);margin:.25rem 0 0 0; line-height: 1.6;}
#NQS_GLOBAL_CONTAINER .nqs-toggle-switch{display:flex;align-items:center;justify-content:flex-end}
#NQS_GLOBAL_CONTAINER .nqs-toggle-switch .switch{position:relative;display:inline-block;width:50px;height:28px}
#NQS_GLOBAL_CONTAINER .nqs-toggle-switch .switch input{opacity:0;width:0;height:0}
#NQS_GLOBAL_CONTAINER .nqs-toggle-switch .slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background-color:#ccc;transition:.4s;border-radius:28px}
#NQS_GLOBAL_CONTAINER[data-theme='dark'] .nqs-toggle-switch .slider { background-color: var(--nqs-border); }
#NQS_GLOBAL_CONTAINER .nqs-toggle-switch .slider:before{position:absolute;content:"";height:20px;width:20px;left:4px;bottom:4px;background-color:#fff;transition:.4s;border-radius:50%}
#NQS_GLOBAL_CONTAINER .nqs-toggle-switch input:checked+.slider{background-color:var(--nqs-accent)}
#NQS_GLOBAL_CONTAINER .nqs-toggle-switch input:checked+.slider:before{transform:translateX(22px)}
#NQS_GLOBAL_CONTAINER .nqs-input,#NQS_GLOBAL_CONTAINER .nqs-textarea{width:100%;padding:.75rem 1rem;font-size:1rem;font-family:inherit;color:var(--nqs-text-primary);background-color:var(--nqs-bg);border:1px solid var(--nqs-border);border-radius:8px;transition:all .2s ease-in-out;}
#NQS_GLOBAL_CONTAINER .nqs-input:focus,#NQS_GLOBAL_CONTAINER .nqs-textarea:focus{outline:0;border-color:var(--nqs-accent);box-shadow:0 0 0 .2rem rgba(49,130,206,.25)}
#NQS_GLOBAL_CONTAINER .nqs-section{margin-bottom:2.5rem}.nqs-section:last-child{margin-bottom:0}
#NQS_GLOBAL_CONTAINER .nqs-section-title{font-size:1.25rem;font-weight:600;color:var(--nqs-text-primary);border-bottom:1px solid var(--nqs-border);padding-bottom:.75rem;margin-bottom:1.5rem}
#NQS_GLOBAL_CONTAINER .nqs-subsection-title{font-size:1rem;font-weight:600;color:var(--nqs-text-primary);margin-top:1.5rem;margin-bottom:0.5rem;}
#NQS_GLOBAL_CONTAINER .nqs-subsection-p{font-size:.85rem;color:var(--nqs-text-secondary);margin-top:0;margin-bottom:1rem;}
#NQS_GLOBAL_CONTAINER .nqs-field{display:grid;grid-template-columns:1fr 1.5fr;gap:1rem 2rem;align-items:flex-start;margin-bottom:1.5rem}
#NQS_GLOBAL_CONTAINER .nqs-field-full{grid-template-columns:1fr}
#NQS_GLOBAL_CONTAINER .nqs-category-manager{display: flex; margin-top: 0.5rem;}
#NQS_GLOBAL_CONTAINER .nqs-category-manager input{border-top-right-radius:0;border-bottom-right-radius:0}
#NQS_GLOBAL_CONTAINER .nqs-category-manager button{border-top-left-radius:0;border-bottom-left-radius:0}
#NQS_GLOBAL_CONTAINER .nqs-model-selector-wrapper { position: relative; }
#NQS_GLOBAL_CONTAINER .nqs-model-selector-wrapper .nqs-input { padding-right: 44px !important; }
#NQS_GLOBAL_CONTAINER .nqs-icon-button { position: absolute; top: 50%; right: 5px; transform: translateY(-50%); width: 34px; height: 34px; border: none; background: transparent; color: var(--nqs-text-secondary); border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: color 0.2s, background-color 0.2s; }
#NQS_GLOBAL_CONTAINER .nqs-icon-button:hover { background-color: var(--nqs-bg-subtle); color: var(--nqs-text-primary); }
#NQS_GLOBAL_CONTAINER .nqs-icon-button:disabled { cursor: not-allowed; color: var(--nqs-border); }
#NQS_GLOBAL_CONTAINER .nqs-icon-button.is-loading svg { animation: nqs-spin 1s linear infinite; }
#NQS_GLOBAL_CONTAINER .nqs-custom-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background: var(--nqs-bg); border: 1px solid var(--nqs-border); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 0.5rem; max-height: 200px; overflow-y: auto; opacity: 0; transform: translateY(-10px); transition: opacity .2s ease, transform .2s ease; pointer-events: none; }
#NQS_GLOBAL_CONTAINER .nqs-custom-dropdown.is-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
#NQS_GLOBAL_CONTAINER .nqs-dropdown-item { padding: 0.75rem 1rem; cursor: pointer; transition: background-color .2s; color: var(--nqs-text-primary); }
#NQS_GLOBAL_CONTAINER .nqs-dropdown-item:hover, #NQS_GLOBAL_CONTAINER .nqs-dropdown-item.is-active { background-color: var(--nqs-bg-subtle); }
#NQS_GLOBAL_CONTAINER .nqs-textarea{min-height:150px;resize:vertical}
#NQS_GLOBAL_CONTAINER #nqs-category-list{list-style:none;padding:0;margin-top:1rem;display:flex;flex-wrap:wrap;gap:.75rem}
#NQS_GLOBAL_CONTAINER #nqs-category-list li{display:flex;align-items:center;background:var(--nqs-bg-subtle);color:var(--nqs-text-primary);padding:.5rem 1rem;border-radius:8px;font-size:.9rem;font-weight:500; line-height:1; border: 1px solid var(--nqs-border);}
#NQS_GLOBAL_CONTAINER #nqs-category-list .delete-cat{margin-left:.75rem;cursor:pointer;color:var(--nqs-text-secondary);font-weight:700;font-size:1.3em;line-height:1;transition:color .2s ease}
#NQS_GLOBAL_CONTAINER #nqs-category-list .delete-cat:hover{color:var(--nqs-danger)}
#NQS_GLOBAL_CONTAINER .nqs-button-text{background:none;border:none;padding:4px 8px;color:var(--nqs-accent);font-weight:500;cursor:pointer;text-align:left;line-height:1.5; font-size: 0.8rem;}
#NQS_GLOBAL_CONTAINER .nqs-button-text:hover{text-decoration:underline}
#NQS_GLOBAL_CONTAINER #nqs-selector-list {display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1.5rem;}
#NQS_GLOBAL_CONTAINER #nqs-selector-list .nqs-button-secondary { background: var(--nqs-bg-subtle); color: var(--nqs-text-primary); border-color: var(--nqs-border); }
/* === FAB (悬浮球) 样式 === */
#NQS_GLOBAL_CONTAINER #nqs-fab-container { position: fixed; z-index: 99999; width: 48px; height: 48px; transition: transform 0.3s ease-out; }
#NQS_GLOBAL_CONTAINER #nqs-fab-trigger { width: 100%; height: 100%; background: var(--nqs-accent); color: white; border-radius: 50%; display: flex; justify-content: center; align-items: center; font-size: 22px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); cursor: grab; transition: transform 0.2s ease, box-shadow 0.2s ease; position: relative; z-index: 2; }
#NQS_GLOBAL_CONTAINER #nqs-fab-trigger:active { cursor: grabbing; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.is-dragging #nqs-fab-trigger { transform: scale(1.1); }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.is-expanded #nqs-fab-trigger { transform: rotate(90deg); }
#NQS_GLOBAL_CONTAINER .nqs-fab-options { position: absolute; bottom: calc(100% + 15px); right: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 12px; transition: opacity 0.2s ease, transform 0.2s ease; opacity: 0; transform: translateY(10px); pointer-events: none; z-index: 1; }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.align-left .nqs-fab-options { right: auto; left: 0; align-items: flex-start; }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.is-expanded .nqs-fab-options { opacity: 1; transform: translateY(0); pointer-events: auto; }
#NQS_GLOBAL_CONTAINER .nqs-fab-action-btn { padding: 8px 16px; color: white; border-radius: 20px; font-size: 14px; font-weight: 600; box-shadow: 0 6px 15px rgba(0,0,0,0.15); transition: all .2s ease-out; white-space: nowrap; cursor: pointer; }
#NQS_GLOBAL_CONTAINER #nqs-save-button { background-color: rgba(0, 122, 255, 0.95); }
#NQS_GLOBAL_CONTAINER #nqs-read-later-button { background-color: rgba(90, 90, 90, 0.95); }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.is-expanded .nqs-fab-options #nqs-read-later-button { transition-delay: 0.05s; }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.is-expanded .nqs-fab-options #nqs-save-button { transition-delay: 0.1s; }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.snapped-right { transform: translateX(30%); }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.snapped-right:hover, #NQS_GLOBAL_CONTAINER #nqs-fab-container.snapped-right.is-expanded { transform: translateX(0); }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.snapped-left { transform: translateX(-30%); }
#NQS_GLOBAL_CONTAINER #nqs-fab-container.snapped-left:hover, #NQS_GLOBAL_CONTAINER #nqs-fab-container.snapped-left.is-expanded { transform: translateX(0); }
`; const style = document.createElement('style'); style.id = styleId; style.textContent = css; container.appendChild(style); }
const showNotification = (button, message, stay = false, originalText) => { if (!button) return; button.textContent = message; if (!stay) { setTimeout(() => { button.textContent = originalText; }, 3000); } };
async function initFloatingButtons() { if (fabInstance) fabInstance.destroy(); fabInstance = new FloatingActionButton(); await fabInstance.init(); }
function savePageViaMenu() { startSaveProcess({ source: 'menu' }); }
function savePageForLaterViaMenu() { startReadLaterSave({ source: 'menu' }); }
async function runScript() {
await applyTheme();
GM_registerMenuCommand('⚙️ 设置 (Settings)', openSettingsPanel);
GM_registerMenuCommand('📋 查看日志 (View Logs)', openLogViewerPanel);
GM_registerMenuCommand('─'.repeat(20), () => {});
GM_registerMenuCommand('➤ 保存到 Notion', savePageViaMenu);
GM_registerMenuCommand('◷ 保存为稍后读', savePageForLaterViaMenu);
try {
initFloatingButtons();
} catch (e) {
console.error("NQS: 无法初始化悬浮按钮UI。这可能是由于网站的安全策略(CSP)限制。请放心使用油猴菜单中的备用按钮,功能完全相同。", e);
addLog('error', '悬浮按钮UI初始化失败', { error: e.message, stack: e.stack });
}
}
runScript();
})();