简道云表单后台快速切表器

在简道云表单后台快速切换同应用下的不同表单。支持中英文首字母排序和分组。

// ==UserScript==
// @name         简道云表单后台快速切表器
// @namespace    zerobiubiu.top
// @version      1.2
// @description  在简道云表单后台快速切换同应用下的不同表单。支持中英文首字母排序和分组。
// @author       zerobiubiu
// @match        https://www.jiandaoyun.com/dashboard/app/*/form/*/edit
// @license      MIT
// @icon         chrome-extension://jpejneelbjckppjapemgfeheifljmaib/_favicon/?pageUrl=https%3A%2F%2Fwww.jiandaoyun.com%2Fdashboard%23%2F&size=32
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js
// @grant        GM_xmlhttpRequest
// @grant        GM_listValues
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(async function () {
    'use strict';

    // 获取公司信息
    async function fetchCorpInfo() {
        const csrf = document.querySelector('meta[name="csrf-token"]').content;
        const requestId = crypto.randomUUID();

        const resp = await fetch("https://www.jiandaoyun.com/profile/get_corp", {
            method: "POST",
            credentials: "include",
            headers: {
                "accept": "application/json, text/plain, */*",
                "content-type": "application/json",
                "x-csrf-token": csrf,
                "x-request-id": requestId,
            },
            body: "{}"
        });

        return resp.json();
    }

    // 启动密钥检查
    async function getSecret(KEY, NAME) {
        let secret = await GM_getValue(KEY);
        if (!secret) {
            secret = prompt('请输入密钥(只需输入一次,后续会自动复用):');
            if (secret) {
                await GM_setValue(KEY, secret);
                await GM_setValue(KEY + "-companyName", NAME);
            }
        }
        return secret;
    }

    // 从 URL 中提取 app_id
    function getAppId() {
        const match = location.href.match(/\/app\/([^/]+)\/form\//);
        return match ? match[1] : null;
    }

    // 从 URL 提取 entry_id
    function getEntryId() {
        const match = location.href.match(/\/form\/([^/]+)/);
        return match ? match[1] : null;
    }

    // 请求 API 获取所有表单(自动翻页)
    async function fetchAllForms(appId, secret) {
        const allForms = [];
        let skip = 0;
        const limit = 100;

        while (true) {
            const chunk = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "POST",
                    url: "https://api.jiandaoyun.com/api/v5/app/entry/list",
                    headers: {
                        "Content-Type": "application/json",
                        "Authorization": "Bearer " + secret
                    },
                    data: JSON.stringify({
                        app_id: appId,
                        limit,
                        skip
                    }),
                    onload: function (res) {
                        try {
                            const data = JSON.parse(res.responseText);
                            resolve(data.forms || []);
                        } catch (e) {
                            reject("解析失败: " + e);
                        }
                    },
                    onerror: function (err) {
                        reject("请求失败: " + err);
                    }
                });
            });

            if (chunk.length === 0) break; // 没有更多了
            allForms.push(...chunk);
            skip += limit;
        }

        return allForms;
    }

    // 菜单注册标记
    let menuCreated = false;

    // 创建菜单
    function createMenu(KEY, NAME) {
        if (menuCreated) return;
        menuCreated = true;
        GM_registerMenuCommand('重置密钥(设置密钥)', async () => {
            const secret = prompt('请输入新的密钥:');
            if (!secret) return;
            await GM_setValue(KEY, secret);
            await GM_setValue(KEY + "-companyName", NAME);
            alert('密钥已更新!');
        });
        GM_registerMenuCommand("删除当前企业密钥", () => {
            if (confirm("⚠️ 确认要删除当前企业密钥吗?此操作不可恢复!")) {
                GM_deleteValue(KEY);
                GM_deleteValue(KEY + "-companyName");
                alert("✅ 密钥已删除!");
            } else {
                alert("❎ 已取消删除操作");
            }
        });
        GM_registerMenuCommand("查看所有key(控制台输出)", async () => {
            console.log(await GM_listValues());
        });
        GM_registerMenuCommand("查看当前企业密钥", async () => {
            const secret = await GM_getValue(KEY);
            if (secret) {
                alert(NAME + "  当前密钥为:" + secret);
            } else {
                alert("当前未设置密钥!");
            }
        });
        GM_registerMenuCommand("清空所有密钥(慎点!!)", async () => {
            if (confirm("⚠️ 确认要清空所有 密钥 数据吗?此操作不可恢复!")) {
                const keys = await GM_listValues();
                for (const key of keys) {
                    await GM_deleteValue(key);
                    console.log("已删除:", key);
                }
                alert("✅ 所有 密钥 数据已清空");
            } else {
                alert("❎ 已取消清空操作");
            }
        });
    }

    // 提取当前表单ID
    const currentEntryId = getEntryId();

    // 创建下拉框
    const select = document.createElement('select');
    select.id = 'formSelect';
    select.style.marginLeft = '10px';
    select.style.padding = '2px 6px';

    // 创建选择事件
    select.addEventListener("change", () => {
        const entryId = select.value;
        if (!entryId) return;
        const newUrl = window.location.href.replace(
            /(\/form\/)([^/]+)(\/)/,
            `$1${entryId}$3`
        );
        window.location.href = newUrl;
    });

    // 监听执行锁
    let executed = false;
    // 创建监听事件
    const observer = new MutationObserver(async (mutationsList, observer) => {
        const navigation_left = document.querySelector("#root > div > div.fx-navigation-bar.fx-form-navigation-bar > div.navigation-left");

        if (navigation_left && !document.getElementById("formSelect") && !executed) {
            executed = true; // 上锁,防止重复执行
            observer.disconnect(); // 找到后立即停止监听,提高性能

            // 获取当前企业ID作为KEY_ID
            const { KEY, NAME } = await fetchCorpInfo().then(data => {
                console.log("当前企业ID:" + data.corp_id)
                return { KEY: data.corp_id, NAME: data.corp_name }
            });

            createMenu(KEY, NAME);
            const secret = await getSecret(KEY, NAME);
            if (!secret) return; // 如果没有获取到密钥则停止执行

            const forms = await fetchAllForms(getAppId(), secret);

            // 统一的获取首字母/分组的工具函数
            function getGroupKey(str) {
                if (!str || !str.trim()) return '#'; // 处理空名称
                const firstChar = str.trim().charAt(0);

                if (/[a-zA-Z]/.test(firstChar)) return firstChar.toUpperCase();
                if (/[0-9]/.test(firstChar)) return '0-9';

                try {
                    const pinyinResult = pinyinPro.pinyin(firstChar, { pattern: 'first', toneType: 'none' });
                    const letter = pinyinResult ? pinyinResult.toUpperCase() : '#';
                    // 确保pinyin-pro的结果是单个字母
                    return /^[A-Z]$/.test(letter) ? letter : '#';
                } catch (e) {
                    console.error("pinyin-pro 库运行出错:", e);
                    return '#';
                }
            }

            // 1. 排序表单:主排序按分组键,次排序按完整名称
            forms.sort((a, b) => {
                const nameA = a.name || '';
                const nameB = b.name || '';
                const groupA = getGroupKey(nameA);
                const groupB = getGroupKey(nameB);

                if (groupA < groupB) return -1;
                if (groupA > groupB) return 1;

                // numeric: true 选项可以正确处理 "表单1", "表单10", "表单2" 这样的数字排序
                return nameA.localeCompare(nameB, 'zh-Hans-CN', { numeric: true });
            });

            // 2. 构造分组
            const groups = {};
            const groupOrder = [];
            forms.forEach(f => {
                const name = f.name || '';
                const groupKey = getGroupKey(name);
                if (!groups[groupKey]) {
                    groups[groupKey] = [];
                    groupOrder.push(groupKey);
                }
                groups[groupKey].push(f);
            });

            // 3. 对分组键本身进行排序:字母 -> 数字 -> 其他
            groupOrder.sort((a, b) => {
                const isLetterA = /^[A-Z]$/.test(a);
                const isLetterB = /^[A-Z]$/.test(b);
                const isNumA = a === '0-9';
                const isNumB = b === '0-9';

                if (isLetterA && !isLetterB) return -1; // 字母优先
                if (!isLetterA && isLetterB) return 1;
                if (isLetterA && isLetterB) return a.localeCompare(b); // 字母内按A-Z排序

                if (isNumA && !isNumB) return -1; // 数字其次
                if (!isNumA && isNumB) return 1;

                return a.localeCompare(b); // 其他符号按字符排序
            });


            // 4. 渲染分组到下拉框
            select.innerHTML = ''; // 清空旧内容
            groupOrder.forEach(key => {
                if (groups[key] && groups[key].length > 0) {
                    const optgroup = document.createElement('optgroup');
                    optgroup.label = key;

                    groups[key].forEach(f => {
                        const opt = document.createElement('option');
                        opt.value = f.entry_id;
                        opt.textContent = f.name;
                        if (f.entry_id === currentEntryId) {
                            opt.selected = true;
                        }
                        optgroup.appendChild(opt);
                    });
                    select.appendChild(optgroup);
                }
            });

            // 挂载表单选择器
            navigation_left.appendChild(select);
        }
    });

    // 启动 observer
    observer.observe(document.querySelector("#root"), {
        childList: true,
        subtree: true
    });

})();