MJJBOX 增强

为 MJJBox 设置 快速收藏、话题时间显示、精确回复时间、新标签页打开 等功能。

Od 06.09.2025.. Pogledajte najnovija verzija.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         MJJBOX 增强
// @description  为 MJJBox 设置 快速收藏、话题时间显示、精确回复时间、新标签页打开 等功能。
// @description:en Adds features like Quick Bookmarking, Topic Time Display, Precise Reply Time, and Open in New Tab to MJJBox.
// @description:zh-CN 为 MJJBox 设置 快速收藏、话题时间显示、精确回复时间、新标签页打开 等功能。
// @version      0.2.0
// @author       Zz (Modified by Gemini)
// @match        https://mjjbox.com/*
// @icon         https://www.google.com/s2/favicons?domain=mjjbox.com
// @license      MIT
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @namespace    http://tampermonkey.net/
// ==/UserScript==

(function() {
    'use strict';
    var settings = {};

    const settingsConfig = {
        class_label_topic: "💠 话题内容相关:",
        quick_mark: { type: 'checkbox', label: '快速收藏 ', default: true, style: '', info: '在帖子上增加一个⭐用于快速收藏到书签' },
        show_floor_time: { type: 'checkbox', label: '更精确的回复时间', default: true, style: '', info: '帖子的回复时间改为绝对时间并精确到分钟' },

        class_label_list: "💠 话题列表相关:",
        show_up_time: { type: 'checkbox', label: '显示话题时间', default: true, style: '', info: '话题列表的帖子显示创建/更新时间,老的帖子会褪色泛黄' },

        class_label_all: "💠 通用:",
        open_in_new: { type: 'checkbox', label: '新标签页打开', default: false, style: '', info: '让所有链接默认从新标签页打开' },

        class_label_end: "",
    };

    // Load settings from storage
    Object.keys(settingsConfig).forEach(key => {
        if (typeof settingsConfig[key] === 'object') {
            settings[key] = GM_getValue(key, settingsConfig[key].default);
        }
    });

    // --- Individual Menu Commands ---
    // Register a toggle command for each setting in the Tampermonkey menu
    Object.keys(settingsConfig).forEach(key => {
        const config = settingsConfig[key];
        if (typeof config === 'object' && config.type === 'checkbox') {
            const isEnabled = settings[key];
            const label = `${isEnabled ? '✅' : '❌'} ${config.label.trim()}`;

            GM_registerMenuCommand(label, () => {
                GM_setValue(key, !isEnabled);
                window.location.reload();
            });
        }
    });
    GM_registerMenuCommand('⚙️ 打开详细设置面板 (Open Settings Panel)', openSettings);


    // --- Settings Panel ---
    function openSettings() {
        if (document.querySelector('#mjjbox-custom-setting')) {
            return;
        }
        const shadow = document.createElement('div');
        shadow.style = `position: fixed; top: 0; left: 0; z-index: 8888; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.6);`;
        const panel = document.createElement('div');
        panel.style = `max-width: calc(100% - 100px); width: max-content; position: fixed; top: 50%; left: 50%; z-index: 9999; transform: translate(-50%, -50%); background-color: var(--secondary); color: var(--primary); padding: 15px 25px; box-shadow: 0 0 15px rgba(0, 0, 0, 0.7); max-height: calc(95vh - 40px); overflow-y: auto; border-radius: 8px;`;
        panel.id = "mjjbox-custom-setting";
        let html = `
     <style type="text/css">
       #mjjbox-custom-setting { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
       #mjjbox-custom-setting label { font-size: 16px; display: flex; justify-content: space-between; align-items: center; margin: 12px; }
       #mjjbox-custom-setting label span { color: #6bc; font-size: 12px; font-weight: normal; padding: 0 6px; margin-right: auto; }
       #mjjbox-custom-setting label input { margin: 0 5px 0 15px; }
       #mjjbox-custom-setting label input[type="text"] { width: 350px; padding: 2px; font-size: 14px; }
       #mjjbox-custom-setting label input[type="number"] { width: 70px; padding: 0 0 0 10px; text-align: center; }
       #mjjbox-custom-setting label input[disabled] { background: #CCC; }
       #mjjbox-custom-setting .settings-buttons { display: flex; justify-content: space-around; margin-top: 20px; }
       #mjjbox-custom-setting .settings-buttons button { user-select: none; color: #333; padding: 8px 16px; border-radius: 5px; border: none; line-height: normal; cursor: pointer; }
       #mjjbox-custom-setting hr { display: block; height: 1px; border: 0; border-top: 1px solid var(--primary-low); margin: 1em 0; padding: 0; }
     </style>
     <h2 style="text-align:center; margin-top:.5rem;">MJJBox 自定义设置</h2>
     `;
        Object.keys(settingsConfig).forEach(key => {
            const cfg = settingsConfig[key];
            if (typeof cfg === 'string') {
                html += `<hr><h3 style="margin-top:5px; font-size: 1.1em;">${cfg}</h3>`;
            } else {
                const val = settings[key];
                const checked = cfg.type === 'checkbox' && val ? 'checked' : '';
                html += `<label style="${cfg.style}">${cfg.label}<span>${cfg.info}</span><input type="${cfg.type}" id="ujs_set_${key}" value="${val}" ${checked}></label>`;
            }
        });
        html += `
     <div class="settings-buttons">
         <button id="ld_userjs_apply" style="font-weight: bold; background: var(--tertiary); color: var(--secondary)">保存并刷新</button>
         <button id="ld_userjs_reset" style="background: #fbb;">重置</button>
         <button id="ld_userjs_close" style="background: #ddd;">取消</button>
     </div>`;
        panel.innerHTML = html;

        document.body.append(shadow, panel);

        function saveAndReload() {
            Object.keys(settingsConfig).forEach(key => {
                const element = document.getElementById(`ujs_set_${key}`);
                if (element) {
                    settings[key] = element.type === 'checkbox' ? element.checked : element.value;
                    GM_setValue(key, settings[key]);
                }
            });
            window.location.reload();
        }

        document.querySelector('button#ld_userjs_apply').addEventListener('click', saveAndReload);

        document.querySelector('button#ld_userjs_reset').addEventListener('click', () => {
            Object.keys(settingsConfig).forEach(key => {
                if (typeof settingsConfig[key] === 'object') {
                    GM_deleteValue(key);
                }
            });
            window.location.reload();
        });

        function setting_hide() {
            panel.remove();
            shadow.remove();
        }

        document.querySelector('button#ld_userjs_close').addEventListener('click', setting_hide);
        shadow.addEventListener('click', setting_hide);
    }

    // == 功能区 ==

    // Function 1: 快速收藏
    if (settings.quick_mark) {
        const starSvg = `<svg class="svg-icon" aria-hidden="true" style="text-indent: 1px; transform: scale(1); width:18px; height:18px;">
       <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
       <path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l130.7 68.7c23.2 12.2 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z"></path></svg></svg> `;
        let markMap = new Map();

        function handleResponse(xhr, successCallback, errorCallback) {
            xhr.onreadystatechange = function() {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        successCallback(xhr);
                    } else {
                        errorCallback(xhr);
                    }
                }
            };
        }

        function TryParseJson(str) {
            try {
                const jsonObj = JSON.parse(str);
                return JSON.stringify(jsonObj, null, 1);
            } catch (error) {
                return str;
            }
        }

        function deleteStarMark(mark_btn, data_id) {
            if (markMap.has(data_id)) {
                const mark_id = markMap.get(data_id);
                var xhr = new XMLHttpRequest();
                xhr.open('DELETE', `/bookmarks/${mark_id}`, true);
                xhr.setRequestHeader('Content-Type', 'application/json');
                xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
                xhr.setRequestHeader("x-csrf-token", document.head.querySelector("meta[name=csrf-token]")?.content);

                handleResponse(xhr, (xhr) => {
                    mark_btn.style.color = '#777';
                    mark_btn.title = "收藏";
                    mark_btn.onclick = () => addStarMark(mark_btn, data_id);
                }, (xhr) => {
                    console.error('删除收藏失败!', xhr.statusText, TryParseJson(xhr.responseText));
                });

                xhr.send();
            }
        }

        function addStarMark(mark_btn, data_id) {
            const xhr = new XMLHttpRequest();
            xhr.open('POST', '/bookmarks', true);
            xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
            xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
            xhr.setRequestHeader('discourse-logged-in', 'true');
            xhr.setRequestHeader('discourse-present', 'true');
            xhr.setRequestHeader("x-csrf-token", document.head.querySelector("meta[name=csrf-token]")?.content);
            const postData = `name=%E6%94%B6%E8%97%8F&auto_delete_preference=3&bookmarkable_id=${data_id}&bookmarkable_type=Post`;

            handleResponse(xhr, (xhr) => {
                mark_btn.style.color = '#fdd459';
                mark_btn.title = "删除收藏";
                const newMark = JSON.parse(xhr.responseText);
                markMap.set(String(newMark.bookmarkable_id), String(newMark.id));
                mark_btn.onclick = () => deleteStarMark(mark_btn, data_id);
            }, (xhr) => {
                console.error('收藏失败!', xhr.statusText, TryParseJson(xhr.responseText));
            });

            xhr.send(postData);
        }

        function addMarkBtn() {
            let articles = document.querySelectorAll("article[data-post-id]");
            if (articles.length <= 0) return;

            articles.forEach(article => {
                const target = article.querySelector("div.topic-body.clearfix > div.regular.contents > section > nav > div.actions");
                if (target && !article.querySelector("span.star-bookmark")) {
                    const dataPostId = article.getAttribute('data-post-id');
                    const starButton = document.createElement('span');

                    starButton.innerHTML = starSvg;
                    starButton.className = "star-bookmark";
                    starButton.style.cursor = 'pointer';
                    starButton.style.margin = '0px 12px';

                    if (markMap.has(dataPostId)) {
                        starButton.style.color = '#fdd459';
                        starButton.title = "删除收藏";
                        starButton.onclick = () => deleteStarMark(starButton, dataPostId);
                    } else {
                        starButton.style.color = '#777';
                        starButton.title = "收藏";
                        starButton.onclick = () => addStarMark(starButton, dataPostId);
                    }
                    target.after(starButton);
                }
            });
        }

        function getStarMark() {
            const currentUserElement = document.querySelector('#current-user button > img[src]');
            if (!currentUserElement) return;

            const srcString = currentUserElement.getAttribute('src');
            const regex = /\/user_avatar\/[^\/]+\/([^\/]+)\/\d+\//;
            const match = srcString.match(regex);
            const currentUsername = match ? match[1] : null;

            if (!currentUsername) return;

            const xhr = new XMLHttpRequest();
            xhr.open('GET', `/u/${currentUsername}/user-menu-bookmarks`, true);
            xhr.setRequestHeader("x-csrf-token", document.head.querySelector("meta[name=csrf-token]")?.content);

            handleResponse(xhr, (xhr) => {
                var response = JSON.parse(xhr.responseText);
                markMap.clear();
                response.bookmarks.forEach(mark => {
                    markMap.set(mark.bookmarkable_id.toString(), mark.id.toString());
                });
                addMarkBtn();
            }, (xhr) => {
                console.error('获取收藏列表失败:', xhr.statusText);
            });

            xhr.send();
        }

        let lastUpdateMarkTime = 0;

        function mutationCallback() {
            const currentTime = Date.now();
            if (currentTime - lastUpdateMarkTime > 5000) { // Reduce frequency of bookmark checks
                getStarMark();
                lastUpdateMarkTime = currentTime;
            } else {
                addMarkBtn(); // Add buttons more frequently for newly loaded posts
            }
        }

        const mainNode = document.querySelector("#main-outlet");
        if (mainNode) {
            const observer = new MutationObserver(mutationCallback);
            observer.observe(mainNode, { childList: true, subtree: true });
            getStarMark(); // Initial run
        }
    }


    // Function 2: 显示话题时间
    if (settings.show_up_time) {
        function getHue(date, currentDate) {
            const diff = Math.abs(currentDate - date);
            const baseday = 30 * 24 * 60 * 60 * 1000; // 30 day
            const diffRatio = Math.min(Math.log(diff / baseday + 1), 1);
            return 120 - (140 * diffRatio); // green to red
        }

        function formatDate(date) {
            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');
            return `${year}-${month}-${day} ${hours}:${minutes}`;
        }

        function parseDate(dateStr) {
            let parts;
            // Chinese format: 2024 年 5 月 20 日 14:30
            if (dateStr.match(/(\d+)\s*年\s*(\d+)\s*月\s*(\d+)\s*日\s*(\d+):(\d+)/)) {
                parts = dateStr.match(/(\d+)\s*年\s*(\d+)\s*月\s*(\d+)\s*日\s*(\d+):(\d+)/);
                return new Date(parts[1], parts[2] - 1, parts[3], parts[4], parts[5]);
            }
            // English format: May 20, 2024 2:30 pm
            if (dateStr.match(/(\w+)\s*(\d+),\s*(\d+)\s*(\d+):(\d+)\s*(am|pm)/i)) {
                parts = dateStr.match(/(\w+)\s*(\d+),\s*(\d+)\s*(\d+):(\d+)\s*(am|pm)/i);
                const monthMap = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
                let hour = parseInt(parts[4], 10);
                if (parts[6].toLowerCase() === 'pm' && hour < 12) hour += 12;
                else if (parts[6].toLowerCase() === 'am' && hour === 12) hour = 0;
                return new Date(parts[3], monthMap[parts[1]], parts[2], hour, parts[5]);
            }
            return null; // Return null if format is not supported
        }

        GM_addStyle(`
        .topic-list .topic-list-data.age.activity { width: 12em; text-align: left; }
        .topic-list .topic-list-data.age.activity > a.post-activity{
          font-size: 13px; line-height: 1.5; text-wrap: nowrap; padding: 4px 5px; display: block;
        }`);

        function creatTimeShow() {
            document.querySelectorAll(".topic-list-item").forEach(function(row) {
                const item = row.querySelector(".num.topic-list-data.age.activity");
                if (!item || item.dataset.customized) return;
                item.dataset.customized = "true";

                const timeSpan = item.querySelector("a.post-activity");
                if (!timeSpan) return;

                const timeInfo = item.title;
                let createDateString = timeInfo.match(/创建日期:([\s\S]+?)最新:/) || timeInfo.match(/Created: ([\s\S]+?)Latest:/);
                let updateDateString = timeInfo.match(/最新:([\s\S]+)/) || timeInfo.match(/Latest: ([\s\S]+)/);

                if (!createDateString) return;

                createDateString = (createDateString[1] ?? '').trim();
                const createDate = parseDate(createDateString);
                if (!createDate) return;

                const currentDate = new Date();
                const createHue = getHue(createDate, currentDate);
                const formatCreateDate = formatDate(createDate);
                timeSpan.innerHTML = `<span style="color: hsl(${createHue}, 35%, 50%);">创建: ${formatCreateDate}</span><br>`;

                if (updateDateString) {
                    updateDateString = (updateDateString[1] ?? '').trim();
                    const updateDate = parseDate(updateDateString);
                    if (updateDate) {
                        const updateHue = getHue(updateDate, currentDate);
                        const formatNewDate = formatDate(updateDate);
                        timeSpan.innerHTML += `<span style="color: hsl(${updateHue}, 35%, 50%);">最新: ${formatNewDate}</span>`;
                    }
                } else {
                    timeSpan.innerHTML += `<span style="color:#888;">最新: 暂无回复</span>`;
                }

                const pastDays = Math.abs(createDate - currentDate) / (24 * 60 * 60 * 1000);
                const topicTitle = row.querySelector(".main-link");
                if (topicTitle) {
                    if (pastDays > 120) {
                        topicTitle.style.filter = "sepia(90%) brightness(85%)";
                    } else if (pastDays > 60) {
                        topicTitle.style.opacity = 0.8;
                        topicTitle.style.filter = "sepia(40%) brightness(85%)";
                    } else if (pastDays > 30) {
                        topicTitle.style.opacity = 0.9;
                        topicTitle.style.filter = "grayscale(10%) sepia(10%)";
                    }
                }
            });
        }
        setInterval(creatTimeShow, 1000);
    }

    // Function 3: 新窗口打开 (FIXED)
    if (settings.open_in_new) {
        document.addEventListener('click', function(event) {
            const anchor = event.target.closest('a');

            // 1. If it's not a link, or doesn't have an href, or already has a target, ignore it.
            if (!anchor || !anchor.href || anchor.target) {
                return;
            }

            // 2. Ignore middle-clicks or ctrl/cmd-clicks which browsers handle correctly.
            if (event.button !== 0 || event.ctrlKey || event.metaKey) {
                return;
            }

            // 3. Ignore javascript actions.
            if (anchor.href.startsWith('javascript:')) {
                return;
            }

            // 4. Ignore specific UI elements that shouldn't open in a new tab.
            const excludedSelectors = [
                '.d-header-icons', '.nav-pills', '.post-controls',
                '.topic-meta-data .contents', '.topic-timeline',
                '.user-card-actions', '.category-breadcrumb',
                '.select-kit-header', '.select-kit-row', '.modal-body',
                '.actions' // General action buttons
            ].join(', ');

            if (anchor.closest(excludedSelectors)) {
                return;
            }

            // 5. If all checks pass, open the link in a new tab.
            event.preventDefault();
            event.stopImmediatePropagation();
            window.open(anchor.href, '_blank');
        }, true); // Use capture phase to catch the event before the site's own scripts do.
    }


    // Function 4: 显示更精确的回复时间
    if (settings.show_floor_time) {
        GM_addStyle(`
     .post-info.post-date > a > .relative-date {
         font-size: 0; /* Hide original relative date text */
     }
     .post-info.post-date > a > .relative-date::after {
         content: attr(title);
         font-size: 14px; /* Set font size for absolute date */
     }`);
    }

})();