[油猴脚本]一个L站原生风格的历史浏览记录

参考了站内几位大佬的脚本,把入口由悬浮窗改为顶栏,风格也更贴合

优雅++,舒适++ :smiling_face_with_sunglasses:

功能如下:

  • 自定义保留时长
  • 自定义保留数目
  • 自定义时间排序
  • PC-移动端支持,深-浅色支持
  • 支持图标入口右上角角标开关

特殊说明:

100%字研,Codex立大功 :nerd_face:

功能截图


欢迎体验与反馈或者二创
点击获取脚本

23 个赞

佬牛啊!

2 个赞

cool

1 个赞

得感谢GPT-5.2,主播学嵌入式的,根本不会js :joy:

1 个赞

挺好的 支持一下

好看的呢

1 个赞

提个功能上的建议,点击框选的按钮,能够跳转 https://linux.do/u/[id]/activity/read 即官方的历史记录

1 个赞

ok,确实可以加一个

另外这个角标能不能给个开关,强迫症看着很难受 :bili_057: 毕竟好像不怎么需要关注历史记录有多少条
image

2 个赞

也可以
其实之前一版是蓝色的,我嫌太显眼才改成这样的 :joy:

1 个赞

感谢大佬!

全部都实现了,你看看有没有bug,我这边测试应该是可以用的 :lark_010:

可能有些bug

感谢分享

上面的无法编辑了,再更新一下,修复了一些显示问题

