adminer数据库zblog meta字段数据库可视化编辑油猴脚本

受够了zblog使用的php数组序列化方法,有时候想手动修改一下数据很不方便,看也不方便看,就一坨在那,现在有了这个插件后可以用json方式查看,还可以对json修改,修改实时序列化后同步到原内容。

看效果:👇

adminer数据库zblog meta字段数据库可视化编辑油猴脚本,第1张

使用方法:👇

浏览器安装油猴脚本,然后添加新脚本,复制下面的代码后保存就行了。so easy!!

adminer数据库zblog meta字段数据库可视化编辑油猴脚本,第2张

脚本代码:👇

// ==UserScript==
// @name         Adminer PHP Serialize 元数据 JSON 美化与双向实时编辑器
// @namespace    https://kfuu.cn/
// @version      1.0
// @description  高亮 JSON 可编辑并实时反序列化同步写回原始 textarea
// @author       kfuu.cn
// @match        *://*/*adminer*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // ==========================================
    // 1. PHP Unserialize (字节流解码)
    // ==========================================
    function phpUnserialize(dataStr) {
        const encoder = new TextEncoder();
        const decoder = new TextDecoder('utf-8');
        const bytes = encoder.encode(dataStr);
        let offset = 0;

        function readUntil(sepByte) {
            let start = offset;
            while (offset < bytes.length && bytes[offset] !== sepByte) offset++;
            if (offset >= bytes.length) throw new Error("Unexpected end of data");
            let str = decoder.decode(bytes.subarray(start, offset));
            offset++;
            return str;
        }

        function readBytes(len) {
            if (offset + len > bytes.length) throw new Error("Out of bounds");
            let sub = bytes.subarray(offset, offset + len);
            offset += len;
            return decoder.decode(sub);
        }

        function parse() {
            if (offset >= bytes.length) return null;
            let type = String.fromCharCode(bytes[offset]);
            offset += 2;

            switch (type) {
                case 'i':
                case 'd':
                case 'b': {
                    let val = readUntil(59);
                    return type === 'b' ? val === '1' : Number(val);
                }
                case 's': {
                    let len = parseInt(readUntil(58), 10);
                    offset++;
                    let str = readBytes(len);
                    offset += 2;
                    return str;
                }
                case 'a': {
                    let count = parseInt(readUntil(58), 10);
                    offset++;
                    let obj = {};
                    let isArray = true;
                    for (let i = 0; i < count; i++) {
                        let k = parse();
                        let v = parse();
                        if (k !== i) isArray = false;
                        obj[k] = v;
                    }
                    offset++;
                    if (isArray) {
                        let arr = [];
                        for (let i = 0; i < count; i++) arr.push(obj[i]);
                        return arr;
                    }
                    return obj;
                }
                case 'N': return null;
                default: return null;
            }
        }

        try {
            return parse();
        } catch (e) {
            return null;
        }
    }

    // ==========================================
    // 2. PHP Serialize (JS 对象转 PHP 序列化字符串)
    // ==========================================
    function phpSerialize(val) {
        const encoder = new TextEncoder();

        function getByteLength(str) {
            return encoder.encode(str).length;
        }

        function serialize(item) {
            if (item === null || item === undefined) {
                return 'N;';
            }
            if (typeof item === 'boolean') {
                return `b:${item ? 1 : 0};`;
            }
            if (typeof item === 'number') {
                if (Number.isInteger(item)) {
                    return `i:${item};`;
                }
                return `d:${item};`;
            }
            if (typeof item === 'string') {
                return `s:${getByteLength(item)}:"${item}";`;
            }
            if (typeof item === 'object') {
                let keys = Object.keys(item);
                let isArr = Array.isArray(item);
                let result = `a:${keys.length}:{`;

                if (isArr) {
                    item.forEach((v, i) => {
                        result += serialize(i) + serialize(v);
                    });
                } else {
                    for (let k in item) {
                        if (Object.prototype.hasOwnProperty.call(item, k)) {
                            let intKey = parseInt(k, 10);
                            if (!isNaN(intKey) && intKey.toString() === k) {
                                result += serialize(intKey);
                            } else {
                                result += serialize(k);
                            }
                            result += serialize(item[k]);
                        }
                    }
                }
                result += '}';
                return result;
            }
            return 'N;';
        }

        return serialize(val);
    }

    // ==========================================
    // 3. 高亮辅助函数
    // ==========================================
    function highlightJson(json) {
        if (typeof json !== 'string') {
            json = JSON.stringify(json, null, 2);
        }
        json = escapeHtml(json);

        return json.replace(/("(\u[a-zA-Z0-9]{4}|\[^u]|[^\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
            let cls = 'json-number';
            if (/^"/.test(match)) {
                if (/:$/.test(match)) {
                    cls = 'json-key';
                } else {
                    cls = 'json-string';
                }
            } else if (/true|false/.test(match)) {
                cls = 'json-boolean';
            } else if (/null/.test(match)) {
                cls = 'json-null';
            }
            return `<span class="${cls}">${match}</span>`;
        });
    }

    function escapeHtml(str) {
        return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
    }

    // ==========================================
    // 4. 主逻辑
    // ==========================================
    function init() {
        const nameRegex = /^fields\[.*meta.*\]$/i;
        const textareas = document.querySelectorAll('textarea');

        textareas.forEach(textarea => {
            const nameAttr = textarea.getAttribute('name');
            if (!nameAttr || !nameRegex.test(nameAttr)) return;
            if (textarea.dataset.jsonBeautified) return;
            textarea.dataset.jsonBeautified = 'true';

            const td = textarea.closest('td');
            if (!td) return;

            // 高度优先按原本框高度,拿不到就默认 220px
            const rect = textarea.getBoundingClientRect();
            const targetHeight = rect.height > 50 ? rect.height + 'px' : (textarea.offsetHeight ? textarea.offsetHeight + 'px' : '220px');

            const rawData = textarea.value.trim();

            let parsedData = rawData ? phpUnserialize(rawData) : null;
            let formattedHtml = '';

            if (parsedData !== null) {
                formattedHtml = highlightJson(parsedData);
            } else if (rawData) {
                formattedHtml = `<span class="json-error">【解析失败】非标准 PHP Serialize 格式</span>`;
            } else {
                formattedHtml = `<span class="json-null">{}</span>`;
            }

            textarea.style.display = 'none';

            // 构建 overlay,宽度写死 450px
            const overlay = document.createElement('div');
            overlay.className = 'meta-json-overlay';
            overlay.style.width = '450px';
            overlay.style.height = targetHeight;

            overlay.innerHTML = `
                <div class="overlay-header">
                    <span class="overlay-title">✏️ JSON 编辑器 (${escapeHtml(nameAttr)})</span>
                    <span class="status-tip">已同步原文本</span>
                    <button class="overlay-btn toggle-btn">显示原始文本</button>
                </div>
                <div class="overlay-content"><code contenteditable="true" spellcheck="false">${formattedHtml}</code></div>
            `;

            injectStyles();
            td.appendChild(overlay);

            const toggleBtn = overlay.querySelector('.toggle-btn');
            const contentDiv = overlay.querySelector('.overlay-content');
            const editableCode = overlay.querySelector('code[contenteditable]');
            const statusTip = overlay.querySelector('.status-tip');

            let showRaw = false;

            // 实时编辑 & 双向同步
            let syncTimer = null;
            editableCode.addEventListener('input', () => {
                if (syncTimer) clearTimeout(syncTimer);

                syncTimer = setTimeout(() => {
                    const text = editableCode.innerText;
                    try {
                        const jsonObj = JSON.parse(text);
                        const phpSerializedString = phpSerialize(jsonObj);

                        textarea.value = phpSerializedString;

                        textarea.dispatchEvent(new Event('input', { bubbles: true }));
                        textarea.dispatchEvent(new Event('change', { bubbles: true }));

                        statusTip.textContent = "✓ 已实时同步到原文本框";
                        statusTip.className = "status-tip success";
                    } catch (err) {
                        statusTip.textContent = "❌ JSON 格式错误 (未同步)";
                        statusTip.className = "status-tip error";
                    }
                }, 300);
            });

            // 切换原始文本模式
            toggleBtn.addEventListener('click', (e) => {
                e.preventDefault();
                showRaw = !showRaw;
                if (showRaw) {
                    contentDiv.style.display = 'none';
                    overlay.style.height = 'auto';
                    textarea.style.display = 'block';
                    toggleBtn.textContent = '切回 JSON 视图';
                } else {
                    const latestRaw = textarea.value.trim();
                    let latestParsed = latestRaw ? phpUnserialize(latestRaw) : null;
                    if (latestParsed !== null) {
                        editableCode.innerHTML = highlightJson(latestParsed);
                        statusTip.textContent = "已同步原文本";
                        statusTip.className = "status-tip";
                    }
                    textarea.style.display = 'none';
                    contentDiv.style.display = 'block';
                    overlay.style.height = targetHeight;
                    toggleBtn.textContent = '显示原始文本';
                }
            });
        });
    }

    // ==========================================
    // 5. CSS 样式
    // ==========================================
    function injectStyles() {
        if (document.getElementById('meta-json-styles')) return;
        const style = document.createElement('style');
        style.id = 'meta-json-styles';
        style.textContent = `
            .meta-json-overlay {
                background: #1e1e1e !important;
                color: #d4d4d4 !important;
                border: 1px solid #454545;
                border-radius: 4px;
                display: flex;
                flex-direction: column;
                font-family: Consolas, Monaco, "Courier New", monospace !important;
                box-sizing: border-box;
                overflow: hidden;
                resize: both;
            }
            .overlay-header {
                background: #2d2d2d;
                padding: 4px 10px;
                display: flex;
                justify-content: space-between;
                align-items: center;
                border-bottom: 1px solid #3c3c3c;
                font-size: 12px;
                flex-shrink: 0;
            }
            .overlay-title {
                color: #cccccc;
                font-weight: 600;
            }
            .status-tip {
                font-size: 11px;
                color: #888888;
                transition: color 0.2s;
            }
            .status-tip.success {
                color: #4ec9b0;
            }
            .status-tip.error {
                color: #f48771;
                font-weight: bold;
            }
            .overlay-btn {
                background: #3c3c3c;
                color: #cccccc;
                border: 1px solid #555555;
                padding: 2px 8px;
                border-radius: 3px;
                cursor: pointer;
                font-size: 11px;
                transition: all 0.2s;
            }
            .overlay-btn:hover {
                background: #0e639c;
                color: #ffffff;
                border-color: #1177bb;
            }
            .overlay-content {
                margin: 0 !important;
                padding: 10px !important;
                flex: 1;
                overflow: auto;
                font-size: 13px !important;
                line-height: 1.5 !important;
                background: #1e1e1e !important;
            }
            .overlay-content code {
                background: transparent !important;
                color: inherit !important;
                padding: 0 !important;
                border: none !important;
                font-family: inherit !important;
                white-space: pre-wrap !important;
                word-break: break-all !important;
                outline: none !important;
                display: block;
                min-height: 100%;
            }
            .json-key { color: #9cdcfe !important; font-weight: bold; }
            .json-string { color: #ce9178 !important; }
            .json-number { color: #b5cea8 !important; }
            .json-boolean { color: #569cd6 !important; }
            .json-null { color: #569cd6 !important; }
            .json-error { color: #f48771 !important; }
        `;
        document.head.appendChild(style);
    }

    init();
    const observer = new MutationObserver(init);
    observer.observe(document.body, { childList: true, subtree: true });
})();