您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
一个由 AI 驱动的用户脚本(UserScript),可以快速将网页保存并智能分类到您的 Notion 数据库中。它拥有一个设计优雅、可拖动的悬浮 UI、一个功能全面的设置面板,并为兼容现代网站而精心设计。
当前为
// ==UserScript== // @name Notion AI 快速保存助手 // @namespace http://tampermonkey.net/ // @version 1.0 // @description 一个由 AI 驱动的用户脚本(UserScript),可以快速将网页保存并智能分类到您的 Notion 数据库中。它拥有一个设计优雅、可拖动的悬浮 UI、一个功能全面的设置面板,并为兼容现代网站而精心设计。 // @author tsdw // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @license MIT // @connect * // ==/UserScript== (function() { 'use strict'; // =================================================================== // ===================== 全局容器与样式隔离 ======================== // =================================================================== 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: '', openai_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: 15000, 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);} // 【V39 核心重构】完全使用 DOM API 构建设置面板,取代 innerHTML async function openSettingsPanel() { closeAllNQSPopups(); const { body, footer, close } = createBasePanel('脚本设置', '在这里配置您的 Notion AI Quick Saver,实现高效网页收藏'); const elements = {}; // 用于存储创建的 DOM 元素 // -- 辅助函数 -- 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.textContent = descText; 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 openAIKeyField = createField(aiSection, 'openai_api_key', 'AI API Key', '例如OpenAI的sk-...,若使用本地模型可留空'); openAIKeyField.appendChild(createInput('openai_api_key', 'password')); const aiApiUrlField = createField(aiSection, 'ai_api_url', 'AI API Endpoint', '兼容OpenAI格式的API地址'); aiApiUrlField.appendChild(createInput('ai_api_url', 'text')); const aiModelField = createField(aiSection, 'ai_model', 'AI 模型名称', '您想要使用的具体AI模型名称'); aiModelField.appendChild(createInput('ai_model', 'text')); 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. 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'; }; elements['ai_enabled'].addEventListener('change', toggleAISectionVisibility); const renderCategories = () => { while (categoryListEl.firstChild) { // 使用 DOM API 清空,避免 innerHTML categoryListEl.removeChild(categoryListEl.firstChild); } 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; } }); // 加载现有设置 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(); // 保存与取消按钮事件 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'; let 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) => { let { openai_api_key, ai_api_url, ai_model, ai_prompt, user_categories, ai_include_body, ai_timeout, read_later_enabled, read_later_category } = settings; if (!ai_api_url || !ai_model) return reject(new Error("AI Endpoint或Model未在设置中指定")); 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', { prompt: finalPrompt, model: ai_model, endpoint: ai_api_url }); GM_xmlhttpRequest({ method: 'POST', url: ai_api_url, headers: { 'Authorization': `Bearer ${openai_api_key}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ model: ai_model, messages: [{ role: 'user', content: finalPrompt }], temperature: 0.0, max_tokens: 30 }), timeout: parseInt(ai_timeout, 10) || 15000, ontimeout: () => reject(new Error("AI_TIMEOUT")), 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返回了空响应。请检查API状态或尝试优化Prompt。"); 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}`)); } }, onerror: () => reject(new Error('AI网络请求失败')) }); }); } 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网络请求失败!')) }); }); } // =================================================================== // ====================== 统一的保存逻辑 (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"; 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 }); if (uiContext.source === 'fab') showNotification(saveButton, notionResponse, false, originalText); else console.log(`NQS: [Menu] ${notionResponse}`); } 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); } #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-text-secondary);color:#fff;border-color:var(--nqs-text-secondary)} #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-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(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址