// ==UserScript==
// @name         Linux.do 浏览历史记录
// @namespace    http://tampermonkey.net/
// @version      2.42
// @description  记录并显示 Linux.do 的浏览历史(修复标题过长换行问题)
// @author       GPT, Gemini
// @match        https://linux.do/*
// @icon         data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%2212%22%20fill%3D%22%23f7f3e8%22/%3E%3Cpath%20fill%3D%22%23222%22%20d%3D%22M13%203a9%209%200%200%200-9%209H1l3.89%203.89.07.14L9%2012H6c0-3.87%203.13-7%207-7s7%203.13%207%207-3.13%207-7%207c-1.93%200-3.68-.79-4.94-2.06l-1.42%201.42A8.954%208.954%200%200%200%2013%2021a9%209%200%200%200%200-18zm-1%205v5l4.28%202.54.72-1.21-3.5-2.08V8H12z%22/%3E%3C/svg%3E
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // ===== 常量配置 =====
    const KEYS = {
        STORAGE: 'linuxdo_browse_history',
        VIEW_MODE: 'linuxdo_browse_history_view_mode',
        RETENTION: 'linuxdo_browse_history_retention',
        MAX_SIZE: 'linuxdo_browse_history_max_size',
        SHOW_BADGE: 'linuxdo_browse_history_show_badge'
    };

    const RECENT_LIMIT = 50;
    const SEARCH_RESULT_LIMIT = 200;
    const DEFAULT_MAX_SIZE = 2000;
    const MAX_SIZE_OPTIONS = [100, 200, 500, 1000, 2000, 5000, 10000];

    // 图标常量
    const ICONS = {
        GEAR: `<svg class="fa d-icon d-icon-cog svg-icon svg-string" viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.04-.7-1.64-.94l-.37-2.85A.507.507 0 0 0 13.94 2h-3.88c-.25 0-.47.18-.5.43l-.37 2.85c-.6.24-1.15.56-1.64.94l-2.39-.96a.495.495 0 0 0-.59.22L2.65 8.8c-.12.21-.08.48.12.61l2.03 1.58c-.05.3-.07.62-.07.94 0 .31.02.63.06.94L2.72 14.52c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.04.7 1.64.94l.37 2.85c.03.25.25.43.5.43h3.88c.25 0 .47-.18.5-.43l.37-2.85c.6-.24 1.15-.56 1.64-.94l2.39.96c.22.09.47 0 .59-.22l1.92-3.32c.12-.22.08-.48-.12-.61l-2.01-1.58zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5S10.07 8.5 12 8.5s3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></svg>`,
        ARROW: `<svg class="fa d-icon d-icon-arrow-left svg-icon svg-string" viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>`,
        TRASH: `<svg class="fa d-icon d-icon-trash-can svg-icon svg-string" viewBox="0 0 24 24"><path d="M9 3h6l1 2h5v2H3V5h5l1-2zm-2 6h2v10h2V9h2v10h2V9h2v10h2V9h2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V9z"/></svg>`,
        SEARCH: `<svg class="fa d-icon d-icon-search svg-icon svg-string" viewBox="0 0 24 24"><path d="M21.71 20.29l-3.38-3.38A7.92 7.92 0 0 0 19 11a8 8 0 1 0-8 8 7.92 7.92 0 0 0 5.91-2.67l3.38 3.38a1 1 0 0 0 1.42-1.42zM11 17a6 6 0 1 1 6-6 6 6 0 0 1-6 6z"/></svg>`,
        CLOCK: `<svg class="fa d-icon d-icon-clock svg-icon svg-string" viewBox="0 0 24 24"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>`
    };

    // ===== 状态变量 =====
    let currentSearchQuery = '';
    let historyCache = null;
    let historyCacheRaw = null;
    let historyCacheDirty = true;

    // ===== 工具函数 =====
    const debounce = (func, wait) => {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    };

    function safeJSONParse(str, fallback = []) {
        try { return JSON.parse(str) || fallback; } catch (e) { return fallback; }
    }

    // ===== 数据层 =====
    function getSetting(key, def, normalize) {
        try {
            const val = GM_getValue(key);
            return normalize ? normalize(val) : (val ?? def);
        } catch (e) { return def; }
    }

    function saveSetting(key, val, normalize) {
        try {
            const v = normalize ? normalize(val) : val;
            GM_setValue(key, v);
            if (key === KEYS.RETENTION || key === KEYS.MAX_SIZE) historyCacheDirty = true;
        } catch (e) {}
    }

    const Settings = {
        getViewMode: () => getSetting(KEYS.VIEW_MODE, 'recent', v => v === 'all' ? 'all' : 'recent'),
        saveViewMode: v => saveSetting(KEYS.VIEW_MODE, v),
        getRetention: () => getSetting(KEYS.RETENTION, 'permanent', v => ['7d','14d','1m','3m','6m','1y','permanent'].includes(v) ? v : 'permanent'),
        saveRetention: v => saveSetting(KEYS.RETENTION, v),
        getMaxSize: () => getSetting(KEYS.MAX_SIZE, DEFAULT_MAX_SIZE, v => {
            const n = Number(v); return (n > 0) ? MAX_SIZE_OPTIONS.reduce((p,c) => Math.abs(c-n) < Math.abs(p-n)?c:p) : DEFAULT_MAX_SIZE;
        }),
        saveMaxSize: v => saveSetting(KEYS.MAX_SIZE, v),
        getShowBadge: () => getSetting(KEYS.SHOW_BADGE, true, v => !(v === false || v === 'false' || v === '0')),
        saveShowBadge: v => saveSetting(KEYS.SHOW_BADGE, v)
    };

    function getHistory() {
        try {
            const raw = GM_getValue(KEYS.STORAGE, '[]');
            if (!historyCacheDirty && historyCache && raw === historyCacheRaw) return historyCache;

            let list = safeJSONParse(raw);
            if (!Array.isArray(list)) list = [];

            const map = new Map();
            for (const item of list) {
                if (!item.topicId || !item.url) continue;
                const existing = map.get(item.topicId);
                if (existing) {
                    existing.lastVisitedAt = Math.max(existing.lastVisitedAt || 0, item.lastVisitedAt || item.timestamp || 0);
                    existing.firstVisitedAt = Math.min(existing.firstVisitedAt || Infinity, item.firstVisitedAt || item.timestamp || Infinity);
                    existing.visitCount = (existing.visitCount || 1) + (item.visitCount || 1);
                    existing.title = item.title || existing.title;
                } else {
                    map.set(item.topicId, {
                        ...item,
                        lastVisitedAt: item.lastVisitedAt || item.timestamp || 0,
                        firstVisitedAt: item.firstVisitedAt || item.timestamp || 0,
                        visitCount: item.visitCount || 1
                    });
                }
            }
            list = Array.from(map.values());

            const ret = Settings.getRetention();
            if (ret !== 'permanent') {
                const daysMap = { '7d':7, '14d':14, '1m':30, '3m':90, '6m':180, '1y':365 };
                const limit = Date.now() - (daysMap[ret] * 86400000);
                list = list.filter(i => i.lastVisitedAt >= limit);
            }

            list.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt);
            const maxSize = Settings.getMaxSize();
            if (list.length > maxSize) list = list.slice(0, maxSize);

            const newRaw = JSON.stringify(list);
            if (newRaw !== raw) GM_setValue(KEYS.STORAGE, newRaw);

            historyCache = list;
            historyCacheRaw = newRaw;
            historyCacheDirty = false;
            return list;
        } catch (e) { return []; }
    }

    function addToHistory() {
        const url = location.href;
        const match = url.match(/linux\.do\/t\/([^\/]+)\/(\d+)/);
        if (!match) return;

        const titleEl = document.querySelector('h1 a.fancy-title') || document.querySelector('.fancy-title') || document.querySelector('h1');
        const title = titleEl?.textContent.trim();
        if (!title) return;

        const topicId = match[2];
        const cleanUrl = `https://linux.do/t/${match[1]}/${match[2]}`;
        const now = Date.now();
        const history = getHistory();

        const idx = history.findIndex(i => String(i.topicId) === topicId);
        let item = idx >= 0 ? history.splice(idx, 1)[0] : { topicId, url: cleanUrl, firstVisitedAt: now, visitCount: 0 };

        item.title = title;
        item.lastVisitedAt = now;
        item.visitCount = (item.visitCount || 0) + 1;

        history.unshift(item);
        const maxSize = Settings.getMaxSize();
        if (history.length > maxSize) history.length = maxSize;

        GM_setValue(KEYS.STORAGE, JSON.stringify(history));
        historyCacheDirty = true;
        updateHistoryPanel();
    }

    let saveTimer = null;
    function scheduleSave() {
        if (saveTimer) clearTimeout(saveTimer);
        saveTimer = setTimeout(addToHistory, 1500);
    }

    // ===== 导入导出 =====
    function exportData() {
        const data = GM_getValue(KEYS.STORAGE, '[]');
        const blob = new Blob([data], {type: 'application/json'});
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
        a.href = url;
        a.download = `linuxdo_history_${date}.json`;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    function importData(file) {
        if (!file) return;
        const reader = new FileReader();
        reader.onload = (e) => {
            try {
                const imported = JSON.parse(e.target.result);
                if (!Array.isArray(imported)) throw new Error('无效的格式');
                const current = safeJSONParse(GM_getValue(KEYS.STORAGE, '[]'));
                const merged = [...imported, ...current];
                GM_setValue(KEYS.STORAGE, JSON.stringify(merged));
                historyCacheDirty = true;
                alert(`导入成功!共合并处理记录,面板将刷新。`);
                updateHistoryPanel();
            } catch (err) { alert('导入失败:文件格式不正确'); }
        };
        reader.readAsText(file);
    }

    // ===== UI 构建 =====
    function injectStyles() {
        const css = `
            #ld-history-container { position: relative; }
            #ld-history-btn { position: relative; }
            #ld-history-btn svg { fill: currentColor; }
            #ld-history-badge {
                position: absolute; top: -3px; right: -3px; min-width: 18px; height: 18px;
                padding: 0 4px; border-radius: 9px; background: var(--primary-low-mid, #cfcfcf);
                color: var(--primary, #333); font-size: 11px; line-height: 18px;
                text-align: center; display: none; opacity: 0.9; pointer-events: none;
            }
            #ld-history-badge.show { display: inline-block; }
            #ld-history-panel {
                position: absolute; top: calc(100% + 8px); right: 0;
                width: min(360px, calc(100vw - 24px)); max-height: 0;
                background: var(--secondary, #fff); border: 1px solid var(--primary-low, #e5e5e5);
                border-radius: 4px; box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
                z-index: 99999; overflow: hidden; opacity: 0; pointer-events: none;
                transition: max-height 0.2s ease, opacity 0.2s ease; display: flex; flex-direction: column;
            }
            #ld-history-panel.show { max-height: min(560px, calc(100vh - 72px)); opacity: 1; pointer-events: auto; }

            .ld-history-header {
                padding: 8px 10px; background: var(--primary-low, #f6f6f6); color: var(--primary, #333);
                font-weight: bold; font-size: 14px; display: flex; flex-shrink: 0;
                justify-content: space-between; align-items: center; gap: 4px;
                border-bottom: 1px solid var(--primary-low, #e5e5e5);
                flex-wrap: nowrap; /* 强制不换行 */
            }
            .ld-history-header-title {
                display: flex; align-items: baseline; gap: 4px; flex: 1;
                overflow: hidden; min-width: 0; /* 允许子元素缩小 */
                white-space: nowrap; /* 核心:禁止文字换行 */
            }
            .ld-history-header-title-text {
                background:none; border:none; padding:0; font:inherit; color:inherit; cursor:pointer;
                white-space: nowrap; flex-shrink: 0;
            }
            .ld-history-count {
                white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
                font-weight: normal; font-size: 11px; opacity: 0.8;
            }

            .ld-history-header-actions { display: flex; gap: 4px; flex-shrink: 0; }
            .ld-history-header-icons { display: flex; gap: 2px; flex-shrink: 0; }

            .ld-icon-btn {
                width: 28px; height: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
                color: var(--primary-high, #555); background: transparent; border: none; cursor: pointer; border-radius: 4px;
            }
            .ld-icon-btn:hover { background: var(--primary-low-mid, #e5e5e5); color: var(--primary, #222); }
            /* 强制约束SVG尺寸,防止d-icon样式污染导致的布局崩坏 */
            .ld-icon-btn svg { width: 14px !important; height: 14px !important; fill: currentColor; pointer-events: none; max-width: 14px; max-height: 14px; }

            .ld-search-bar { display: none; flex-shrink: 0; padding: 8px 10px; background: var(--primary-low, #f6f6f6); border-bottom: 1px solid var(--primary-low, #e5e5e5); }
            .ld-search-bar.show { display: block; }
            .ld-search-input { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--primary-medium, #999); border-radius: 4px; font-size: 13px; }

            .ld-history-list { flex: 1; overflow-y: auto; padding: 0; min-height: 0; }
            .ld-history-settings { display: none; flex: 1; overflow-y: auto; padding: 10px 12px; min-height: 0; }
            #ld-history-panel.view-settings .ld-history-settings { display: block; }
            #ld-history-panel.view-settings .ld-history-list,
            #ld-history-panel.view-settings .ld-history-header-actions,
            #ld-history-panel.view-settings .ld-search-bar { display: none; }

            .ld-settings-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
            .ld-settings-section-title { font-size: 13px; font-weight: 600; }
            .ld-settings-value { font-size: 12px; font-weight: 600; }
            .ld-retention-slider { width: 100%; margin-bottom: 6px; accent-color: var(--tertiary, #0088cc); }
            .ld-settings-toggle-row { display: flex; align-items: center; gap: 4px; margin-top: 10px; cursor: pointer; }
            .ld-settings-actions { display: flex; justify-content: flex-end; margin-top: 10px; gap: 8px; }
            .ld-settings-divider { height: 1px; background: var(--primary-low, #e5e5e5); margin: 15px 0 10px; }

            .ld-history-item { display: block; padding: 10px 12px; border-bottom: 1px solid var(--primary-low, #f0f0f0); text-decoration: none; color: inherit; cursor: pointer; }
            .ld-history-item:hover { background: var(--primary-low, #f6f6f6); }
            .ld-history-title { font-size: 14px; color: var(--primary, #333); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
            .ld-history-time { font-size: 12px; color: var(--primary-medium, #777); margin-top: 4px; }
            .ld-history-empty { padding: 32px; text-align: center; color: var(--primary-medium, #777); }

            .ld-history-list::-webkit-scrollbar { width: 4px; }
            .ld-history-list::-webkit-scrollbar-thumb { background: var(--primary-low-mid, #cfcfcf); border-radius: 6px; }

            @media (max-width: 480px) {
                #ld-history-panel { position: fixed; left: 8px; right: 8px; width: auto; top: var(--ld-history-panel-top, 56px); }
            }
        `;
        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);
    }

    function formatTime(ts) {
        const diff = Date.now() - ts;
        if (diff < 60000) return '刚刚';
        if (diff < 3600000) return Math.floor(diff/60000) + ' 分钟前';
        if (diff < 86400000) return Math.floor(diff/3600000) + ' 小时前';
        return Math.floor(diff/86400000) + ' 天前';
    }

    // 尝试使用sprite提升主题兼容性,但也提供fallback
    function upgradeIcon(btn, idStr) {
        if (!btn) return;
        const svg = btn.querySelector('svg');
        if (!svg) return;
        const syms = document.querySelectorAll('symbol[id]');
        const found = Array.from(syms).find(s => idStr.includes(s.id) || idStr.some(k => s.id.includes(k)));
        if (found) {
             svg.innerHTML = `<use href="#${found.id}"></use>`;
             svg.classList.add('d-icon', `d-icon-${found.id}`);
             svg.removeAttribute('viewBox'); // sprite通常不需要viewBox
        }
    }

    function renderSettingsView(container, panel) {
        const ret = Settings.getRetention();
        const size = Settings.getMaxSize();
        const badge = Settings.getShowBadge();

        container.innerHTML = `
            <div class="ld-settings-row"><span class="ld-settings-section-title">保留时间</span><span class="ld-settings-value" id="val-ret">${ret==='permanent'?'永久':ret}</span></div>
            <input type="range" class="ld-retention-slider" id="rng-ret" min="0" max="6" value="${['7d','14d','1m','3m','6m','1y','permanent'].indexOf(ret)}">
            <div class="ld-settings-row"><span class="ld-settings-section-title">最大条数</span><span class="ld-settings-value" id="val-size">${size}条</span></div>
            <input type="range" class="ld-retention-slider" id="rng-size" min="0" max="${MAX_SIZE_OPTIONS.length-1}" value="${MAX_SIZE_OPTIONS.indexOf(size)}">
            <label class="ld-settings-toggle-row"><span class="ld-settings-section-title">显示角标</span><input type="checkbox" id="chk-badge" ${badge?'checked':''}></label>

            <div class="ld-settings-divider"></div>
            <div class="ld-settings-row">
                <span class="ld-settings-section-title">数据备份</span>
                <div>
                    <button id="btn-export" class="btn btn-default btn-small">导出</button>
                    <button id="btn-import" class="btn btn-default btn-small">导入</button>
                    <input type="file" id="inp-file" style="display:none" accept=".json">
                </div>
            </div>

            <div class="ld-settings-actions"><button id="btn-save" class="btn btn-primary btn-small">保存设置</button></div>
        `;

        const opts = ['7d','14d','1m','3m','6m','1y','permanent'];
        const rngRet = container.querySelector('#rng-ret');
        const rngSize = container.querySelector('#rng-size');

        rngRet.oninput = () => container.querySelector('#val-ret').textContent = (opts[rngRet.value] === 'permanent' ? '永久' : opts[rngRet.value]);
        rngSize.oninput = () => container.querySelector('#val-size').textContent = MAX_SIZE_OPTIONS[rngSize.value] + '条';

        container.querySelector('#btn-export').onclick = (e) => { e.stopPropagation(); exportData(); };
        const inpFile = container.querySelector('#inp-file');
        container.querySelector('#btn-import').onclick = (e) => { e.stopPropagation(); inpFile.click(); };
        inpFile.onchange = (e) => { if(e.target.files[0]) importData(e.target.files[0]); e.target.value = ''; };

        container.querySelector('#btn-save').onclick = (e) => {
            e.stopPropagation();
            Settings.saveRetention(opts[rngRet.value]);
            Settings.saveMaxSize(MAX_SIZE_OPTIONS[rngSize.value]);
            Settings.saveShowBadge(container.querySelector('#chk-badge').checked);
            panel.classList.remove('view-settings');

            // 退出设置页时,恢复齿轮图标
            const setBtn = panel.querySelector('#ld-settings-btn');
            if (setBtn) {
                setBtn.title = '设置';
                setBtn.innerHTML = ICONS.GEAR;
            }
            const hdr = panel.querySelector('.ld-history-header-title-text');
            if (hdr) hdr.textContent = '浏览历史';

            updateHistoryPanel();
        };
    }

    function updateHistoryPanel() {
        const panel = document.getElementById('ld-history-panel');
        if (!panel) return;

        const listEl = panel.querySelector('.ld-history-list');
        const viewMode = Settings.getViewMode();
        panel.dataset.viewMode = viewMode;

        const viewBtn = panel.querySelector('#ld-view-toggle-btn');
        if (viewBtn) {
            viewBtn.textContent = viewMode === 'all' ? '全部记录' : '最近50条';
            viewBtn.classList.toggle('btn-primary', viewMode === 'all');
        }

        const all = getHistory();
        let display = [];
        let infoText = '';

        if (currentSearchQuery) {
            const lowerQ = currentSearchQuery.toLowerCase();
            display = all.filter(i => (i.title + i.url).toLowerCase().includes(lowerQ));
            infoText = `(搜: ${display.length})`;
            if (display.length > SEARCH_RESULT_LIMIT) display = display.slice(0, SEARCH_RESULT_LIMIT);
        } else {
            display = (viewMode === 'all') ? all : all.slice(0, RECENT_LIMIT);
            infoText = (viewMode !== 'all' && all.length > RECENT_LIMIT) ? `( ${display.length}/${all.length})` : `(${all.length})`;
        }

        display.sort((a,b) => b.lastVisitedAt - a.lastVisitedAt);
        if (panel.classList.contains('sort-ascending')) display.reverse();

        const badgeEl = document.getElementById('ld-history-badge');
        if (badgeEl) {
            badgeEl.textContent = all.length > 99 ? '99+' : all.length;
            badgeEl.classList.toggle('show', Settings.getShowBadge() && all.length > 0);
        }

        const countEl = panel.querySelector('.ld-history-count');
        if (countEl) countEl.textContent = infoText;

        if (!display.length) {
            listEl.innerHTML = `<div class="ld-history-empty">${currentSearchQuery ? '无搜索结果' : '暂无记录'}</div>`;
            return;
        }

        const esc = t => { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; };
        listEl.innerHTML = display.map(i => `
            <a class="ld-history-item" href="${esc(i.url)}">
                <div class="ld-history-title">${esc(i.title)}</div>
                <div class="ld-history-time">${formatTime(i.lastVisitedAt)}</div>
            </a>
        `).join('');
    }

    function ensureUI() {
        if (document.getElementById('ld-history-container')) return true;
        const host = document.querySelector('.d-header-icons') || document.querySelector('.header-buttons');
        if (!host) return false;

        const container = document.createElement(host.tagName === 'UL' ? 'li' : 'span');
        container.id = 'ld-history-container';
        if (host.tagName === 'UL') container.className = 'custom-header-icon-link';

        container.innerHTML = `
            <button id="ld-history-btn" class="btn no-text icon btn-flat" title="浏览历史">
                ${ICONS.CLOCK}
                <span id="ld-history-badge"></span>
            </button>
            <div id="ld-history-panel" role="dialog">
                <div class="ld-history-header">
                    <span class="ld-history-header-title">
                        <button class="ld-history-header-title-text" id="ld-goto-read">浏览历史</button>
                        <span class="ld-history-count"></span>
                    </span>
                    <div class="ld-history-header-actions">
                        <button id="ld-view-toggle-btn" class="btn btn-default btn-small">最近50条</button>
                        <button id="ld-sort-btn" class="btn btn-default btn-small">时间↑</button>
                    </div>
                    <div class="ld-history-header-icons">
                        <button id="ld-search-toggle-btn" class="ld-icon-btn" title="搜索">${ICONS.SEARCH}</button>
                        <button id="ld-clear-btn" class="ld-icon-btn" title="清空">${ICONS.TRASH}</button>
                        <button id="ld-settings-btn" class="ld-icon-btn" title="设置">${ICONS.GEAR}</button>
                    </div>
                </div>
                <div id="ld-search-bar" class="ld-search-bar">
                    <input type="text" id="ld-search-input" class="ld-search-input" placeholder="搜索历史..." autocomplete="off">
                </div>
                <div class="ld-history-list"></div>
                <div class="ld-history-settings"></div>
            </div>
        `;
        host.appendChild(container);

        const panel = container.querySelector('#ld-history-panel');
        const btn = container.querySelector('#ld-history-btn');
        const searchBar = container.querySelector('#ld-search-bar');
        const searchInput = container.querySelector('#ld-search-input');

        btn.onclick = (e) => {
            e.preventDefault(); e.stopPropagation();
            const show = panel.classList.toggle('show');
            if (show) {
                if (window.innerWidth <= 480) panel.style.setProperty('--ld-history-panel-top', (btn.getBoundingClientRect().bottom + 8) + 'px');
                panel.classList.remove('view-settings');
                // 恢复默认图标状态
                container.querySelector('#ld-settings-btn').innerHTML = ICONS.GEAR;
                container.querySelector('.ld-history-header-title-text').textContent = '浏览历史';

                upgradeIcon(container.querySelector('#ld-clear-btn'), ['trash']);
                upgradeIcon(container.querySelector('#ld-search-toggle-btn'), ['search']);

                if (currentSearchQuery) { searchBar.classList.add('show'); searchInput.value = currentSearchQuery; }
                updateHistoryPanel();
            }
        };

        container.querySelector('#ld-search-toggle-btn').onclick = (e) => {
            e.stopPropagation();
            if (searchBar.classList.toggle('show')) searchInput.focus();
            else { currentSearchQuery = ''; searchInput.value = ''; updateHistoryPanel(); }
        };
        searchBar.onclick = (e) => e.stopPropagation();

        const performSearch = debounce((q) => { currentSearchQuery = q; updateHistoryPanel(); }, 300);
        searchInput.oninput = (e) => performSearch(e.target.value.trim());

        container.querySelector('#ld-view-toggle-btn').onclick = (e) => {
            e.stopPropagation();
            Settings.saveViewMode(Settings.getViewMode() === 'all' ? 'recent' : 'all');
            updateHistoryPanel();
        };

        container.querySelector('#ld-sort-btn').onclick = (e) => {
            e.stopPropagation();
            const isAsc = panel.classList.toggle('sort-ascending');
            e.target.textContent = isAsc ? '时间↓' : '时间↑';
            updateHistoryPanel();
        };

        container.querySelector('#ld-clear-btn').onclick = (e) => {
            e.stopPropagation();
            if(confirm('确定清空历史?')) { GM_setValue(KEYS.STORAGE, '[]'); historyCacheDirty = true; updateHistoryPanel(); }
        };

        const setBtn = container.querySelector('#ld-settings-btn');
        const headerTitle = container.querySelector('.ld-history-header-title-text');

        // 修复后的切换逻辑:直接替换innerHTML,避免样式错乱
        setBtn.onclick = (e) => {
            e.stopPropagation();
            const isSet = panel.classList.toggle('view-settings');
            if (isSet) {
                renderSettingsView(container.querySelector('.ld-history-settings'), panel);
                headerTitle.textContent = '设置';
                setBtn.title = '返回';
                setBtn.innerHTML = ICONS.ARROW;
            } else {
                headerTitle.textContent = '浏览历史';
                setBtn.title = '设置';
                setBtn.innerHTML = ICONS.GEAR;
                updateHistoryPanel();
            }
        };

        container.querySelector('#ld-goto-read').onclick = (e) => {
            e.stopPropagation();
            if(panel.classList.contains('view-settings')) return;
            const meta = document.querySelector('meta[name="current_username"], meta[name="discourse_username"]');
            const u = meta?.content?.trim() || window.Discourse?.User?.current?.username;
            if (u) { window.open(`/u/${u}/activity/read`, '_blank'); panel.classList.remove('show'); }
        };

        container.querySelector('.ld-history-list').onclick = (e) => {
            const item = e.target.closest('a.ld-history-item');
            if (item && !e.ctrlKey && !e.metaKey && e.button !== 1) {
                e.preventDefault();
                panel.classList.remove('show');
                window.location.href = item.getAttribute('href');
            }
        };

        document.addEventListener('click', (e) => { if(!container.contains(e.target)) panel.classList.remove('show'); });

        return true;
    }

    function init() {
        injectStyles();
        let retry = 0;
        const tryCreate = () => {
            if(ensureUI()) {
                updateHistoryPanel();
                scheduleSave();
                let lastUrl = location.href;
                new MutationObserver(() => {
                    if (location.href !== lastUrl) { lastUrl = location.href; scheduleSave(); setTimeout(ensureUI, 0); }
                }).observe(document.body, {childList:true, subtree:true});
                const ps = history.pushState; history.pushState = function() { ps.apply(this, arguments); scheduleSave(); };
                const rs = history.replaceState; history.replaceState = function() { rs.apply(this, arguments); scheduleSave(); };
            } else if(retry++ < 30) setTimeout(tryCreate, 500);
        };
        tryCreate();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
})();
1 个赞