您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
收藏 linux.do 的帖子。
// ==UserScript== // @name linux.do 收藏夹 // @namespace http://tampermonkey.net/ // @version 6.3.2 // @description 收藏 linux.do 的帖子。 // @match https://linux.do/* // @exclude https://linux.do/a/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect * // ==/UserScript== (function () { "use strict"; // 使用常量管理所有键名、ID和类名 const CONSTANTS = { STORAGE_KEYS: { BOOKMARKS: "linuxdo_bookmarks", TRASH: "linuxdo_trash", // 回收站 WEBDAV_SERVER: "webdav_server", WEBDAV_USER: "webdav_user", WEBDAV_PASS: "webdav_pass", AUTO_SYNC: "webdav_auto_sync_enabled", TAG_ORDER: "bm_tag_order", FLOATING_BUTTON_POSITION: "bm_floating_button_position", }, IDS: { MANAGER_MODAL: "bookmark-manager-modal", SETTINGS_MODAL: "bookmark-settings-modal", // [NEW] 配置页面 WEBDAV_SETTINGS_MODAL: "webdav-settings-modal", WEBDAV_BROWSER_MODAL: "webdav-browser-modal", SEARCH_INPUT: "bookmark-search-input", TABLE_CONTAINER: "bookmarks-table-container", TABLE: "bookmarks-table", ROW_TEMPLATE: "bm-row-template", TAG_FILTER_CONTAINER: "bm-tag-filter-container", TAG_EDIT_INPUT: "bm-tag-edit-input", RENAME_TAGS_BUTTON: "rename-tags-btn", TRASH_TOGGLE_BUTTON: "toggle-trash-btn", EMPTY_TRASH_BUTTON: "empty-trash-btn", SETTINGS_BUTTON: "open-settings-btn", // [NEW] 打开配置按钮 WEBDAV_TEST_RESULT: "webdav-test-result", AUTO_SYNC_TOGGLE: "auto-sync-toggle", WEBDAV_BROWSER_LIST: "webdav-browser-list", MANAGE_BUTTON: "manage-bookmarks-button", PAGINATION_INFO: "bm-pagination-info", // [NEW] 分页信息 PAGINATION_CONTROLS: "bm-pagination-controls", // [NEW] 分页控件 FOOTER_CONTAINER: "bm-footer-container", // [NEW] 底部容器 PAGE_SIZE_SELECT: "bm-page-size-select", // [NEW] 每页显示数量选择器 TRASH_BACK_BUTTON: "return-to-bookmarks-btn", // [NEW] 回收站返回收藏按钮 }, CLASSES: { DELETE_BTN: "delete-btn", RESTORE_BTN: "restore-btn", PURGE_BTN: "purge-btn", RENAME_BTN: "rename-btn", SAVE_BTN: "save-btn", CANCEL_BTN: "cancel-btn", PIN_BTN: "pin-btn", UNPIN_BTN: "unpin-btn", PINNED_ROW: "pinned-bookmark", EDIT_INPUT: "edit-name-input", MODAL_BACKDROP: "bm-modal-backdrop", CLOSE_BTN: "bm-close-btn", CONTENT_PANEL: "bm-content-panel", ROW_HIDING: "bm-row-hiding", TAG_FILTER_BTN: "bm-tag-filter-btn", TAG_ACTIVE: "active", TAG_CELL: "bm-tag-cell", TAG_PILL: "bm-tag-pill", TAG_EDIT_BTN: "bm-tag-edit-btn", TAG_ADD_BTN: "bm-tag-add-btn", TAG_REMOVE_BTN: "bm-tag-remove-btn", TAG_SAVE_BTN: "bm-tag-save-btn", TAG_CANCEL_BTN: "bm-tag-cancel-btn", }, WEBDAV_DIR: "LinuxDoBookmarks/", }; const TAG_COLLATOR = new Intl.Collator(undefined, { sensitivity: "base", numeric: true, }); let activeTagFilter = null; // 用于存储当前激活的标签过滤器 let viewMode = "bookmarks"; // 视图模式:bookmarks | trash let currentPage = 1; // 当前页码 let itemsPerPage = 10; // 每页显示数量 let openModalCount = 0; function updateModalLockState() { const root = document.documentElement; const body = document.body; if (!root || !body) return; if (openModalCount > 0) { root.classList.add("bm-modal-open"); body.classList.add("bm-modal-open"); } else { root.classList.remove("bm-modal-open"); body.classList.remove("bm-modal-open"); } } function openModal(modal) { if (!modal || modal.style.display === "flex") return; modal.style.display = "flex"; openModalCount += 1; updateModalLockState(); } function closeModal(modal) { if (!modal || modal.style.display === "none") return; modal.style.display = "none"; openModalCount = Math.max(0, openModalCount - 1); updateModalLockState(); } // --- Part 1: 定义样式和 HTML --- GM_addStyle(` .bm-modal-backdrop { display: none; position: fixed; z-index: 2147483647; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4); justify-content: center; align-items: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; pointer-events: auto; } html.bm-modal-open, body.bm-modal-open { overflow: hidden !important; touch-action: none; } body.bm-modal-open .action-button { pointer-events: none; } .bm-content-panel { background-color: #ffffff; border-radius: 12px; padding: 25px 30px; border: 1px solid #EAEAEA; box-shadow: 0 10px 25px rgba(0,0,0,0.1); display: flex; flex-direction: column; } #${CONSTANTS.IDS.MANAGER_MODAL} .bm-content-panel { width: 1280px; height: 70vh; max-width: 95vw; max-height: 95vh; } .bm-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #EAEAEA; padding-bottom: 15px; margin-bottom: 20px; flex-shrink: 0; } .bm-header h2 { margin: 0; font-size: 24px; color: #333; font-weight: 600; } .bm-header-actions { display: flex; align-items: center; gap: 12px; margin-left: auto; } .bm-close-btn { color: #333; cursor: pointer; line-height: 1; transition: background-color 0.2s; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; padding: 0; margin: 0; background: none; border: none; border-radius: 4px; } .bm-close-btn svg { width: 20px; height: 20px; } .bm-close-btn:hover { background-color: #ff4444; color: #fff; } .bm-settings-btn { color: #333; font-size: 18px; font-weight: normal; cursor: pointer; line-height: 1; background: none; border: none; padding: 0; margin: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; } .bm-settings-btn svg { width: 20px; height: 20px; } .search-input-container { position: relative; flex: 1; min-width: 200px; display: flex; align-items: center; height: 26px; border: 1px solid #DDD; border-radius: 4px; padding: 0 25px 0 10px; background: white; } #${CONSTANTS.IDS.SEARCH_INPUT} { width: 100%; height: 100%; padding: 0; margin: 0; font-size: 13px; border: none; outline: none; background: transparent; box-sizing: border-box; } .search-clear-btn { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 13px; height: 13px; border-radius: 50%; background-color: #555; color: #fff; border: none; cursor: pointer; display: none; align-items: center; justify-content: center; font-size: 10px; line-height: 1; padding: 0; transition: background-color 0.2s; } .search-clear-btn:hover { background-color: #ff4444; } .search-clear-btn.visible { display: flex; } .controls-buttons { display: flex; flex-wrap: wrap; gap: 8px; } #${CONSTANTS.IDS.TABLE_CONTAINER} { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; width: 100%; scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE and Edge */ } #${CONSTANTS.IDS.TABLE_CONTAINER}::-webkit-scrollbar { display: none; /* Chrome, Safari and Opera */ } #${CONSTANTS.IDS.TABLE} { width: 100%; min-width: 100%; border-collapse: separate; border-spacing: 0; border: 1px solid #EAEAEA; border-radius: 8px; overflow: hidden; table-layout: fixed; } #${CONSTANTS.IDS.TABLE} th { position: sticky; top: 0; z-index: 1; background-color: #F9F9F9; padding: 12px 8px; text-align: left; border-bottom: 2px solid #EAEAEA; border-right: 1px solid #EAEAEA; box-sizing: border-box;} #${CONSTANTS.IDS.TABLE} th:last-child { border-right: none; } #${CONSTANTS.IDS.TABLE} td { border-bottom: 1px solid #EAEAEA; border-right: 1px solid #EAEAEA; padding: 12px 8px; text-align: left; transition: background-color 0.3s; vertical-align: middle; word-wrap: break-word; word-break: break-word; box-sizing: border-box; } #${CONSTANTS.IDS.TABLE} td:last-child { border-right: none; } #${CONSTANTS.IDS.TABLE} tr:last-child td { border-bottom: none; } #${CONSTANTS.IDS.TABLE} tbody tr:hover { background-color: #F8F9FA; } #${CONSTANTS.IDS.TABLE} td a { color: #007AFF; text-decoration: none; word-break: break-all; } #${CONSTANTS.IDS.TABLE} td a:hover { text-decoration: underline; } /* 固定列宽的单元格样式 - 允许内容换行 */ #${CONSTANTS.IDS.TABLE} th:nth-child(1), .bm-name-cell { width: 31%; word-wrap: break-word; word-break: break-word; } .bm-name-cell { font-size: 14px; } #${CONSTANTS.IDS.TABLE} th:nth-child(2), .bm-url-cell { width: 23%; word-wrap: break-word; } .bm-url-cell { font-size: 14px; } .bm-url-cell a { display: block; word-wrap: break-word; word-break: break-all; } #${CONSTANTS.IDS.TABLE} th:nth-child(3), .${CONSTANTS.CLASSES.TAG_CELL} { width: 12%; word-wrap: break-word; } #${CONSTANTS.IDS.TABLE} th:nth-child(4), .bm-time-cell { width: 12%; word-wrap: break-word; } .bm-time-cell { font-size: 13px !important; color: #666; } #${CONSTANTS.IDS.TABLE} th:nth-child(5), .bm-actions-cell { width: 22%; text-align: center; white-space: nowrap; } .bm-btn { border: 1px solid #CCC; background-color: #FFF; color: #333; padding: 4px 10px; height: 26px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: all 0.2s; white-space: nowrap; display: inline-flex; align-items: center; justify-content: center; } .bm-actions-cell .bm-btn { padding: 4px 10px; font-size: 13px; margin: 0 2px; border-radius: 4px; } .bm-btn-io { border-color: #81C784; color: #2E7D32; } .bm-btn-cloud { border-color: #64B5F6; color: #1976D2; } .bm-btn-danger { border-color: #E57373; color: #D32F2F; } .bm-toast { position: fixed; bottom: 20px; right: 20px; z-index: 10001; background-color: #333; color: white; padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); opacity: 0; transition: opacity 0.3s, transform 0.3s; transform: translateY(20px); font-size: 15px; display: flex; align-items: center; gap: 10px; } .bm-toast.show { opacity: 1; transform: translateY(0); } .bm-toast.error { background-color: #D32F2F; } .bm-toast-action { color: #4CAF50; font-weight: bold; cursor: pointer; text-decoration: underline; } #${CONSTANTS.IDS.WEBDAV_BROWSER_MODAL} .bm-content-panel { max-width: 700px; height: 75vh; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex-grow: 1; border: 1px solid #eee; border-radius: 6px; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 15px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background-color 0.2s; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li:hover { background-color: #f5f5f5; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li .webdav-backup-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .webdav-delete-btn { flex-shrink: 0; padding: 4px 8px; font-size: 12px; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li.loading-text { cursor: default; display: block; text-align: center; color: #888; pointer-events: none; } #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li.loading-text:hover { background-color: transparent; } #${CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL} .bm-content-panel { max-width: 550px; } .webdav-form-group { margin-bottom: 15px; } .webdav-form-group label { display: block; margin-bottom: 5px; color: #555; font-weight: 500; user-select: none;} .webdav-form-group input[type="checkbox"] { margin-right: 5px; vertical-align: middle; } .webdav-form-group input[type="text"], .webdav-form-group input[type="password"] { width: 100%; padding: 8px 12px; font-size: 15px; border-radius: 6px; border: 1px solid #DDD; box-sizing: border-box; } .webdav-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; flex-wrap: wrap; gap: 10px; } .webdav-footer-buttons { margin-left: auto; } .${CONSTANTS.CLASSES.PINNED_ROW} { background-color: #FFF8E1; } .${CONSTANTS.CLASSES.PINNED_ROW} td:first-child::before { content: ""; display: inline-block; width: 14px; height: 14px; margin-right: 4px; background-image: url('data:image/svg+xml;utf8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="%23F57C00"/></svg>'); background-size: contain; background-repeat: no-repeat; vertical-align: middle; position: relative; top: -2px; } .action-button { position: fixed; z-index: 9998; padding: 10px 15px; background-color: #fff; color: #333; border: 1px solid #DDD; border-radius: 20px; cursor: grab; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); right: 24px; top: 50%; transform: translateY(-50%); touch-action: none; user-select: none; } .action-button.dragging { cursor: grabbing; } #${CONSTANTS.IDS.MANAGE_BUTTON} { right: 20px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; gap: 4px; } .heart-icon { font-size: 18px; transition: all 0.2s ease; cursor: pointer; user-select: none; } .heart-icon.empty { color: #999; } .heart-icon.filled { color: #ff4444; } .heart-icon:hover { transform: scale(1.2); } .divider { color: #DDD; margin: 0 2px; user-select: none; } .${CONSTANTS.CLASSES.ROW_HIDING} { opacity: 0; transform: scale(0.95); } #${CONSTANTS.IDS.TABLE} tr { transition: opacity 0.3s ease, transform 0.3s ease; } /* [NEW] Tag Styles - 优化版 */ #${CONSTANTS.IDS.TAG_FILTER_CONTAINER} { display: flex; flex-wrap: nowrap; gap: 0; margin-bottom: 15px; padding: 12px; background: linear-gradient(to bottom, #FAFAFA, #F5F5F5); border-radius: 8px; border: 1px solid #E8E8E8; align-items: center; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; user-select: none; } #${CONSTANTS.IDS.TAG_FILTER_CONTAINER}::-webkit-scrollbar { display: none; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN} { border: 1px solid #DDD; background-color: #FFF; color: #555; padding: 3px 10px; height: 24px; border-radius: 12px; cursor: pointer; font-size: 12px; transition: all 0.2s; display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 1px 2px rgba(0,0,0,0.05); flex: 0 0 auto; margin-right: 6px; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.tag-draggable { cursor: grab; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.tag-draggable:active { cursor: grabbing; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.tag-dragging { opacity: 0.75; cursor: grabbing !important; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}:hover:not(.${CONSTANTS.CLASSES.TAG_ACTIVE}) { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.custom-tag { background: linear-gradient(135deg, #E8F5FE, #DAEFFF); border-color: #90CAF9; color: #1565C0; font-weight: 500; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.original-tag { background-color: #F5F5F5; border-color: #CCC; color: #666; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN}.${CONSTANTS.CLASSES.TAG_ACTIVE} { background: linear-gradient(135deg, #007AFF, #0066DD) !important; color: white !important; border-color: #0066DD !important; transform: scale(1.05); } .${CONSTANTS.CLASSES.TAG_PILL} { display: inline-block; background-color: #EFEFEF; color: #555; padding: 3px 8px; border-radius: 10px; font-size: 12px; margin-right: 5px; margin-bottom: 5px; position: relative; } .${CONSTANTS.CLASSES.TAG_PILL}.editable { padding-right: 20px; } .${CONSTANTS.CLASSES.TAG_REMOVE_BTN} { position: absolute; right: 2px; top: 50%; transform: translateY(-50%); background: none; border: none; color: #999; cursor: pointer; font-size: 10px; padding: 0; width: 14px; height: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .${CONSTANTS.CLASSES.TAG_REMOVE_BTN}:hover { background-color: #ff4444; color: white; } .${CONSTANTS.CLASSES.TAG_EDIT_BTN} { background-color: #E8F5E8; color: #2E7D32; border: 1px solid #81C784; padding: 4px 10px; height: 26px; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; } .${CONSTANTS.CLASSES.TAG_ADD_BTN} { background-color: #E3F2FD; color: #1976D2; border: 1px solid #64B5F6; font-size: 12px; padding: 4px 8px; height: 26px; margin-left: 5px; display: inline-flex; align-items: center; justify-content: center; } .${CONSTANTS.IDS.TAG_EDIT_INPUT} { width: 120px; padding: 2px 6px; font-size: 12px; border: 1px solid #DDD; border-radius: 3px; margin-right: 5px; } .tag-edit-mode { background-color: #F5F5F5; padding: 8px; border-radius: 4px; display: flex; flex-wrap: wrap; gap: 5px; align-items: center; line-height: 1.8; } .tag-edit-mode br { width: 100%; margin: 4px 0; } .bm-custom-tag-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; width: 100%; } .bm-tag-dropdown { position: relative; display: inline-flex; } .bm-tag-dropdown-toggle { background-color: #FFF; color: #1976D2; border: 1px solid #64B5F6; font-size: 12px; padding: 4px 12px 4px 10px; border-radius: 14px; cursor: pointer; transition: all 0.2s; } .bm-tag-dropdown-toggle::after { content: "▼"; font-size: 10px; margin-left: 6px; transition: transform 0.2s; color: inherit; } .bm-tag-dropdown.open .bm-tag-dropdown-toggle::after { transform: rotate(180deg); } .bm-tag-dropdown-list { display: none; position: absolute; top: calc(100% + 4px); left: 0; min-width: 200px; max-height: 220px; overflow-y: auto; background: #FFF; border: 1px solid #90CAF9; border-radius: 6px; box-shadow: 0 8px 20px rgba(0,0,0,0.15); padding: 6px; z-index: 1000; } .bm-tag-dropdown.open .bm-tag-dropdown-list { display: block; } .bm-tag-option { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 4px; font-size: 12px; color: #1976D2; cursor: pointer; } .bm-tag-option:hover { background-color: #E3F2FD; } .bm-tag-option input { margin: 0; } .bm-selected-tags { display: flex; flex-wrap: wrap; gap: 6px; width: 100%; margin: 6px 0; } .bm-selected-tag-pill { display: inline-flex; align-items: center; gap: 4px; background-color: #E3F2FD; color: #1976D2; border-left: 3px solid #64B5F6; border-radius: 10px; padding: 2px 8px; font-size: 12px; position: relative; } .bm-selected-tag-pill .${CONSTANTS.CLASSES.TAG_REMOVE_BTN} { position: static; background: none; color: #1976D2; width: auto; height: auto; font-size: 12px; } .bm-selected-tag-pill .${CONSTANTS.CLASSES.TAG_REMOVE_BTN}:hover { background-color: #ff4444; color: #FFF; } .bm-tag-empty-hint { font-size: 12px; color: #999; margin: 4px 0; display: block; } .${CONSTANTS.CLASSES.TAG_SAVE_BTN}, .${CONSTANTS.CLASSES.TAG_CANCEL_BTN} { font-size: 12px; padding: 4px 8px; height: 26px; display: inline-flex; align-items: center; justify-content: center; } /* Settings Modal Styles */ #${CONSTANTS.IDS.SETTINGS_MODAL} .bm-content-panel { max-width: 800px; } .settings-sections { display: flex; flex-direction: column; gap: 20px; } .settings-section { background-color: #F9F9F9; border-radius: 8px; padding: 20px; border: 1px solid #EAEAEA; } .settings-section h3 { margin: 0 0 12px 0; font-size: 18px; color: #333; border-bottom: 2px solid #007AFF; padding-bottom: 8px; } .settings-buttons { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; } .settings-desc { margin: 0; font-size: 13px; color: #666; line-height: 1.4; } /* Footer Styles - Left Search, Right Controls */ #${CONSTANTS.IDS.FOOTER_CONTAINER} { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding-top: 10px; margin-top: 10px; border-top: 2px solid #EAEAEA; flex-shrink: 0; gap: 20px; } .footer-left { display: flex; align-items: center; gap: 8px; flex: 0 1 350px; } .footer-right { display: flex; align-items: center; gap: 15px; flex-shrink: 0; } .footer-show { display: flex; align-items: center; gap: 6px; } #${CONSTANTS.IDS.PAGE_SIZE_SELECT} { padding: 0 2px; border: none; font-size: 14px; cursor: pointer; background-color: transparent; width: auto; color: #666; appearance: none; -webkit-appearance: none; -moz-appearance: none; outline: none; transition: color 0.2s; vertical-align: baseline; line-height: 1; margin: 0; font-family: inherit; } #${CONSTANTS.IDS.PAGE_SIZE_SELECT}:hover { color: #007AFF; } #${CONSTANTS.IDS.PAGE_SIZE_SELECT}:focus { color: #007AFF; } .search-label, .show-label { font-size: 13px; color: #666; margin: 0; padding: 0; line-height: 1; vertical-align: baseline; } #${CONSTANTS.IDS.PAGINATION_INFO} { font-size: 13px; color: #666; } #${CONSTANTS.IDS.PAGINATION_CONTROLS} { display: flex; gap: 4px; align-items: center; } .pagination-btn { padding: 4px 8px; border: 1px solid #DDD; background-color: #FFF; border-radius: 4px; cursor: pointer; font-size: 13px; transition: all 0.2s; min-width: 32px; text-align: center; } .pagination-btn:hover:not(:disabled) { background-color: #F0F0F0; border-color: #007AFF; } .pagination-btn:disabled { opacity: 0.4; cursor: not-allowed; } .pagination-btn.active { background-color: #007AFF; color: white; border-color: #007AFF; } /* 移动端响应式样式 */ @media (max-width: 768px) { /* 模态框调整 */ #${CONSTANTS.IDS.MANAGER_MODAL} .bm-content-panel { width: 100%; height: 100%; max-width: 100%; max-height: 100%; border-radius: 0; padding: 15px; } #${CONSTANTS.IDS.SETTINGS_MODAL} .bm-content-panel, #${CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL} .bm-content-panel, #${CONSTANTS.IDS.WEBDAV_BROWSER_MODAL} .bm-content-panel { width: 100%; max-width: 100%; padding: 15px; border-radius: 0; } /* 标题和按钮调整 */ .bm-header h2 { font-size: 18px; } .bm-header-actions { gap: 8px; } /* 标签过滤器优化 */ #${CONSTANTS.IDS.TAG_FILTER_CONTAINER} { padding: 8px; gap: 4px; } .${CONSTANTS.CLASSES.TAG_FILTER_BTN} { font-size: 11px; padding: 2px 8px; height: 22px; } /* 隐藏表格,使用卡片布局 */ #${CONSTANTS.IDS.TABLE} { display: block; border: none; } #${CONSTANTS.IDS.TABLE} thead { display: none; } #${CONSTANTS.IDS.TABLE} tbody { display: block; } #${CONSTANTS.IDS.TABLE} tr { display: block; margin-bottom: 12px; border: 1px solid #EAEAEA; border-radius: 8px; padding: 12px; background-color: #FFF; } #${CONSTANTS.IDS.TABLE} tr:hover { background-color: #F8F9FA; } #${CONSTANTS.IDS.TABLE} td { display: block; border: none; padding: 6px 0; width: 100% !important; text-align: left !important; } #${CONSTANTS.IDS.TABLE} td::before { content: attr(data-label); font-weight: 600; color: #666; display: block; margin-bottom: 4px; font-size: 12px; } /* 名称单元格 */ .bm-name-cell { font-size: 15px; font-weight: 600; color: #333; padding-top: 0 !important; } .bm-name-cell::before { display: none; } /* URL 单元格 */ .bm-url-cell { font-size: 13px; } /* 时间单元格 */ .bm-time-cell { font-size: 12px !important; } /* 操作按钮单元格 */ .bm-actions-cell { display: flex !important; flex-wrap: wrap; gap: 6px; padding-top: 8px !important; } .bm-actions-cell::before { width: 100%; } .bm-actions-cell .bm-btn { flex: 1 1 auto; min-width: calc(50% - 3px); font-size: 12px; padding: 6px 8px; height: 32px; } /* 底部搜索和分页 */ #${CONSTANTS.IDS.FOOTER_CONTAINER} { flex-direction: column; gap: 12px; padding-top: 12px; } .footer-left { width: 100%; flex: 1 1 100%; } .footer-right { width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 8px; } .search-input-container { min-width: 0; flex: 1; } #${CONSTANTS.IDS.PAGINATION_INFO} { font-size: 12px; order: 1; } #${CONSTANTS.IDS.PAGINATION_CONTROLS} { order: 3; width: 100%; justify-content: center; } .footer-show { order: 2; font-size: 12px; } .pagination-btn { font-size: 12px; padding: 6px 10px; min-width: 36px; height: 36px; } /* 控制按钮组 */ .controls-buttons { gap: 6px; } .controls-buttons .bm-btn { font-size: 12px; padding: 6px 10px; height: 32px; } /* 浮动按钮调整 */ .action-button { font-size: 13px; padding: 8px 12px; border-radius: 18px; } /* 表单调整 */ .webdav-form-group input[type="text"], .webdav-form-group input[type="password"] { font-size: 14px; } /* Toast 通知 */ .bm-toast { font-size: 13px; padding: 10px 16px; bottom: 12px; right: 12px; left: 12px; max-width: calc(100% - 24px); } /* 配置页面调整 */ .settings-section h3 { font-size: 16px; } .settings-desc { font-size: 12px; } .settings-buttons .bm-btn { font-size: 12px; padding: 6px 10px; height: 32px; } /* WebDAV 浏览器列表 */ #${CONSTANTS.IDS.WEBDAV_BROWSER_LIST} li { flex-direction: column; align-items: flex-start; gap: 8px; } .webdav-delete-btn { align-self: flex-end; } /* 触摸目标优化 */ .bm-close-btn, .bm-settings-btn { width: 32px; height: 32px; } .bm-close-btn svg, .bm-settings-btn svg { width: 22px; height: 22px; } /* 标签编辑 */ .${CONSTANTS.IDS.TAG_EDIT_INPUT} { width: 100%; font-size: 14px; padding: 6px 8px; } .${CONSTANTS.CLASSES.TAG_PILL} { font-size: 11px; } /* 搜索清除按钮 */ .search-clear-btn { width: 16px; height: 16px; font-size: 11px; right: 8px; } /* 置顶行样式 */ .${CONSTANTS.CLASSES.PINNED_ROW} td:first-child::before { content: ""; display: inline-block; width: 14px; height: 14px; margin-right: 4px; background-image: url('data:image/svg+xml;utf8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="%23F57C00"/></svg>'); background-size: contain; background-repeat: no-repeat; vertical-align: middle; position: relative; top: -2px; } } `); document.body.insertAdjacentHTML( "beforeend", ` <div id="${CONSTANTS.IDS.MANAGER_MODAL}" class="${CONSTANTS.CLASSES.MODAL_BACKDROP}"> <div class="bm-content-panel"> <div class="bm-header"> <h2 id="bm-header-title">收藏夹</h2> <div class="bm-header-actions"> <button id="${CONSTANTS.IDS.TRASH_BACK_BUTTON}" class="bm-settings-btn" aria-label="返回收藏夹" title="返回收藏夹" style="display:none;"> <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"> <path fill="currentColor" d="M14.5 3H7.71l-.85-.85L6.51 2h-5l-.5.5v11l.5.5h13l.5-.5v-10L14.5 3zm-.51 8.49V13h-12V7h4.49l.35-.15.86-.86H14v1.5l-.01 4zm0-6.49h-6.5l-.35.15-.86.86H2v-3h4.29l.85.85.36.15H14l-.01.99z"/> </svg> </button> <button id="${CONSTANTS.IDS.SETTINGS_BUTTON}" class="bm-settings-btn" aria-label="打开配置"> <svg class="bm-settings-icon" viewBox="0 0 72 72" aria-hidden="true" focusable="false"> <g> <g> <path fill="currentColor" d="M35.649,45.188c-5.066,0-9.188-4.121-9.188-9.188c0-5.066,4.121-9.188,9.188-9.188c5.065,0,9.188,4.122,9.188,9.188 C44.837,41.067,40.715,45.188,35.649,45.188z M35.649,30.812c-2.86,0-5.188,2.327-5.188,5.188s2.327,5.188,5.188,5.188 s5.188-2.327,5.188-5.188S38.51,30.812,35.649,30.812z"/> </g> <g> <path fill="currentColor" d="M35.913,68.5H35.72c-3.65,0-6.958-2.748-8.125-6.572c-1.561-0.485-3.078-1.115-4.536-1.881 c-1.498,0.842-3.155,1.297-4.777,1.297c-2.091,0-4.015-0.773-5.418-2.177l-0.172-0.182c-2.541-2.54-2.924-6.823-1.034-10.357 c-0.752-1.44-1.274-2.976-1.768-4.585c-3.83-1.157-6.39-4.326-6.39-7.92v-0.193c0-3.607,2.558-6.795,6.384-7.966 c0.519-1.718,1.103-3.29,1.876-4.755c-1.943-3.501-1.652-7.604,0.887-10.144l0.126-0.139c1.462-1.462,3.494-2.269,5.731-2.269 c1.628,0,3.261,0.424,4.717,1.211c1.364-0.705,2.781-1.292,4.236-1.753C28.459,6.262,31.806,3.5,35.72,3.5h0.193 c3.859,0,7.03,2.714,7.96,6.625c1.474,0.469,2.909,1.065,4.288,1.783c1.399-0.82,2.991-1.262,4.597-1.262 c2.262,0,4.368,0.86,5.931,2.422l0.103,0.095c2.772,2.771,3.104,6.936,1.002,10.354c0.73,1.394,1.436,2.813,1.897,4.242 c3.905,0.924,6.81,4.232,6.81,8.171v0.193c0,3.918-2.906,7.207-6.815,8.121c-0.438,1.333-1.068,2.691-1.781,4.062 c2.063,3.443,1.637,7.79-1.157,10.585l-0.146,0.213C57.103,60.6,55.098,61.5,52.983,61.5h0.002c-1.601,0-3.214-0.549-4.656-1.42 c-1.47,0.779-3.003,1.38-4.583,1.873C42.663,65.838,39.531,68.5,35.913,68.5z M22.983,55.719c0.349,0,0.697,0.09,1.01,0.273 c1.821,1.064,3.767,1.873,5.78,2.4c0.76,0.198,1.333,0.824,1.465,1.598c0.431,2.529,2.399,4.51,4.481,4.51h0.193 c2.282,0,3.818-2.252,4.155-4.477c0.119-0.79,0.697-1.433,1.47-1.635c2.061-0.541,4.042-1.37,5.891-2.465 c0.721-0.428,1.632-0.359,2.282,0.169c0.989,0.806,2.153,1.249,3.277,1.25l0,0c1.062,0,2.019-0.396,2.768-1.145l0.133-0.134 c1.654-1.654,1.683-4.389,0.066-6.361c-0.524-0.641-0.6-1.539-0.189-2.258c1.075-1.887,1.856-3.732,2.323-5.488 c0.217-0.817,1.029-1.41,1.872-1.48c2.569-0.213,4.539-2.045,4.539-4.354v-0.193c0-2.336-1.971-4.189-4.542-4.408 c-0.841-0.071-1.6-0.663-1.816-1.479c-0.504-1.899-1.333-3.802-2.41-5.654c-0.421-0.724-0.359-1.633,0.174-2.279 c1.648-1.997,1.688-4.496,0.106-6.078l-0.104-0.095c-0.85-0.848-1.954-1.292-3.147-1.292c-1.151,0-2.298,0.414-3.229,1.164 c-0.645,0.52-1.544,0.589-2.261,0.172c-1.784-1.038-3.692-1.83-5.671-2.355c-0.809-0.214-1.399-0.909-1.479-1.742 C39.871,9.303,38.142,7.5,35.913,7.5H35.72c-2.313,0-4.264,1.895-4.535,4.407c-0.089,0.823-0.676,1.506-1.477,1.719 c-1.935,0.512-3.806,1.283-5.562,2.291c-0.68,0.392-1.528,0.348-2.165-0.112c-1.011-0.729-2.28-1.146-3.482-1.146 c-1.169,0-2.203,0.391-2.912,1.1l-0.14,0.139c-1.6,1.6-1.107,4.267,0.206,6.074c0.471,0.647,0.511,1.513,0.101,2.2 c-1.046,1.755-1.841,3.707-2.431,5.965c-0.197,0.755-0.71,1.326-1.479,1.463C9.308,32.052,7.5,33.914,7.5,35.931v0.193 c0,1.996,1.806,3.835,4.34,4.279c0.771,0.135,1.339,0.707,1.537,1.465c0.547,2.094,1.319,4.047,2.348,5.805 c0.397,0.678,0.347,1.527-0.109,2.168c-1.491,2.098-1.515,4.893-0.045,6.362l0.168,0.181c0.787,0.786,1.821,0.961,2.544,0.961 c1.171,0,2.452-0.451,3.515-1.235C22.149,55.85,22.565,55.719,22.983,55.719z"/> </g> <g> <g> <path fill="currentColor" d="M52.704,30.489c-0.402,0-0.781-0.244-0.934-0.642c-2.54-6.612-9.007-11.054-16.092-11.055c-0.553,0-1-0.448-1-1 s0.447-1,1-1l0,0c7.906,0,15.124,4.958,17.959,12.338c0.197,0.515-0.06,1.094-0.575,1.292 C52.944,30.468,52.823,30.489,52.704,30.489z"/> </g> <g> <path fill="currentColor" d="M18.936,44.918c-0.339,0-0.67-0.173-0.857-0.484c-0.199-0.33-0.288-0.574-0.448-1.02 c-0.077-0.211-0.175-0.483-0.318-0.857c-0.198-0.516,0.06-1.094,0.575-1.291c0.515-0.199,1.094,0.06,1.292,0.574 c0.149,0.39,0.252,0.674,0.331,0.894c0.146,0.401,0.188,0.513,0.281,0.667c0.285,0.474,0.133,1.088-0.34,1.373 C19.289,44.872,19.111,44.918,18.936,44.918z"/> </g> <g> <path fill="currentColor" d="M35.271,54.895L35.271,54.895c-5.683,0-11.045-2.494-14.711-6.843c-0.356-0.423-0.303-1.054,0.12-1.409 c0.421-0.356,1.053-0.303,1.409,0.12c3.285,3.896,8.09,6.132,13.182,6.132c0.552,0,1,0.448,1,1 C36.271,54.448,35.823,54.895,35.271,54.895z"/> </g> </g> </g> </svg> </button> <button type="button" class="${CONSTANTS.CLASSES.CLOSE_BTN}" data-target-modal="${CONSTANTS.IDS.MANAGER_MODAL}" aria-label="关闭"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> </button> </div> </div> <div id="${CONSTANTS.IDS.TAG_FILTER_CONTAINER}"></div> <div id="${CONSTANTS.IDS.TABLE_CONTAINER}"></div> <div id="${CONSTANTS.IDS.FOOTER_CONTAINER}"> <div class="footer-left"> <label for="${CONSTANTS.IDS.SEARCH_INPUT}" class="search-label">Search:</label> <div class="search-input-container"> <input type="text" id="${CONSTANTS.IDS.SEARCH_INPUT}" placeholder="搜索..."> <button class="search-clear-btn" aria-label="清除搜索">✕</button> </div> </div> <div class="footer-right"> <div id="${CONSTANTS.IDS.PAGINATION_INFO}"></div> <div id="${CONSTANTS.IDS.PAGINATION_CONTROLS}"></div> <div class="footer-show"> <label for="${CONSTANTS.IDS.PAGE_SIZE_SELECT}" class="show-label">Show:</label> <select id="${CONSTANTS.IDS.PAGE_SIZE_SELECT}"> <option value="5">5</option> <option value="10" selected>10</option> <option value="20">20</option> <option value="50">50</option> </select> </div> </div> </div> </div> </div> <div id="${CONSTANTS.IDS.SETTINGS_MODAL}" class="${CONSTANTS.CLASSES.MODAL_BACKDROP}"> <div class="bm-content-panel" style="max-width: 800px;"> <div class="bm-header"> <h2>配置与管理</h2> <button type="button" class="${CONSTANTS.CLASSES.CLOSE_BTN}" data-target-modal="${CONSTANTS.IDS.SETTINGS_MODAL}" aria-label="关闭"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> </button> </div> <div class="settings-sections"> <div class="settings-section"> <h3>数据管理</h3> <div class="settings-buttons"> <button id="import-bookmarks-btn" class="bm-btn bm-btn-io">导入收藏</button> <button id="export-bookmarks-btn" class="bm-btn bm-btn-io">导出收藏</button> </div> <p class="settings-desc">从本地文件导入或导出收藏数据</p> </div> <div class="settings-section"> <h3>云端同步</h3> <div class="settings-buttons"> <button id="sync-from-cloud-btn" class="bm-btn bm-btn-cloud">恢复</button> <button id="sync-to-cloud-btn" class="bm-btn bm-btn-cloud">备份</button> <button id="webdav-settings-btn" class="bm-btn">云同步设置</button> </div> <p class="settings-desc">通过 WebDAV 在多设备间同步收藏</p> </div> <div class="settings-section"> <h3>标签管理</h3> <div class="settings-buttons"> <button id="${CONSTANTS.IDS.RENAME_TAGS_BUTTON}" class="bm-btn">批量重命名标签</button> </div> <p class="settings-desc">批量重命名自定义标签</p> </div> <div class="settings-section"> <h3>回收站</h3> <div class="settings-buttons"> <button id="${CONSTANTS.IDS.TRASH_TOGGLE_BUTTON}" class="bm-btn bm-btn-danger">查看回收站</button> <button id="${CONSTANTS.IDS.EMPTY_TRASH_BUTTON}" class="bm-btn bm-btn-danger">清空回收站</button> </div> <p class="settings-desc">查看已删除的收藏或清空回收站</p> </div> </div> </div> </div> <div id="${CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL}" class="${CONSTANTS.CLASSES.MODAL_BACKDROP}"> <div class="bm-content-panel"> <div class="bm-header"> <h2>WebDAV 云同步设置</h2> <button type="button" class="${CONSTANTS.CLASSES.CLOSE_BTN}" data-target-modal="${CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL}" aria-label="关闭"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> </button> </div> <div class="webdav-form-group"><label for="webdav-server">服务器地址:</label><input type="text" id="webdav-server" class="webdav-input" placeholder="例如: https://dav.jianguoyun.com/dav/"></div> <div class="webdav-form-group"><label for="webdav-user">用户名:</label><input type="text" id="webdav-user" class="webdav-input"></div> <div class="webdav-form-group"><label for="webdav-pass">应用密码 (非登录密码):</label><input type="password" id="webdav-pass" class="webdav-input"></div> <div class="webdav-form-group"><label><input type="checkbox" id="${CONSTANTS.IDS.AUTO_SYNC_TOGGLE}">当收藏变化时自动备份</label></div> <div class="webdav-footer"> <div id="${CONSTANTS.IDS.WEBDAV_TEST_RESULT}"></div> <div class="webdav-footer-buttons"><button id="test-webdav-connection" class="bm-btn">测试连接</button><button id="save-webdav-settings" class="bm-btn bm-btn-io">保存</button></div> </div> </div> </div> <div id="${CONSTANTS.IDS.WEBDAV_BROWSER_MODAL}" class="${CONSTANTS.CLASSES.MODAL_BACKDROP}"> <div class="bm-content-panel"> <div class="bm-header"> <h2>选择一个云端备份进行恢复</h2> <button type="button" class="${CONSTANTS.CLASSES.CLOSE_BTN}" data-target-modal="${CONSTANTS.IDS.WEBDAV_BROWSER_MODAL}" aria-label="关闭"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/> </svg> </button> </div> <ul id="${CONSTANTS.IDS.WEBDAV_BROWSER_LIST}"><li class="loading-text">正在加载备份列表...</li></ul> </div> </div> <template id="${CONSTANTS.IDS.ROW_TEMPLATE}"> <tr data-url-key=""> <td class="bm-name-cell" data-label="名称"></td> <td class="bm-url-cell" data-label="链接"><a href="" target="_blank" title=""></a></td> <td class="${CONSTANTS.CLASSES.TAG_CELL}" data-label="标签"></td> <td class="bm-time-cell" data-label="时间"></td> <td class="bm-actions-cell" data-label="操作" style="text-align:center; white-space:nowrap;"> <button class="bm-btn bm-btn-pin ${CONSTANTS.CLASSES.PIN_BTN}">置顶</button> <button class="bm-btn ${CONSTANTS.CLASSES.RENAME_BTN}">重命名</button> <button class="bm-btn ${CONSTANTS.CLASSES.TAG_EDIT_BTN}">编辑标签</button> <button class="bm-btn bm-btn-danger ${CONSTANTS.CLASSES.DELETE_BTN}">删除</button> </td> </tr> </template> ` ); // --- Part 2: DOM 元素获取与核心变量 --- const getEl = (id) => document.getElementById(id); const managerModal = getEl(CONSTANTS.IDS.MANAGER_MODAL); const settingsModal = getEl(CONSTANTS.IDS.SETTINGS_MODAL); const webdavSettingsModal = getEl(CONSTANTS.IDS.WEBDAV_SETTINGS_MODAL); const webdavBrowserModal = getEl(CONSTANTS.IDS.WEBDAV_BROWSER_MODAL); const trashBackButton = getEl(CONSTANTS.IDS.TRASH_BACK_BUTTON); const searchInput = getEl(CONSTANTS.IDS.SEARCH_INPUT); const tableContainer = getEl(CONSTANTS.IDS.TABLE_CONTAINER); const webdavTestResult = getEl(CONSTANTS.IDS.WEBDAV_TEST_RESULT); const autoSyncToggle = getEl(CONSTANTS.IDS.AUTO_SYNC_TOGGLE); const webdavBrowserList = getEl(CONSTANTS.IDS.WEBDAV_BROWSER_LIST); const rowTemplate = getEl(CONSTANTS.IDS.ROW_TEMPLATE); const tagFilterContainer = getEl(CONSTANTS.IDS.TAG_FILTER_CONTAINER); const pageSizeSelect = getEl(CONSTANTS.IDS.PAGE_SIZE_SELECT); const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = ".json"; fileInput.style.display = "none"; document.body.appendChild(fileInput); // --- Part 3: 核心函数 --- const getRootTopicUrl = (url) => (url.match(/(https:\/\/linux\.do\/t\/[^\/]+\/\d+)/) || [])[0] || url; const pad = (num) => num.toString().padStart(2, "0"); const getTimestampedFilename = () => { const d = new Date(); const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( d.getDate() )}`; const time = `${pad(d.getHours())}-${pad(d.getMinutes())}-${pad( d.getSeconds() )}`; return `linuxdo-backup-${date}_${time}.json`; }; function showToast(message, options = {}) { const { isError = false, duration = 3000, actions = [] } = options; const toast = document.createElement("div"); toast.className = `bm-toast ${isError ? "error" : ""}`; const messageSpan = document.createElement("span"); messageSpan.textContent = message; toast.appendChild(messageSpan); actions.forEach((action) => { const actionLink = document.createElement("a"); actionLink.textContent = action.text; actionLink.className = "bm-toast-action"; actionLink.onclick = (e) => { e.stopPropagation(); action.onClick(); toast.remove(); }; toast.appendChild(actionLink); }); document.body.appendChild(toast); setTimeout(() => { toast.classList.add("show"); setTimeout(() => { toast.classList.remove("show"); setTimeout(() => toast.remove(), 500); }, duration); }, 10); } const getTagOrderKey = (tag, isCustom) => `${isCustom ? "custom" : "original"}:${tag}`; const tagDragState = { draggedButton: null, hasMoved: false, }; function renderTagFilters() { // 在回收站视图隐藏标签筛选 if (viewMode === "trash") { tagFilterContainer.style.display = "none"; return; } tagFilterContainer.style.display = "block"; const allBookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const originalTags = new Set(); // 帖子自带的标签 const customTags = new Set(); // 用户自定义的标签 allBookmarks.forEach((bm) => { // 帖子自带的标签(从 tags 数组获取) bm.tags?.forEach((tag) => originalTags.add(tag)); // 用户自定义的标签(从 customTags 数组获取) bm.customTags?.forEach((tag) => customTags.add(tag)); }); // 清空容器 tagFilterContainer.innerHTML = ""; ensureTagDragHandlersInitialized(); const createButton = (text, tag, isCustom = false) => { const btn = document.createElement("button"); btn.textContent = text; btn.className = CONSTANTS.CLASSES.TAG_FILTER_BTN; btn.dataset.tag = tag === null ? "" : tag; btn.dataset.isCustom = isCustom; if (tag !== null) { btn.classList.add(isCustom ? "custom-tag" : "original-tag"); btn.dataset.tagKey = getTagOrderKey(tag, isCustom); btn.draggable = true; btn.classList.add("tag-draggable"); } else { btn.draggable = false; } if (activeTagFilter === tag) { btn.classList.add(CONSTANTS.CLASSES.TAG_ACTIVE); } return btn; }; // 添加"所有标签"按钮 tagFilterContainer.appendChild(createButton("所有标签", null)); const storedOrderRaw = GM_getValue(CONSTANTS.STORAGE_KEYS.TAG_ORDER, []); const tagEntries = []; Array.from(originalTags).forEach((tag) => { tagEntries.push({ label: tag, tag, isCustom: false, key: getTagOrderKey(tag, false), }); }); Array.from(customTags).forEach((tag) => { tagEntries.push({ label: tag, tag, isCustom: true, key: getTagOrderKey(tag, true), }); }); const availableKeys = new Set(tagEntries.map((entry) => entry.key)); const cleanedOrder = storedOrderRaw.filter((key) => availableKeys.has(key) ); if (cleanedOrder.length !== storedOrderRaw.length) { GM_setValue(CONSTANTS.STORAGE_KEYS.TAG_ORDER, cleanedOrder); } const orderMap = new Map(); cleanedOrder.forEach((key, index) => orderMap.set(key, index)); tagEntries.sort((a, b) => { const idxA = orderMap.has(a.key) ? orderMap.get(a.key) : Number.MAX_SAFE_INTEGER; const idxB = orderMap.has(b.key) ? orderMap.get(b.key) : Number.MAX_SAFE_INTEGER; if (idxA !== idxB) return idxA - idxB; if (a.isCustom !== b.isCustom) return a.isCustom ? 1 : -1; return TAG_COLLATOR.compare(a.tag, b.tag); }); let orderUpdated = false; tagEntries.forEach((entry) => { if (!orderMap.has(entry.key)) { orderMap.set(entry.key, cleanedOrder.length); cleanedOrder.push(entry.key); orderUpdated = true; } }); if (orderUpdated) { GM_setValue(CONSTANTS.STORAGE_KEYS.TAG_ORDER, cleanedOrder); } tagEntries.forEach((entry) => { tagFilterContainer.appendChild( createButton(entry.label, entry.tag, entry.isCustom) ); }); } function ensureTagDragHandlersInitialized() { if (ensureTagDragHandlersInitialized.initialized || !tagFilterContainer) { return; } tagFilterContainer.addEventListener("dragstart", handleTagDragStart); tagFilterContainer.addEventListener("dragover", handleTagDragOver); tagFilterContainer.addEventListener("drop", handleTagDrop); tagFilterContainer.addEventListener("dragend", handleTagDragEnd); ensureTagDragHandlersInitialized.initialized = true; } ensureTagDragHandlersInitialized.initialized = false; function handleTagDragStart(event) { const button = event.target.closest(`.${CONSTANTS.CLASSES.TAG_FILTER_BTN}`); if (!button || !button.dataset.tagKey) return; tagDragState.draggedButton = button; tagDragState.hasMoved = false; button.classList.add("tag-dragging"); if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", button.dataset.tag || ""); } } function handleTagDragOver(event) { if (!tagDragState.draggedButton) return; event.preventDefault(); const container = tagFilterContainer; if (!container) return; const hoveredButton = event.target.closest(`.${CONSTANTS.CLASSES.TAG_FILTER_BTN}`); if (!hoveredButton || hoveredButton === tagDragState.draggedButton) { return; } if (!hoveredButton.dataset.tagKey) { return; } const rect = hoveredButton.getBoundingClientRect(); const isBefore = event.clientX < rect.left + rect.width / 2; const referenceNode = isBefore ? hoveredButton : hoveredButton.nextSibling; if (referenceNode !== tagDragState.draggedButton) { container.insertBefore(tagDragState.draggedButton, referenceNode); tagDragState.hasMoved = true; } } function handleTagDrop(event) { if (!tagDragState.draggedButton) return; event.preventDefault(); } function handleTagDragEnd() { if (tagDragState.draggedButton) { tagDragState.draggedButton.classList.remove("tag-dragging"); } if (tagDragState.hasMoved) { updateTagOrderStorage(); } tagDragState.draggedButton = null; tagDragState.hasMoved = false; } function updateTagOrderStorage() { if (!tagFilterContainer) return; const buttons = tagFilterContainer.querySelectorAll( `.${CONSTANTS.CLASSES.TAG_FILTER_BTN}` ); const newOrder = []; buttons.forEach((btn) => { if (btn.dataset.tagKey) { newOrder.push(btn.dataset.tagKey); } }); const currentOrder = GM_getValue( CONSTANTS.STORAGE_KEYS.TAG_ORDER, [] ); if ( currentOrder.length === newOrder.length && currentOrder.every((key, index) => key === newOrder[index]) ) { return; } GM_setValue(CONSTANTS.STORAGE_KEYS.TAG_ORDER, newOrder); } function renderBookmarksTable() { if (viewMode === "trash") { renderTrashTable(); return; } const searchText = searchInput.value.toLowerCase(); const allBookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const filteredBookmarks = allBookmarks.filter((bm) => { const hasTag = !activeTagFilter || (bm.tags && bm.tags.includes(activeTagFilter)) || (bm.customTags && bm.customTags.includes(activeTagFilter)); const hasText = !searchText || bm.name.toLowerCase().includes(searchText) || bm.url.toLowerCase().includes(searchText) || (bm.tags && bm.tags.some((t) => t.toLowerCase().includes(searchText))) || (bm.customTags && bm.customTags.some((t) => t.toLowerCase().includes(searchText))); return hasTag && hasText; }); if (filteredBookmarks.length === 0) { tableContainer.innerHTML = '<p style="text-align:center; color:#888; padding:20px 0;">没有找到匹配的收藏。</p>'; updatePagination(0, 0); return; } const sortedBookmarks = [...filteredBookmarks].sort((a, b) => a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1 ); // 分页计算 const totalItems = sortedBookmarks.length; const totalPages = Math.ceil(totalItems / itemsPerPage); if (currentPage > totalPages) currentPage = totalPages; if (currentPage < 1) currentPage = 1; const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = Math.min(startIndex + itemsPerPage, totalItems); const pageBookmarks = sortedBookmarks.slice(startIndex, endIndex); const table = document.createElement("table"); table.id = CONSTANTS.IDS.TABLE; table.innerHTML = `<thead><tr><th style="width: 31%;">名称</th><th style="width: 20%;">链接</th><th style="width: 16%;">标签</th><th style="width: 11%;">收藏时间</th><th style="width: 22%; text-align:center;">操作</th></tr></thead>`; const tbody = document.createElement("tbody"); pageBookmarks.forEach((bookmark) => { const row = rowTemplate.content.cloneNode(true).firstElementChild; row.dataset.urlKey = getRootTopicUrl(bookmark.url); row.querySelector(".bm-name-cell").textContent = bookmark.name; const link = row.querySelector(".bm-url-cell a"); link.href = bookmark.url; link.textContent = bookmark.url; link.title = bookmark.url; const tagCell = row.querySelector(`.${CONSTANTS.CLASSES.TAG_CELL}`); // 显示帖子自带的标签 if (bookmark.tags && bookmark.tags.length > 0) { bookmark.tags.forEach((tag) => { const pill = document.createElement("span"); pill.className = `${CONSTANTS.CLASSES.TAG_PILL}`; pill.textContent = tag; pill.style.backgroundColor = "#EFEFEF"; pill.style.color = "#555"; tagCell.appendChild(pill); }); } // 显示用户自定义的标签 if (bookmark.customTags && bookmark.customTags.length > 0) { bookmark.customTags.forEach((tag) => { const pill = document.createElement("span"); pill.className = `${CONSTANTS.CLASSES.TAG_PILL} editable`; pill.textContent = tag; pill.style.backgroundColor = "#E3F2FD"; pill.style.color = "#1976D2"; pill.style.borderLeft = "3px solid #64B5F6"; // 添加删除按钮(只有自定义标签可以删除) const removeBtn = document.createElement("button"); removeBtn.className = CONSTANTS.CLASSES.TAG_REMOVE_BTN; removeBtn.textContent = "×"; removeBtn.title = "删除自定义标签"; removeBtn.onclick = (e) => { e.stopPropagation(); removeCustomTagFromBookmark(getRootTopicUrl(bookmark.url), tag); }; pill.appendChild(removeBtn); tagCell.appendChild(pill); }); } // 显示收藏时间 const timeCell = row.querySelector(".bm-time-cell"); if (bookmark.timestamp) { const date = new Date(bookmark.timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); timeCell.textContent = `${year}-${month}-${day} ${hours}:${minutes}`; } else { timeCell.textContent = "未知"; } const pinBtn = row.querySelector(`.${CONSTANTS.CLASSES.PIN_BTN}`); if (bookmark.pinned) { row.classList.add(CONSTANTS.CLASSES.PINNED_ROW); pinBtn.style.backgroundColor = "#f39c12"; pinBtn.style.color = "#fff"; pinBtn.classList.add(CONSTANTS.CLASSES.UNPIN_BTN); } else { pinBtn.style.backgroundColor = ""; pinBtn.style.color = ""; pinBtn.classList.remove(CONSTANTS.CLASSES.UNPIN_BTN); } tbody.appendChild(row); }); table.appendChild(tbody); tableContainer.innerHTML = ""; tableContainer.appendChild(table); // 更新分页信息 updatePagination(totalItems, totalPages); } // 更新分页信息和控件 function updatePagination(totalItems, totalPages) { const paginationInfo = getEl(CONSTANTS.IDS.PAGINATION_INFO); const paginationControls = getEl(CONSTANTS.IDS.PAGINATION_CONTROLS); if (totalItems === 0) { paginationInfo.textContent = ""; paginationControls.innerHTML = ""; return; } // 显示信息: "Showing 1 to 20 of 432 entries" const startIndex = (currentPage - 1) * itemsPerPage + 1; const endIndex = Math.min(currentPage * itemsPerPage, totalItems); paginationInfo.textContent = `Showing ${startIndex} to ${endIndex} of ${totalItems} entries`; // 生成分页按钮 paginationControls.innerHTML = ""; // 上一页按钮 const prevBtn = document.createElement("button"); prevBtn.className = "pagination-btn"; prevBtn.textContent = "<"; prevBtn.disabled = currentPage === 1; prevBtn.onclick = () => { if (currentPage > 1) { currentPage--; renderBookmarksTable(); } }; paginationControls.appendChild(prevBtn); // 页码按钮 (显示当前页附近的页码) const maxButtons = 5; let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2)); let endPage = Math.min(totalPages, startPage + maxButtons - 1); if (endPage - startPage < maxButtons - 1) { startPage = Math.max(1, endPage - maxButtons + 1); } if (startPage > 1) { const firstBtn = document.createElement("button"); firstBtn.className = "pagination-btn"; firstBtn.textContent = "1"; firstBtn.onclick = () => { currentPage = 1; renderBookmarksTable(); }; paginationControls.appendChild(firstBtn); if (startPage > 2) { const dots = document.createElement("span"); dots.textContent = "..."; dots.style.padding = "0 8px"; paginationControls.appendChild(dots); } } for (let i = startPage; i <= endPage; i++) { const pageBtn = document.createElement("button"); pageBtn.className = "pagination-btn" + (i === currentPage ? " active" : ""); pageBtn.textContent = i; pageBtn.onclick = () => { currentPage = i; renderBookmarksTable(); }; paginationControls.appendChild(pageBtn); } if (endPage < totalPages) { if (endPage < totalPages - 1) { const dots = document.createElement("span"); dots.textContent = "..."; dots.style.padding = "0 8px"; paginationControls.appendChild(dots); } const lastBtn = document.createElement("button"); lastBtn.className = "pagination-btn"; lastBtn.textContent = totalPages; lastBtn.onclick = () => { currentPage = totalPages; renderBookmarksTable(); }; paginationControls.appendChild(lastBtn); } // 下一页按钮 const nextBtn = document.createElement("button"); nextBtn.className = "pagination-btn"; nextBtn.textContent = ">"; nextBtn.disabled = currentPage === totalPages; nextBtn.onclick = () => { if (currentPage < totalPages) { currentPage++; renderBookmarksTable(); } }; paginationControls.appendChild(nextBtn); } // [NEW] 回收站渲染 function renderTrashTable() { const searchText = searchInput.value.toLowerCase(); const allTrash = GM_getValue(CONSTANTS.STORAGE_KEYS.TRASH, []); const filtered = allTrash.filter((bm) => { const hasText = !searchText || (bm.name && bm.name.toLowerCase().includes(searchText)) || (bm.url && bm.url.toLowerCase().includes(searchText)) || (bm.tags && bm.tags.some((t) => t.toLowerCase().includes(searchText))) || (bm.customTags && bm.customTags.some((t) => t.toLowerCase().includes(searchText))); return hasText; }); if (filtered.length === 0) { tableContainer.innerHTML = '<p style="text-align:center; color:#888; padding:20px 0;">回收站为空或没有匹配项。</p>'; return; } const table = document.createElement("table"); table.id = CONSTANTS.IDS.TABLE; table.innerHTML = `<thead><tr><th style="width: 31%;">名称</th><th style="width: 23%;">链接</th><th style="width: 12%;">标签</th><th style="width: 12%;">收藏时间</th><th style="width: 22%; text-align:center;">操作</th></tr></thead>`; const tbody = document.createElement("tbody"); filtered.forEach((bm) => { const tr = document.createElement("tr"); tr.dataset.urlKey = getRootTopicUrl(bm.url); const nameTd = document.createElement("td"); nameTd.textContent = bm.name || "(无标题)"; const urlTd = document.createElement("td"); const a = document.createElement("a"); a.href = bm.url; a.target = "_blank"; a.textContent = bm.url; a.title = bm.url; urlTd.appendChild(a); const tagTd = document.createElement("td"); if (bm.tags) bm.tags.forEach((t) => { const s = document.createElement("span"); s.className = `${CONSTANTS.CLASSES.TAG_PILL}`; s.textContent = t; tagTd.appendChild(s); }); if (bm.customTags) bm.customTags.forEach((t) => { const s = document.createElement("span"); s.className = `${CONSTANTS.CLASSES.TAG_PILL}`; s.textContent = t; s.style.backgroundColor = "#E3F2FD"; s.style.color = "#1976D2"; s.style.borderLeft = "3px solid #64B5F6"; tagTd.appendChild(s); }); // 添加时间列 const timeTd = document.createElement("td"); timeTd.style.fontSize = "12px"; timeTd.style.color = "#666"; if (bm.timestamp) { const date = new Date(bm.timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); timeTd.textContent = `${year}-${month}-${day} ${hours}:${minutes}`; } else { timeTd.textContent = "未知"; } const actTd = document.createElement("td"); actTd.style.textAlign = "center"; actTd.style.whiteSpace = "nowrap"; const restoreBtn = document.createElement("button"); restoreBtn.className = `bm-btn bm-btn-restore ${CONSTANTS.CLASSES.RESTORE_BTN}`; restoreBtn.textContent = "恢复"; const purgeBtn = document.createElement("button"); purgeBtn.className = `bm-btn bm-btn-purge ${CONSTANTS.CLASSES.PURGE_BTN}`; purgeBtn.style.marginLeft = "6px"; purgeBtn.textContent = "彻底删除"; actTd.appendChild(restoreBtn); actTd.appendChild(purgeBtn); tr.appendChild(nameTd); tr.appendChild(urlTd); tr.appendChild(tagTd); tr.appendChild(timeTd); tr.appendChild(actTd); tbody.appendChild(tr); }); table.appendChild(tbody); tableContainer.innerHTML = ""; tableContainer.appendChild(table); } function modifyBookmarks(updateFunction) { let bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const result = updateFunction(bookmarks); if (result === false) return; GM_setValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, result.bookmarks); if (result.changed) triggerAutoWebDAVSync(); return result.bookmarks; } // [NEW] 回收站修改器 function modifyTrash(updateFunction) { let trash = GM_getValue(CONSTANTS.STORAGE_KEYS.TRASH, []); const result = updateFunction(trash); if (result === false) return; GM_setValue(CONSTANTS.STORAGE_KEYS.TRASH, result.trash); return result.trash; } // 批量重命名自定义标签(将 oldTag -> newTag) function bulkRenameCustomTag(oldTag, newTag) { if (!oldTag || !newTag || oldTag === newTag) { showToast("无效的标签重命名参数", { isError: true }); return; } let changedCount = 0; modifyBookmarks((bookmarks) => { bookmarks.forEach((bm) => { if (bm.customTags && bm.customTags.includes(oldTag)) { // 避免重复:如果 newTag 已存在,则仅删除 oldTag if (bm.customTags.includes(newTag)) { bm.customTags = bm.customTags.filter((t) => t !== oldTag); } else { bm.customTags = bm.customTags.map((t) => t === oldTag ? newTag : t ); } // 清空后删除字段 if (bm.customTags.length === 0) delete bm.customTags; changedCount++; } }); return { bookmarks, changed: changedCount > 0 }; }); if (changedCount > 0) { renderBookmarksTable(); renderTagFilters(); showToast(`已重命名 ${changedCount} 条中的自定义标签`); } else { showToast("未找到需要重命名的自定义标签", { isError: true }); } } // 通过 prompt 启动批量重命名流程 function startBulkRenameFlow(presetOldTag = "") { const oldTag = presetOldTag || prompt("请输入需要重命名的【旧自定义标签】(精确匹配):"); if (!oldTag) return; const newTag = prompt(`将自定义标签 "${oldTag}" 重命名为:`); if (!newTag) return; if (/[,/]/.test(newTag)) { showToast("新标签中不允许包含逗号或斜杠", { isError: true }); return; } bulkRenameCustomTag(oldTag.trim(), newTag.trim()); } // 标签管理功能 function removeTagFromBookmark(urlKey, tagToRemove) { modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark && bookmark.tags) { bookmark.tags = bookmark.tags.filter((tag) => tag !== tagToRemove); if (bookmark.tags.length === 0) { delete bookmark.tags; } } return { bookmarks, changed: true }; }); renderBookmarksTable(); renderTagFilters(); showToast(`已删除标签: ${tagToRemove}`); } function removeCustomTagFromBookmark(urlKey, tagToRemove) { modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark && bookmark.customTags) { bookmark.customTags = bookmark.customTags.filter( (tag) => tag !== tagToRemove ); if (bookmark.customTags.length === 0) { delete bookmark.customTags; } } return { bookmarks, changed: true }; }); renderBookmarksTable(); renderTagFilters(); showToast(`已删除自定义标签: ${tagToRemove}`); } function addTagToBookmark(urlKey, newTag) { if (!newTag || !newTag.trim()) { showToast("标签名称不能为空!", { isError: true }); return; } newTag = newTag.trim(); modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark) { if (!bookmark.customTags) { bookmark.customTags = []; } // 检查是否与原生标签重复 if (bookmark.tags && bookmark.tags.includes(newTag)) { showToast("该标签已存在于帖子标签中!", { isError: true }); return false; } // 检查是否与自定义标签重复 if (!bookmark.customTags.includes(newTag)) { bookmark.customTags.push(newTag); return { bookmarks, changed: true }; } else { showToast("该自定义标签已存在!", { isError: true }); return false; } } return false; }); renderBookmarksTable(); renderTagFilters(); showToast(`已添加自定义标签: ${newTag}`); } function getAllCustomTags() { const allBookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const tagSet = new Set(); allBookmarks.forEach((bookmark) => { if (!bookmark?.customTags) return; bookmark.customTags.forEach((tag) => { if (typeof tag === "string") { const trimmed = tag.trim(); if (trimmed) { tagSet.add(trimmed); } } }); }); return Array.from(tagSet).sort((a, b) => TAG_COLLATOR.compare(a, b)); } function setCustomTagsForBookmark(urlKey, tags) { const sanitizedTags = Array.from( new Set( tags .map((tag) => (typeof tag === "string" ? tag.trim() : "")) .filter((tag) => tag.length > 0) ) ); let conflictTag = null; let changed = false; let bookmarkFound = false; modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (!bookmark) { return false; } bookmarkFound = true; const originalTags = bookmark.tags || []; const conflict = sanitizedTags.find((tag) => originalTags.includes(tag)); if (conflict) { conflictTag = conflict; return false; } if (sanitizedTags.length === 0) { if (bookmark.customTags) { delete bookmark.customTags; changed = true; return { bookmarks, changed: true }; } return { bookmarks, changed: false }; } const current = bookmark.customTags || []; const same = current.length === sanitizedTags.length && sanitizedTags.every((tag) => current.includes(tag)); if (same) { return { bookmarks, changed: false }; } bookmark.customTags = sanitizedTags; changed = true; return { bookmarks, changed: true }; }); if (!bookmarkFound) { showToast("未找到对应的收藏记录,无法更新标签", { isError: true }); return false; } if (conflictTag) { showToast(`标签 "${conflictTag}" 已存在于帖子标签中!`, { isError: true }); return false; } renderBookmarksTable(); if (changed) { renderTagFilters(); showToast("自定义标签已更新"); } return true; } function enterTagEditMode(row) { const tagCell = row.querySelector(`.${CONSTANTS.CLASSES.TAG_CELL}`); if (!tagCell || tagCell.querySelector(".tag-edit-mode")) return; const urlKey = row.dataset.urlKey; const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (!bookmark) { showToast("未找到对应的收藏记录,无法编辑标签", { isError: true }); return; } const originalTags = bookmark.tags || []; const customTags = bookmark.customTags || []; const originalTagSet = new Set(originalTags); const availableTags = new Set( getAllCustomTags().filter((tag) => !originalTagSet.has(tag)) ); customTags.forEach((tag) => availableTags.add(tag)); const selectedCustomTags = new Set(customTags); const editContainer = document.createElement("div"); editContainer.className = "tag-edit-mode"; if (originalTags.length > 0) { const originalLabel = document.createElement("span"); originalLabel.textContent = "帖子标签: "; originalLabel.style.fontSize = "12px"; originalLabel.style.color = "#666"; originalLabel.style.marginRight = "8px"; editContainer.appendChild(originalLabel); originalTags.forEach((tag) => { const pill = document.createElement("span"); pill.className = CONSTANTS.CLASSES.TAG_PILL; pill.textContent = tag; pill.style.backgroundColor = "#EFEFEF"; pill.style.color = "#555"; editContainer.appendChild(pill); }); editContainer.appendChild(document.createElement("br")); } const customHeader = document.createElement("div"); customHeader.className = "bm-custom-tag-header"; const customLabel = document.createElement("span"); customLabel.textContent = "自定义标签: "; customLabel.style.fontSize = "12px"; customLabel.style.color = "#1976D2"; customLabel.style.fontWeight = "bold"; customLabel.style.marginRight = "8px"; const dropdownWrapper = document.createElement("div"); dropdownWrapper.className = "bm-tag-dropdown"; const dropdownButton = document.createElement("button"); dropdownButton.type = "button"; dropdownButton.className = "bm-btn bm-tag-dropdown-toggle"; dropdownButton.textContent = "选择已有标签"; dropdownWrapper.appendChild(dropdownButton); const dropdownList = document.createElement("div"); dropdownList.className = "bm-tag-dropdown-list"; dropdownWrapper.appendChild(dropdownList); customHeader.appendChild(customLabel); customHeader.appendChild(dropdownWrapper); editContainer.appendChild(customHeader); const selectedContainer = document.createElement("div"); selectedContainer.className = "bm-selected-tags"; editContainer.appendChild(selectedContainer); const optionCheckboxMap = new Map(); const renderSelectedTags = () => { selectedContainer.innerHTML = ""; if (selectedCustomTags.size === 0) { const hint = document.createElement("span"); hint.className = "bm-tag-empty-hint"; hint.textContent = "尚未选择自定义标签"; selectedContainer.appendChild(hint); return; } Array.from(selectedCustomTags) .sort((a, b) => TAG_COLLATOR.compare(a, b)) .forEach((tag) => { const pill = document.createElement("span"); pill.className = "bm-selected-tag-pill"; pill.textContent = tag; const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = CONSTANTS.CLASSES.TAG_REMOVE_BTN; removeBtn.textContent = "×"; removeBtn.title = "移除该标签"; removeBtn.onclick = (event) => { event.stopPropagation(); selectedCustomTags.delete(tag); const checkbox = optionCheckboxMap.get(tag); if (checkbox) { checkbox.checked = false; } renderSelectedTags(); }; pill.appendChild(removeBtn); selectedContainer.appendChild(pill); }); }; const renderOptions = () => { optionCheckboxMap.clear(); dropdownList.innerHTML = ""; const sortedTags = Array.from(availableTags).sort((a, b) => TAG_COLLATOR.compare(a, b) ); if (sortedTags.length === 0) { const empty = document.createElement("span"); empty.className = "bm-tag-empty-hint"; empty.textContent = "暂无可复用标签"; dropdownList.appendChild(empty); return; } sortedTags.forEach((tag) => { const option = document.createElement("label"); option.className = "bm-tag-option"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.value = tag; checkbox.checked = selectedCustomTags.has(tag); checkbox.onchange = (event) => { event.stopPropagation(); if (checkbox.checked) { selectedCustomTags.add(tag); } else { selectedCustomTags.delete(tag); } renderSelectedTags(); }; const text = document.createElement("span"); text.textContent = tag; option.appendChild(checkbox); option.appendChild(text); option.addEventListener("click", (event) => event.stopPropagation()); dropdownList.appendChild(option); optionCheckboxMap.set(tag, checkbox); }); }; dropdownList.addEventListener("click", (event) => event.stopPropagation()); const handleOutsideClick = (event) => { if (!dropdownWrapper.contains(event.target)) { dropdownWrapper.classList.remove("open"); document.removeEventListener("click", handleOutsideClick); } }; dropdownButton.addEventListener("click", (event) => { event.stopPropagation(); const isOpen = dropdownWrapper.classList.toggle("open"); if (isOpen) { document.addEventListener("click", handleOutsideClick); } else { document.removeEventListener("click", handleOutsideClick); } }); const input = document.createElement("input"); input.className = CONSTANTS.IDS.TAG_EDIT_INPUT; input.type = "text"; input.placeholder = "输入新的自定义标签..."; editContainer.appendChild(input); const addBtn = document.createElement("button"); addBtn.className = `bm-btn ${CONSTANTS.CLASSES.TAG_ADD_BTN}`; addBtn.textContent = "添加"; const attemptAddNewTag = () => { const raw = input.value.trim(); if (!raw) { return; } if (originalTagSet.has(raw)) { showToast("该标签已存在于帖子标签中!", { isError: true }); return; } if (selectedCustomTags.has(raw)) { showToast("该自定义标签已存在!", { isError: true }); return; } selectedCustomTags.add(raw); availableTags.add(raw); input.value = ""; renderOptions(); renderSelectedTags(); }; addBtn.onclick = () => { attemptAddNewTag(); input.focus(); }; editContainer.appendChild(addBtn); const buttonCleanup = () => { document.removeEventListener("click", handleOutsideClick); dropdownWrapper.classList.remove("open"); }; const saveBtn = document.createElement("button"); saveBtn.className = `bm-btn ${CONSTANTS.CLASSES.TAG_SAVE_BTN}`; saveBtn.textContent = "完成"; saveBtn.onclick = () => { const success = setCustomTagsForBookmark( urlKey, Array.from(selectedCustomTags) ); if (success) { buttonCleanup(); } }; editContainer.appendChild(saveBtn); const cancelBtn = document.createElement("button"); cancelBtn.className = `bm-btn ${CONSTANTS.CLASSES.TAG_CANCEL_BTN}`; cancelBtn.textContent = "取消"; cancelBtn.onclick = () => { buttonCleanup(); exitTagEditMode(row); }; editContainer.appendChild(cancelBtn); tagCell.innerHTML = ""; tagCell.appendChild(editContainer); renderOptions(); renderSelectedTags(); input.focus(); input.onkeydown = (event) => { if (event.key === "Enter") { event.preventDefault(); attemptAddNewTag(); } if (event.key === "Escape") { event.preventDefault(); buttonCleanup(); exitTagEditMode(row); } }; } function exitTagEditMode(row) { renderBookmarksTable(); // 重新渲染表格以退出编辑模式 } function enterEditMode(row) { const nameCell = row.querySelector(".bm-name-cell"); if (nameCell.querySelector(`.${CONSTANTS.CLASSES.EDIT_INPUT}`)) return; // Already in edit mode const originalName = nameCell.textContent; nameCell.innerHTML = `<textarea class="${CONSTANTS.CLASSES.EDIT_INPUT}" style="width:100%; min-height:40px; max-height:120px; padding:8px; border-radius:4px; border:1px solid #DDD; font-family:inherit; font-size:inherit; resize:vertical; overflow-y:auto; line-height:1.4;">${originalName}</textarea>`; const actionCell = row.querySelector(".bm-actions-cell"); const originalButtons = actionCell.innerHTML; actionCell.innerHTML = `<button class="bm-btn ${CONSTANTS.CLASSES.SAVE_BTN}">保存</button><button class="bm-btn ${CONSTANTS.CLASSES.CANCEL_BTN}">取消</button>`; const input = nameCell.querySelector(`.${CONSTANTS.CLASSES.EDIT_INPUT}`); input.focus(); input.select(); // 自动调整 textarea 高度 const autoResize = () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 120) + "px"; }; autoResize(); input.addEventListener("input", autoResize); const handleBlur = () => { saveRename(row, input.value); }; input.addEventListener("blur", handleBlur); const cancelAction = () => { input.removeEventListener("blur", handleBlur); input.removeEventListener("input", autoResize); nameCell.textContent = originalName; actionCell.innerHTML = originalButtons; }; const saveAction = () => { input.removeEventListener("blur", handleBlur); input.removeEventListener("input", autoResize); saveRename(row, input.value); }; row.querySelector(`.${CONSTANTS.CLASSES.SAVE_BTN}`).onclick = saveAction; row.querySelector(`.${CONSTANTS.CLASSES.CANCEL_BTN}`).onclick = cancelAction; input.onkeydown = (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveAction(); } if (e.key === "Escape") { cancelAction(); } }; } function saveRename(row, newName) { if (!newName.trim()) { showToast("名称不能为空!", { isError: true }); return; } const urlKey = row.dataset.urlKey; modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark) bookmark.name = newName; return { bookmarks, changed: true }; }); renderBookmarksTable(); showToast("名称已更新!"); } function togglePinBookmark(row) { const urlKey = row.dataset.urlKey; let status = ""; modifyBookmarks((bookmarks) => { const bookmark = bookmarks.find((b) => getRootTopicUrl(b.url) === urlKey); if (bookmark) { bookmark.pinned = !bookmark.pinned; status = bookmark.pinned ? "置顶" : "取消置顶"; } return { bookmarks, changed: true }; }); showToast(`已${status}收藏`); renderBookmarksTable(); } function deleteBookmark(row) { const urlKey = row.dataset.urlKey; const allBookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const index = allBookmarks.findIndex( (b) => getRootTopicUrl(b.url) === urlKey ); if (index === -1) return; const item = allBookmarks[index]; // 从书签移除并移入回收站 modifyBookmarks((bookmarks) => { bookmarks.splice(index, 1); return { bookmarks, changed: true }; }); modifyTrash((trash) => { // 避免重复添加 const exists = trash.some((t) => getRootTopicUrl(t.url) === urlKey); if (!exists) trash.unshift({ ...item, deletedAt: Date.now() }); return { trash }; }); row.classList.add(CONSTANTS.CLASSES.ROW_HIDING); setTimeout(() => (row.style.display = "none"), 300); renderTagFilters(); showToast("已移入回收站", { actions: [ { text: "撤销", onClick: () => restoreFromTrash(urlKey, { showToastMsg: true }), }, ], }); } // [NEW] 从回收站恢复 function restoreFromTrash(urlKey, { showToastMsg = false } = {}) { const trash = GM_getValue(CONSTANTS.STORAGE_KEYS.TRASH, []); const idx = trash.findIndex((t) => getRootTopicUrl(t.url) === urlKey); if (idx === -1) return; const item = trash[idx]; modifyTrash((t) => { t.splice(idx, 1); return { trash: t }; }); modifyBookmarks((bookmarks) => { // 避免重复恢复 const exists = bookmarks.some((b) => getRootTopicUrl(b.url) === urlKey); if (!exists) bookmarks.unshift({ ...item, deletedAt: undefined }); return { bookmarks, changed: true }; }); renderBookmarksTable(); renderTagFilters(); if (showToastMsg) showToast("已恢复到收藏"); } // [NEW] 彻底删除单条 function purgeFromTrash(urlKey) { modifyTrash((trash) => { const idx = trash.findIndex((t) => getRootTopicUrl(t.url) === urlKey); if (idx !== -1) trash.splice(idx, 1); return { trash }; }); renderTrashTable(); showToast("已彻底删除"); } // [NEW] 清空回收站 function emptyTrash() { if (!confirm("确认清空回收站?此操作不可恢复!")) return; GM_setValue(CONSTANTS.STORAGE_KEYS.TRASH, []); renderTrashTable(); showToast("🧹 回收站已清空"); } function handleLocalImport(file) { const reader = new FileReader(); reader.onload = (e) => { try { const importedBookmarks = JSON.parse(e.target.result); if (!Array.isArray(importedBookmarks)) throw new Error("文件格式不正确。"); promptAndMergeBookmarks(importedBookmarks); } catch (error) { showToast("导入失败: " + error.message, { isError: true }); } finally { fileInput.value = ""; } }; reader.readAsText(file); } function handleLocalExport() { const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); if (bookmarks.length === 0) { showToast("没有收藏可以导出。", { isError: true }); return; } const dataStr = JSON.stringify(bookmarks, null, 2); const blob = new Blob([dataStr], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "linuxdo_bookmarks_backup.json"; a.click(); URL.revokeObjectURL(url); } // --- Part 4: WebDAV 核心功能 --- function getWebDAVConfig(fromStorage = true) { const server = fromStorage ? GM_getValue(CONSTANTS.STORAGE_KEYS.WEBDAV_SERVER) : getEl("webdav-server").value.trim(); const user = fromStorage ? GM_getValue(CONSTANTS.STORAGE_KEYS.WEBDAV_USER) : getEl("webdav-user").value.trim(); const pass = fromStorage ? GM_getValue(CONSTANTS.STORAGE_KEYS.WEBDAV_PASS) : getEl("webdav-pass").value; if (!server || !user || !pass) return null; return { server: server.endsWith("/") ? server : server + "/", user, pass }; } function saveWebDAVConfig() { const config = getWebDAVConfig(false); if (!config) { showToast("服务器、用户名和应用密码均不能为空!", { isError: true }); return; } GM_setValue(CONSTANTS.STORAGE_KEYS.WEBDAV_SERVER, config.server); GM_setValue(CONSTANTS.STORAGE_KEYS.WEBDAV_USER, config.user); GM_setValue(CONSTANTS.STORAGE_KEYS.WEBDAV_PASS, config.pass); GM_setValue( CONSTANTS.STORAGE_KEYS.AUTO_SYNC, getEl(CONSTANTS.IDS.AUTO_SYNC_TOGGLE).checked ); showToast("WebDAV 配置已保存!"); closeModal(webdavSettingsModal); } function webdavRequest(options) { const config = getWebDAVConfig(true); if (!config) { if (options.onerror) options.onerror({ status: 0, statusText: "WebDAV 配置不完整" }); else showToast("操作失败: WebDAV 配置不完整", { isError: true }); return; } GM_xmlhttpRequest({ method: options.method, url: config.server + (options.path || ""), headers: { Authorization: "Basic " + btoa(config.user + ":" + config.pass), ...options.headers, }, data: options.data, onload: options.onload, onerror: options.onerror, }); } function testWebDAVConnection() { const config = getWebDAVConfig(false); if (!config) { webdavTestResult.textContent = "请填写所有字段!"; return; } webdavTestResult.textContent = "正在测试连接..."; webdavRequest({ method: "PROPFIND", path: "", headers: { Depth: "0" }, onload: (res) => { if (res.status === 207 || res.status === 200) webdavTestResult.textContent = "连接成功!"; else if (res.status === 401) webdavTestResult.textContent = "连接失败: 用户名或密码错误 (401)"; else webdavTestResult.textContent = `连接失败: 服务器返回 ${res.status}`; }, onerror: () => (webdavTestResult.textContent = "连接失败: 请检查服务器地址或网络"), }); } function uploadToWebDAV(isAuto = false) { const filename = getTimestampedFilename(); const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); if (!isAuto) showToast(`正在手动备份到云端...`); const performPut = () => { webdavRequest({ method: "PUT", path: CONSTANTS.WEBDAV_DIR + filename, headers: { "Content-Type": "application/json; charset=utf-8" }, data: JSON.stringify(bookmarks, null, 2), onload: (res) => { if (res.status === 201 || res.status === 204) showToast(`${isAuto ? "自动" : "手动"}备份成功!`); else showToast(`备份失败: ${res.status} ${res.statusText}`, { isError: true, }); }, onerror: (res) => showToast(`备份出错: ${res.statusText}`, { isError: true }), }); }; webdavRequest({ method: "MKCOL", path: CONSTANTS.WEBDAV_DIR, onload: (res) => { if ([201, 405, 409].includes(res.status)) performPut(); else showToast(`创建云端目录失败: ${res.status} ${res.statusText}`, { isError: true, }); }, onerror: (res) => showToast(`创建云端目录出错: ${res.statusText}`, { isError: true }), }); } function triggerAutoWebDAVSync() { if (GM_getValue(CONSTANTS.STORAGE_KEYS.AUTO_SYNC, false)) { uploadToWebDAV(true); } } function listWebDAVBackups() { openModal(webdavBrowserModal); webdavBrowserList.innerHTML = '<li class="loading-text">正在加载备份列表...</li>'; webdavRequest({ method: "PROPFIND", path: CONSTANTS.WEBDAV_DIR, headers: { Depth: "1" }, onload: (res) => { if (res.status !== 207) { webdavBrowserList.innerHTML = `<li class="loading-text">加载失败: ${res.statusText} (请确保目录已存在)</li>`; return; } const parser = new DOMParser(); const xmlDoc = parser.parseFromString( res.responseText, "application/xml" ); const files = Array.from(xmlDoc.getElementsByTagName("d:href")) .concat(Array.from(xmlDoc.getElementsByTagName("D:href"))) .map((node) => node.textContent.split("/").pop()) .filter( (name) => name.startsWith("linuxdo-backup-") && name.endsWith(".json") ) .sort() .reverse(); if (files.length === 0) { webdavBrowserList.innerHTML = '<li class="loading-text">云端没有找到任何备份文件。</li>'; return; } webdavBrowserList.innerHTML = ""; files.forEach((file) => { const li = document.createElement("li"); li.className = "webdav-backup-item"; const nameSpan = document.createElement("span"); nameSpan.className = "webdav-backup-filename"; nameSpan.textContent = file; const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.className = "bm-btn bm-btn-danger webdav-delete-btn"; deleteBtn.textContent = "删除"; deleteBtn.onclick = (event) => { event.stopPropagation(); event.preventDefault(); deleteWebDAVBackup(file, li, deleteBtn); }; li.onclick = () => { if (deleteBtn.disabled) return; downloadFromWebDAV(file); }; li.appendChild(nameSpan); li.appendChild(deleteBtn); webdavBrowserList.appendChild(li); }); }, onerror: (res) => (webdavBrowserList.innerHTML = `<li class="loading-text">加载出错: ${res.statusText}</li>`), }); } function downloadFromWebDAV(filename) { showToast(`即将从云端恢复备份: ${filename}`); webdavRequest({ method: "GET", path: CONSTANTS.WEBDAV_DIR + filename, onload: (res) => { if (res.status === 200) { try { const cloudBookmarks = JSON.parse(res.responseText); if (!Array.isArray(cloudBookmarks)) throw new Error("云端数据格式错误。"); closeModal(webdavBrowserModal); promptAndMergeBookmarks(cloudBookmarks); } catch (e) { showToast("解析云端数据失败!" + e.message, { isError: true }); } } else { showToast(`下载失败!服务器响应: ${res.status} ${res.statusText}`, { isError: true, }); } }, onerror: (res) => showToast(`下载出错!详情: ${res.statusText}`, { isError: true }), }); } function deleteWebDAVBackup(filename, listItem, deleteButton) { if (!confirm(`确定要删除云端备份文件 ${filename} 吗?`)) return; const originalLabel = deleteButton.textContent; deleteButton.textContent = "删除中..."; deleteButton.disabled = true; const finalizeSuccess = () => { listItem.remove(); if (!webdavBrowserList.querySelector("li")) { webdavBrowserList.innerHTML = '<li class="loading-text">云端没有找到任何备份文件。</li>'; } showToast(`已删除云端备份: ${filename}`); }; const resetButton = () => { deleteButton.disabled = false; deleteButton.textContent = originalLabel; }; webdavRequest({ method: "DELETE", path: CONSTANTS.WEBDAV_DIR + filename, onload: (res) => { if ([200, 202, 204, 404].includes(res.status)) { finalizeSuccess(); } else { showToast(`删除失败: ${res.status} ${res.statusText}`, { isError: true, }); resetButton(); } }, onerror: (res) => { showToast(`删除出错: ${res.statusText}`, { isError: true }); resetButton(); }, }); } // --- Part 5: 通用逻辑与事件绑定 --- function promptAndMergeBookmarks(newBookmarks) { const choice = prompt( "请选择恢复模式:\n1. 增量合并 (智能去重)\n2. 完全覆盖 (清空本地后恢复)\n\n请输入数字 1 或 2" ); let dataChanged = false; modifyBookmarks((bookmarks) => { if (choice === "1") { const currentUrls = new Set( bookmarks.map((b) => getRootTopicUrl(b.url)) ); let addedCount = 0; newBookmarks.forEach((b) => { if (b.url && !currentUrls.has(getRootTopicUrl(b.url))) { bookmarks.unshift(b); addedCount++; } }); if (addedCount > 0) dataChanged = true; showToast( `合并完成!新增 ${addedCount} 条,跳过 ${newBookmarks.length - addedCount } 条。` ); } else if (choice === "2") { if (confirm("警告:此操作将清空您本地的所有收藏,确定要继续吗?")) { bookmarks = newBookmarks; dataChanged = true; showToast(`覆盖完成!成功恢复 ${newBookmarks.length} 条收藏。`); } } else { showToast("操作已取消。"); return false; } return { bookmarks, changed: dataChanged }; }); renderBookmarksTable(); } document.body.addEventListener("click", function (event) { // 处理可能的 SVG 或内部元素点击,找到真正的按钮元素 const target = event.target.closest('button') || event.target; if (target.classList.contains(CONSTANTS.CLASSES.TAG_FILTER_BTN)) { const tag = target.dataset.tag === "" ? null : target.dataset.tag; activeTagFilter = tag; currentPage = 1; // 切换标签时重置到第一页 renderTagFilters(); renderBookmarksTable(); return; } const row = target.closest("tr"); if (row && row.dataset.urlKey) { if (target.classList.contains(CONSTANTS.CLASSES.DELETE_BTN)) deleteBookmark(row); else if (target.classList.contains(CONSTANTS.CLASSES.RENAME_BTN)) enterEditMode(row); else if (target.classList.contains(CONSTANTS.CLASSES.TAG_EDIT_BTN)) enterTagEditMode(row); else if (target.classList.contains(CONSTANTS.CLASSES.PIN_BTN)) togglePinBookmark(row); else if (target.classList.contains(CONSTANTS.CLASSES.RESTORE_BTN)) restoreFromTrash(row.dataset.urlKey, { showToastMsg: true }); else if (target.classList.contains(CONSTANTS.CLASSES.PURGE_BTN)) purgeFromTrash(row.dataset.urlKey); return; } const buttonActions = { "manage-bookmarks-button": () => { setViewMode("bookmarks"); openModal(managerModal); }, [CONSTANTS.IDS.TRASH_BACK_BUTTON]: () => { setViewMode("bookmarks"); }, [CONSTANTS.IDS.SETTINGS_BUTTON]: () => { openModal(settingsModal); }, "webdav-settings-btn": () => { webdavTestResult.textContent = ""; getEl("webdav-server").value = GM_getValue( CONSTANTS.STORAGE_KEYS.WEBDAV_SERVER, "" ); getEl("webdav-user").value = GM_getValue( CONSTANTS.STORAGE_KEYS.WEBDAV_USER, "" ); getEl("webdav-pass").value = GM_getValue( CONSTANTS.STORAGE_KEYS.WEBDAV_PASS, "" ); autoSyncToggle.checked = GM_getValue( CONSTANTS.STORAGE_KEYS.AUTO_SYNC, false ); openModal(webdavSettingsModal); }, "save-webdav-settings": saveWebDAVConfig, "test-webdav-connection": testWebDAVConnection, "import-bookmarks-btn": () => fileInput.click(), "export-bookmarks-btn": handleLocalExport, "sync-from-cloud-btn": listWebDAVBackups, "sync-to-cloud-btn": () => uploadToWebDAV(false), [CONSTANTS.IDS.RENAME_TAGS_BUTTON]: () => startBulkRenameFlow(), [CONSTANTS.IDS.TRASH_TOGGLE_BUTTON]: () => { // 从配置页面进入回收站,需要先关闭配置页面,再打开主窗口显示回收站 closeModal(settingsModal); setViewMode("trash"); openModal(managerModal); }, [CONSTANTS.IDS.EMPTY_TRASH_BUTTON]: () => emptyTrash(), }; if (buttonActions[target.id]) buttonActions[target.id](); // 处理关闭按钮点击 if (target.classList.contains(CONSTANTS.CLASSES.CLOSE_BTN)) { const modalId = target.dataset.targetModal; if (modalId) { const modal = getEl(modalId); if (modal) { closeModal(modal); console.log(`关闭模态框: ${modalId}`); } else { console.error(`找不到模态框: ${modalId}`); } } } // 处理点击背景关闭模态框 if ( target.classList.contains(CONSTANTS.CLASSES.MODAL_BACKDROP) && !event.target.closest(`.${CONSTANTS.CLASSES.CONTENT_PANEL}`) ) closeModal(target); }); fileInput.addEventListener( "change", (e) => e.target.files[0] && handleLocalImport(e.target.files[0]) ); // 右键自定义标签快速重命名 document.body.addEventListener("contextmenu", (e) => { const pill = e.target.closest(`.${CONSTANTS.CLASSES.TAG_PILL}`); if ( pill && pill.parentElement && pill.parentElement.classList.contains(CONSTANTS.CLASSES.TAG_CELL) ) { // 仅对自定义标签启用(蓝色,有 editable 类 或 具有删除按钮) if (pill.classList.contains("editable")) { e.preventDefault(); const oldTag = pill.firstChild?.textContent || pill.textContent.replace(/×$/, "").trim(); if (oldTag) startBulkRenameFlow(oldTag); } } }); // 搜索框输入事件 const searchClearBtn = document.querySelector(".search-clear-btn"); searchInput.addEventListener("input", () => { currentPage = 1; // 搜索时重置到第一页 renderBookmarksTable(); // 显示/隐藏清除按钮 if (searchInput.value.trim()) { searchClearBtn.classList.add("visible"); } else { searchClearBtn.classList.remove("visible"); } }); // 清除按钮点击事件 searchClearBtn.addEventListener("click", () => { searchInput.value = ""; searchClearBtn.classList.remove("visible"); currentPage = 1; renderBookmarksTable(); searchInput.focus(); // 清除后聚焦到输入框 }); // 每页显示数量切换 pageSizeSelect.addEventListener("change", (e) => { itemsPerPage = parseInt(e.target.value, 10); currentPage = 1; // 切换每页数量时重置到第一页 renderBookmarksTable(); }); autoSyncToggle.addEventListener("change", (e) => GM_setValue(CONSTANTS.STORAGE_KEYS.AUTO_SYNC, e.target.checked) ); // --- Part 6: 页面按钮与初始化 --- const FLOATING_BUTTON_MARGIN = 12; const CLICK_MOVE_THRESHOLD = 3; function clamp(value, min, max) { if (!Number.isFinite(value)) return min; if (value < min) return min; if (value > max) return max; return value; } function applyFloatingButtonPosition(button, rightPercent, bottomPercent) { const width = button.offsetWidth || button.getBoundingClientRect().width || 0; const height = button.offsetHeight || button.getBoundingClientRect().height || 0; // 计算实际的right和bottom像素值 const rightPx = (window.innerWidth * rightPercent) / 100; const bottomPx = (window.innerHeight * bottomPercent) / 100; // 限制范围,确保按钮不会超出视口 const maxRight = Math.max(FLOATING_BUTTON_MARGIN, window.innerWidth - width - FLOATING_BUTTON_MARGIN); const maxBottom = Math.max(FLOATING_BUTTON_MARGIN, window.innerHeight - height - FLOATING_BUTTON_MARGIN); const clampedRight = clamp(rightPx, FLOATING_BUTTON_MARGIN, maxRight); const clampedBottom = clamp(bottomPx, FLOATING_BUTTON_MARGIN, maxBottom); button.style.right = `${clampedRight}px`; button.style.bottom = `${clampedBottom}px`; button.style.left = "auto"; button.style.top = "auto"; // 返回实际使用的百分比 return { rightPercent: (clampedRight / window.innerWidth) * 100, bottomPercent: (clampedBottom / window.innerHeight) * 100 }; } function loadSavedFloatingButtonPosition(button) { const savedPosition = GM_getValue( CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, null ); // 如果没有保存的位置,使用默认位置:右侧5%,垂直居中50% let rightPercent = 5; let bottomPercent = 50; if (savedPosition && typeof savedPosition === "object") { // 兼容旧版本的left/top存储格式,转换为百分比 if (typeof savedPosition.left === "number" && typeof savedPosition.top === "number") { rightPercent = ((window.innerWidth - savedPosition.left) / window.innerWidth) * 100; bottomPercent = ((window.innerHeight - savedPosition.top) / window.innerHeight) * 100; } else if (typeof savedPosition.rightPercent === "number" && typeof savedPosition.bottomPercent === "number") { rightPercent = savedPosition.rightPercent; bottomPercent = savedPosition.bottomPercent; } } requestAnimationFrame(() => { const applied = applyFloatingButtonPosition(button, rightPercent, bottomPercent); // 如果位置被调整或格式需要更新,保存新的百分比位置 if (Math.abs(applied.rightPercent - rightPercent) > 0.1 || Math.abs(applied.bottomPercent - bottomPercent) > 0.1 || !savedPosition || savedPosition.left !== undefined) { GM_setValue( CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, applied ); } }); } function persistFloatingButtonPosition(rightPx, bottomPx) { if (!Number.isFinite(rightPx) || !Number.isFinite(bottomPx)) return; // 将像素值转换为百分比保存 const rightPercent = (rightPx / window.innerWidth) * 100; const bottomPercent = (bottomPx / window.innerHeight) * 100; GM_setValue(CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, { rightPercent, bottomPercent, }); } // 将创建和更新浮动按钮的逻辑封装成一个独立的函数 function updateFloatingButtons() { // a. 首先,移除可能已存在的旧按钮 const existingButton = document.getElementById(CONSTANTS.IDS.MANAGE_BUTTON); if (existingButton) { existingButton.remove(); } // b. 创建新的组合按钮 const floatingButton = document.createElement("button"); floatingButton.id = CONSTANTS.IDS.MANAGE_BUTTON; floatingButton.className = "action-button"; // c. 创建按钮内部结构 const bookmarksText = document.createElement("span"); bookmarksText.textContent = "收藏夹"; bookmarksText.style.cursor = "pointer"; const heartIcon = document.createElement("span"); heartIcon.className = "heart-icon"; let dragState = null; let suppressNextClick = false; const capturedClickHandler = (event) => { if (!suppressNextClick) return; suppressNextClick = false; event.stopImmediatePropagation(); event.preventDefault(); }; floatingButton.addEventListener("click", capturedClickHandler, true); // d. 检查是否在帖子页面 const isTopicPage = window.location.href.includes("linux.do/t/"); if (isTopicPage) { // 检查当前页面的收藏状态 const currentUrl = window.location.href; const currentUrlKey = getRootTopicUrl(currentUrl); const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const isBookmarked = bookmarks.some( (b) => getRootTopicUrl(b.url) === currentUrlKey ); // 设置心形图标样式 if (isBookmarked) { heartIcon.textContent = "♥"; heartIcon.className = "heart-icon filled"; } else { heartIcon.textContent = "♡"; heartIcon.className = "heart-icon empty"; } // 为心形图标绑定点击事件 heartIcon.addEventListener("click", (e) => { e.stopPropagation(); const bookmarks = GM_getValue(CONSTANTS.STORAGE_KEYS.BOOKMARKS, []); const currentUrlKey = getRootTopicUrl(window.location.href); const bookmarkIndex = bookmarks.findIndex( (b) => getRootTopicUrl(b.url) === currentUrlKey ); if (bookmarkIndex !== -1) { // 已收藏,执行取消收藏 const deletedItem = bookmarks[bookmarkIndex]; // 移到回收站 modifyTrash((trash) => { const exists = trash.some((t) => getRootTopicUrl(t.url) === currentUrlKey); if (!exists) trash.unshift({ ...deletedItem, deletedAt: Date.now() }); return { trash }; }); // 从书签中删除 modifyBookmarks((bookmarks) => { bookmarks.splice(bookmarkIndex, 1); return { bookmarks, changed: true }; }); // 更新图标状态 heartIcon.textContent = "♡"; heartIcon.className = "heart-icon empty"; } else { // 未收藏,执行收藏 const postUrl = window.location.href; const fullTitle = document.title.replace(/\s*-\s*LINUX\s*DO\s*$/i, ""); let cleanTitle = fullTitle; let tags = []; const tagMatch = fullTitle.match(/\s*-\s*([^\-]+)$/); if (tagMatch && tagMatch[1]) { const rawTagString = tagMatch[1].trim(); const primaryTag = rawTagString.split(/[\/,]/)[0].trim(); tags.push(primaryTag); cleanTitle = fullTitle.replace(tagMatch[0], "").trim(); } modifyBookmarks((bookmarks) => { bookmarks.unshift({ name: cleanTitle, url: postUrl, pinned: false, tags: tags, timestamp: Date.now(), // 添加收藏时间戳 }); return { bookmarks, changed: true }; }); // 更新图标状态 heartIcon.textContent = "♥"; heartIcon.className = "heart-icon filled"; } }); // 组装按钮结构(带心形图标) floatingButton.appendChild(bookmarksText); floatingButton.appendChild(heartIcon); } else { // 非帖子页面,只显示收藏夹文本 floatingButton.appendChild(bookmarksText); } // e. 使用 pointer 事件统一处理拖拽和轻击打开逻辑 floatingButton.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; if (event.target === heartIcon) return; suppressNextClick = false; const initialRect = floatingButton.getBoundingClientRect(); const computedStyle = window.getComputedStyle(floatingButton); // 确保按钮使用right/bottom定位 if (computedStyle.right === "auto" || computedStyle.bottom === "auto") { floatingButton.style.right = `${window.innerWidth - initialRect.right}px`; floatingButton.style.bottom = `${window.innerHeight - initialRect.bottom}px`; floatingButton.style.left = "auto"; floatingButton.style.top = "auto"; } const rect = floatingButton.getBoundingClientRect(); dragState = { pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startRight: window.innerWidth - rect.right, startBottom: window.innerHeight - rect.bottom, moved: false, latestRight: window.innerWidth - rect.right, latestBottom: window.innerHeight - rect.bottom, initialTarget: event.target, }; floatingButton.setPointerCapture(event.pointerId); }); floatingButton.addEventListener("pointermove", (event) => { if (!dragState || event.pointerId !== dragState.pointerId) return; const deltaX = event.clientX - dragState.startClientX; const deltaY = event.clientY - dragState.startClientY; if (!dragState.moved) { if (Math.abs(deltaX) + Math.abs(deltaY) < CLICK_MOVE_THRESHOLD) { return; } dragState.moved = true; floatingButton.classList.add("dragging"); } // 注意:向右拖动时deltaX为正,但right应该减小;向下拖动时deltaY为正,但bottom应该减小 const proposedRight = dragState.startRight - deltaX; const proposedBottom = dragState.startBottom - deltaY; // 使用百分比计算 const proposedRightPercent = (proposedRight / window.innerWidth) * 100; const proposedBottomPercent = (proposedBottom / window.innerHeight) * 100; const applied = applyFloatingButtonPosition( floatingButton, proposedRightPercent, proposedBottomPercent ); // 保存当前的像素位置用于后续计算 dragState.latestRight = (applied.rightPercent / 100) * window.innerWidth; dragState.latestBottom = (applied.bottomPercent / 100) * window.innerHeight; }); const finishPointerInteraction = (event, shouldTreatAsClick) => { if (!dragState || event.pointerId !== dragState.pointerId) return; floatingButton.releasePointerCapture(event.pointerId); floatingButton.classList.remove("dragging"); if (dragState.moved) { suppressNextClick = true; event.preventDefault(); event.stopPropagation(); persistFloatingButtonPosition(dragState.latestRight, dragState.latestBottom); } else if (shouldTreatAsClick) { suppressNextClick = true; event.stopPropagation(); const managerModal = document.getElementById(CONSTANTS.IDS.MANAGER_MODAL); if (managerModal) { setViewMode("bookmarks"); openModal(managerModal); } } dragState = null; if (suppressNextClick) { setTimeout(() => { suppressNextClick = false; }, 0); } }; floatingButton.addEventListener("pointerup", (event) => { finishPointerInteraction(event, true); }); floatingButton.addEventListener("pointercancel", (event) => { finishPointerInteraction(event, false); }); document.body.appendChild(floatingButton); loadSavedFloatingButtonPosition(floatingButton); } // 页面首次加载时,立即执行一次函数来创建按钮 updateFloatingButtons(); window.addEventListener("resize", () => { const button = document.getElementById(CONSTANTS.IDS.MANAGE_BUTTON); if (!button) return; const saved = GM_getValue( CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, null ); if (!saved || typeof saved !== "object") return; // 支持新旧两种存储格式 let rightPercent, bottomPercent; if (typeof saved.rightPercent === "number" && typeof saved.bottomPercent === "number") { rightPercent = saved.rightPercent; bottomPercent = saved.bottomPercent; } else if (typeof saved.left === "number" && typeof saved.top === "number") { // 兼容旧格式,转换为百分比 rightPercent = ((window.innerWidth - saved.left) / window.innerWidth) * 100; bottomPercent = ((window.innerHeight - saved.top) / window.innerHeight) * 100; } else { return; } const applied = applyFloatingButtonPosition(button, rightPercent, bottomPercent); if (Math.abs(applied.rightPercent - rightPercent) > 0.1 || Math.abs(applied.bottomPercent - bottomPercent) > 0.1) { GM_setValue(CONSTANTS.STORAGE_KEYS.FLOATING_BUTTON_POSITION, applied); } }); // 创建一个 MutationObserver 来监听页面标题的变化 // 这是修复 SPA 页面切换 bug 的核心 const observer = new MutationObserver(() => { // 当监听到变化(意味着可能切换了帖子),就重新调用函数来更新按钮状态 // 使用 setTimeout 做一个小的延迟,确保页面其他部分也已加载完毕 setTimeout(updateFloatingButtons, 200); }); // 让观察者开始监视 <title> 元素的变化 const titleElement = document.querySelector("title"); if (titleElement) { observer.observe(titleElement, { childList: true }); } console.log("超级收藏夹 (v6.0 简化UI版) 已加载!"); // 视图切换 function setViewMode(mode) { viewMode = mode; const header = document.getElementById("bm-header-title"); activeTagFilter = null; currentPage = 1; // 切换视图时重置页码 if (trashBackButton) { trashBackButton.style.display = viewMode === "trash" ? "inline-flex" : "none"; } if (viewMode === "trash") { header && (header.textContent = "回收站"); } else { header && (header.textContent = "收藏夹"); } renderTagFilters(); renderBookmarksTable(); } })();