文章详情

返回首页

CF 上搭建新导航【20251224】

分享文章 作者: Ws01 创建时间: 2025-12-24 更新时间: 2025-12-25 📝 字数: 138,102 字 👁️ 阅读: 6 次

原始 Markdown

```
const HTML_CONTENT = `
<!DOCTYPE html>
<html lang="zh-CN" class="scroll-smooth">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ws01-导航</title>
    <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2280%22>⭐</text></svg>">
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
        tailwind.config = {
            darkMode: 'class',
            theme: {
                extend: {
                    colors: {
                        glass: {
                            border: 'rgba(255, 255, 255, 0.2)',
                            darkBorder: 'rgba(255, 255, 255, 0.1)',
                        }
                    },
                    animation: {
                        'blob': 'blob 10s infinite',
                    },
                    keyframes: {
                        blob: {
                            '0%': { transform: 'translate(0px, 0px) scale(1)' },
                            '33%': { transform: 'translate(30px, -50px) scale(1.1)' },
                            '66%': { transform: 'translate(-20px, 20px) scale(0.9)' },
                            '100%': { transform: 'translate(0px, 0px) scale(1)' },
                        }
                    },
                    boxShadow: {
                        'glass': '0 4px 30px rgba(0, 0, 0, 0.1)',
                        'glass-hover': '0 10px 40px rgba(0, 0, 0, 0.2)',
                    }
                }
            }
        }
    </script>
    <style>
        ::-webkit-scrollbar { width: 6px; height: 6px; }
        ::-webkit-scrollbar-track { background: transparent; }
        ::-webkit-scrollbar-thumb { background: rgba(156, 163, 175, 0.3); border-radius: 4px; }
        ::-webkit-scrollbar-thumb:hover { background: rgba(156, 163, 175, 0.6); }

        @media (max-width: 640px) {
            ::-webkit-scrollbar { display: none; }
            * { scrollbar-width: none; /* Firefox */ }
        }
        
        .card.dragging {
            opacity: 0.8;
            transform: scale(1.05);
            border: 2px dashed #10b981;
            box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
            z-index: 50;
            position: relative;
        }

        .edit-mode .card {
            /* touch-action: none; */  
            touch-action: pan-y;
        }

        body.edit-mode .card,
        body.edit-mode .card:hover {
            transform: none !important;
            box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; 
        }
        
        html.dark body.edit-mode .card,
        html.dark body.edit-mode .card:hover {
            box-shadow: none !important;
            border-color: rgba(51, 65, 85, 0.5) !important;
        }

        .add-card-placeholder {
            pointer-events: auto !important;
            z-index: 10;
        }
        
        .card-clone-dragging {
            pointer-events: none !important; /* 关键:让触摸穿透克隆体 */
            z-index: 9999 !important;
        }
        
        .no-scrollbar::-webkit-scrollbar { display: none; }
        .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }

        .dropdown-enter {
            animation: dropdown-in 0.2s ease-out forwards;
        }
        @keyframes dropdown-in {
            from { opacity: 0; transform: translateY(-10px) scale(0.95); }
            to { opacity: 1; transform: translateY(0) scale(1); }
        }

        .overlay-hidden {
            opacity: 0;
            pointer-events: none;
        }
        .overlay-visible {
            opacity: 1;
            pointer-events: auto;
        }
        .dialog-scale-hidden {
            transform: scale(0.95);
            opacity: 0;
        }
        .dialog-scale-visible {
            transform: scale(1);
            opacity: 1;
        }

        .section-anchor {
            scroll-margin-top: 160px;
        }

        input:-webkit-autofill,
        input:-webkit-autofill:hover, 
        input:-webkit-autofill:focus, 
        input:-webkit-autofill:active {
            -webkit-transition: background-color 99999s ease-out;
            -webkit-transition-delay: 99999s;
            -webkit-text-fill-color: #475569 !important; 
        }

        html.dark input:-webkit-autofill,
        html.dark input:-webkit-autofill:hover, 
        html.dark input:-webkit-autofill:focus, 
        html.dark input:-webkit-autofill:active {
            -webkit-text-fill-color: #CBD5E1 !important;
            box-shadow: 0 0 0px 1000px #1e293b inset !important;
            transition: background-color 5000s ease-in-out 0s;
        }
        
        #custom-tooltip {
            z-index: 100;
            transition: opacity 0.1s ease-in-out;
        }
    </style>
    <script>
        (function () {
            let isDark;
            const savePreferences = localStorage.getItem('savePreferences');
            if (savePreferences === 'true') {
                const savedTheme = localStorage.getItem('theme');
                isDark = savedTheme === 'dark';
            } else {
                const hour = new Date().getHours();
                isDark = (hour >= 21 || hour < 6);
            }
            window.isDarkTheme = isDark;
            if (isDark) document.documentElement.classList.add('dark');
        })();
    </script>
</head>

<body class="min-h-screen font-sans text-slate-800 dark:text-slate-100 selection:bg-emerald-200 dark:selection:bg-emerald-900 transition-colors duration-300">
    
    <!-- 背景层 -->
    <div class="fixed inset-0 -z-10 h-full w-full overflow-hidden bg-gray-100 dark:bg-[#0f172a]">
        <div class="absolute inset-0 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-[#0f172a] dark:to-[#1e293b]"></div>
        <div class="absolute top-[-10%] left-[-10%] w-[800px] h-[800px] bg-emerald-200/30 dark:bg-indigo-900/20 rounded-full blur-[150px] mix-blend-multiply dark:mix-blend-screen animate-blob"></div>
        <div class="absolute bottom-[-10%] right-[-10%] w-[500px] h-[500px] bg-blue-200/30 dark:bg-purple-900/20 rounded-full blur-[120px] mix-blend-multiply dark:mix-blend-screen animate-blob animation-delay-2000"></div>
    </div>

    <!-- 顶部固定导航 -->
    <div class="fixed top-0 left-0 right-0 z-50 transition-all duration-300">
        <div class="backdrop-blur-xl bg-gray-100/60 dark:bg-[#0f172a]/60 border-b border-slate-200/40 dark:border-slate-700/40 shadow-sm supports-[backdrop-filter]:bg-gray-100/70">
            <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                <div class="flex items-center justify-between h-16 gap-4">
                    
                    <!-- Logo -->
                    <a class="flex items-center gap-2 flex-shrink-0 group cursor-pointer bg-white/50 dark:bg-transparent hover:bg-white dark:hover:bg-slate-800 px-3 py-1.5 rounded-xl border border-slate-200/50 dark:border-transparent transition-all duration-300 hover:shadow-md hover:shadow-emerald-500/10 hover:-translate-y-0.5" href="#" onclick="location.reload()">
                        <div class="w-8 h-8 flex items-center justify-center bg-gradient-to-tr from-emerald-500 to-teal-600 rounded-lg text-white shadow-lg shadow-emerald-500/30 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-300">
                            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
                                <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"></path>
                            </svg>
                        </div>
                        <span class="font-bold text-lg tracking-wide text-slate-700 dark:text-slate-100 hidden sm:block">ws01导航</span>
                    </a>

                    <!-- Search Bar -->
                    <div class="flex-1 max-w-2xl mx-auto">
                        <div class="relative flex items-center w-full h-10 rounded-xl focus-within:ring-2 focus-within:ring-emerald-500/50 focus-within:shadow-lg focus-within:-translate-y-0.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 shadow-sm hover:shadow-lg transition-all duration-300">
                            
                            <!-- Custom Search Engine Dropdown -->
                            <div class="relative h-full" id="search-engine-wrapper">
                                <button id="search-engine-btn" class="h-full pl-3 pr-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300 hover:text-emerald-500 hover:bg-slate-50 dark:hover:bg-slate-700/50 rounded-l-xl transition-colors outline-none w-auto md:min-w-[5.5rem]">
                                    <!-- 默认显示本站图标 -->
                                    <span id="current-engine-icon" class="flex-shrink-0 w-5 h-5 flex items-center justify-center">
                                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
                                    </span>
                                    <span id="current-engine-label" class="font-medium truncate hidden md:block">本站</span>
                                    <svg class="w-3 h-3 opacity-60 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
                                </button>
                                
                                <!-- Dropdown Menu -->
                                <div id="search-engine-menu" class="hidden absolute top-full left-0 mt-2 w-24 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-100 dark:border-slate-700 overflow-hidden z-50 dropdown-enter">
                                    <div class="py-1" id="search-engine-list">
                                        <div class="px-3 py-2 text-xs font-semibold text-slate-400 uppercase tracking-wider">搜索引擎</div>
                                        <!-- JS 自动插入按钮 -->
                                    </div>
                                </div>
                            </div>

                            <div class="h-4 w-px bg-slate-200 dark:bg-slate-600 mx-1"></div>
                            
                            <input type="text" id="search-input" class="flex-1 bg-transparent border-none text-slate-700 dark:text-slate-200 text-sm focus:ring-0 placeholder-slate-400 h-full w-full outline-none px-2" placeholder="搜索...">
                            
                            <button id="clear-search-button" class="hidden p-1.5 mr-1 rounded-full text-slate-400 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition-all">
                                <svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"></path></svg>
                            </button>
                            
                            <button id="search-button" class="h-full px-4 rounded-r-xl text-slate-500 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-slate-700/50 transition-colors border-l border-transparent dark:border-slate-700/50 flex items-center justify-center">
                                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
                            </button>
                        </div>
                    </div>

                    <!-- Profile / Settings -->
                    <div class="relative flex items-center gap-2">
                        <div id="profile-dropdown-wrapper" class="relative">
                            <button id="profile-menu-toggle" class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-white dark:hover:bg-slate-800 transition-all text-sm font-medium border border-transparent hover:border-slate-200 dark:hover:border-slate-700 hover:shadow-sm">
                                <div class="w-7 h-7 rounded-full bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-600 flex items-center justify-center shadow-inner">
                                     <svg class="w-4 h-4 text-slate-500 dark:text-slate-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
                                </div>
                                <span id="menu-toggle" class="hidden md:inline">设置</span>
                            </button>
                            
                            <!-- Dropdown Menu -->
                            <div id="profile-dropdown" class="hidden absolute right-0 mt-2 w-60 bg-white dark:bg-[#1e293b] rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/10 overflow-hidden transform origin-top-right transition-all z-50 dropdown-enter">
                                <div class="p-2 space-y-1">
                                    <!-- Edit Mode -->
                                    <button id="edit-mode-btn" onclick="toggleEditMode()" class="w-full text-left px-3 py-2.5 rounded-lg text-sm text-slate-700 dark:text-slate-200 hover:bg-emerald-50 dark:hover:bg-slate-700/50 hover:text-emerald-600 transition-colors flex items-center gap-3 font-medium">
                                        <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
                                        编辑模式
                                    </button>
                                    
                                    <!-- 导入导出 (仅登录显示) -->
                                    <div id="data-tools-menu" class="hidden border-t border-slate-100 dark:border-slate-700/50 my-1 pt-1">
                                         <button onclick="exportData()" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-700 dark:text-slate-200 hover:bg-amber-50 dark:hover:bg-slate-700/50 hover:text-amber-600 transition-colors flex items-center gap-3">
                                            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
                                            导出配置
                                        </button>
                                        <button onclick="importData()" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-700 dark:text-slate-200 hover:bg-green-50 dark:hover:bg-slate-700/50 hover:text-green-600 transition-colors flex items-center gap-3">
                                            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4-4m0 0l-4 4m4-4v12"></path></svg>
                                            导入配置
                                        </button>
                                        <!-- 文件输入框 (隐藏) -->
                                        <input type="file" id="import-file-input" accept=".json" class="hidden">
                                    </div>
                                    
                                    <div class="h-px bg-slate-100 dark:bg-slate-700/50 mx-1 my-1"></div>

                                    <!-- 【新增】APP 布局切换 -->
                                    <div class="px-3 py-2.5 flex items-center justify-between text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/30 rounded-lg group">
                                        <span class="flex items-center gap-3">
                                            <svg class="w-4 h-4 text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                                                <rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect>
                                                <rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect>
                                            </svg>
                                            APP 视图
                                        </span>
                                        <label class="relative inline-flex items-center cursor-pointer">
                                            <input type="checkbox" id="layout-switch-checkbox" onchange="toggleAppLayout()" class="sr-only peer">
                                            <div class="w-9 h-5 bg-slate-300 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-emerald-500"></div>
                                        </label>
                                    </div>
                                    
                                    <div class="px-3 py-2.5 flex items-center justify-between text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/30 rounded-lg group">
                                        <span class="flex items-center gap-3">
                                            <svg class="w-4 h-4 text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
                                            深色模式
                                        </span>
                                        <label class="relative inline-flex items-center cursor-pointer">
                                            <input type="checkbox" id="theme-switch-checkbox" class="sr-only peer">
                                            <div class="w-9 h-5 bg-slate-300 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-emerald-500"></div>
                                        </label>
                                    </div>
                                    <div class="px-3 py-2.5 flex items-center justify-between text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/30 rounded-lg group">
                                        <span class="flex items-center gap-3">
                                            <svg class="w-4 h-4 text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
                                            记住设置
                                        </span>
                                        <label class="relative inline-flex items-center cursor-pointer">
                                            <input type="checkbox" id="save-preference-checkbox" class="sr-only peer">
                                            <div class="w-9 h-5 bg-slate-300 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-emerald-500"></div>
                                        </label>
                                    </div>
                                    <div class="h-px bg-slate-100 dark:bg-slate-700/50 mx-1 my-1"></div>
                                    <button id="login-Btn" onclick="toggleLogin()" class="w-full text-left px-3 py-2.5 rounded-lg text-sm text-slate-700 dark:text-slate-200 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors flex items-center gap-3 font-medium">
                                        <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
                                        登录 / 退出
                                    </button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                
                <!-- 快捷分类栏 -->
                <div id="category-buttons-container" class="py-2 flex gap-2 overflow-x-auto no-scrollbar mask-gradient items-center">
                    <!-- JS 生成按钮 -->
                </div>
            </div>
        </div>
    </div>

    <!-- 主要内容区 -->
    <main class="pt-36 pb-20 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 min-h-screen">
        <!-- 添加分类按钮 (仅编辑模式显示) -->
        <div id="add-category-container" class="hidden mt-12 mb-8">
            <button onclick="addCategory()" class="w-full py-4 rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-emerald-500 hover:text-emerald-600 dark:hover:border-emerald-500 dark:hover:text-emerald-500 hover:bg-emerald-50/50 dark:hover:bg-slate-800/50 transition-all flex items-center justify-center gap-2 group">
                <div class="w-8 h-8 rounded-full bg-slate-100 dark:bg-slate-800 group-hover:bg-emerald-100 dark:group-hover:bg-emerald-900/30 flex items-center justify-center transition-colors">
                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
                </div>
                <span class="font-medium text-lg">新建分类</span>
            </button>
        </div>

        <!-- 内容渲染容器 -->
        <div id="sections-container" class="space-y-10"></div>

        <!-- 返回顶部按钮独立放置 -->
        <div class="fixed bottom-8 right-8 z-50">
            <button id="back-to-top-btn" onclick="scrollToTop()" class="hidden w-12 h-12 rounded-2xl bg-white/90 dark:bg-slate-800/90 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 shadow-lg backdrop-blur-sm flex items-center justify-center transition-all hover:scale-110 hover:bg-slate-50 dark:hover:bg-slate-700 has-tooltip group" data-tooltip="返回顶部">
                <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path></svg>
            </button>
        </div>
        
    </main>

    <!-- 页脚 -->
    <footer class="mt-8 pb-8 text-center text-slate-500 dark:text-slate-400 text-sm">
        <div>
            导航在手,网络随便走 | 
            @<span class="font-mono">ws01 v1.1</span>
        </div>
        <div class="mt-2">
<span id="timeDate">载入天数...</span>
            <script language="javascript"> 
            var now = new Date();
            function createtime(){
                var grt= new Date("12/22/2025 00:00:00");/*---这里是网站的启用时间:月/日/年--*/
                now.setTime(now.getTime()+250);
                days = (now - grt ) / 1000 / 60 / 60 / 24;
                dnum = Math.floor(days);
                document.getElementById("timeDate").innerHTML = "稳定运行"+dnum+"天";
            }
            setInterval("createtime()",250); 
        </script> 
        
                <span <p> | 总访问量 <span id="busuanzi_site_pv"></span> 次 | <a href="https://boke.199881.xyz/" target="_blank" rel="noopener noreferrer" class="text-emerald-500 hover:text-emerald-600 transition-colors font-medium">博客 | <a href="https://www.199881.xyz/" target="_blank" rel="noopener noreferrer" class="text-emerald-500 hover:text-emerald-600 transition-colors font-medium">导航 </p></span>
                <script defer src="https://bsz.211119.xyz/js"></script>

        </div>
    </footer>

    <!-- 模态框:添加/编辑链接 -->
    <div id="dialog-overlay" class="fixed inset-0 z-[60] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden">
        <div id="dialog-box" class="bg-white dark:bg-[#1e293b] rounded-2xl shadow-2xl w-full max-w-md p-6 transform transition-all duration-300 border border-slate-100 dark:border-slate-700 dialog-scale-hidden">
            <h3 class="text-xl font-bold mb-5 text-slate-800 dark:text-slate-100 flex items-center gap-2">
                <span class="w-1 h-6 bg-emerald-500 rounded-full"></span>
                编辑信息
            </h3>
            <div class="space-y-4">
                <div>
                    <label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider">名称 <span class="text-red-500">*</span></label>
                    <input type="text" id="name-input" class="w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 outline-none transition-all dark:text-white" placeholder="网站名称">
                </div>
                <div>
                    <label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider">地址 <span class="text-red-500">*</span></label>
                    <input type="text" id="url-input" class="w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 outline-none transition-all dark:text-white" placeholder="https://...">
                </div>
                <div>
                    <label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider">描述</label>
                    <input type="text" id="tips-input" class="w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 outline-none transition-all dark:text-white" placeholder="简短的描述...">
                </div>
                <div>
                    <label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider">图标 URL</label>
                    <input type="text" id="icon-input" class="w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 outline-none transition-all dark:text-white" placeholder="留空自动获取">
                </div>
                
                <!-- Custom Category Dropdown -->
                <div class="relative z-20" id="category-select-wrapper">
                    <label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider">分类</label>
                    <input type="hidden" id="category-select-value">
                    <button id="category-select-btn" class="w-full px-4 py-2.5 text-left rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 outline-none transition-all text-slate-700 dark:text-white flex items-center justify-between">
                        <span id="category-select-text">请选择分类</span>
                        <svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
                    </button>
                    <!-- Dropdown List -->
                    <div id="category-select-menu" class="hidden absolute top-full left-0 mt-2 w-full max-h-48 overflow-y-auto bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-100 dark:border-slate-700 z-50 custom-scrollbar">
                        <!-- Items populated by JS -->
                    </div>
                </div>

                <div class="flex items-center gap-2 pt-2">
                    <input type="checkbox" id="private-checkbox" class="w-5 h-5 text-emerald-500 rounded focus:ring-emerald-500 border-gray-300 bg-gray-100">
                    <label for="private-checkbox" class="text-sm text-slate-600 dark:text-slate-300 font-medium">设为私密链接 (仅登录可见)</label>
                </div>
            </div>
            <div class="flex justify-end gap-3 mt-8">
                <button id="dialog-cancel-btn" class="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700 transition-colors">取消</button>
                <button id="dialog-confirm-btn" class="px-5 py-2.5 rounded-xl text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 transition-all hover:translate-y-[-1px]">确定</button>
            </div>
        </div>
    </div>

    <!-- 密码弹窗 -->
    <div id="password-dialog-overlay" class="fixed inset-0 z-[70] bg-slate-900/70 backdrop-blur-md flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden">
        <div id="password-dialog-box" class="bg-white dark:bg-[#1e293b] rounded-2xl shadow-2xl p-8 w-full max-w-sm border border-slate-100 dark:border-slate-700 text-center transform transition-all duration-300 dialog-scale-hidden">
            <div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-full flex items-center justify-center mx-auto mb-4 text-emerald-500">
                <svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
            </div>
            <h3 class="text-xl font-bold mb-2 text-slate-800 dark:text-white">身份验证</h3>
            <p class="text-sm text-slate-500 dark:text-slate-400 mb-6">请输入管理员密码以继续操作</p>
            <input type="password" id="password-input" placeholder="访问密码" class="w-full px-4 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none mb-6 dark:text-white text-center tracking-widest text-lg transition-all">
            <div class="flex gap-3">
                <button id="password-cancel-btn" class="flex-1 py-2.5 rounded-xl text-slate-600 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600 font-medium transition-colors">取消</button>
                <button id="password-confirm-btn" class="flex-1 py-2.5 rounded-xl text-white bg-emerald-500 hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 font-medium transition-colors">确认登录</button>
            </div>
        </div>
    </div>

    <!-- 自定义 Alert -->
    <div id="custom-alert-overlay" class="fixed inset-0 z-[110] bg-slate-900/50 backdrop-blur-[2px] flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden">
        <div id="custom-alert-box" class="bg-white dark:bg-[#1e293b] rounded-2xl shadow-2xl p-6 max-w-sm w-full border border-slate-100 dark:border-slate-700 transform transition-all duration-300 dialog-scale-hidden">
            <h3 id="custom-alert-title" class="text-lg font-bold mb-2 text-slate-800 dark:text-white">提示</h3>
            <p id="custom-alert-content" class="text-slate-600 dark:text-slate-300 mb-6 text-sm leading-relaxed"></p>
            <div class="flex justify-end">
                <button id="custom-alert-confirm" class="px-5 py-2 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl text-sm font-medium transition-colors shadow-lg shadow-emerald-500/20">我知道了</button>
            </div>
        </div>
    </div>

    <!-- 自定义 Confirm -->
    <div id="custom-confirm-overlay" class="fixed inset-0 z-[80] bg-slate-900/50 backdrop-blur-[2px] flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden">
        <div id="custom-confirm-box" class="bg-white dark:bg-[#1e293b] rounded-2xl shadow-2xl p-6 max-w-sm w-full border border-slate-100 dark:border-slate-700 transform transition-all duration-300 dialog-scale-hidden">
            <h3 class="text-lg font-bold mb-3 text-slate-800 dark:text-white">确认操作</h3>
            <p id="custom-confirm-message" class="text-slate-600 dark:text-slate-300 mb-6 text-sm"></p>
            <div class="flex justify-end gap-3">
                <button id="custom-confirm-cancel" class="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-xl dark:text-slate-400 dark:hover:bg-slate-700 transition-colors font-medium">取消</button>
                <button id="custom-confirm-ok" class="px-4 py-2 text-sm text-white bg-emerald-500 hover:bg-emerald-600 rounded-xl shadow-lg shadow-emerald-500/20 transition-colors font-medium">确定</button>
            </div>
        </div>
    </div>

    <!-- 分类输入弹窗 -->
    <div id="category-dialog" class="fixed inset-0 z-[65] bg-slate-900/50 backdrop-blur-sm flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden">
        <div id="category-dialog-box" class="bg-white dark:bg-[#1e293b] rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-slate-100 dark:border-slate-700 transform transition-all duration-300 dialog-scale-hidden">
            <h3 id="category-dialog-title" class="text-lg font-bold mb-4 text-slate-800 dark:text-white">分类名称</h3>
            <input type="text" id="category-name-input" class="w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500 outline-none mb-6 dark:text-white transition-all" placeholder="输入分类名称">
            <div class="flex justify-end gap-3">
                <button id="category-cancel-btn" class="px-4 py-2 text-sm rounded-xl text-slate-600 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 font-medium">取消</button>
                <button id="category-confirm-btn" class="px-4 py-2 text-sm rounded-xl text-white bg-emerald-500 hover:bg-emerald-600 shadow-md font-medium">确定</button>
            </div>
        </div>
    </div>

    <!-- Loading Mask (z-index 100) -->
    <div id="loading-mask" class="fixed inset-0 z-[100] bg-white/90 dark:bg-slate-900/90 backdrop-blur-sm hidden flex flex-col items-center justify-center transition-opacity">
        <div class="relative w-16 h-16">
            <div class="absolute inset-0 border-4 border-slate-200 dark:border-slate-700 rounded-full"></div>
            <div class="absolute inset-0 border-4 border-emerald-500 rounded-full border-t-transparent animate-spin"></div>
        </div>
        <p class="mt-4 text-emerald-600 dark:text-emerald-400 font-medium animate-pulse tracking-wide">加载中...</p>
    </div>

    <!-- Tooltip Container -->
    <div id="custom-tooltip" class="fixed hidden pointer-events-none max-w-xs whitespace-pre-wrap border leading-relaxed tracking-wide backdrop-blur-md rounded-xl shadow-glass px-4 py-2 text-sm transition-opacity duration-150
        bg-white/90 dark:bg-slate-800/90 text-slate-700 dark:text-slate-200 border-slate-200/50 dark:border-slate-700/50">
    </div>

    <script>
    let isEditMode = false;
    let isLoggedIn = false;
    let isAppLayout = localStorage.getItem('appLayout') === 'true';

    let editCardMode = false;
    let isEditCategoryMode = false;
    
    const categories = {};
    let currentEngine;
    let initialDragState = { category: null, index: -1 };

    function toggleAppLayout() {
        isAppLayout = !isAppLayout;
        localStorage.setItem('appLayout', isAppLayout);
        
        const checkbox = document.getElementById('layout-switch-checkbox');
        if (checkbox) checkbox.checked = isAppLayout;

        loadSections();
    }

    function logAction(action, details) {
        console.log(\`\${new Date().toISOString()}: \${action} - \`, details);
    }

    // 搜索引擎
    const searchEngines = {
        baidu: "https://www.baidu.com/s?wd=",
        bing: "https://www.bing.com/search?q=",
        google: "https://www.google.com/search?q=",
        site: ""
    };
    
    // 搜索引擎显示名称映射
    const searchEngineLabels = {
        baidu: "百度",
        bing: "必应",
        google: "谷歌",
        site: "本站"
    };

    // 搜索引擎图标映射 (SVG路径)
    const searchEngineIcons = {
        site:   '<svg width="16" height="16" fill="#FFD700" stroke="#FFD700" viewBox="0 0 24 24"><path fill="#FFD700" stroke="#FFD700" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>',
        baidu:  '<svg width="16" height="16" viewBox="0 0 32 32"><path fill="#4285F4" d="M5.749 16.864c3.48-.744 3-4.911 2.901-5.817c-.172-1.401-1.823-3.853-4.057-3.656c-2.812.249-3.224 4.323-3.224 4.323c-.385 1.88.907 5.901 4.38 5.151zm6.459-6.984c1.923 0 3.475-2.213 3.475-4.948C15.683 2.213 14.136 0 12.214 0c-1.916 0-3.479 2.197-3.479 4.932s1.557 4.948 3.479 4.948zm8.281.328c2.573.344 4.213-2.401 4.547-4.479c.333-2.068-1.333-4.484-3.145-4.896c-1.823-.421-4.079 2.5-4.307 4.401c-.24 2.333.333 4.651 2.895 4.979zm-3.864 8.714s-3.985-3.077-6.303-6.4c-3.145-4.901-7.62-2.907-9.115-.423c-1.489 2.511-3.812 4.084-4.14 4.505c-.333.412-4.797 2.823-3.803 7.224c1 4.401 4.479 4.323 4.479 4.323s2.557.251 5.548-.416c2.984-.667 5.547.161 5.547.161s6.943 2.333 8.864-2.147c1.896-4.495-1.083-6.812-1.083-6.812z"/></svg>',
        bing:   '<svg width="16" height="16" viewBox="0 0 32 32"><path fill="#008373" d="m4.807 0l6.391 2.25v22.495l9.005-5.193l-4.411-2.073l-2.786-6.932l14.188 4.984v7.245L11.204 32l-6.396-3.563z"/></svg>',
        google: '<svg width="16" height="16" viewBox="0 0 256 262"><path fill="#4285F4" d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"/><path fill="#34A853" d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"/><path fill="#FBBC05" d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"/><path fill="#EB4335" d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"/></svg>'
    };

    const engineList = ['site', 'baidu', 'bing', 'google'];

    function renderSearchEngineMenu() {
        const container = document.getElementById('search-engine-list');
        const title = container.querySelector('div');
        container.innerHTML = '';
        container.appendChild(title);

        engineList.forEach(key => {
            const label = searchEngineLabels[key];
            const icon = searchEngineIcons[key];
            
            const btn = document.createElement('button');
            btn.className = "w-full text-left px-3 py-2.5 text-sm text-slate-700 dark:text-slate-200 hover:bg-emerald-50 dark:hover:bg-slate-700 hover:text-emerald-600 transition-colors flex items-center gap-3";
            btn.onclick = () => selectSearchEngine(key, label);
            
            btn.innerHTML = \`\${icon}<span>\${label}</span>\`;
            
            container.appendChild(btn);
        });
    }

    function setActiveEngine(engine) {
        if (!searchEngines.hasOwnProperty(engine) && engine !== 'site') engine = 'site';
        selectSearchEngine(engine, searchEngineLabels[engine]);
    }

    function updateSearchEngineUI(value) {
        const label = searchEngineLabels[value] || "本站";
        const icon = searchEngineIcons[value] || searchEngineIcons['site'];
        
        document.getElementById('current-engine-label').textContent = label;
        document.getElementById('current-engine-icon').innerHTML = icon;
    }

    document.addEventListener('DOMContentLoaded', async () => {
        initializeUIComponents();
        renderSearchEngineMenu();
        await checkLoginStatusAndLoad();
    });

    async function checkLoginStatusAndLoad() {
        const isValid = await validateToken();
        if (isValid) {
            isLoggedIn = true;
        } else {
            isLoggedIn = false;
            isEditMode = false;
        }
        await loadLinks();
    }

    function initializeUIComponents() {
        const elements = {
            themeSwitchCheckbox: document.getElementById('theme-switch-checkbox'),
            layoutSwitchCheckbox: document.getElementById('layout-switch-checkbox'),
            savePrefCheckbox: document.getElementById('save-preference-checkbox'),
            searchButton: document.getElementById('search-button'),
            searchInput: document.getElementById('search-input'),
            clearSearchButton: document.getElementById('clear-search-button'),
            menuToggleBtn: document.getElementById('profile-menu-toggle'),
            dropdown: document.getElementById('profile-dropdown'),
            dropdownWrapper: document.getElementById('profile-dropdown-wrapper'),
            backToTopBtn: document.getElementById('back-to-top-btn')
        };
        
        elements.themeSwitchCheckbox.checked = document.documentElement.classList.contains('dark');
        elements.themeSwitchCheckbox.addEventListener('change', (e) => {
            const isDark = e.target.checked;
            window.isDarkTheme = isDark;
            applyTheme(isDark);
            
            const savePrefCheckbox = document.getElementById('save-preference-checkbox');
            if (savePrefCheckbox && savePrefCheckbox.checked) {
                localStorage.setItem('theme', isDark ? 'dark' : 'light');
            }
        });

        if(elements.layoutSwitchCheckbox) {
            elements.layoutSwitchCheckbox.checked = isAppLayout;
        }
        
        const savedPref = localStorage.getItem('savePreferences') === 'true';
        elements.savePrefCheckbox.checked = savedPref;

        currentEngine = (savedPref && localStorage.getItem('searchEngine')) || 'site';
        updateSearchEngineUI(currentEngine);

        const searchWrapper = document.getElementById('search-engine-wrapper');
        const searchBtn = document.getElementById('search-engine-btn');
        const searchMenu = document.getElementById('search-engine-menu');
        
        searchBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            searchMenu.classList.toggle('hidden');
        });

        const catWrapper = document.getElementById('category-select-wrapper');
        const catBtn = document.getElementById('category-select-btn');
        const catMenu = document.getElementById('category-select-menu');
        
        catBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            catMenu.classList.toggle('hidden');
        });

        const toggleDropdown = () => {
            elements.dropdown.classList.toggle('hidden');
        };

        elements.menuToggleBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            toggleDropdown();
        });

        document.addEventListener('click', (e) => {
            if (!elements.dropdownWrapper.contains(e.target)) {
                 elements.dropdown.classList.add('hidden');
            }
            if (!searchWrapper.contains(e.target)) {
                searchMenu.classList.add('hidden');
            }
            if (!catWrapper.contains(e.target)) {
                catMenu.classList.add('hidden');
            }
        });

        elements.dropdown.addEventListener('click', (e) => { e.stopPropagation(); });

        elements.savePrefCheckbox.addEventListener('change', () => {
            const enabled = elements.savePrefCheckbox.checked;
            localStorage.setItem('savePreferences', enabled);
            if (!enabled) {
                localStorage.removeItem('searchEngine');
                localStorage.removeItem('theme');
            } else {
                localStorage.setItem('searchEngine', currentEngine);
                localStorage.setItem('theme', window.isDarkTheme ? 'dark' : 'light');
            }
        });

        elements.searchButton.addEventListener('click', async () => {
            const query = elements.searchInput.value.trim();
            if (query) {
                if (currentEngine === 'site') {
                    await searchLinks(query); 
                } else {
                    window.open(searchEngines[currentEngine] + encodeURIComponent(query), '_blank');
                }
            }
        });

        if (elements.clearSearchButton) {
            elements.clearSearchButton.addEventListener('click', () => {
                elements.searchInput.value = '';
                loadSections(); 
            });
        }

        if (elements.searchInput) {
            elements.searchInput.addEventListener('keypress', (e) => {
                if (e.key === 'Enter') elements.searchButton.click();
            });
            elements.searchInput.addEventListener('input', (e) => {
                if(e.target.value) elements.clearSearchButton.classList.remove('hidden');
                else elements.clearSearchButton.classList.add('hidden');
            });
        }
        
        window.addEventListener('scroll', () => {
            if (window.scrollY > 300) {
                elements.backToTopBtn.classList.remove('hidden');
            } else {
                elements.backToTopBtn.classList.add('hidden');
            }
        });
        
        setupScrollSpy();
        
        setupTooltipDelegation();
    }

    function selectSearchEngine(value, label) {
        currentEngine = value;
        updateSearchEngineUI(value);
        
        const savePrefCheckbox = document.getElementById('save-preference-checkbox');
        if (savePrefCheckbox && savePrefCheckbox.checked) {
            localStorage.setItem('searchEngine', value);
        }
        document.getElementById('search-engine-menu').classList.add('hidden');
    }


    function getAllLinks() {
        return Object.values(categories).map(category => category.links || []).flat();
    }
    
    async function loadLinks() {
        const headers = { 'Content-Type': 'application/json' };
        if (isLoggedIn) {
            const token = localStorage.getItem('authToken');
            if (token) headers['Authorization'] = token;
        }
        
        try {
            const response = await fetchWithAuth('/api/getLinks');
            if (!response.ok) throw new Error("HTTP error! status: " + response.status);
            
            const data = await response.json();
            if (data.categories) {
                Object.keys(categories).forEach(key => delete categories[key]);
                Object.assign(categories, data.categories);
            }

            loadSections();
            updateCategorySelect();
            updateUIState();
        } catch (error) {
            console.error('Error loading links:', error);
            await customAlert('加载链接时出错,请刷新页面重试');
        }
    }

    async function saveDataToServer(actionName, data) {
        try {
            const response = await fetchWithAuth('/api/saveData', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ categories: data }),
            });

            if (response.status === 401) {
                logout();
                await customAlert('登录凭证已过期,请重新登录');
                throw new Error('Unauthorized');
            }

            const result = await response.json();
            if (!result.success) throw new Error('Failed to save');
            logAction(actionName + '成功', {});
        } catch (error) {
            logAction(actionName + '失败', { error: error.message });
            if (error.message !== 'Unauthorized') {
                 await customAlert(actionName + '失败,请重试');
            }
        }
    }

    async function saveLinks() {
        if (isEditMode) {
            await saveDataToServer('保存数据', categories);
        }
    }
    
    async function addCategory() {
        if (!await validateTokenOrRedirect()) return;
        const categoryName = await showCategoryDialog('请输入新分类名称');
        if (!categoryName) return;
        if (categories[categoryName]) {
            await customAlert('该分类已存在');
            return;
        }
        categories[categoryName] = { isHidden: false, links: [] };
        updateCategorySelect();
        renderCategories();
        setTimeout(() => window.scrollTo(0, document.body.scrollHeight), 100);
        await saveLinks();
    }

    async function editCategoryName(oldName) {
        if (!await validateTokenOrRedirect()) return;
        const newName = await showCategoryDialog('请输入新的分类名称', oldName);
        if (!newName || newName === oldName) return;
        if (categories[newName]) {
            await customAlert('该名称已存在');
            return;
        }

        const keys = Object.keys(categories);
        const newCategories = {};

        keys.forEach(key => {
            if (key === oldName) {
                const data = categories[oldName];
                data.links.forEach(item => item.category = newName);
                newCategories[newName] = data;
            } else {
                newCategories[key] = categories[key];
            }
        });

        Object.keys(categories).forEach(k => delete categories[k]);
        Object.assign(categories, newCategories);

        renderCategories();
        renderCategoryButtons();
        updateCategorySelect();
        await saveLinks();
    }

    async function deleteCategory(category) {
        if (!await validateTokenOrRedirect()) return;
        if (await customConfirm(\`确定删除 "\${category}" 分类及其所有链接吗?\`)) {
            delete categories[category];
            updateCategorySelect();
            renderCategories();
            renderCategoryButtons();
            await saveLinks();
        }
    } 
    
    async function moveCategory(categoryName, direction) {
        if (!await validateTokenOrRedirect()) return;
        const keys = Object.keys(categories);
        const index = keys.indexOf(categoryName);
        if (index < 0) return;
        const newIndex = index + direction;
        if (newIndex < 0 || newIndex >= keys.length) return;
    
        const newCategories = {};
        const reordered = [...keys];
        [reordered[index], reordered[newIndex]] = [reordered[newIndex], reordered[index]];
        reordered.forEach(key => newCategories[key] = categories[key]);
        
        Object.keys(categories).forEach(k => delete categories[k]);
        Object.assign(categories, newCategories);
    
        renderCategories();
        renderCategoryButtons();
        await saveLinks(); 
    }

    async function toggleCategoryHidden(category, isHidden) {
        if (!await validateTokenOrRedirect()) return;
        categories[category].isHidden = isHidden;
        await saveLinks();
    }

    async function pinCategory(categoryName) {
        if (!await validateTokenOrRedirect()) return;
        const keys = Object.keys(categories);
        const index = keys.indexOf(categoryName);
        if (index < 0) return;
        
        const newCategories = {};
        const reordered = [...keys];
        reordered.splice(index, 1);
        reordered.unshift(categoryName);
        reordered.forEach(key => newCategories[key] = categories[key]);
        
        Object.keys(categories).forEach(k => delete categories[k]);
        Object.assign(categories, newCategories);
        
        renderCategories();
        renderCategoryButtons();
        await saveLinks();
    }
    
    function getFilteredCategoriesByKeyword(query) {
        const lowerQuery = query.toLowerCase();
        const result = {};
        Object.keys(categories).forEach(category => {
            const categoryData = categories[category];
            const matchedLinks = (categoryData.links || []).filter(link => {
                const nameMatch = link.name && link.name.toLowerCase().includes(lowerQuery);
                const tipsMatch = link.tips && link.tips.toLowerCase().includes(lowerQuery);
                const urlMatch = link.url && link.url.toLowerCase().includes(lowerQuery);
                return nameMatch || tipsMatch || urlMatch;
            });
            if (matchedLinks.length > 0) {
                result[category] = { ...categoryData, links: matchedLinks };
            }
        });
        return result;
    }

    function renderCategorySections({ renderButtons = false, searchMode = false, filteredCategories = null } = {}) {
        const container = document.getElementById('sections-container');
        container.innerHTML = '';
        const sourceCategories = searchMode && filteredCategories ? filteredCategories : categories;

        Object.entries(sourceCategories).forEach(([category, { links, isHidden }]) => {
            if (!isEditMode && !isLoggedIn && isHidden && !searchMode) return;

            const section = document.createElement('div');
            section.className = 'section section-anchor';
            section.id = category;

            // 标题区域
            const titleContainer = document.createElement('div');
            titleContainer.className = 'flex items-center gap-3 mb-5 pb-2 border-b border-slate-200/60 dark:border-slate-700/60';
            
            const title = document.createElement('h2');
            title.className = 'text-lg font-bold text-slate-700 dark:text-slate-100 flex items-center gap-2';
            title.innerHTML = \`<span class="w-1.5 h-5 bg-emerald-500 rounded-full inline-block shadow-sm"></span> \${category}\`;
            titleContainer.appendChild(title);

            // 编辑模式下的标题栏操作
            if (isEditMode) {
                const controls = document.createElement('div');
                controls.className = 'flex items-center gap-1 ml-auto bg-slate-300/50 dark:bg-slate-800/50 p-1 rounded-xl border border-slate-300/50 dark:border-slate-700/50 backdrop-blur-sm';
                const btnBase = "w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-200 hover:scale-105 active:scale-95";
                
                controls.innerHTML = \`
                    <!-- 编辑名称 -->
                    <button class="\${btnBase} text-slate-500 hover:text-blue-600 hover:bg-blue-100 dark:text-slate-400 dark:hover:bg-blue-900/30 dark:hover:text-blue-400 has-tooltip" data-tooltip="重命名" onclick="editCategoryName('\${category}')">
                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
                    </button>
                    
                    <div class="w-px h-4 bg-slate-300 dark:bg-slate-600 mx-0.5"></div>

                    <!-- 排序组 -->
                    <button class="\${btnBase} text-slate-500 hover:text-emerald-600 hover:bg-emerald-100 dark:text-slate-400 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-400 has-tooltip" data-tooltip="上移" onclick="moveCategory('\${category}', -1)">
                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path></svg>
                    </button>
                    <button class="\${btnBase} text-slate-500 hover:text-emerald-600 hover:bg-emerald-100 dark:text-slate-400 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-400 has-tooltip" data-tooltip="下移" onclick="moveCategory('\${category}', 1)">
                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
                    </button>
                    <button class="\${btnBase} text-slate-500 hover:text-amber-600 hover:bg-amber-100 dark:text-slate-400 dark:hover:bg-amber-900/30 dark:hover:text-amber-400 has-tooltip" data-tooltip="置顶" onclick="pinCategory('\${category}')">
                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3h14M18 13l-6-6l-6 6M12 7v14"></path></svg>
                    </button>

                    <div class="w-px h-4 bg-slate-300 dark:bg-slate-600 mx-0.5"></div>

                    <!-- 隐藏开关 -->
                    <div class="flex items-center justify-center w-8 h-8 has-tooltip cursor-pointer" data-tooltip="\${isHidden ? '显示分类' : '隐藏分类'}">
                        <label class="relative inline-flex items-center cursor-pointer">
                            <!-- 下面这一行增加了 DOM 属性更新逻辑 -->
                            <input type="checkbox" \${isHidden ? 'checked' : ''} 
                                onchange="this.closest('.has-tooltip').setAttribute('data-tooltip', this.checked ? '显示分类' : '隐藏分类'); toggleCategoryHidden('\${category}', this.checked)" 
                                class="sr-only peer">
                            <div class="w-3.5 h-3.5 rounded-full border-2 border-slate-400 peer-focus:outline-none peer dark:border-slate-500 peer-checked:bg-slate-500 peer-checked:border-slate-500 transition-colors"></div>
                        </label>
                    </div>

                    <div class="w-px h-4 bg-slate-300 dark:bg-slate-600 mx-0.5"></div>

                    <!-- 删除 -->
                    <button class="\${btnBase} text-slate-400 hover:text-red-600 hover:bg-red-100 dark:text-slate-500 dark:hover:bg-red-900/30 dark:hover:text-red-400 has-tooltip" data-tooltip="删除分类" onclick="deleteCategory('\${category}')">
                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
                    </button>
                \`;
                titleContainer.appendChild(controls);
            }

            // 卡片网格
            const cardContainer = document.createElement('div');
            // 根据布局模式调整 Grid 列数
            // APP 模式下,手机端一行4个,平板6个,大屏8-10个
            const gridClasses = isAppLayout ? 
                'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-x-2 gap-y-6' : 
                'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4';
            
            cardContainer.className = \`grid \${gridClasses} card-container relative\`;
            cardContainer.id = category; // ID for drag logic

            section.appendChild(titleContainer);
            section.appendChild(cardContainer);
            container.appendChild(section);

            links.forEach(link => createCard(link, cardContainer));

            if (isEditMode) {
                const addCardPlaceholder = document.createElement('div');
                const sizeClasses = isAppLayout 
                    ? 'w-16 h-16 rounded-[1.2rem] mx-auto' 
                    : 'min-h-[100px] p-4 rounded-2xl w-full';
                
                addCardPlaceholder.className = \`add-card-placeholder group flex flex-col h-full w-full \${sizeClasses} rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 hover:border-emerald-500 dark:hover:border-emerald-500 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 transition-all cursor-pointer flex items-center justify-center\`;
                addCardPlaceholder.innerHTML = \`
                    <div class="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 group-hover:bg-emerald-100 dark:group-hover:bg-emerald-900/30 flex items-center justify-center transition-colors pointer-events-none">
                        <svg class="w-6 h-6 text-slate-400 group-hover:text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
                    </div>
                \`;
                
                addCardPlaceholder.addEventListener('dragover', (e) => {
                    e.preventDefault();
                    const dragging = document.querySelector('.card.dragging');
                    if(dragging && dragging.parentElement === cardContainer) {
                        cardContainer.insertBefore(dragging, addCardPlaceholder);
                    }
                });
                
                addCardPlaceholder.onclick = () => {
                     showAddDialog();
                     document.getElementById('category-select-value').value = category;
                     document.getElementById('category-select-text').textContent = category;
                };
                cardContainer.appendChild(addCardPlaceholder);
            }
        });

        if (renderButtons) renderCategoryButtons();
        
        setupScrollSpy();
    }

    function renderCategories() {
        renderCategorySections({ renderButtons: false });
    } 

    async function searchLinks(query) {
        const clearBtn = document.getElementById('clear-search-button');
        const filteredData = getFilteredCategoriesByKeyword(query);
        const hasMatchingLinks = Object.values(filteredData).some(c => c.links.length > 0);

        if (!hasMatchingLinks) {
            await customAlert('没有找到相关站点。');
            return;
        }
        clearBtn.classList.remove('hidden');
        renderCategorySections({ renderButtons: true, searchMode: true, filteredCategories: filteredData });
    }
    
    function renderCategoryButtons() {
        const container = document.getElementById('category-buttons-container');
        container.innerHTML = '';
        const visibleCategories = Object.keys(categories).filter(c => 
            (categories[c].links || []).some(l => !l.isPrivate || isLoggedIn) && 
            (!categories[c].isHidden || isEditMode || isLoggedIn)
        );

        if (visibleCategories.length === 0) return;

        visibleCategories.forEach(cat => {
            const btn = document.createElement('button');
            btn.className = 'category-button whitespace-nowrap px-4 py-1.5 text-xs font-medium rounded-xl border border-slate-300 dark:border-slate-600 transition-all active:scale-95 shadow-sm scroll-snap-align-start';
            btn.classList.add('bg-slate-100', 'dark:bg-slate-800', 'text-slate-600', 'dark:text-slate-300', 'hover:bg-emerald-50', 'hover:text-emerald-600', 'dark:hover:bg-slate-700', 'hover:border-emerald-300', 'dark:hover:border-emerald-500/50');
            
            btn.textContent = cat;
            btn.dataset.target = cat;
            btn.onclick = () => {
                scrollToCategory(cat);
            };
            container.appendChild(btn);
        });
    }

    function scrollToCategory(catId) {
        const section = document.getElementById(catId);
        if(section) {
            section.scrollIntoView({ behavior: 'smooth', block: 'start' });
        }
    }

    function setupScrollSpy() {
        const sections = document.querySelectorAll('.section');
        const buttons = document.querySelectorAll('.category-button');
        
        if (!sections.length || !buttons.length) return;

        const observerOptions = {
            root: null,
            rootMargin: '-100px 0px -70% 0px', 
            threshold: 0
        };

        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const id = entry.target.id;
                    highlightButton(id);
                }
            });
        }, observerOptions);

        sections.forEach(section => observer.observe(section));
    }

    function highlightButton(id) {
        const buttons = document.querySelectorAll('.category-button');
        buttons.forEach(btn => {
            if (btn.dataset.target === id) {
                btn.classList.remove('bg-slate-100', 'dark:bg-slate-800', 'text-slate-600', 'dark:text-slate-300', 'hover:bg-emerald-50', 'hover:text-emerald-600', 'dark:hover:bg-slate-700');
                btn.classList.add('bg-emerald-500', 'text-white', 'shadow-md', 'dark:bg-emerald-600');
                btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
            } else {
                btn.classList.remove('bg-emerald-500', 'text-white', 'shadow-md', 'dark:bg-emerald-600');
                btn.classList.add('bg-slate-100', 'dark:bg-slate-800', 'text-slate-600', 'dark:text-slate-300', 'hover:bg-emerald-50', 'hover:text-emerald-600', 'dark:hover:bg-slate-700');
            }
        });
    }

    // --- 遮罩层过渡辅助函数 ---
    function toggleOverlay(id, show) {
        const overlay = document.getElementById(id);
        const box = overlay.querySelector('div[id$="-box"]'); 
        
        if (show) {
            overlay.classList.remove('hidden');
            void overlay.offsetWidth; 
            overlay.classList.remove('overlay-hidden');
            overlay.classList.add('overlay-visible');
            
            if(box) {
                box.classList.remove('dialog-scale-hidden');
                box.classList.add('dialog-scale-visible');
            }
        } else {
            overlay.classList.remove('overlay-visible');
            overlay.classList.add('overlay-hidden');
            
            if(box) {
                box.classList.remove('dialog-scale-visible');
                box.classList.add('dialog-scale-hidden');
            }
            
            setTimeout(() => {
                if(overlay.classList.contains('overlay-hidden')) {
                    overlay.classList.add('hidden');
                }
            }, 300); 
        }
    }

    function updateUIState() {
        const editModeBtn = document.getElementById('edit-mode-btn');
        const loginBtn = document.getElementById('login-Btn');
        const addCategoryContainer = document.getElementById('add-category-container');
        const dataToolsMenu = document.getElementById('data-tools-menu');
        
        loginBtn.innerHTML = isLoggedIn ? 
            '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg> 退出登录' : 
            '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg> 登录';
        
        if(isLoggedIn) {
            loginBtn.classList.replace('text-red-500', 'text-slate-700');
            if(dataToolsMenu) dataToolsMenu.classList.remove('hidden');
        } else {
            if(dataToolsMenu) dataToolsMenu.classList.add('hidden');
        }
        
        if (isEditMode) {
            editModeBtn.innerHTML = '<span class="text-red-500 flex items-center gap-2"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>退出编辑</span>';
            document.body.classList.add('edit-mode');
            if(addCategoryContainer) addCategoryContainer.classList.remove('hidden');
        } else {
            editModeBtn.innerHTML = isLoggedIn ? 
                '<span class="flex items-center gap-3"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>进入编辑模式</span>' : 
                '<span class="flex items-center gap-3 text-slate-400"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>编辑模式 (需登录)</span>';
            document.body.classList.remove('edit-mode');
            if(addCategoryContainer) addCategoryContainer.classList.add('hidden');
        }
    }
    
    function loadSections() {
        document.getElementById('clear-search-button').classList.add('hidden');
        document.getElementById('search-input').value = '';
        renderCategorySections({ renderButtons: true });
    }

    const imgApi = '/api/icon?url='; 

    function createCard(link, container) {
        if (!isEditMode && link.isPrivate && !isLoggedIn) return;

        const card = document.createElement('div');
        
        let cardBaseClass = isAppLayout 
            ? 'flex flex-col items-center justify-start py-1 gap-1.5 hover:z-10' 
            : 'flex flex-col p-4 bg-white/90 dark:bg-[#1e293b]/60 backdrop-blur-md supports-[backdrop-filter]:bg-white/80 border border-gray-200 dark:border-slate-700/50 hover:border-emerald-500/50 dark:hover:border-emerald-400/50 shadow-sm hover:shadow-[0_8px_20px_-6px_rgba(0,0,0,0.1)] dark:shadow-none dark:hover:shadow-[0_8px_20px_-6px_rgba(0,0,0,0.4)] hover:-translate-y-1.5';
            
        if (link.isPrivate && !isAppLayout) {
            cardBaseClass += ' ring-1 ring-amber-400/40 bg-amber-50/80 dark:bg-amber-900/10 !border-amber-200 dark:!border-amber-700/50';
        }

        card.className = \`group relative h-full w-full rounded-2xl transition-all duration-300 ease-[cubic-bezier(0.25,0.8,0.25,1)] cursor-pointer select-none \${cardBaseClass}\`;
        
        if (isEditMode) {
            card.setAttribute('draggable', 'true');
            card.classList.add('card'); 
            card.classList.add('cursor-move');
        }
        
        card.dataset.isPrivate = link.isPrivate;
        card.setAttribute('data-url', link.url);

        const header = document.createElement('div');
        header.className = isAppLayout 
            ? 'flex flex-col items-center justify-center w-full relative' 
            : 'flex items-center gap-3 mb-2.5 w-full';
        
        const icon = document.createElement('img');
        icon.setAttribute('loading', 'lazy'); 
        
        // 图标样式
        let iconClass = '';
        if (isAppLayout) {
             // APP 风格:大图标、白底、大圆角、阴影
             iconClass = 'w-14 h-14 sm:w-16 sm:h-16 rounded-[1.2rem] object-contain bg-white dark:bg-slate-600 p-2 shadow-md hover:shadow-lg transition-transform duration-300 group-hover:scale-105 group-active:scale-95 z-10';
             if (link.isPrivate) {
                 iconClass += ' ring-2 ring-amber-400';
             }
        } else {
             // 列表风格:小图标、淡底
             iconClass = 'w-9 h-9 rounded-lg object-contain bg-slate-100 dark:bg-slate-900 p-1 border border-slate-200 dark:border-slate-700 transition-transform group-hover:scale-105 pointer-events-none';
        }
        icon.className = iconClass;

        icon.src = (!link.icon || !link.icon.startsWith('http')) ? imgApi + link.url : link.icon;
        icon.onerror = function() {
             this.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y='8' x2='12' y='12'/%3E%3Cline x1='12' y='16' x2='12.01' y='16'/%3E%3C/svg%3E";
        };
        header.appendChild(icon);

        const name = document.createElement('div');
        name.className = isAppLayout 
            ? 'text-sm font-semibold text-center mt-1.5' 
            : 'text-sm font-semibold';
        name.textContent = link.name;
        header.appendChild(name);

        card.appendChild(header);

        if (!isAppLayout) {
            const desc = document.createElement('div');
            desc.className = 'text-xs text-slate-500 dark:text-slate-400 line-clamp-2 min-h-[1.25rem] card-tip leading-relaxed pointer-events-none';
            desc.textContent = link.tips || '';
            card.appendChild(desc);
        }

        if (link.isPrivate && !isAppLayout) {
            const badge = document.createElement('div');
            badge.className = 'absolute top-0 right-0 w-8 h-8 pointer-events-none overflow-hidden rounded-tr-2xl';
            badge.innerHTML = '<div class="absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 rotate-45 w-8 h-8 bg-amber-400"></div>';
            card.appendChild(badge);
        }

        if (isEditMode) {
            const actionWrapper = document.createElement('div');
            actionWrapper.className = isAppLayout 
                ? 'absolute top-[-4px] right-[-4px] z-30' 
                : 'absolute top-2 right-2 z-30';

            const menuBtn = document.createElement('button');
            const btnStyle = isAppLayout
                ? 'w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300 shadow-sm hover:bg-emerald-500 hover:text-white'
                : 'w-7 h-7 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100/80 backdrop-blur-sm';
            
            menuBtn.className = \`\${btnStyle} flex items-center justify-center transition-all duration-200\`;
            menuBtn.innerHTML = '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>';
            
            const dropdown = document.createElement('div');
            dropdown.className = 'hidden absolute right-0 top-6 w-28 bg-white dark:bg-[#1e293b] rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/10 overflow-hidden transform origin-top-right transition-all z-50 flex flex-col p-1 card-menu-dropdown';
            
            dropdown.innerHTML = \`
                <button class="menu-edit w-full text-left px-3 py-2 rounded-lg text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-emerald-50 dark:hover:bg-slate-700/50 hover:text-emerald-600 transition-colors flex items-center gap-2">
                    <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
                    编辑
                </button>
                <button class="menu-delete w-full text-left px-3 py-2 rounded-lg text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors flex items-center gap-2">
                    <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
                    删除
                </button>
            \`;

            menuBtn.onclick = (e) => {
                e.stopPropagation();
                document.querySelectorAll('.card-menu-dropdown').forEach(el => {
                    if (el !== dropdown) el.classList.add('hidden');
                });
                dropdown.classList.toggle('hidden');
            };

            dropdown.querySelector('.menu-edit').onclick = (e) => {
                e.stopPropagation();
                dropdown.classList.add('hidden');
                showEditDialog(link);
            };

            dropdown.querySelector('.menu-delete').onclick = (e) => {
                e.stopPropagation();
                dropdown.classList.add('hidden');
                removeCard(card);
            };

            actionWrapper.appendChild(menuBtn);
            actionWrapper.appendChild(dropdown);
            card.appendChild(actionWrapper);
        }

        if (!isEditMode) {
            card.onclick = () => {
                 let url = link.url.startsWith('http') ? link.url : 'http://' + link.url;
                 window.open(url, '_blank');
            };
        }

        card.addEventListener('dragstart', dragStart);
        card.addEventListener('dragover', dragOver);
        card.addEventListener('dragend', dragEnd);
        card.addEventListener('drop', drop);
        
        if (!isEditMode && link.tips) {
            card.classList.add('has-tooltip');
            card.setAttribute('data-tooltip', link.tips);
        } else if (!isEditMode && !link.tips) {
            // 为没有描述的卡片添加URL作为悬停提示
            card.classList.add('has-tooltip');
            card.setAttribute('data-tooltip', link.url);
        }

        card.addEventListener('touchstart', touchStart, { passive: false });
        
        container.appendChild(card);
        
        if (!window.hasAddedCardMenuListener) {
            document.addEventListener('click', (e) => {
                if (!e.target.closest('.card-menu-dropdown') && !e.target.closest('button')) {
                    document.querySelectorAll('.card-menu-dropdown').forEach(el => el.classList.add('hidden'));
                }
            });
            window.hasAddedCardMenuListener = true;
        }
    }
    
    function updateCategorySelect() {
        const menu = document.getElementById('category-select-menu');
        menu.innerHTML = '';
        Object.keys(categories).forEach(cat => {
            const item = document.createElement('div');
            item.className = 'px-4 py-2.5 text-sm text-slate-700 dark:text-slate-200 hover:bg-emerald-50 dark:hover:bg-slate-700 cursor-pointer transition-colors';
            item.textContent = cat;
            item.onclick = () => {
                document.getElementById('category-select-value').value = cat;
                document.getElementById('category-select-text').textContent = cat;
                menu.classList.add('hidden');
            };
            menu.appendChild(item);
        });
    }

    async function addCard() {
        if (!await validateTokenOrRedirect()) return;
        const name = document.getElementById('name-input').value.trim();
        const url = document.getElementById('url-input').value.trim();
        const category = document.getElementById('category-select-value').value;
        
        if (!name || !url || !category) {
            await customAlert('请填写必要信息 (名称, URL, 分类)');
            return;
        }

        const newLink = {
            name, url, category,
            tips: document.getElementById('tips-input').value.trim(),
            icon: document.getElementById('icon-input').value.trim(),
            isPrivate: document.getElementById('private-checkbox').checked
        };

        try {
            categories[category].links.push(newLink);
            await saveLinks();
            if (isEditMode || !newLink.isPrivate || isLoggedIn) {
                 renderCategories();
            }
            hideAddDialog();
        } catch (e) {
            await customAlert('添加失败: ' + e);
        }
    }

    async function updateCard(oldLink) {
        if (!await validateTokenOrRedirect()) return;
        
        const updatedLink = {
            name: document.getElementById('name-input').value.trim(),
            url: document.getElementById('url-input').value.trim(),
            tips: document.getElementById('tips-input').value.trim(),
            icon: document.getElementById('icon-input').value.trim(),
            category: document.getElementById('category-select-value').value,
            isPrivate: document.getElementById('private-checkbox').checked
        };

        let found = false;
        
        for (const cat in categories) {
             const idx = categories[cat].links.findIndex(l => l.url === oldLink.url);
             
             if (idx !== -1) {
                 found = true;
                 
                 if (cat === updatedLink.category) {
                     categories[cat].links[idx] = updatedLink;
                 } 
                 else {
                     categories[cat].links.splice(idx, 1);
                     
                     if(!categories[updatedLink.category]) {
                         categories[updatedLink.category] = { isHidden:false, links:[] };
                     }
                     categories[updatedLink.category].links.push(updatedLink);
                 }
                 break; 
             }
        }
        
        if (!found) {
             if(!categories[updatedLink.category]) {
                 categories[updatedLink.category] = { isHidden:false, links:[] };
             }
             categories[updatedLink.category].links.push(updatedLink);
        }

        await saveLinks();
        renderCategories();
        hideAddDialog();
    }

    async function removeCard(card) {
        if (!await validateTokenOrRedirect()) return;
        const url = card.getAttribute('data-url');
        for (const cat in categories) {
            const idx = categories[cat].links.findIndex(l => l.url === url);
            if (idx !== -1) {
                categories[cat].links.splice(idx, 1);
                break;
            }
        }
        card.remove();
        await saveLinks();
    }

    // --- 拖拽辅助函数 ---
    function getCardState(card) {
        if(!card) return { category: null, index: -1 };
        const section = card.closest('.section');
        const index = Array.from(section.querySelectorAll('.card')).indexOf(card);
        return { category: section.id, index: index };
    }

    // --- 拖拽(电脑端) ---
    let draggedCard = null;
    function dragStart(e) {
        if (!isEditMode) { e.preventDefault(); return; }
        draggedCard = this;
        this.classList.add('dragging');
        e.dataTransfer.effectAllowed = "move";
        initialDragState = getCardState(this);
    }
    function dragOver(e) {
        if (!isEditMode) return;
        e.preventDefault();
        const target = e.target.closest('.card');
        if (target && target !== draggedCard) {
            const container = target.parentElement;
            const rect = target.getBoundingClientRect();
            if (e.clientX < rect.left + rect.width / 2) {
                container.insertBefore(draggedCard, target);
            } else {
                container.insertBefore(draggedCard, target.nextSibling);
            }
        }
    }
    function dragEnd() {
        this.classList.remove('dragging');
    }
    async function drop(e) {
        if (!isEditMode) return;
        e.preventDefault();
        if (draggedCard) {
            const newState = getCardState(draggedCard);
            if (newState.category !== initialDragState.category || newState.index !== initialDragState.index) {
                updateCardCategory(draggedCard, newState.category);
                await saveCardOrder();
            }
            draggedCard = null;
        }
    }

    // 移动端拖拽
    let mobileDragTimer = null;
    let isMobileDragging = false;
    let mobilePlaceholder = null; 
    let mobileClone = null;       
    let mobileTouchOffset = { x: 0, y: 0 }; 
    let rafId = null;             
    let lastTouchX = 0;
    let lastTouchY = 0;
    let lastSwapTime = 0;         
    let activeContainer = null;
    let cloneWidth = 0;
    let cloneHeight = 0;

    function touchStart(e) {
        if (!isEditMode) return;
        if (e.touches.length > 1) return; 

        const card = e.target.closest('.card');
        if (!card) return;

        const touch = e.touches[0];
        const startX = touch.clientX;
        const startY = touch.clientY;

        if (mobileDragTimer) clearTimeout(mobileDragTimer);

        mobileDragTimer = setTimeout(() => {
            isMobileDragging = true;
            mobilePlaceholder = card;
            activeContainer = mobilePlaceholder.parentElement;
            
            initialDragState = getCardState(mobilePlaceholder);

            const rect = mobilePlaceholder.getBoundingClientRect();
            cloneWidth = rect.width;
            cloneHeight = rect.height;

            mobileTouchOffset.x = startX - rect.left;
            mobileTouchOffset.y = startY - rect.top;
            
            lastTouchX = startX;
            lastTouchY = startY;

            mobileClone = mobilePlaceholder.cloneNode(true);
            
            Object.assign(mobileClone.style, {
                position: 'fixed',
                left: rect.left + 'px',
                top: rect.top + 'px',
                width: rect.width + 'px',
                height: rect.height + 'px',
                zIndex: '9999',
                opacity: '0.95',
                boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.2)', 
                transform: 'scale(1.05)', 
                transition: 'none'
            });
            mobileClone.classList.add('card-clone-dragging');
            mobileClone.classList.remove('group', 'hover:-translate-y-1', 'transition-all', 'duration-300');
            document.body.appendChild(mobileClone);

            // 占位符样式
            mobilePlaceholder.style.opacity = '0.3';
            mobilePlaceholder.classList.add('border-dashed', 'border-2', 'border-emerald-400');

            if (navigator.vibrate) navigator.vibrate(50);
            
            updatePosition();
        }, 500);

        document.addEventListener('touchmove', handleTouchMove, { passive: false });
        document.addEventListener('touchend', handleTouchEnd);
        document.addEventListener('touchcancel', handleTouchEnd);

        function updatePosition() {
            if (!isMobileDragging || !mobileClone) return;
            
            const x = lastTouchX - mobileTouchOffset.x;
            const y = lastTouchY - mobileTouchOffset.y;
            
            mobileClone.style.left = x + 'px';
            mobileClone.style.top = y + 'px';
            
            rafId = requestAnimationFrame(updatePosition);
        }

        function handleTouchMove(moveEvent) {
            const moveTouch = moveEvent.touches[0];

            if (!isMobileDragging) {
                const diffX = moveTouch.clientX - startX;
                const diffY = moveTouch.clientY - startY;
                const distance = Math.sqrt(diffX * diffX + diffY * diffY);

                if (distance > 10) {
                    clearTimeout(mobileDragTimer);
                    mobileDragTimer = null;
                }
                
                return;
            }

            if (isMobileDragging) {
                moveEvent.preventDefault(); 
                
                lastTouchX = moveTouch.clientX;
                lastTouchY = moveTouch.clientY;

                const now = Date.now();
                if (now - lastSwapTime > 30) { 
                    detectSort(moveTouch.clientX, moveTouch.clientY);
                }
            }
        }

        function detectSort(fingerX, fingerY) {
            let elementBelow = document.elementFromPoint(fingerX, fingerY);
            if (!elementBelow) return;

            let targetCard = elementBelow.closest('.card') || elementBelow.closest('.add-card-placeholder');
            let targetContainer = targetCard ? targetCard.parentElement : elementBelow.closest('.card-container');
            
            if (!targetContainer) return;

            if (activeContainer !== targetContainer) {
                activeContainer = targetContainer;
                const placeholderBtn = activeContainer.querySelector('.add-card-placeholder');
                if (placeholderBtn) {
                    activeContainer.insertBefore(mobilePlaceholder, placeholderBtn);
                } else {
                    activeContainer.appendChild(mobilePlaceholder);
                }
                lastSwapTime = Date.now();
                return;
            }

            const containerRect = activeContainer.getBoundingClientRect();
            
            const cloneViewportCenterX = lastTouchX - mobileTouchOffset.x + (cloneWidth / 2);
            const cloneViewportCenterY = lastTouchY - mobileTouchOffset.y + (cloneHeight / 2);

            const cloneRelX = cloneViewportCenterX - containerRect.left + activeContainer.scrollLeft;
            const cloneRelY = cloneViewportCenterY - containerRect.top + activeContainer.scrollTop;

            const siblings = Array.from(activeContainer.children).filter(c => 
                (c.classList.contains('card') || c.classList.contains('add-card-placeholder')) && c !== mobilePlaceholder
            );

            if (siblings.length === 0) return;

            let closestElement = null;
            let minDistance = Infinity;

            for (const child of siblings) {
                const childCenterX = child.offsetLeft + child.offsetWidth / 2;
                const childCenterY = child.offsetTop + child.offsetHeight / 2;
                
                const dist = Math.hypot(cloneRelX - childCenterX, cloneRelY - childCenterY);
                
                if (dist < minDistance) {
                    minDistance = dist;
                    closestElement = child;
                }
            }

            if (closestElement) {
                const positionsBefore = new Map();
                const allChildren = Array.from(activeContainer.children).filter(el => 
                    el.classList.contains('card') || el.classList.contains('add-card-placeholder')
                );
                allChildren.forEach(el => positionsBefore.set(el, el.getBoundingClientRect()));

                const placeholderIndex = allChildren.indexOf(mobilePlaceholder);
                const targetIndex = allChildren.indexOf(closestElement);

                if (targetIndex > placeholderIndex) {
                    activeContainer.insertBefore(mobilePlaceholder, closestElement.nextSibling);
                } else {
                    activeContainer.insertBefore(mobilePlaceholder, closestElement);
                }
                
                animateFlip(activeContainer, positionsBefore);
                
                lastSwapTime = Date.now();
                if(navigator.vibrate) navigator.vibrate(10);
            }
        }

        function animateFlip(container, positionsBefore) {
            const siblings = Array.from(container.children);
            siblings.forEach(el => {
                if (el === mobilePlaceholder) return;
                
                const rectAfter = el.getBoundingClientRect();
                const rectBefore = positionsBefore.get(el);

                if (rectBefore && (rectBefore.left !== rectAfter.left || rectBefore.top !== rectAfter.top)) {
                    const dx = rectBefore.left - rectAfter.left;
                    const dy = rectBefore.top - rectAfter.top;

                    el.style.transition = 'none';
                    el.style.transform = \`translate(\${dx}px, \${dy}px)\`;
                    el.offsetHeight; 
                    el.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.2, 1)';
                    el.style.transform = '';

                    setTimeout(() => {
                        if (el.style.transform === '') {
                            el.style.transition = '';
                        }
                    }, 200);
                }
            });
        }

        function handleTouchEnd() {
            if (mobileDragTimer) {
                clearTimeout(mobileDragTimer);
                mobileDragTimer = null;
            }
            if (rafId) cancelAnimationFrame(rafId);
            
            if (isMobileDragging) {
                // 离场动画
                if (mobileClone && mobilePlaceholder) {
                    const rect = mobilePlaceholder.getBoundingClientRect();
                    mobileClone.style.transition = 'all 0.2s ease-out';
                    mobileClone.style.left = rect.left + 'px';
                    mobileClone.style.top = rect.top + 'px';
                    mobileClone.style.opacity = '0';

                    setTimeout(() => {
                        if (mobileClone) mobileClone.remove();
                        if (mobilePlaceholder) {
                             mobilePlaceholder.style.opacity = '';
                             mobilePlaceholder.classList.remove('border-dashed', 'border-2', 'border-emerald-400');
                        }
                        
                        // 保存排序
                        saveCardOrder();

                        mobilePlaceholder = null;
                        mobileClone = null;
                    }, 200);
                } else {
                    if (mobileClone) mobileClone.remove();
                    if (mobilePlaceholder) mobilePlaceholder.style.opacity = '';
                }

                document.body.style.overflow = '';
            }
            
            isMobileDragging = false;
            cleanupListeners();
        }

        function cleanupListeners() {
            document.removeEventListener('touchmove', handleTouchMove);
            document.removeEventListener('touchend', handleTouchEnd);
            document.removeEventListener('touchcancel', handleTouchEnd);
        }
    }

    function updateCardCategory(card, newCategory) {
        const url = card.getAttribute('data-url');
        let item = null;
        for (const cat in categories) {
             const idx = categories[cat].links.findIndex(l => l.url === url);
             if (idx !== -1) {
                 item = categories[cat].links.splice(idx, 1)[0];
                 break;
             }
        }
        if(item) {
            item.category = newCategory;
            categories[newCategory].links.push(item);
        }
    }

    async function saveCardOrder() {
        const newCategories = {};
        const sections = document.querySelectorAll('.section');
        sections.forEach(sec => {
            const catName = sec.id;
            const oldCat = categories[catName];
            newCategories[catName] = { isHidden: oldCat ? oldCat.isHidden : false, links: [] };
            
            const cards = sec.querySelectorAll('.card');
            cards.forEach(c => {
                 const url = c.getAttribute('data-url');
                 const original = Object.values(categories).flatMap(x=>x.links).find(l=>l.url === url);
                 if(original) {
                     original.category = catName;
                     newCategories[catName].links.push(original);
                 }
            });
        });
        
        Object.keys(categories).forEach(k => delete categories[k]);
        Object.assign(categories, newCategories);
        await saveDataToServer('保存排序', categories);
    }

    function applyTheme(isDark) {
        if (isDark) {
             document.documentElement.classList.add('dark');
        } else {
             document.documentElement.classList.remove('dark');
        }
        updateThemeSwitchUI();
    }
    
    function updateThemeSwitchUI() {
        const isDark = document.documentElement.classList.contains('dark');
        const checkbox = document.getElementById('theme-switch-checkbox');
        if(checkbox) checkbox.checked = isDark;
    }

    function scrollToTop() {
        window.scrollTo({ top: 0, behavior: 'smooth' });
    }
    
    // 认证和模式
    async function toggleEditMode() {
        document.getElementById('profile-dropdown').classList.add('hidden');
        if (!isLoggedIn) {
            toggleLogin();
            return;
        }

        if (!isEditMode) {
             isEditMode = true;
             updateUIState();
             
             renderCategories(); 
             
             // 提示用户
             // logAction('进入编辑模式', {}); 
        } else {
             // 退出编辑模式
             isEditMode = false;
             updateUIState();
             renderCategories();
        }
    }
    
    async function toggleLogin() {
        if (!isLoggedIn) {
             toggleOverlay('password-dialog-overlay', true);
             document.getElementById('password-input').focus();
        } else {
             if (await customConfirm('确定退出登录吗?')) {
                 logout();
             }
        }
    }
    
    function showAddDialog() {
        toggleOverlay('dialog-overlay', true);
        document.getElementById('name-input').value = '';
        document.getElementById('url-input').value = '';
        document.getElementById('tips-input').value = '';
        document.getElementById('icon-input').value = '';
        document.getElementById('private-checkbox').checked = false;
        
        document.getElementById('category-select-value').value = '';
        document.getElementById('category-select-text').textContent = '请选择分类';
        
        const btn = document.getElementById('dialog-confirm-btn');
        const newBtn = btn.cloneNode(true);
        btn.parentNode.replaceChild(newBtn, btn);
        newBtn.onclick = addCard;
        
        document.getElementById('dialog-cancel-btn').onclick = hideAddDialog;
    }
    
    function showEditDialog(link) {
        toggleOverlay('dialog-overlay', true);
        document.getElementById('name-input').value = link.name;
        document.getElementById('url-input').value = link.url;
        document.getElementById('tips-input').value = link.tips || '';
        document.getElementById('icon-input').value = link.icon || '';
        document.getElementById('private-checkbox').checked = link.isPrivate;
        
        document.getElementById('category-select-value').value = link.category;
        document.getElementById('category-select-text').textContent = link.category;
        
        const btn = document.getElementById('dialog-confirm-btn');
        const newBtn = btn.cloneNode(true);
        btn.parentNode.replaceChild(newBtn, btn);
        newBtn.onclick = () => updateCard(link);
        
        document.getElementById('dialog-cancel-btn').onclick = hideAddDialog;
    }
    
    function hideAddDialog() {
        toggleOverlay('dialog-overlay', false);
    }

    document.getElementById('password-confirm-btn').onclick = async () => {
         const pwd = document.getElementById('password-input').value;
         if(!pwd) return;
         try {
             const res = await fetch('/api/login', {
                 method: 'POST',
                 headers: {'Content-Type': 'application/json'},
                 body: JSON.stringify({password: pwd})
             });
             const data = await res.json();
             if(data.valid) {
                 localStorage.setItem('authToken', data.token);
                 isLoggedIn = true;
                 toggleOverlay('password-dialog-overlay', false);
                 await loadLinks();
                 await customAlert('登录成功');
             } else {
                 await customAlert('密码错误');
             }
         } catch(e) { await customAlert('Login Error'); }
    }

    async function fetchWithAuth(url, options = {}) {
        const token = localStorage.getItem('authToken');
        const headers = options.headers || {};
        headers.Authorization = token;
        options.headers = headers;

        let res = await fetch(url, options);

        if (res.status === 401) {
            try {
                const refreshRes = await fetch('/api/refreshToken', {
                    method: 'POST',
                    credentials: 'include' 
                });

                if (refreshRes.ok) {
                    const refreshData = await refreshRes.json();
                    localStorage.setItem('authToken', refreshData.accessToken);
                    headers.Authorization = refreshData.accessToken;
                    options.headers = headers;
                    res = await fetch(url, options);
                } else {
                    throw new Error('Refresh token expired');
                }
            } catch (refreshError) {
                localStorage.removeItem('authToken');
                isLoggedIn = false;
                toggleOverlay('password-dialog-overlay', true);
                await customAlert('登录已过期,请重新登录');
                throw new Error('Unauthorized');
            }
        }

        return res;
    };
    
    document.getElementById('password-cancel-btn').onclick = () => {
         toggleOverlay('password-dialog-overlay', false);
    };

    function showCategoryDialog(title, defaultVal = '') {
        return new Promise(resolve => {
            toggleOverlay('category-dialog', true);
            document.getElementById('category-dialog-title').innerText = title;
            const input = document.getElementById('category-name-input');
            input.value = defaultVal;
            input.focus();
            
            const close = (val) => {
                toggleOverlay('category-dialog', false);
                document.getElementById('category-confirm-btn').onclick = null;
                document.getElementById('category-cancel-btn').onclick = null;
                resolve(val);
            };
            
            document.getElementById('category-confirm-btn').onclick = () => close(input.value.trim());
            document.getElementById('category-cancel-btn').onclick = () => close(null);
        });
    }

    function customConfirm(msg) {
        return new Promise(resolve => {
            toggleOverlay('custom-confirm-overlay', true);
            document.getElementById('custom-confirm-message').innerText = msg;
            
            const close = (val) => {
                toggleOverlay('custom-confirm-overlay', false);
                document.getElementById('custom-confirm-ok').onclick = null;
                document.getElementById('custom-confirm-cancel').onclick = null;
                resolve(val);
            };
            document.getElementById('custom-confirm-ok').onclick = () => close(true);
            document.getElementById('custom-confirm-cancel').onclick = () => close(false);
        });
    }

    function customAlert(msg) {
        return new Promise(resolve => {
             toggleOverlay('custom-alert-overlay', true);
             document.getElementById('custom-alert-content').innerText = msg;
             document.getElementById('custom-alert-confirm').onclick = () => {
                 toggleOverlay('custom-alert-overlay', false);
                 resolve();
             }
        });
    }

    function setupTooltipDelegation() {
        const tooltip = document.getElementById('custom-tooltip');
        let activeTarget = null;

        document.body.addEventListener('mousemove', (e) => {
            const target = e.target.closest('.has-tooltip');

            if (target) {
                const text = target.getAttribute('data-tooltip');
                if (text) {
                    activeTarget = target;
                    showTooltip(e, text);
                } else {
                    hideTooltip();
                }
            } else {
                if (activeTarget) {
                    hideTooltip();
                    activeTarget = null;
                }
            }
        });

        window.addEventListener('scroll', hideTooltip, { passive: true });
    }

    function showTooltip(e, text) {
        const tooltip = document.getElementById('custom-tooltip');
        tooltip.textContent = text;
        tooltip.classList.remove('hidden');

        const offset = 12; 
        let left = e.clientX + offset;
        let top = e.clientY + offset;
        
        const tooltipRect = tooltip.getBoundingClientRect();
        
        if (left + tooltipRect.width > window.innerWidth) {
            left = e.clientX - tooltipRect.width - offset;
        }
        if (top + tooltipRect.height > window.innerHeight) {
            top = e.clientY - tooltipRect.height - offset;
        }
        
        tooltip.style.left = left + 'px';
        tooltip.style.top = top + 'px';
    }

    function hideTooltip() {
        const tooltip = document.getElementById('custom-tooltip');
        if (tooltip && !tooltip.classList.contains('hidden')) {
            tooltip.classList.add('hidden');
        }
    }
    

    async function backupUserData() {
        try {
            const res = await fetchWithAuth('/api/backupData', {
                method: 'POST',
                headers: { 
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({}),
            });
            if(res.status === 401) {
                logout();
                await customAlert('登录凭证已过期,请重新登录');
                return false;
            }
            const d = await res.json();
            return d.success;
        } catch(e) { return false; }
    }
    
    async function reloadCardsAsAdmin() {
         await loadLinks();
    }
    
    async function validateTokenOrRedirect() {
        const valid = await validateToken();
        if(!valid) {
            logout();
            await customAlert('登录凭证已过期,请重新登录');
            return false;
        }
        return true;
    }
    
    async function validateToken() {
        const t = localStorage.getItem('authToken');
        if(!t) return false;
        try {
            const r = await fetchWithAuth('/api/validateToken');
            return r.status === 200;
        } catch(e) { return false; }
    }
    
    function logout() {
        localStorage.removeItem('authToken');
        isLoggedIn = false;
        isEditMode = false;
        location.reload();
    }
    
    async function exportData() {
        if(!await validateTokenOrRedirect()) return;
        if(!await customConfirm("确定要导出数据吗?")) return;
        
        try {
            const res = await fetchWithAuth("/api/exportData", {
                method: "POST"
            });
            
            if (res.status === 401) {
                logout();
                await customAlert('登录凭证已过期,请重新登录');
                return;
            }
            
            if (!res.ok) throw new Error("Export failed");
            const data = await res.json();
            const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
            const url = URL.createObjectURL(blob);
            const a = document.createElement("a");
            a.href = url;
            a.download = "nav_export_" + new Date().toISOString().split("T")[0] + ".json";
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        } catch(e) { 
            if(e.message !== 'Unauthorized') await customAlert("导出失败"); 
        }
    }
    
    async function importData() {
        if(!await validateTokenOrRedirect()) return;
        if(!await customConfirm("确定要导入数据吗?导入将覆盖现有数据!")) return;
        
        const fileInput = document.getElementById('import-file-input');
        fileInput.value = '';
        
        fileInput.onchange = async (e) => {
            const file = e.target.files[0];
            if (!file) return;
            
            try {
                const reader = new FileReader();
                reader.onload = async (event) => {
                    try {
                        const data = JSON.parse(event.target.result);
                        const res = await fetchWithAuth("/api/importData", {
                            method: "POST",
                            headers: {
                                "Content-Type": "application/json"
                            },
                            body: JSON.stringify(data)
                        });
                        
                        if (res.status === 401) {
                            logout();
                            await customAlert('登录凭证已过期,请重新登录');
                            return;
                        }
                        
                        if (!res.ok) throw new Error("Import failed");
                        
                        await customAlert('数据导入成功!');
                        location.reload(); 
                    } catch (error) {
                        console.error("解析文件失败:", error);
                        await customAlert('文件格式错误,请检查文件内容!');
                    }
                };
                reader.readAsText(file);
            } catch (error) {
                console.error("导入失败:", error);
                await customAlert('数据导入失败,请重试!');
            }
        };
        fileInput.click();
    }

    </script>
</body>
</html>
`;

const DEFAULT_USER = 'testUser';
const DEFAULT_IMGAPI = 'https://api.xinac.net/icon/?url=';
let USE_DEFAULT_IMGAPI = true;

function base64UrlEncode(str) {
    return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function base64UrlEncodeUint8(arr) {
    const str = String.fromCharCode(...arr);
    return base64UrlEncode(str);
}

function base64UrlDecode(str) {
    str = str.replace(/-/g, '+').replace(/_/g, '/');
    while (str.length % 4) str += '=';
    return atob(str);
}

async function createJWT(payload, secret) {
    const encoder = new TextEncoder();
    const header = { alg: 'HS256', typ: 'JWT' };
    const headerEncoded = base64UrlEncode(JSON.stringify(header));
    const payloadEncoded = base64UrlEncode(JSON.stringify(payload));
    const toSign = encoder.encode(`${headerEncoded}.${payloadEncoded}`);

    const key = await crypto.subtle.importKey(
        'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
    );

    const signature = await crypto.subtle.sign('HMAC', key, toSign);
    const signatureEncoded = base64UrlEncodeUint8(new Uint8Array(signature));

    return `${headerEncoded}.${payloadEncoded}.${signatureEncoded}`;
}

async function validateJWT(token, secret) {
    try {
        const encoder = new TextEncoder();
        const parts = token.split('.');
        if (parts.length !== 3) return null;

        const [headerEncoded, payloadEncoded, signature] = parts;
        const data = encoder.encode(`${headerEncoded}.${payloadEncoded}`);

        const key = await crypto.subtle.importKey(
            'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
        );

        const expectedSigBuffer = await crypto.subtle.sign('HMAC', key, data);
        const expectedSig = base64UrlEncodeUint8(new Uint8Array(expectedSigBuffer));

        if (signature !== expectedSig) return null;

        const payloadStr = base64UrlDecode(payloadEncoded);
        return JSON.parse(payloadStr);
    } catch (e) {
        return null;
    }
}

function parseCookie(cookieHeader) {
    const cookies = {};
    if (!cookieHeader) return cookies;
    cookieHeader.split(';').forEach(cookie => {
        const [name, value] = cookie.trim().split('=');
        cookies[name] = decodeURIComponent(value);
    });
    return cookies;
}

async function validateServerToken(authHeader, env) {
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return { isValid: false, status: 401, response: { error: 'Unauthorized', message: '未登录' } };
    }
    const token = authHeader.slice(7);
    
    const payload = await validateJWT(token, env.JWT_SECRET);
    
    if (!payload) {
        return { isValid: false, status: 401, response: { error: 'Invalid', message: 'Token无效' } };
    }
    
    if (payload.exp < Math.floor(Date.now() / 1000)) {
        return { isValid: false, status: 401, response: { error: 'Expired', message: 'Token过期' } };
    }

    if (payload.type !== 'access') {
        return { isValid: false, status: 403, response: { error: 'Forbidden', message: '令牌类型错误' } };
    }

    return { isValid: true, payload };
}

function normalizeCategories(categories) {
    for (const key in categories) {
        if (Array.isArray(categories[key])) {
            categories[key] = { isHidden: false, links: categories[key] };
        }
    }
    return categories;
}

const corsHeaders = {
    'Access-Control-Allow-Origin': '*', 
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cookie',
    'Access-Control-Allow-Credentials': 'true' 
};

async function fetchBestIcon(targetUrl) {
    const headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
    };

    try {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), 5000); 
        
        const response = await fetch(targetUrl, {
            headers: headers,
            redirect: 'follow',
            signal: controller.signal
        });
        clearTimeout(timeoutId);

        if (!response.ok) throw new Error('Site unreachable');

        let iconUrl = null;
        
        const rewriter = new HTMLRewriter()
            .on('link[rel="apple-touch-icon"]', { 
                element(e) {
                    if (!iconUrl) {
                        const href = e.getAttribute('href');
                        if (href) iconUrl = href;
                    }
                }
            })
            .on('link[rel~="icon"]', {
                element(e) {
                    if (!iconUrl) {
                        const href = e.getAttribute('href');
                        if (href) iconUrl = href;
                    }
                }
            });

        await rewriter.transform(response).text();

        let finalUrl;
        if (iconUrl) {
            finalUrl = new URL(iconUrl, targetUrl).toString();
        } else {
            finalUrl = new URL('/favicon.ico', targetUrl).toString();
        }

        const iconResponse = await fetch(finalUrl, { 
            headers: headers 
        });

        if (iconResponse.ok && iconResponse.headers.get('content-type')?.includes('image')) {
            return iconResponse;
        }
        
        throw new Error('Icon fetch failed');

    } catch (e) {
    }
    return null;
}

async function handleIconProxy(request, ctx) {
    const url = new URL(request.url);
    const targetUrl = url.searchParams.get('url');

    if (!targetUrl) return new Response('Missing URL', { status: 400 });

    const cacheKey = new Request(url.toString(), request);
    const cache = caches.default;
    
    let response = await cache.match(cacheKey);

    if (response) {
        response = new Response(response.body, response);
        response.headers.set('X-Icon-Cache-Status', 'HIT');
    } else {
        let upstreamResponse = null;
        if (USE_DEFAULT_IMGAPI) {
            const upstreamApi = `${DEFAULT_IMGAPI}${encodeURIComponent(targetUrl)}`;
            upstreamResponse = await fetch(upstreamApi, {
                headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }
            });
        } else {
            upstreamResponse = await fetchBestIcon(targetUrl);
        }
        if (upstreamResponse) {
            response = new Response(upstreamResponse.body, upstreamResponse);
            response.headers.set('Cache-Control', 'public, max-age=604800, s-maxage=604800');
            response.headers.set('Access-Control-Allow-Origin', '*');
            response.headers.set('X-Icon-Cache-Status', 'MISS');
            ctx.waitUntil(cache.put(cacheKey, response.clone()));
        } else {
            const defaultSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 64 64">
                <path fill="#000000" d="M62 32C62 15.432 48.568 2 32 2C15.861 2 2.703 14.746 2.031 30.72c-.008.196-.01.395-.014.592c-.005.23-.017.458-.017.688v.101C2 48.614 15.432 62 32 62s30-13.386 30-29.899l-.002-.049zM37.99 59.351c-.525-.285-1.029-.752-1.234-1.388c-.371-1.152-.084-2.046.342-3.086c.34-.833-.117-1.795.109-2.667c.441-1.697.973-3.536.809-5.359c-.102-1.119-.35-1.17-1.178-1.816c-.873-.685-.873-1.654-1.457-2.52c-.529-.787.895-3.777.498-3.959c-.445-.205-1.457.063-1.777-.362c-.344-.458-.584-.999-1.057-1.354c-.305-.229-1.654-.995-2.014-.941c-1.813.271-3.777-1.497-4.934-2.65c-.797-.791-1.129-1.678-1.713-2.593c-.494-.775-1.242-.842-1.609-1.803c-.385-1.004-.156-2.29-.273-3.346c-.127-1.135-.691-1.497-1.396-2.365c-1.508-1.863-2.063-4.643-4.924-4.643c-1.537 0-1.428 3.348-2.666 2.899c-1.4-.507-3.566 1.891-3.535 1.568c.164-1.674 1.883-2.488 2.051-2.987c.549-1.638-2.453-1.246-2.068-2.612c.188-.672 2.098-1.161 1.703-1.562c-.119-.122-1.58-1.147-1.508-1.198c.271-.19 1.449.412 1.193-.37c-.086-.26-.225-.499-.357-.74a28 28 0 0 1 1.92-1.975c1.014-.083 2.066-.02 2.447.054c2.416.476 3.256 1.699 5.672.794c1.162-.434 5.445.319 6.059 1.537c.334.666 1.578-.403 2.063-.475c.52-.078 1.695.723 2.053.232c.943-1.291-.604-1.827 1.223-.833c1.225.667 3.619-2.266 2.861 1.181c-.547 2.485-2.557 2.54-4.031 4.159c-1.451 1.594 2.871 2.028 2.982 3.468c.32 4.146 2.531-.338 1.939-1.812c-1.145-2.855 1.303-2.071 2.289-.257c.547 1.007.963.159 1.633-.192c.543-.283.688 1.25.805 1.517c.385.887 1.65 1.152 1.436 2.294c-.238 1.259-1.133.881-2.008 1.094c-.977.237.158 1.059.016 1.359c-.154.328-1.332.464-1.646.65c-.924.544-.359 1.605-1.082 2.175c-.496.392-.996.137-1.092.871c-.113.865-1.707 1.143-1.5 1.97c.057.227.516 1.923.227 2.013c-.133.043-1.184-1.475-1.471-1.627c-.568-.301-3.15-.055-3.482 1.654c-.215 1.105 1.563 2.85 2.016 1.328c.561-1.873.828 1.091.693 1.207c.268.234 1.836-.385 1.371.7c-.197.459.193 1.656.889 1.287c.291-.154 1.041.31 1.172.061a2.14 2.14 0 0 1 .742-.692c.701-.41 1.75-.025 2.518.02c.469.027 4.313 2.124 4.334 2.545c.084 1.575 2.99 1.37 3.436 1.933c1.199 1.526.83.751-.045 2.706c-.441.984-.057 2.191-1.125 2.904c-.514.342-1.141.171-1.598.655c-.412.437-.25.959-.5 1.464c-.301.601-4.346 4.236-4.613 5.115c-.133.441-1.34.825-.322 1.248c.592.174-1.311 1.973-.396 2.718c.223.181.369.334.479.471c-.457.122-.91.233-1.369.333M35.594 4.237c-.039.145.02.316.271.483c.566.375-.162 1.208-.943.671c-.779-.537-2.531.241-2.41.644c.119.403.66.563 1.496.242c.834-.322 1.178.048 1.318.43c.096.259 0 .403-.027.752c-.025.349-.996.107-1.803.162c-.809.054-1.67-.162-1.645-.619c.027-.456-.861-1.289-1.391-1.637c-.529-.348.232-1.1.934-.537c.699.564.727-.107 1.535-.321c.459-.122.275-.305.119-.479q1.29.047 2.546.209m3.517 8.869c.605.164 1.656.929 1.656 1.291c0 .363-.477.817-.688.765c-1.523-.371-2.807-1.874-3.514-2.697c-1.234-1.435-1.156-.205-3.111-.826c-.5-.16-1.293-1.711-.768-2.476s1.131-.886 1.615-.683c.484.2 1.898-.645 2.223.362c.322 1.007 1.211 2.292 2.02 2.636c.81.342-.04 1.464.567 1.628m.485 4.673c.242.483-1.455-.564-1.859-1.047c-.402-.482-1.01-1.571-.523-2.054c.484-.482 1.57 1.005 2.141 1.33c1.129.645-.001 1.289.241 1.771m-8.594-7.315c.117-.161.365.242.586.645s-.084.971-.586.885c-.502-.084-.281-1.136 0-1.53m0-4.052s.473 1.154 0 .966s-.496-.671 0-.966m.096 3.65c-.135-.321-.166-1.64.162-2.04c.484-.59 1.266.564.74 1.02c-.525.457-.768 1.343-.902 1.02m-6.077 1.415c-.879-.063-.898-.823-1.02-1.226s-.85.765-1.586 0s.172-1.771.01-2.376c-.162-.604 1.736 0 2.02 0s1.051 1.248 1.252 1.227c.203-.02 1.293.987 1.293.584c0-.402.166-1.088.93-1.168c1.172-.121.121 1.289.08 1.838c-.039.549.891 1.504 1.232 1.907c.344.403-.867.686-1.07.443c-.201-.242-.727 0-1.172.322c-.443.322-1.656-.443-2.221-.685c-.566-.241 1.131-.804.252-.866m3.141-6.354c.781.269 1.225.51 1.609 0c.371-.492.654 1.073.385 1.502c-.27.431-.781.324-.863 0c-.08-.32-1.912-1.771-1.131-1.502m1.131 4.859c-.268-.35-.295-.752 0-1.047c.297-.295.201-.644.729-.751c.26-.054.295.348.295.724s.324.859 0 1.448c-.323.589-.754-.026-1.024-.374m2.205-5.969c-.012.074-.061.118-.184.106a.6.6 0 0 1-.236-.095q.21-.008.42-.011M25.389 5.15c.619 0 .539.418 1.051.719c.512.3.242-1.552.592-.854c.35.697 1.389 1.664.889 1.851c-.43.163-2.234.859-2.396.739s-.377-.63-.809-.739c-.432-.107-.889-1.127-1.186-1.1c-.113.01-.123-.184-.049-.442a28 28 0 0 1 1.572-.455c.058.158.146.281.336.281m13.519 30.025c-.645.666-1.756-.464-2.523-.424s-1.152-.765-1.818-.684c-.668.079.182-.847 1.111-.362c.927.483 3.756.925 3.23 1.47m12.93-22.934c-.188.24-.402.408-.607.585c-.605.524-1.736.484-1.898.846s-.566 1.489-1.98 1.494s-1.01 2.131-1.131 2.738s-.443 1.325-.848.801s-.566-.323-1.816-1.853s-.77-2.375-.365-2.818c.404-.442.566-1.49 0-1.329s-.889-.202-.768-.703s.727-.867 0-1.402s-.324-2.445-.889-4.189c-.566-1.745-1.334-.51-2.586-.443s-1.455-.873-.889-1.303a27.95 27.95 0 0 1 13.777 7.576"/>
            </svg>`;
            
            response = new Response(defaultSVG, {
                status: 200,
                headers: {
                    'Content-Type': 'image/svg+xml',
                    'Cache-Control': 'public, max-age=3600' 
                }
            });
            response.headers.set('X-Icon-Cache-Status', 'DEFAULT');
        }
        response.headers.set('Access-Control-Allow-Origin', '*');
    
    }

    return response;
}

const MIN_BACKUP_INTERVAL_MS = 10 * 60 * 1000; 

async function handleSmartBackup(env, currentData) {
    try {
        const list = await env.CARD_ORDER.list({ prefix: `backup_${DEFAULT_USER}_` });
        let keys = list.keys;
        
        keys.sort((a, b) => a.name.localeCompare(b.name));
        
        let shouldBackup = true;

        if (keys.length > 0) {
            const lastBackupMeta = keys[keys.length - 1].metadata;
            if (lastBackupMeta && lastBackupMeta.timestamp) {
                 const timeDiff = Date.now() - lastBackupMeta.timestamp;
                 if (timeDiff < MIN_BACKUP_INTERVAL_MS) {
                     shouldBackup = false; 
                 }
            }
        }

        if (shouldBackup) {
            const now = Date.now();
            const date = new Date(now + 8 * 3600 * 1000);
            const dateStr = date.toISOString().replace(/[:.]/g, '-');
            const backupKey = `backup_${DEFAULT_USER}_${dateStr}`;
            
            await env.CARD_ORDER.put(backupKey, currentData, {
                metadata: { timestamp: now }
            });

            if (keys.length >= 10) { 
                const deleteCount = keys.length + 1 - 10;
                if(deleteCount > 0) {
                    const toDelete = keys.slice(0, deleteCount);
                    for (const key of toDelete) {
                        await env.CARD_ORDER.delete(key.name);
                    }
                }
            }
        }
    } catch (e) {
        console.error("Smart backup failed:", e);
    }
}

export default {
    async fetch(request, env, ctx) {
        const url = new URL(request.url);

        if (request.method === 'OPTIONS') {
            return new Response(null, { headers: corsHeaders });
        }

        if (url.pathname === '/api/icon') {
            return handleIconProxy(request, ctx);
        }

        if (url.pathname === '/') {
            return new Response(HTML_CONTENT, { headers: { 'Content-Type': 'text/html' } });
        }

        if (url.pathname === '/api/login' && request.method === 'POST') {
            try {
                const { password } = await request.json();
                if (password !== env.ADMIN_PASSWORD) throw new Error('Password mismatch');
                
                const currentTime = Math.floor(Date.now() / 1000);

                const accessTokenPayload = { 
                    iat: currentTime, 
                    exp: currentTime + 7200, 
                    role: 'admin',
                    type: 'access' 
                };
                const accessToken = await createJWT(accessTokenPayload, env.JWT_SECRET);
                
                const refreshTokenPayload = { 
                    iat: currentTime, 
                    exp: currentTime + 2592000, 
                    role: 'admin',
                    type: 'refresh' 
                };
                const refreshToken = await createJWT(refreshTokenPayload, env.JWT_SECRET);
                
                const response = new Response(JSON.stringify({ 
                    valid: true, 
                    token: `Bearer ${accessToken}` 
                }), { 
                    status: 200, 
                    headers: { ...corsHeaders, 'Content-Type': 'application/json' } 
                });
                
                response.headers.append('Set-Cookie', `refreshToken=${refreshToken}; HttpOnly; Secure; SameSite=Strict; Path=/api/refreshToken; Max-Age=2592000`);
                
                return response;
            } catch (e) {
                return new Response(JSON.stringify({ valid: false, error: 'Auth failed' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
            }
        }

        if (url.pathname === '/api/refreshToken' && request.method === 'POST') {
            try {
                const cookies = parseCookie(request.headers.get('Cookie'));
                const refreshToken = cookies.refreshToken;
                
                if (!refreshToken) {
                    return new Response(JSON.stringify({ error: 'Refresh token missing' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
                }
                
                const payload = await validateJWT(refreshToken, env.JWT_SECRET);
                const currentTime = Math.floor(Date.now() / 1000);

                if (!payload || payload.exp < currentTime) {
                    return new Response(JSON.stringify({ error: 'Refresh token expired' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
                }
                
                if (payload.type !== 'refresh') {
                    return new Response(JSON.stringify({ error: 'Invalid token type' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
                }
                
                const newAccessTokenPayload = { 
                    iat: currentTime, 
                    exp: currentTime + 7200, 
                    role: 'admin',
                    type: 'access'
                };
                const newAccessToken = await createJWT(newAccessTokenPayload, env.JWT_SECRET);

                const newRefreshTokenPayload = {
                    iat: currentTime,
                    exp: currentTime + 2592000,
                    role: 'admin',
                    type: 'refresh'
                };
                const newRefreshToken = await createJWT(newRefreshTokenPayload, env.JWT_SECRET);
                
                const response = new Response(JSON.stringify({ 
                    accessToken: `Bearer ${newAccessToken}` 
                }), { 
                    status: 200, 
                    headers: { ...corsHeaders, 'Content-Type': 'application/json' } 
                });

                response.headers.append('Set-Cookie', `refreshToken=${newRefreshToken}; HttpOnly; Secure; SameSite=Strict; Path=/api/refreshToken; Max-Age=2592000`);

                return response;
            } catch (e) {
                return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
            }
        }

        if (url.pathname === '/api/validateToken') {
            const validation = await validateServerToken(request.headers.get('Authorization'), env);
            return new Response(JSON.stringify(validation.isValid ? { valid: true } : validation.response), {
                status: validation.status || 200, 
                headers: { ...corsHeaders, 'Content-Type': 'application/json' }
            });
        }

        if (url.pathname === '/api/getLinks') {
            const authToken = request.headers.get('Authorization');
            const dataStr = await env.CARD_ORDER.get(DEFAULT_USER);

            if (dataStr) {
                const parsedData = JSON.parse(dataStr);
                const normalizedCategories = normalizeCategories(parsedData.categories || {});
                let isAuthorized = false;

                if (authToken) {
                    const validation = await validateServerToken(authToken, env);
                    if (validation.isValid) {
                        isAuthorized = true;
                    }
                }

                if (isAuthorized) {
                    return new Response(JSON.stringify(parsedData), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
                }

                const filteredCategories = {};
                for (const cat in normalizedCategories) {
                    const catData = normalizedCategories[cat];
                    if (!catData.isHidden) {
                        const publicLinks = (catData.links || []).filter(l => !l.isPrivate);
                        if (publicLinks.length > 0) {
                            filteredCategories[cat] = { ...catData, links: publicLinks };
                        }
                    }
                }
                return new Response(JSON.stringify({ categories: filteredCategories }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
            }
            return new Response(JSON.stringify({ categories: {} }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
        }

        if (url.pathname === '/api/saveData' && request.method === 'POST') {
            const validation = await validateServerToken(request.headers.get('Authorization'), env);
            if (!validation.isValid) return new Response(JSON.stringify(validation.response), { status: validation.status, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });

            try {
                const { categories } = await request.json();
                
                const currentData = await env.CARD_ORDER.get(DEFAULT_USER);
                
                if (currentData) {
                    ctx.waitUntil(handleSmartBackup(env, currentData));
                }

                await env.CARD_ORDER.put(DEFAULT_USER, JSON.stringify({ categories }));
                
                return new Response(JSON.stringify({ success: true }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
            } catch (e) {
                return new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
            }
        }

        if (url.pathname === '/api/backupData' && request.method === 'POST') {
            const validation = await validateServerToken(request.headers.get('Authorization'), env);
            if (!validation.isValid) return new Response(JSON.stringify(validation.response), { status: validation.status, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
            
            const sourceData = await env.CARD_ORDER.get(DEFAULT_USER);
            
            if(sourceData) {
                 const now = Date.now();
                 const date = new Date(now + 8 * 3600 * 1000);
                 const dateStr = date.toISOString().replace(/[:.]/g, '-');
                 await env.CARD_ORDER.put(`backup_${DEFAULT_USER}_${dateStr}`, sourceData, {
                     metadata: { timestamp: now }
                 });
                 
                 return new Response(JSON.stringify({ success: true }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
            }
            return new Response(JSON.stringify({ success: false, error: 'User data not found' }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
        }
        
        if (url.pathname === '/api/exportData' && request.method === 'POST') {
             const validation = await validateServerToken(request.headers.get('Authorization'), env);
             if (!validation.isValid) return new Response(JSON.stringify(validation.response), { status: validation.status, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
             
             const data = await env.CARD_ORDER.get(DEFAULT_USER);
             return new Response(data || '{}', { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
        }
        
        if (url.pathname === '/api/importData' && request.method === 'POST') {
             const validation = await validateServerToken(request.headers.get('Authorization'), env);
             if (!validation.isValid) return new Response(JSON.stringify(validation.response), { status: validation.status, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
              
             const body = await request.json();
             
             const cleanData = {
                 categories: body.categories || {}
             };
             
             await env.CARD_ORDER.put(DEFAULT_USER, JSON.stringify(cleanData));
             return new Response(JSON.stringify({ success: true }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json'} });
        }

        return new Response('Not Found', { status: 404, headers: corsHeaders });
    }
};

```

预览

const HTML_CONTENT = 

&lt;!DOCTYPE html&gt; &lt;html lang=&quot;zh-CN&quot; class=&quot;scroll-smooth&quot;&gt; &lt;head&gt; &lt;meta charset=&quot;UTF-8&quot;&gt; &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt; &lt;title&gt;ws01-导航&lt;/title&gt; &lt;link rel=&quot;icon&quot; href=&quot;data:image/svg+xml,&lt;svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22&gt;&lt;text y=%22.9em%22 font-size=%2280%22&gt;⭐&lt;/text&gt;&lt;/svg&gt;&quot;&gt; &lt;script src=&quot;https://cdn.tailwindcss.com&quot;&gt;&lt;/script&gt; &lt;script&gt; tailwind.config = { darkMode: &#039;class&#039;, theme: { extend: { colors: { glass: { border: &#039;rgba(255, 255, 255, 0.2)&#039;, darkBorder: &#039;rgba(255, 255, 255, 0.1)&#039;, } }, animation: { &#039;blob&#039;: &#039;blob 10s infinite&#039;, }, keyframes: { blob: { &#039;0%&#039;: { transform: &#039;translate(0px, 0px) scale(1)&#039; }, &#039;33%&#039;: { transform: &#039;translate(30px, -50px) scale(1.1)&#039; }, &#039;66%&#039;: { transform: &#039;translate(-20px, 20px) scale(0.9)&#039; }, &#039;100%&#039;: { transform: &#039;translate(0px, 0px) scale(1)&#039; }, } }, boxShadow: { &#039;glass&#039;: &#039;0 4px 30px rgba(0, 0, 0, 0.1)&#039;, &#039;glass-hover&#039;: &#039;0 10px 40px rgba(0, 0, 0, 0.2)&#039;, } } } } &lt;/script&gt; &lt;style&gt; ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: rgba(156, 163, 175, 0.3); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: rgba(156, 163, 175, 0.6); }

@media (max-width: 640px) { ::-webkit-scrollbar { display: none; }

{ scrollbar-width: none; / Firefox */ }

}

.card.dragging { opacity: 0.8; transform: scale(1.05); border: 2px dashed #10b981; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2), 0 10px 10px -5px rgba(0, 0, 0, 0.04); z-index: 50; position: relative; }

.edit-mode .card { / touch-action: none; / touch-action: pan-y; }

body.edit-mode .card, body.edit-mode .card:hover { transform: none !important; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; }

html.dark body.edit-mode .card, html.dark body.edit-mode .card:hover { box-shadow: none !important; border-color: rgba(51, 65, 85, 0.5) !important; }

.add-card-placeholder { pointer-events: auto !important; z-index: 10; }

.card-clone-dragging { pointer-events: none !important; / 关键:让触摸穿透克隆体 / z-index: 9999 !important; }

.no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }

.dropdown-enter { animation: dropdown-in 0.2s ease-out forwards; } @keyframes dropdown-in { from { opacity: 0; transform: translateY(-10px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }

.overlay-hidden { opacity: 0; pointer-events: none; } .overlay-visible { opacity: 1; pointer-events: auto; } .dialog-scale-hidden { transform: scale(0.95); opacity: 0; } .dialog-scale-visible { transform: scale(1); opacity: 1; }

.section-anchor { scroll-margin-top: 160px; }

input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus, input:-webkit-autofill:active { -webkit-transition: background-color 99999s ease-out; -webkit-transition-delay: 99999s; -webkit-text-fill-color: #475569 !important; }

html.dark input:-webkit-autofill, html.dark input:-webkit-autofill:hover, html.dark input:-webkit-autofill:focus, html.dark input:-webkit-autofill:active { -webkit-text-fill-color: #CBD5E1 !important; box-shadow: 0 0 0px 1000px #1e293b inset !important; transition: background-color 5000s ease-in-out 0s; }

#custom-tooltip { z-index: 100; transition: opacity 0.1s ease-in-out; } &lt;/style&gt; &lt;script&gt; (function () { let isDark; const savePreferences = localStorage.getItem(&#039;savePreferences&#039;); if (savePreferences === &#039;true&#039;) { const savedTheme = localStorage.getItem(&#039;theme&#039;); isDark = savedTheme === &#039;dark&#039;; } else { const hour = new Date().getHours(); isDark = (hour &gt;= 21 || hour &lt; 6); } window.isDarkTheme = isDark; if (isDark) document.documentElement.classList.add(&#039;dark&#039;); })(); &lt;/script&gt; &lt;/head&gt;

&lt;body class=&quot;min-h-screen font-sans text-slate-800 dark:text-slate-100 selection:bg-emerald-200 dark:selection:bg-emerald-900 transition-colors duration-300&quot;&gt;

&lt;!-- 背景层 --&gt; &lt;div class=&quot;fixed inset-0 -z-10 h-full w-full overflow-hidden bg-gray-100 dark:bg-[#0f172a]&quot;&gt; &lt;div class=&quot;absolute inset-0 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-[#0f172a] dark:to-[#1e293b]&quot;&gt;&lt;/div&gt; &lt;div class=&quot;absolute top-[-10%] left-[-10%] w-[800px] h-[800px] bg-emerald-200/30 dark:bg-indigo-900/20 rounded-full blur-[150px] mix-blend-multiply dark:mix-blend-screen animate-blob&quot;&gt;&lt;/div&gt; &lt;div class=&quot;absolute bottom-[-10%] right-[-10%] w-[500px] h-[500px] bg-blue-200/30 dark:bg-purple-900/20 rounded-full blur-[120px] mix-blend-multiply dark:mix-blend-screen animate-blob animation-delay-2000&quot;&gt;&lt;/div&gt; &lt;/div&gt;

&lt;!-- 顶部固定导航 --&gt; &lt;div class=&quot;fixed top-0 left-0 right-0 z-50 transition-all duration-300&quot;&gt; &lt;div class=&quot;backdrop-blur-xl bg-gray-100/60 dark:bg-[#0f172a]/60 border-b border-slate-200/40 dark:border-slate-700/40 shadow-sm supports-[backdrop-filter]:bg-gray-100/70&quot;&gt; &lt;div class=&quot;max-w-7xl mx-auto px-4 sm:px-6 lg:px-8&quot;&gt; &lt;div class=&quot;flex items-center justify-between h-16 gap-4&quot;&gt;

&lt;!-- Logo --&gt; &lt;a class=&quot;flex items-center gap-2 flex-shrink-0 group cursor-pointer bg-white/50 dark:bg-transparent hover:bg-white dark:hover:bg-slate-800 px-3 py-1.5 rounded-xl border border-slate-200/50 dark:border-transparent transition-all duration-300 hover:shadow-md hover:shadow-emerald-500/10 hover:-translate-y-0.5&quot; href=&quot;#&quot; onclick=&quot;location.reload()&quot;&gt; &lt;div class=&quot;w-8 h-8 flex items-center justify-center bg-gradient-to-tr from-emerald-500 to-teal-600 rounded-lg text-white shadow-lg shadow-emerald-500/30 group-hover:scale-110 group-hover:rotate-3 transition-transform duration-300&quot;&gt; &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;20&quot; height=&quot;20&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2.5&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot;&gt; &lt;path d=&quot;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&quot;&gt;&lt;/path&gt; &lt;/svg&gt; &lt;/div&gt; &lt;span class=&quot;font-bold text-lg tracking-wide text-slate-700 dark:text-slate-100 hidden sm:block&quot;&gt;ws01导航&lt;/span&gt; &lt;/a&gt;

&lt;!-- Search Bar --&gt; &lt;div class=&quot;flex-1 max-w-2xl mx-auto&quot;&gt; &lt;div class=&quot;relative flex items-center w-full h-10 rounded-xl focus-within:ring-2 focus-within:ring-emerald-500/50 focus-within:shadow-lg focus-within:-translate-y-0.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 shadow-sm hover:shadow-lg transition-all duration-300&quot;&gt;

&lt;!-- Custom Search Engine Dropdown --&gt; &lt;div class=&quot;relative h-full&quot; id=&quot;search-engine-wrapper&quot;&gt; &lt;button id=&quot;search-engine-btn&quot; class=&quot;h-full pl-3 pr-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300 hover:text-emerald-500 hover:bg-slate-50 dark:hover:bg-slate-700/50 rounded-l-xl transition-colors outline-none w-auto md:min-w-[5.5rem]&quot;&gt; &lt;!-- 默认显示本站图标 --&gt; &lt;span id=&quot;current-engine-icon&quot; class=&quot;flex-shrink-0 w-5 h-5 flex items-center justify-center&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M13 10V3L4 14h7v7l9-11h-7z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/span&gt; &lt;span id=&quot;current-engine-label&quot; class=&quot;font-medium truncate hidden md:block&quot;&gt;本站&lt;/span&gt; &lt;svg class=&quot;w-3 h-3 opacity-60 ml-auto&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M19 9l-7 7-7-7&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt;

&lt;!-- Dropdown Menu --&gt; &lt;div id=&quot;search-engine-menu&quot; class=&quot;hidden absolute top-full left-0 mt-2 w-24 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-100 dark:border-slate-700 overflow-hidden z-50 dropdown-enter&quot;&gt; &lt;div class=&quot;py-1&quot; id=&quot;search-engine-list&quot;&gt; &lt;div class=&quot;px-3 py-2 text-xs font-semibold text-slate-400 uppercase tracking-wider&quot;&gt;搜索引擎&lt;/div&gt; &lt;!-- JS 自动插入按钮 --&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt;

&lt;div class=&quot;h-4 w-px bg-slate-200 dark:bg-slate-600 mx-1&quot;&gt;&lt;/div&gt;

&lt;input type=&quot;text&quot; id=&quot;search-input&quot; class=&quot;flex-1 bg-transparent border-none text-slate-700 dark:text-slate-200 text-sm focus:ring-0 placeholder-slate-400 h-full w-full outline-none px-2&quot; placeholder=&quot;搜索...&quot;&gt;

&lt;button id=&quot;clear-search-button&quot; class=&quot;hidden p-1.5 mr-1 rounded-full text-slate-400 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-700 transition-all&quot;&gt; &lt;svg class=&quot;w-3.5 h-3.5&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M18 6L6 18M6 6l12 12&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt;

&lt;button id=&quot;search-button&quot; class=&quot;h-full px-4 rounded-r-xl text-slate-500 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-slate-700/50 transition-colors border-l border-transparent dark:border-slate-700/50 flex items-center justify-center&quot;&gt; &lt;svg class=&quot;w-5 h-5&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt; &lt;/div&gt; &lt;/div&gt;

&lt;!-- Profile / Settings --&gt; &lt;div class=&quot;relative flex items-center gap-2&quot;&gt; &lt;div id=&quot;profile-dropdown-wrapper&quot; class=&quot;relative&quot;&gt; &lt;button id=&quot;profile-menu-toggle&quot; class=&quot;flex items-center gap-2 px-3 py-1.5 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-white dark:hover:bg-slate-800 transition-all text-sm font-medium border border-transparent hover:border-slate-200 dark:hover:border-slate-700 hover:shadow-sm&quot;&gt; &lt;div class=&quot;w-7 h-7 rounded-full bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-600 flex items-center justify-center shadow-inner&quot;&gt; &lt;svg class=&quot;w-4 h-4 text-slate-500 dark:text-slate-300&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2&quot;&gt;&lt;/path&gt;&lt;circle cx=&quot;12&quot; cy=&quot;7&quot; r=&quot;4&quot;&gt;&lt;/circle&gt;&lt;/svg&gt; &lt;/div&gt; &lt;span id=&quot;menu-toggle&quot; class=&quot;hidden md:inline&quot;&gt;设置&lt;/span&gt; &lt;/button&gt;

&lt;!-- Dropdown Menu --&gt; &lt;div id=&quot;profile-dropdown&quot; class=&quot;hidden absolute right-0 mt-2 w-60 bg-white dark:bg-[#1e293b] rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/10 overflow-hidden transform origin-top-right transition-all z-50 dropdown-enter&quot;&gt; &lt;div class=&quot;p-2 space-y-1&quot;&gt; &lt;!-- Edit Mode --&gt; &lt;button id=&quot;edit-mode-btn&quot; onclick=&quot;toggleEditMode()&quot; class=&quot;w-full text-left px-3 py-2.5 rounded-lg text-sm text-slate-700 dark:text-slate-200 hover:bg-emerald-50 dark:hover:bg-slate-700/50 hover:text-emerald-600 transition-colors flex items-center gap-3 font-medium&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; 编辑模式 &lt;/button&gt;

&lt;!-- 导入导出 (仅登录显示) --&gt; &lt;div id=&quot;data-tools-menu&quot; class=&quot;hidden border-t border-slate-100 dark:border-slate-700/50 my-1 pt-1&quot;&gt; &lt;button onclick=&quot;exportData()&quot; class=&quot;w-full text-left px-3 py-2 rounded-lg text-sm text-slate-700 dark:text-slate-200 hover:bg-amber-50 dark:hover:bg-slate-700/50 hover:text-amber-600 transition-colors flex items-center gap-3&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4&quot;&gt;&lt;/path&gt;&lt;/svg&gt; 导出配置 &lt;/button&gt; &lt;button onclick=&quot;importData()&quot; class=&quot;w-full text-left px-3 py-2 rounded-lg text-sm text-slate-700 dark:text-slate-200 hover:bg-green-50 dark:hover:bg-slate-700/50 hover:text-green-600 transition-colors flex items-center gap-3&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4-4m0 0l-4 4m4-4v12&quot;&gt;&lt;/path&gt;&lt;/svg&gt; 导入配置 &lt;/button&gt; &lt;!-- 文件输入框 (隐藏) --&gt; &lt;input type=&quot;file&quot; id=&quot;import-file-input&quot; accept=&quot;.json&quot; class=&quot;hidden&quot;&gt; &lt;/div&gt;

&lt;div class=&quot;h-px bg-slate-100 dark:bg-slate-700/50 mx-1 my-1&quot;&gt;&lt;/div&gt;

&lt;!-- 【新增】APP 布局切换 --&gt; &lt;div class=&quot;px-3 py-2.5 flex items-center justify-between text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/30 rounded-lg group&quot;&gt; &lt;span class=&quot;flex items-center gap-3&quot;&gt; &lt;svg class=&quot;w-4 h-4 text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt; &lt;rect x=&quot;3&quot; y=&quot;3&quot; width=&quot;7&quot; height=&quot;7&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;14&quot; y=&quot;3&quot; width=&quot;7&quot; height=&quot;7&quot;&gt;&lt;/rect&gt; &lt;rect x=&quot;14&quot; y=&quot;14&quot; width=&quot;7&quot; height=&quot;7&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;3&quot; y=&quot;14&quot; width=&quot;7&quot; height=&quot;7&quot;&gt;&lt;/rect&gt; &lt;/svg&gt; APP 视图 &lt;/span&gt; &lt;label class=&quot;relative inline-flex items-center cursor-pointer&quot;&gt; &lt;input type=&quot;checkbox&quot; id=&quot;layout-switch-checkbox&quot; onchange=&quot;toggleAppLayout()&quot; class=&quot;sr-only peer&quot;&gt; &lt;div class=&quot;w-9 h-5 bg-slate-300 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[&#039;&#039;] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-emerald-500&quot;&gt;&lt;/div&gt; &lt;/label&gt; &lt;/div&gt;

&lt;div class=&quot;px-3 py-2.5 flex items-center justify-between text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/30 rounded-lg group&quot;&gt; &lt;span class=&quot;flex items-center gap-3&quot;&gt; &lt;svg class=&quot;w-4 h-4 text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; 深色模式 &lt;/span&gt; &lt;label class=&quot;relative inline-flex items-center cursor-pointer&quot;&gt; &lt;input type=&quot;checkbox&quot; id=&quot;theme-switch-checkbox&quot; class=&quot;sr-only peer&quot;&gt; &lt;div class=&quot;w-9 h-5 bg-slate-300 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[&#039;&#039;] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-emerald-500&quot;&gt;&lt;/div&gt; &lt;/label&gt; &lt;/div&gt; &lt;div class=&quot;px-3 py-2.5 flex items-center justify-between text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/30 rounded-lg group&quot;&gt; &lt;span class=&quot;flex items-center gap-3&quot;&gt; &lt;svg class=&quot;w-4 h-4 text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z&quot;&gt;&lt;/path&gt;&lt;polyline points=&quot;17 21 17 13 7 13 7 21&quot;&gt;&lt;/polyline&gt;&lt;polyline points=&quot;7 3 7 8 15 8&quot;&gt;&lt;/polyline&gt;&lt;/svg&gt; 记住设置 &lt;/span&gt; &lt;label class=&quot;relative inline-flex items-center cursor-pointer&quot;&gt; &lt;input type=&quot;checkbox&quot; id=&quot;save-preference-checkbox&quot; class=&quot;sr-only peer&quot;&gt; &lt;div class=&quot;w-9 h-5 bg-slate-300 peer-focus:outline-none rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[&#039;&#039;] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-emerald-500&quot;&gt;&lt;/div&gt; &lt;/label&gt; &lt;/div&gt; &lt;div class=&quot;h-px bg-slate-100 dark:bg-slate-700/50 mx-1 my-1&quot;&gt;&lt;/div&gt; &lt;button id=&quot;login-Btn&quot; onclick=&quot;toggleLogin()&quot; class=&quot;w-full text-left px-3 py-2.5 rounded-lg text-sm text-slate-700 dark:text-slate-200 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors flex items-center gap-3 font-medium&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4&quot;/&gt;&lt;polyline points=&quot;10 17 15 12 10 7&quot;/&gt;&lt;line x1=&quot;15&quot; y1=&quot;12&quot; x2=&quot;3&quot; y2=&quot;12&quot;/&gt;&lt;/svg&gt; 登录 / 退出 &lt;/button&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt;

&lt;!-- 快捷分类栏 --&gt; &lt;div id=&quot;category-buttons-container&quot; class=&quot;py-2 flex gap-2 overflow-x-auto no-scrollbar mask-gradient items-center&quot;&gt; &lt;!-- JS 生成按钮 --&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt;

&lt;!-- 主要内容区 --&gt; &lt;main class=&quot;pt-36 pb-20 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 min-h-screen&quot;&gt; &lt;!-- 添加分类按钮 (仅编辑模式显示) --&gt; &lt;div id=&quot;add-category-container&quot; class=&quot;hidden mt-12 mb-8&quot;&gt; &lt;button onclick=&quot;addCategory()&quot; class=&quot;w-full py-4 rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-emerald-500 hover:text-emerald-600 dark:hover:border-emerald-500 dark:hover:text-emerald-500 hover:bg-emerald-50/50 dark:hover:bg-slate-800/50 transition-all flex items-center justify-center gap-2 group&quot;&gt; &lt;div class=&quot;w-8 h-8 rounded-full bg-slate-100 dark:bg-slate-800 group-hover:bg-emerald-100 dark:group-hover:bg-emerald-900/30 flex items-center justify-center transition-colors&quot;&gt; &lt;svg class=&quot;w-5 h-5&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M12 4v16m8-8H4&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/div&gt; &lt;span class=&quot;font-medium text-lg&quot;&gt;新建分类&lt;/span&gt; &lt;/button&gt; &lt;/div&gt;

&lt;!-- 内容渲染容器 --&gt; &lt;div id=&quot;sections-container&quot; class=&quot;space-y-10&quot;&gt;&lt;/div&gt;

&lt;!-- 返回顶部按钮独立放置 --&gt; &lt;div class=&quot;fixed bottom-8 right-8 z-50&quot;&gt; &lt;button id=&quot;back-to-top-btn&quot; onclick=&quot;scrollToTop()&quot; class=&quot;hidden w-12 h-12 rounded-2xl bg-white/90 dark:bg-slate-800/90 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 shadow-lg backdrop-blur-sm flex items-center justify-center transition-all hover:scale-110 hover:bg-slate-50 dark:hover:bg-slate-700 has-tooltip group&quot; data-tooltip=&quot;返回顶部&quot;&gt; &lt;svg class=&quot;w-6 h-6&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M5 10l7-7m0 0l7 7m-7-7v18&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt; &lt;/div&gt;

&lt;/main&gt;

&lt;!-- 页脚 --&gt; &lt;footer class=&quot;mt-8 pb-8 text-center text-slate-500 dark:text-slate-400 text-sm&quot;&gt; &lt;div&gt; 导航在手,网络随便走 | @&lt;span class=&quot;font-mono&quot;&gt;ws01 v1.1&lt;/span&gt; &lt;/div&gt; &lt;div class=&quot;mt-2&quot;&gt; &lt;span id=&quot;timeDate&quot;&gt;载入天数...&lt;/span&gt; &lt;script language=&quot;javascript&quot;&gt; var now = new Date(); function createtime(){ var grt= new Date(&quot;12/22/2025 00:00:00&quot;);/---这里是网站的启用时间:月/日/年--/ now.setTime(now.getTime()+250); days = (now - grt ) / 1000 / 60 / 60 / 24; dnum = Math.floor(days); document.getElementById(&quot;timeDate&quot;).innerHTML = &quot;稳定运行&quot;+dnum+&quot;天&quot;; } setInterval(&quot;createtime()&quot;,250); &lt;/script&gt;

&lt;span &lt;p&gt; | 总访问量 &lt;span id=&quot;busuanzisitepv&quot;&gt;&lt;/span&gt; 次 | &lt;a href=&quot;https://boke.199881.xyz/&quot; target=&quot;blank&quot; rel=&quot;noopener noreferrer&quot; class=&quot;text-emerald-500 hover:text-emerald-600 transition-colors font-medium&quot;&gt;博客 | &lt;a href=&quot;https://www.199881.xyz/&quot; target=&quot;blank&quot; rel=&quot;noopener noreferrer&quot; class=&quot;text-emerald-500 hover:text-emerald-600 transition-colors font-medium&quot;&gt;导航 &lt;/p&gt;&lt;/span&gt; &lt;script defer src=&quot;https://bsz.211119.xyz/js&quot;&gt;&lt;/script&gt;

&lt;/div&gt; &lt;/footer&gt;

&lt;!-- 模态框:添加/编辑链接 --&gt; &lt;div id=&quot;dialog-overlay&quot; class=&quot;fixed inset-0 z-[60] bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden&quot;&gt; &lt;div id=&quot;dialog-box&quot; class=&quot;bg-white dark:bg-[#1e293b] rounded-2xl shadow-2xl w-full max-w-md p-6 transform transition-all duration-300 border border-slate-100 dark:border-slate-700 dialog-scale-hidden&quot;&gt; &lt;h3 class=&quot;text-xl font-bold mb-5 text-slate-800 dark:text-slate-100 flex items-center gap-2&quot;&gt; &lt;span class=&quot;w-1 h-6 bg-emerald-500 rounded-full&quot;&gt;&lt;/span&gt; 编辑信息 &lt;/h3&gt; &lt;div class=&quot;space-y-4&quot;&gt; &lt;div&gt; &lt;label class=&quot;block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider&quot;&gt;名称 &lt;span class=&quot;text-red-500&quot;&gt;*&lt;/span&gt;&lt;/label&gt; &lt;input type=&quot;text&quot; id=&quot;name-input&quot; class=&quot;w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 outline-none transition-all dark:text-white&quot; placeholder=&quot;网站名称&quot;&gt; &lt;/div&gt; &lt;div&gt; &lt;label class=&quot;block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider&quot;&gt;地址 &lt;span class=&quot;text-red-500&quot;&gt;*&lt;/span&gt;&lt;/label&gt; &lt;input type=&quot;text&quot; id=&quot;url-input&quot; class=&quot;w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 outline-none transition-all dark:text-white&quot; placeholder=&quot;https://...&quot;&gt; &lt;/div&gt; &lt;div&gt; &lt;label class=&quot;block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider&quot;&gt;描述&lt;/label&gt; &lt;input type=&quot;text&quot; id=&quot;tips-input&quot; class=&quot;w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 outline-none transition-all dark:text-white&quot; placeholder=&quot;简短的描述...&quot;&gt; &lt;/div&gt; &lt;div&gt; &lt;label class=&quot;block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider&quot;&gt;图标 URL&lt;/label&gt; &lt;input type=&quot;text&quot; id=&quot;icon-input&quot; class=&quot;w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 outline-none transition-all dark:text-white&quot; placeholder=&quot;留空自动获取&quot;&gt; &lt;/div&gt;

&lt;!-- Custom Category Dropdown --&gt; &lt;div class=&quot;relative z-20&quot; id=&quot;category-select-wrapper&quot;&gt; &lt;label class=&quot;block text-xs font-semibold text-slate-500 dark:text-slate-400 mb-1.5 uppercase tracking-wider&quot;&gt;分类&lt;/label&gt; &lt;input type=&quot;hidden&quot; id=&quot;category-select-value&quot;&gt; &lt;button id=&quot;category-select-btn&quot; class=&quot;w-full px-4 py-2.5 text-left rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500/50 outline-none transition-all text-slate-700 dark:text-white flex items-center justify-between&quot;&gt; &lt;span id=&quot;category-select-text&quot;&gt;请选择分类&lt;/span&gt; &lt;svg class=&quot;w-4 h-4 text-slate-400&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M19 9l-7 7-7-7&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt; &lt;!-- Dropdown List --&gt; &lt;div id=&quot;category-select-menu&quot; class=&quot;hidden absolute top-full left-0 mt-2 w-full max-h-48 overflow-y-auto bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-100 dark:border-slate-700 z-50 custom-scrollbar&quot;&gt; &lt;!-- Items populated by JS --&gt; &lt;/div&gt; &lt;/div&gt;

&lt;div class=&quot;flex items-center gap-2 pt-2&quot;&gt; &lt;input type=&quot;checkbox&quot; id=&quot;private-checkbox&quot; class=&quot;w-5 h-5 text-emerald-500 rounded focus:ring-emerald-500 border-gray-300 bg-gray-100&quot;&gt; &lt;label for=&quot;private-checkbox&quot; class=&quot;text-sm text-slate-600 dark:text-slate-300 font-medium&quot;&gt;设为私密链接 (仅登录可见)&lt;/label&gt; &lt;/div&gt; &lt;/div&gt; &lt;div class=&quot;flex justify-end gap-3 mt-8&quot;&gt; &lt;button id=&quot;dialog-cancel-btn&quot; class=&quot;px-5 py-2.5 rounded-xl text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700 transition-colors&quot;&gt;取消&lt;/button&gt; &lt;button id=&quot;dialog-confirm-btn&quot; class=&quot;px-5 py-2.5 rounded-xl text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 transition-all hover:translate-y-[-1px]&quot;&gt;确定&lt;/button&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt;

&lt;!-- 密码弹窗 --&gt; &lt;div id=&quot;password-dialog-overlay&quot; class=&quot;fixed inset-0 z-[70] bg-slate-900/70 backdrop-blur-md flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden&quot;&gt; &lt;div id=&quot;password-dialog-box&quot; class=&quot;bg-white dark:bg-[#1e293b] rounded-2xl shadow-2xl p-8 w-full max-w-sm border border-slate-100 dark:border-slate-700 text-center transform transition-all duration-300 dialog-scale-hidden&quot;&gt; &lt;div class=&quot;w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-full flex items-center justify-center mx-auto mb-4 text-emerald-500&quot;&gt; &lt;svg class=&quot;w-8 h-8&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/div&gt; &lt;h3 class=&quot;text-xl font-bold mb-2 text-slate-800 dark:text-white&quot;&gt;身份验证&lt;/h3&gt; &lt;p class=&quot;text-sm text-slate-500 dark:text-slate-400 mb-6&quot;&gt;请输入管理员密码以继续操作&lt;/p&gt; &lt;input type=&quot;password&quot; id=&quot;password-input&quot; placeholder=&quot;访问密码&quot; class=&quot;w-full px-4 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none mb-6 dark:text-white text-center tracking-widest text-lg transition-all&quot;&gt; &lt;div class=&quot;flex gap-3&quot;&gt; &lt;button id=&quot;password-cancel-btn&quot; class=&quot;flex-1 py-2.5 rounded-xl text-slate-600 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600 font-medium transition-colors&quot;&gt;取消&lt;/button&gt; &lt;button id=&quot;password-confirm-btn&quot; class=&quot;flex-1 py-2.5 rounded-xl text-white bg-emerald-500 hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 font-medium transition-colors&quot;&gt;确认登录&lt;/button&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt;

&lt;!-- 自定义 Alert --&gt; &lt;div id=&quot;custom-alert-overlay&quot; class=&quot;fixed inset-0 z-[110] bg-slate-900/50 backdrop-blur-[2px] flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden&quot;&gt; &lt;div id=&quot;custom-alert-box&quot; class=&quot;bg-white dark:bg-[#1e293b] rounded-2xl shadow-2xl p-6 max-w-sm w-full border border-slate-100 dark:border-slate-700 transform transition-all duration-300 dialog-scale-hidden&quot;&gt; &lt;h3 id=&quot;custom-alert-title&quot; class=&quot;text-lg font-bold mb-2 text-slate-800 dark:text-white&quot;&gt;提示&lt;/h3&gt; &lt;p id=&quot;custom-alert-content&quot; class=&quot;text-slate-600 dark:text-slate-300 mb-6 text-sm leading-relaxed&quot;&gt;&lt;/p&gt; &lt;div class=&quot;flex justify-end&quot;&gt; &lt;button id=&quot;custom-alert-confirm&quot; class=&quot;px-5 py-2 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl text-sm font-medium transition-colors shadow-lg shadow-emerald-500/20&quot;&gt;我知道了&lt;/button&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt;

&lt;!-- 自定义 Confirm --&gt; &lt;div id=&quot;custom-confirm-overlay&quot; class=&quot;fixed inset-0 z-[80] bg-slate-900/50 backdrop-blur-[2px] flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden&quot;&gt; &lt;div id=&quot;custom-confirm-box&quot; class=&quot;bg-white dark:bg-[#1e293b] rounded-2xl shadow-2xl p-6 max-w-sm w-full border border-slate-100 dark:border-slate-700 transform transition-all duration-300 dialog-scale-hidden&quot;&gt; &lt;h3 class=&quot;text-lg font-bold mb-3 text-slate-800 dark:text-white&quot;&gt;确认操作&lt;/h3&gt; &lt;p id=&quot;custom-confirm-message&quot; class=&quot;text-slate-600 dark:text-slate-300 mb-6 text-sm&quot;&gt;&lt;/p&gt; &lt;div class=&quot;flex justify-end gap-3&quot;&gt; &lt;button id=&quot;custom-confirm-cancel&quot; class=&quot;px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-xl dark:text-slate-400 dark:hover:bg-slate-700 transition-colors font-medium&quot;&gt;取消&lt;/button&gt; &lt;button id=&quot;custom-confirm-ok&quot; class=&quot;px-4 py-2 text-sm text-white bg-emerald-500 hover:bg-emerald-600 rounded-xl shadow-lg shadow-emerald-500/20 transition-colors font-medium&quot;&gt;确定&lt;/button&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt;

&lt;!-- 分类输入弹窗 --&gt; &lt;div id=&quot;category-dialog&quot; class=&quot;fixed inset-0 z-[65] bg-slate-900/50 backdrop-blur-sm flex items-center justify-center p-4 transition-opacity duration-300 overlay-hidden&quot;&gt; &lt;div id=&quot;category-dialog-box&quot; class=&quot;bg-white dark:bg-[#1e293b] rounded-2xl p-6 w-full max-w-sm shadow-2xl border border-slate-100 dark:border-slate-700 transform transition-all duration-300 dialog-scale-hidden&quot;&gt; &lt;h3 id=&quot;category-dialog-title&quot; class=&quot;text-lg font-bold mb-4 text-slate-800 dark:text-white&quot;&gt;分类名称&lt;/h3&gt; &lt;input type=&quot;text&quot; id=&quot;category-name-input&quot; class=&quot;w-full px-4 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 focus:ring-2 focus:ring-emerald-500 outline-none mb-6 dark:text-white transition-all&quot; placeholder=&quot;输入分类名称&quot;&gt; &lt;div class=&quot;flex justify-end gap-3&quot;&gt; &lt;button id=&quot;category-cancel-btn&quot; class=&quot;px-4 py-2 text-sm rounded-xl text-slate-600 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 font-medium&quot;&gt;取消&lt;/button&gt; &lt;button id=&quot;category-confirm-btn&quot; class=&quot;px-4 py-2 text-sm rounded-xl text-white bg-emerald-500 hover:bg-emerald-600 shadow-md font-medium&quot;&gt;确定&lt;/button&gt; &lt;/div&gt; &lt;/div&gt; &lt;/div&gt;

&lt;!-- Loading Mask (z-index 100) --&gt; &lt;div id=&quot;loading-mask&quot; class=&quot;fixed inset-0 z-[100] bg-white/90 dark:bg-slate-900/90 backdrop-blur-sm hidden flex flex-col items-center justify-center transition-opacity&quot;&gt; &lt;div class=&quot;relative w-16 h-16&quot;&gt; &lt;div class=&quot;absolute inset-0 border-4 border-slate-200 dark:border-slate-700 rounded-full&quot;&gt;&lt;/div&gt; &lt;div class=&quot;absolute inset-0 border-4 border-emerald-500 rounded-full border-t-transparent animate-spin&quot;&gt;&lt;/div&gt; &lt;/div&gt; &lt;p class=&quot;mt-4 text-emerald-600 dark:text-emerald-400 font-medium animate-pulse tracking-wide&quot;&gt;加载中...&lt;/p&gt; &lt;/div&gt;

&lt;!-- Tooltip Container --&gt; &lt;div id=&quot;custom-tooltip&quot; class=&quot;fixed hidden pointer-events-none max-w-xs whitespace-pre-wrap border leading-relaxed tracking-wide backdrop-blur-md rounded-xl shadow-glass px-4 py-2 text-sm transition-opacity duration-150 bg-white/90 dark:bg-slate-800/90 text-slate-700 dark:text-slate-200 border-slate-200/50 dark:border-slate-700/50&quot;&gt; &lt;/div&gt;

&lt;script&gt; let isEditMode = false; let isLoggedIn = false; let isAppLayout = localStorage.getItem(&#039;appLayout&#039;) === &#039;true&#039;;

let editCardMode = false; let isEditCategoryMode = false;

const categories = {}; let currentEngine; let initialDragState = { category: null, index: -1 };

function toggleAppLayout() { isAppLayout = !isAppLayout; localStorage.setItem(&#039;appLayout&#039;, isAppLayout);

const checkbox = document.getElementById(&#039;layout-switch-checkbox&#039;); if (checkbox) checkbox.checked = isAppLayout;

loadSections(); }

function logAction(action, details) { console.log(\\${new Date().toISOString()}: \${action} - \, details); }

// 搜索引擎 const searchEngines = { baidu: &quot;https://www.baidu.com/s?wd=&quot;, bing: &quot;https://www.bing.com/search?q=&quot;, google: &quot;https://www.google.com/search?q=&quot;, site: &quot;&quot; };

// 搜索引擎显示名称映射 const searchEngineLabels = { baidu: &quot;百度&quot;, bing: &quot;必应&quot;, google: &quot;谷歌&quot;, site: &quot;本站&quot; };

// 搜索引擎图标映射 (SVG路径) const searchEngineIcons = { site: &#039;&lt;svg width=&quot;16&quot; height=&quot;16&quot; fill=&quot;#FFD700&quot; stroke=&quot;#FFD700&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path fill=&quot;#FFD700&quot; stroke=&quot;#FFD700&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot;d=&quot;M13 10V3L4 14h7v7l9-11h-7z&quot;&gt;&lt;/path&gt;&lt;/svg&gt;&#039;, baidu: &#039;&lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 32 32&quot;&gt;&lt;path fill=&quot;#4285F4&quot; d=&quot;M5.749 16.864c3.48-.744 3-4.911 2.901-5.817c-.172-1.401-1.823-3.853-4.057-3.656c-2.812.249-3.224 4.323-3.224 4.323c-.385 1.88.907 5.901 4.38 5.151zm6.459-6.984c1.923 0 3.475-2.213 3.475-4.948C15.683 2.213 14.136 0 12.214 0c-1.916 0-3.479 2.197-3.479 4.932s1.557 4.948 3.479 4.948zm8.281.328c2.573.344 4.213-2.401 4.547-4.479c.333-2.068-1.333-4.484-3.145-4.896c-1.823-.421-4.079 2.5-4.307 4.401c-.24 2.333.333 4.651 2.895 4.979zm-3.864 8.714s-3.985-3.077-6.303-6.4c-3.145-4.901-7.62-2.907-9.115-.423c-1.489 2.511-3.812 4.084-4.14 4.505c-.333.412-4.797 2.823-3.803 7.224c1 4.401 4.479 4.323 4.479 4.323s2.557.251 5.548-.416c2.984-.667 5.547.161 5.547.161s6.943 2.333 8.864-2.147c1.896-4.495-1.083-6.812-1.083-6.812z&quot;/&gt;&lt;/svg&gt;&#039;, bing: &#039;&lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 32 32&quot;&gt;&lt;path fill=&quot;#008373&quot; d=&quot;m4.807 0l6.391 2.25v22.495l9.005-5.193l-4.411-2.073l-2.786-6.932l14.188 4.984v7.245L11.204 32l-6.396-3.563z&quot;/&gt;&lt;/svg&gt;&#039;, google: &#039;&lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 256 262&quot;&gt;&lt;path fill=&quot;#4285F4&quot; d=&quot;M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027&quot;/&gt;&lt;path fill=&quot;#34A853&quot; d=&quot;M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1&quot;/&gt;&lt;path fill=&quot;#FBBC05&quot; d=&quot;M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z&quot;/&gt;&lt;path fill=&quot;#EB4335&quot; d=&quot;M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251&quot;/&gt;&lt;/svg&gt;&#039; };

const engineList = [&#039;site&#039;, &#039;baidu&#039;, &#039;bing&#039;, &#039;google&#039;];

function renderSearchEngineMenu() { const container = document.getElementById(&#039;search-engine-list&#039;); const title = container.querySelector(&#039;div&#039;); container.innerHTML = &#039;&#039;; container.appendChild(title);

engineList.forEach(key =&gt; { const label = searchEngineLabels[key]; const icon = searchEngineIcons[key];

const btn = document.createElement(&#039;button&#039;); btn.className = &quot;w-full text-left px-3 py-2.5 text-sm text-slate-700 dark:text-slate-200 hover:bg-emerald-50 dark:hover:bg-slate-700 hover:text-emerald-600 transition-colors flex items-center gap-3&quot;; btn.onclick = () =&gt; selectSearchEngine(key, label);

btn.innerHTML = \\${icon}&lt;span&gt;\${label}&lt;/span&gt;\;

container.appendChild(btn); }); }

function setActiveEngine(engine) { if (!searchEngines.hasOwnProperty(engine) &amp;&amp; engine !== &#039;site&#039;) engine = &#039;site&#039;; selectSearchEngine(engine, searchEngineLabels[engine]); }

function updateSearchEngineUI(value) { const label = searchEngineLabels[value] || &quot;本站&quot;; const icon = searchEngineIcons[value] || searchEngineIcons[&#039;site&#039;];

document.getElementById(&#039;current-engine-label&#039;).textContent = label; document.getElementById(&#039;current-engine-icon&#039;).innerHTML = icon; }

document.addEventListener(&#039;DOMContentLoaded&#039;, async () =&gt; { initializeUIComponents(); renderSearchEngineMenu(); await checkLoginStatusAndLoad(); });

async function checkLoginStatusAndLoad() { const isValid = await validateToken(); if (isValid) { isLoggedIn = true; } else { isLoggedIn = false; isEditMode = false; } await loadLinks(); }

function initializeUIComponents() { const elements = { themeSwitchCheckbox: document.getElementById(&#039;theme-switch-checkbox&#039;), layoutSwitchCheckbox: document.getElementById(&#039;layout-switch-checkbox&#039;), savePrefCheckbox: document.getElementById(&#039;save-preference-checkbox&#039;), searchButton: document.getElementById(&#039;search-button&#039;), searchInput: document.getElementById(&#039;search-input&#039;), clearSearchButton: document.getElementById(&#039;clear-search-button&#039;), menuToggleBtn: document.getElementById(&#039;profile-menu-toggle&#039;), dropdown: document.getElementById(&#039;profile-dropdown&#039;), dropdownWrapper: document.getElementById(&#039;profile-dropdown-wrapper&#039;), backToTopBtn: document.getElementById(&#039;back-to-top-btn&#039;) };

elements.themeSwitchCheckbox.checked = document.documentElement.classList.contains(&#039;dark&#039;); elements.themeSwitchCheckbox.addEventListener(&#039;change&#039;, (e) =&gt; { const isDark = e.target.checked; window.isDarkTheme = isDark; applyTheme(isDark);

const savePrefCheckbox = document.getElementById(&#039;save-preference-checkbox&#039;); if (savePrefCheckbox &amp;&amp; savePrefCheckbox.checked) { localStorage.setItem(&#039;theme&#039;, isDark ? &#039;dark&#039; : &#039;light&#039;); } });

if(elements.layoutSwitchCheckbox) { elements.layoutSwitchCheckbox.checked = isAppLayout; }

const savedPref = localStorage.getItem(&#039;savePreferences&#039;) === &#039;true&#039;; elements.savePrefCheckbox.checked = savedPref;

currentEngine = (savedPref &amp;&amp; localStorage.getItem(&#039;searchEngine&#039;)) || &#039;site&#039;; updateSearchEngineUI(currentEngine);

const searchWrapper = document.getElementById(&#039;search-engine-wrapper&#039;); const searchBtn = document.getElementById(&#039;search-engine-btn&#039;); const searchMenu = document.getElementById(&#039;search-engine-menu&#039;);

searchBtn.addEventListener(&#039;click&#039;, (e) =&gt; { e.stopPropagation(); searchMenu.classList.toggle(&#039;hidden&#039;); });

const catWrapper = document.getElementById(&#039;category-select-wrapper&#039;); const catBtn = document.getElementById(&#039;category-select-btn&#039;); const catMenu = document.getElementById(&#039;category-select-menu&#039;);

catBtn.addEventListener(&#039;click&#039;, (e) =&gt; { e.stopPropagation(); catMenu.classList.toggle(&#039;hidden&#039;); });

const toggleDropdown = () =&gt; { elements.dropdown.classList.toggle(&#039;hidden&#039;); };

elements.menuToggleBtn.addEventListener(&#039;click&#039;, (e) =&gt; { e.stopPropagation(); toggleDropdown(); });

document.addEventListener(&#039;click&#039;, (e) =&gt; { if (!elements.dropdownWrapper.contains(e.target)) { elements.dropdown.classList.add(&#039;hidden&#039;); } if (!searchWrapper.contains(e.target)) { searchMenu.classList.add(&#039;hidden&#039;); } if (!catWrapper.contains(e.target)) { catMenu.classList.add(&#039;hidden&#039;); } });

elements.dropdown.addEventListener(&#039;click&#039;, (e) =&gt; { e.stopPropagation(); });

elements.savePrefCheckbox.addEventListener(&#039;change&#039;, () =&gt; { const enabled = elements.savePrefCheckbox.checked; localStorage.setItem(&#039;savePreferences&#039;, enabled); if (!enabled) { localStorage.removeItem(&#039;searchEngine&#039;); localStorage.removeItem(&#039;theme&#039;); } else { localStorage.setItem(&#039;searchEngine&#039;, currentEngine); localStorage.setItem(&#039;theme&#039;, window.isDarkTheme ? &#039;dark&#039; : &#039;light&#039;); } });

elements.searchButton.addEventListener(&#039;click&#039;, async () =&gt; { const query = elements.searchInput.value.trim(); if (query) { if (currentEngine === &#039;site&#039;) { await searchLinks(query); } else { window.open(searchEngines[currentEngine] + encodeURIComponent(query), &#039;_blank&#039;); } } });

if (elements.clearSearchButton) { elements.clearSearchButton.addEventListener(&#039;click&#039;, () =&gt; { elements.searchInput.value = &#039;&#039;; loadSections(); }); }

if (elements.searchInput) { elements.searchInput.addEventListener(&#039;keypress&#039;, (e) =&gt; { if (e.key === &#039;Enter&#039;) elements.searchButton.click(); }); elements.searchInput.addEventListener(&#039;input&#039;, (e) =&gt; { if(e.target.value) elements.clearSearchButton.classList.remove(&#039;hidden&#039;); else elements.clearSearchButton.classList.add(&#039;hidden&#039;); }); }

window.addEventListener(&#039;scroll&#039;, () =&gt; { if (window.scrollY &gt; 300) { elements.backToTopBtn.classList.remove(&#039;hidden&#039;); } else { elements.backToTopBtn.classList.add(&#039;hidden&#039;); } });

setupScrollSpy();

setupTooltipDelegation(); }

function selectSearchEngine(value, label) { currentEngine = value; updateSearchEngineUI(value);

const savePrefCheckbox = document.getElementById(&#039;save-preference-checkbox&#039;); if (savePrefCheckbox &amp;&amp; savePrefCheckbox.checked) { localStorage.setItem(&#039;searchEngine&#039;, value); } document.getElementById(&#039;search-engine-menu&#039;).classList.add(&#039;hidden&#039;); }

function getAllLinks() { return Object.values(categories).map(category =&gt; category.links || []).flat(); }

async function loadLinks() { const headers = { &#039;Content-Type&#039;: &#039;application/json&#039; }; if (isLoggedIn) { const token = localStorage.getItem(&#039;authToken&#039;); if (token) headers[&#039;Authorization&#039;] = token; }

try { const response = await fetchWithAuth(&#039;/api/getLinks&#039;); if (!response.ok) throw new Error(&quot;HTTP error! status: &quot; + response.status);

const data = await response.json(); if (data.categories) { Object.keys(categories).forEach(key =&gt; delete categories[key]); Object.assign(categories, data.categories); }

loadSections(); updateCategorySelect(); updateUIState(); } catch (error) { console.error(&#039;Error loading links:&#039;, error); await customAlert(&#039;加载链接时出错,请刷新页面重试&#039;); } }

async function saveDataToServer(actionName, data) { try { const response = await fetchWithAuth(&#039;/api/saveData&#039;, { method: &#039;POST&#039;, headers: { &#039;Content-Type&#039;: &#039;application/json&#039; }, body: JSON.stringify({ categories: data }), });

if (response.status === 401) { logout(); await customAlert(&#039;登录凭证已过期,请重新登录&#039;); throw new Error(&#039;Unauthorized&#039;); }

const result = await response.json(); if (!result.success) throw new Error(&#039;Failed to save&#039;); logAction(actionName + &#039;成功&#039;, {}); } catch (error) { logAction(actionName + &#039;失败&#039;, { error: error.message }); if (error.message !== &#039;Unauthorized&#039;) { await customAlert(actionName + &#039;失败,请重试&#039;); } } }

async function saveLinks() { if (isEditMode) { await saveDataToServer(&#039;保存数据&#039;, categories); } }

async function addCategory() { if (!await validateTokenOrRedirect()) return; const categoryName = await showCategoryDialog(&#039;请输入新分类名称&#039;); if (!categoryName) return; if (categories[categoryName]) { await customAlert(&#039;该分类已存在&#039;); return; } categories[categoryName] = { isHidden: false, links: [] }; updateCategorySelect(); renderCategories(); setTimeout(() =&gt; window.scrollTo(0, document.body.scrollHeight), 100); await saveLinks(); }

async function editCategoryName(oldName) { if (!await validateTokenOrRedirect()) return; const newName = await showCategoryDialog(&#039;请输入新的分类名称&#039;, oldName); if (!newName || newName === oldName) return; if (categories[newName]) { await customAlert(&#039;该名称已存在&#039;); return; }

const keys = Object.keys(categories); const newCategories = {};

keys.forEach(key =&gt; { if (key === oldName) { const data = categories[oldName]; data.links.forEach(item =&gt; item.category = newName); newCategories[newName] = data; } else { newCategories[key] = categories[key]; } });

Object.keys(categories).forEach(k =&gt; delete categories[k]); Object.assign(categories, newCategories);

renderCategories(); renderCategoryButtons(); updateCategorySelect(); await saveLinks(); }

async function deleteCategory(category) { if (!await validateTokenOrRedirect()) return; if (await customConfirm(\确定删除 &quot;\${category}&quot; 分类及其所有链接吗?\)) { delete categories[category]; updateCategorySelect(); renderCategories(); renderCategoryButtons(); await saveLinks(); } }

async function moveCategory(categoryName, direction) { if (!await validateTokenOrRedirect()) return; const keys = Object.keys(categories); const index = keys.indexOf(categoryName); if (index &lt; 0) return; const newIndex = index + direction; if (newIndex &lt; 0 || newIndex &gt;= keys.length) return;

const newCategories = {}; const reordered = [...keys]; [reordered[index], reordered[newIndex]] = [reordered[newIndex], reordered[index]]; reordered.forEach(key =&gt; newCategories[key] = categories[key]);

Object.keys(categories).forEach(k =&gt; delete categories[k]); Object.assign(categories, newCategories);

renderCategories(); renderCategoryButtons(); await saveLinks(); }

async function toggleCategoryHidden(category, isHidden) { if (!await validateTokenOrRedirect()) return; categories[category].isHidden = isHidden; await saveLinks(); }

async function pinCategory(categoryName) { if (!await validateTokenOrRedirect()) return; const keys = Object.keys(categories); const index = keys.indexOf(categoryName); if (index &lt; 0) return;

const newCategories = {}; const reordered = [...keys]; reordered.splice(index, 1); reordered.unshift(categoryName); reordered.forEach(key =&gt; newCategories[key] = categories[key]);

Object.keys(categories).forEach(k =&gt; delete categories[k]); Object.assign(categories, newCategories);

renderCategories(); renderCategoryButtons(); await saveLinks(); }

function getFilteredCategoriesByKeyword(query) { const lowerQuery = query.toLowerCase(); const result = {}; Object.keys(categories).forEach(category =&gt; { const categoryData = categories[category]; const matchedLinks = (categoryData.links || []).filter(link =&gt; { const nameMatch = link.name &amp;&amp; link.name.toLowerCase().includes(lowerQuery); const tipsMatch = link.tips &amp;&amp; link.tips.toLowerCase().includes(lowerQuery); const urlMatch = link.url &amp;&amp; link.url.toLowerCase().includes(lowerQuery); return nameMatch || tipsMatch || urlMatch; }); if (matchedLinks.length &gt; 0) { result[category] = { ...categoryData, links: matchedLinks }; } }); return result; }

function renderCategorySections({ renderButtons = false, searchMode = false, filteredCategories = null } = {}) { const container = document.getElementById(&#039;sections-container&#039;); container.innerHTML = &#039;&#039;; const sourceCategories = searchMode &amp;&amp; filteredCategories ? filteredCategories : categories;

Object.entries(sourceCategories).forEach(([category, { links, isHidden }]) =&gt; { if (!isEditMode &amp;&amp; !isLoggedIn &amp;&amp; isHidden &amp;&amp; !searchMode) return;

const section = document.createElement(&#039;div&#039;); section.className = &#039;section section-anchor&#039;; section.id = category;

// 标题区域 const titleContainer = document.createElement(&#039;div&#039;); titleContainer.className = &#039;flex items-center gap-3 mb-5 pb-2 border-b border-slate-200/60 dark:border-slate-700/60&#039;;

const title = document.createElement(&#039;h2&#039;); title.className = &#039;text-lg font-bold text-slate-700 dark:text-slate-100 flex items-center gap-2&#039;; title.innerHTML = \&lt;span class=&quot;w-1.5 h-5 bg-emerald-500 rounded-full inline-block shadow-sm&quot;&gt;&lt;/span&gt; \${category}\; titleContainer.appendChild(title);

// 编辑模式下的标题栏操作 if (isEditMode) { const controls = document.createElement(&#039;div&#039;); controls.className = &#039;flex items-center gap-1 ml-auto bg-slate-300/50 dark:bg-slate-800/50 p-1 rounded-xl border border-slate-300/50 dark:border-slate-700/50 backdrop-blur-sm&#039;; const btnBase = &quot;w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-200 hover:scale-105 active:scale-95&quot;;

controls.innerHTML = \ &lt;!-- 编辑名称 --&gt; &lt;button class=&quot;\${btnBase} text-slate-500 hover:text-blue-600 hover:bg-blue-100 dark:text-slate-400 dark:hover:bg-blue-900/30 dark:hover:text-blue-400 has-tooltip&quot; data-tooltip=&quot;重命名&quot; onclick=&quot;editCategoryName(&#039;\${category}&#039;)&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt;

&lt;div class=&quot;w-px h-4 bg-slate-300 dark:bg-slate-600 mx-0.5&quot;&gt;&lt;/div&gt;

&lt;!-- 排序组 --&gt; &lt;button class=&quot;\${btnBase} text-slate-500 hover:text-emerald-600 hover:bg-emerald-100 dark:text-slate-400 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-400 has-tooltip&quot; data-tooltip=&quot;上移&quot; onclick=&quot;moveCategory(&#039;\${category}&#039;, -1)&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M5 15l7-7 7 7&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt; &lt;button class=&quot;\${btnBase} text-slate-500 hover:text-emerald-600 hover:bg-emerald-100 dark:text-slate-400 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-400 has-tooltip&quot; data-tooltip=&quot;下移&quot; onclick=&quot;moveCategory(&#039;\${category}&#039;, 1)&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M19 9l-7 7-7-7&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt; &lt;button class=&quot;\${btnBase} text-slate-500 hover:text-amber-600 hover:bg-amber-100 dark:text-slate-400 dark:hover:bg-amber-900/30 dark:hover:text-amber-400 has-tooltip&quot; data-tooltip=&quot;置顶&quot; onclick=&quot;pinCategory(&#039;\${category}&#039;)&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M5 3h14M18 13l-6-6l-6 6M12 7v14&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt;

&lt;div class=&quot;w-px h-4 bg-slate-300 dark:bg-slate-600 mx-0.5&quot;&gt;&lt;/div&gt;

&lt;!-- 隐藏开关 --&gt; &lt;div class=&quot;flex items-center justify-center w-8 h-8 has-tooltip cursor-pointer&quot; data-tooltip=&quot;\${isHidden ? &#039;显示分类&#039; : &#039;隐藏分类&#039;}&quot;&gt; &lt;label class=&quot;relative inline-flex items-center cursor-pointer&quot;&gt; &lt;!-- 下面这一行增加了 DOM 属性更新逻辑 --&gt; &lt;input type=&quot;checkbox&quot; \${isHidden ? &#039;checked&#039; : &#039;&#039;} onchange=&quot;this.closest(&#039;.has-tooltip&#039;).setAttribute(&#039;data-tooltip&#039;, this.checked ? &#039;显示分类&#039; : &#039;隐藏分类&#039;); toggleCategoryHidden(&#039;\${category}&#039;, this.checked)&quot; class=&quot;sr-only peer&quot;&gt; &lt;div class=&quot;w-3.5 h-3.5 rounded-full border-2 border-slate-400 peer-focus:outline-none peer dark:border-slate-500 peer-checked:bg-slate-500 peer-checked:border-slate-500 transition-colors&quot;&gt;&lt;/div&gt; &lt;/label&gt; &lt;/div&gt;

&lt;div class=&quot;w-px h-4 bg-slate-300 dark:bg-slate-600 mx-0.5&quot;&gt;&lt;/div&gt;

&lt;!-- 删除 --&gt; &lt;button class=&quot;\${btnBase} text-slate-400 hover:text-red-600 hover:bg-red-100 dark:text-slate-500 dark:hover:bg-red-900/30 dark:hover:text-red-400 has-tooltip&quot; data-tooltip=&quot;删除分类&quot; onclick=&quot;deleteCategory(&#039;\${category}&#039;)&quot;&gt; &lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/button&gt; \; titleContainer.appendChild(controls); }

// 卡片网格 const cardContainer = document.createElement(&#039;div&#039;); // 根据布局模式调整 Grid 列数 // APP 模式下,手机端一行4个,平板6个,大屏8-10个 const gridClasses = isAppLayout ? &#039;grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-x-2 gap-y-6&#039; : &#039;grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4&#039;;

cardContainer.className = \grid \${gridClasses} card-container relative\; cardContainer.id = category; // ID for drag logic

section.appendChild(titleContainer); section.appendChild(cardContainer); container.appendChild(section);

links.forEach(link =&gt; createCard(link, cardContainer));

if (isEditMode) { const addCardPlaceholder = document.createElement(&#039;div&#039;); const sizeClasses = isAppLayout ? &#039;w-16 h-16 rounded-[1.2rem] mx-auto&#039; : &#039;min-h-[100px] p-4 rounded-2xl w-full&#039;;

addCardPlaceholder.className = \add-card-placeholder group flex flex-col h-full w-full \${sizeClasses} rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-700 hover:border-emerald-500 dark:hover:border-emerald-500 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 transition-all cursor-pointer flex items-center justify-center\; addCardPlaceholder.innerHTML = \ &lt;div class=&quot;w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-800 group-hover:bg-emerald-100 dark:group-hover:bg-emerald-900/30 flex items-center justify-center transition-colors pointer-events-none&quot;&gt; &lt;svg class=&quot;w-6 h-6 text-slate-400 group-hover:text-emerald-500&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M12 4v16m8-8H4&quot;&gt;&lt;/path&gt;&lt;/svg&gt; &lt;/div&gt; \;

addCardPlaceholder.addEventListener(&#039;dragover&#039;, (e) =&gt; { e.preventDefault(); const dragging = document.querySelector(&#039;.card.dragging&#039;); if(dragging &amp;&amp; dragging.parentElement === cardContainer) { cardContainer.insertBefore(dragging, addCardPlaceholder); } });

addCardPlaceholder.onclick = () =&gt; { showAddDialog(); document.getElementById(&#039;category-select-value&#039;).value = category; document.getElementById(&#039;category-select-text&#039;).textContent = category; }; cardContainer.appendChild(addCardPlaceholder); } });

if (renderButtons) renderCategoryButtons();

setupScrollSpy(); }

function renderCategories() { renderCategorySections({ renderButtons: false }); }

async function searchLinks(query) { const clearBtn = document.getElementById(&#039;clear-search-button&#039;); const filteredData = getFilteredCategoriesByKeyword(query); const hasMatchingLinks = Object.values(filteredData).some(c =&gt; c.links.length &gt; 0);

if (!hasMatchingLinks) { await customAlert(&#039;没有找到相关站点。&#039;); return; } clearBtn.classList.remove(&#039;hidden&#039;); renderCategorySections({ renderButtons: true, searchMode: true, filteredCategories: filteredData }); }

function renderCategoryButtons() { const container = document.getElementById(&#039;category-buttons-container&#039;); container.innerHTML = &#039;&#039;; const visibleCategories = Object.keys(categories).filter(c =&gt; (categories[c].links || []).some(l =&gt; !l.isPrivate || isLoggedIn) &amp;&amp; (!categories[c].isHidden || isEditMode || isLoggedIn) );

if (visibleCategories.length === 0) return;

visibleCategories.forEach(cat =&gt; { const btn = document.createElement(&#039;button&#039;); btn.className = &#039;category-button whitespace-nowrap px-4 py-1.5 text-xs font-medium rounded-xl border border-slate-300 dark:border-slate-600 transition-all active:scale-95 shadow-sm scroll-snap-align-start&#039;; btn.classList.add(&#039;bg-slate-100&#039;, &#039;dark:bg-slate-800&#039;, &#039;text-slate-600&#039;, &#039;dark:text-slate-300&#039;, &#039;hover:bg-emerald-50&#039;, &#039;hover:text-emerald-600&#039;, &#039;dark:hover:bg-slate-700&#039;, &#039;hover:border-emerald-300&#039;, &#039;dark:hover:border-emerald-500/50&#039;);

btn.textContent = cat; btn.dataset.target = cat; btn.onclick = () =&gt; { scrollToCategory(cat); }; container.appendChild(btn); }); }

function scrollToCategory(catId) { const section = document.getElementById(catId); if(section) { section.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;start&#039; }); } }

function setupScrollSpy() { const sections = document.querySelectorAll(&#039;.section&#039;); const buttons = document.querySelectorAll(&#039;.category-button&#039;);

if (!sections.length || !buttons.length) return;

const observerOptions = { root: null, rootMargin: &#039;-100px 0px -70% 0px&#039;, threshold: 0 };

const observer = new IntersectionObserver((entries) =&gt; { entries.forEach(entry =&gt; { if (entry.isIntersecting) { const id = entry.target.id; highlightButton(id); } }); }, observerOptions);

sections.forEach(section =&gt; observer.observe(section)); }

function highlightButton(id) { const buttons = document.querySelectorAll(&#039;.category-button&#039;); buttons.forEach(btn =&gt; { if (btn.dataset.target === id) { btn.classList.remove(&#039;bg-slate-100&#039;, &#039;dark:bg-slate-800&#039;, &#039;text-slate-600&#039;, &#039;dark:text-slate-300&#039;, &#039;hover:bg-emerald-50&#039;, &#039;hover:text-emerald-600&#039;, &#039;dark:hover:bg-slate-700&#039;); btn.classList.add(&#039;bg-emerald-500&#039;, &#039;text-white&#039;, &#039;shadow-md&#039;, &#039;dark:bg-emerald-600&#039;); btn.scrollIntoView({ behavior: &#039;smooth&#039;, block: &#039;nearest&#039;, inline: &#039;center&#039; }); } else { btn.classList.remove(&#039;bg-emerald-500&#039;, &#039;text-white&#039;, &#039;shadow-md&#039;, &#039;dark:bg-emerald-600&#039;); btn.classList.add(&#039;bg-slate-100&#039;, &#039;dark:bg-slate-800&#039;, &#039;text-slate-600&#039;, &#039;dark:text-slate-300&#039;, &#039;hover:bg-emerald-50&#039;, &#039;hover:text-emerald-600&#039;, &#039;dark:hover:bg-slate-700&#039;); } }); }

// --- 遮罩层过渡辅助函数 --- function toggleOverlay(id, show) { const overlay = document.getElementById(id); const box = overlay.querySelector(&#039;div[id$=&quot;-box&quot;]&#039;);

if (show) { overlay.classList.remove(&#039;hidden&#039;); void overlay.offsetWidth; overlay.classList.remove(&#039;overlay-hidden&#039;); overlay.classList.add(&#039;overlay-visible&#039;);

if(box) { box.classList.remove(&#039;dialog-scale-hidden&#039;); box.classList.add(&#039;dialog-scale-visible&#039;); } } else { overlay.classList.remove(&#039;overlay-visible&#039;); overlay.classList.add(&#039;overlay-hidden&#039;);

if(box) { box.classList.remove(&#039;dialog-scale-visible&#039;); box.classList.add(&#039;dialog-scale-hidden&#039;); }

setTimeout(() =&gt; { if(overlay.classList.contains(&#039;overlay-hidden&#039;)) { overlay.classList.add(&#039;hidden&#039;); } }, 300); } }

function updateUIState() { const editModeBtn = document.getElementById(&#039;edit-mode-btn&#039;); const loginBtn = document.getElementById(&#039;login-Btn&#039;); const addCategoryContainer = document.getElementById(&#039;add-category-container&#039;); const dataToolsMenu = document.getElementById(&#039;data-tools-menu&#039;);

loginBtn.innerHTML = isLoggedIn ? &#039;&lt;svg class=&quot;w-4 h-4&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4&quot;&gt;&lt;/path&gt;&lt;polyline points=&quot;16 17 21 12 16 7&quot;&gt;&lt;/polyline&gt;&lt;line x1=&quot;21&quot; y1=&quot;12&quot; x2=&quot;9&quot; y2=&quot;12&quot;&gt;&lt;/line&gt;&lt;/svg&gt; 退出登录&#039; : &#039;&lt;svg class=&quot;w-4 h-4&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4&quot;/&gt;&lt;polyline points=&quot;10 17 15 12 10 7&quot;/&gt;&lt;line x1=&quot;15&quot; y1=&quot;12&quot; x2=&quot;3&quot; y2=&quot;12&quot;/&gt;&lt;/svg&gt; 登录&#039;;

if(isLoggedIn) { loginBtn.classList.replace(&#039;text-red-500&#039;, &#039;text-slate-700&#039;); if(dataToolsMenu) dataToolsMenu.classList.remove(&#039;hidden&#039;); } else { if(dataToolsMenu) dataToolsMenu.classList.add(&#039;hidden&#039;); }

if (isEditMode) { editModeBtn.innerHTML = &#039;&lt;span class=&quot;text-red-500 flex items-center gap-2&quot;&gt;&lt;svg class=&quot;w-4 h-4&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M6 18L18 6M6 6l12 12&quot;&gt;&lt;/path&gt;&lt;/svg&gt;退出编辑&lt;/span&gt;&#039;; document.body.classList.add(&#039;edit-mode&#039;); if(addCategoryContainer) addCategoryContainer.classList.remove(&#039;hidden&#039;); } else { editModeBtn.innerHTML = isLoggedIn ? &#039;&lt;span class=&quot;flex items-center gap-3&quot;&gt;&lt;svg class=&quot;w-4 h-4&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z&quot;&gt;&lt;/path&gt;&lt;/svg&gt;进入编辑模式&lt;/span&gt;&#039; : &#039;&lt;span class=&quot;flex items-center gap-3 text-slate-400&quot;&gt;&lt;svg class=&quot;w-4 h-4&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;&lt;path d=&quot;M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z&quot;&gt;&lt;/path&gt;&lt;/svg&gt;编辑模式 (需登录)&lt;/span&gt;&#039;; document.body.classList.remove(&#039;edit-mode&#039;); if(addCategoryContainer) addCategoryContainer.classList.add(&#039;hidden&#039;); } }

function loadSections() { document.getElementById(&#039;clear-search-button&#039;).classList.add(&#039;hidden&#039;); document.getElementById(&#039;search-input&#039;).value = &#039;&#039;; renderCategorySections({ renderButtons: true }); }

const imgApi = &#039;/api/icon?url=&#039;;

function createCard(link, container) { if (!isEditMode &amp;&amp; link.isPrivate &amp;&amp; !isLoggedIn) return;

const card = document.createElement(&#039;div&#039;);

let cardBaseClass = isAppLayout ? &#039;flex flex-col items-center justify-start py-1 gap-1.5 hover:z-10&#039; : &#039;flex flex-col p-4 bg-white/90 dark:bg-[#1e293b]/60 backdrop-blur-md supports-[backdrop-filter]:bg-white/80 border border-gray-200 dark:border-slate-700/50 hover:border-emerald-500/50 dark:hover:border-emerald-400/50 shadow-sm hover:shadow-[08px20px-6pxrgba(0,0,0,0.1)] dark:shadow-none dark:hover:shadow-[08px20px-6pxrgba(0,0,0,0.4)] hover:-translate-y-1.5&#039;;

if (link.isPrivate &amp;&amp; !isAppLayout) { cardBaseClass += &#039; ring-1 ring-amber-400/40 bg-amber-50/80 dark:bg-amber-900/10 !border-amber-200 dark:!border-amber-700/50&#039;; }

card.className = \group relative h-full w-full rounded-2xl transition-all duration-300 ease-[cubic-bezier(0.25,0.8,0.25,1)] cursor-pointer select-none \${cardBaseClass}\;

if (isEditMode) { card.setAttribute(&#039;draggable&#039;, &#039;true&#039;); card.classList.add(&#039;card&#039;); card.classList.add(&#039;cursor-move&#039;); }

card.dataset.isPrivate = link.isPrivate; card.setAttribute(&#039;data-url&#039;, link.url);

const header = document.createElement(&#039;div&#039;); header.className = isAppLayout ? &#039;flex flex-col items-center justify-center w-full relative&#039; : &#039;flex items-center gap-3 mb-2.5 w-full&#039;;

const icon = document.createElement(&#039;img&#039;); icon.setAttribute(&#039;loading&#039;, &#039;lazy&#039;);

// 图标样式 let iconClass = &#039;&#039;; if (isAppLayout) { // APP 风格:大图标、白底、大圆角、阴影 iconClass = &#039;w-14 h-14 sm:w-16 sm:h-16 rounded-[1.2rem] object-contain bg-white dark:bg-slate-600 p-2 shadow-md hover:shadow-lg transition-transform duration-300 group-hover:scale-105 group-active:scale-95 z-10&#039;; if (link.isPrivate) { iconClass += &#039; ring-2 ring-amber-400&#039;; } } else { // 列表风格:小图标、淡底 iconClass = &#039;w-9 h-9 rounded-lg object-contain bg-slate-100 dark:bg-slate-900 p-1 border border-slate-200 dark:border-slate-700 transition-transform group-hover:scale-105 pointer-events-none&#039;; } icon.className = iconClass;

icon.src = (!link.icon || !link.icon.startsWith(&#039;http&#039;)) ? imgApi + link.url : link.icon; icon.onerror = function() { this.src = &quot;data:image/svg+xml,%3Csvg xmlns=&#039;http://www.w3.org/2000/svg&#039; viewBox=&#039;0 0 24 24&#039; fill=&#039;none&#039; stroke=&#039;%2394a3b8&#039; stroke-width=&#039;2&#039; stroke-linecap=&#039;round&#039; stroke-linejoin=&#039;round&#039;%3E%3Ccircle cx=&#039;12&#039; cy=&#039;12&#039; r=&#039;10&#039;/%3E%3Cline x1=&#039;12&#039; y=&#039;8&#039; x2=&#039;12&#039; y=&#039;12&#039;/%3E%3Cline x1=&#039;12&#039; y=&#039;16&#039; x2=&#039;12.01&#039; y=&#039;16&#039;/%3E%3C/svg%3E&quot;; }; header.appendChild(icon);

const name = document.createElement(&#039;div&#039;); name.className = isAppLayout ? &#039;text-sm font-semibold text-center mt-1.5&#039; : &#039;text-sm font-semibold&#039;; name.textContent = link.name; header.appendChild(name);

card.appendChild(header);

if (!isAppLayout) { const desc = document.createElement(&#039;div&#039;); desc.className = &#039;text-xs text-slate-500 dark:text-slate-400 line-clamp-2 min-h-[1.25rem] card-tip leading-relaxed pointer-events-none&#039;; desc.textContent = link.tips || &#039;&#039;; card.appendChild(desc); }

if (link.isPrivate &amp;&amp; !isAppLayout) { const badge = document.createElement(&#039;div&#039;); badge.className = &#039;absolute top-0 right-0 w-8 h-8 pointer-events-none overflow-hidden rounded-tr-2xl&#039;; badge.innerHTML = &#039;&lt;div class=&quot;absolute top-0 right-0 transform translate-x-1/2 -translate-y-1/2 rotate-45 w-8 h-8 bg-amber-400&quot;&gt;&lt;/div&gt;&#039;; card.appendChild(badge); }

if (isEditMode) { const actionWrapper = document.createElement(&#039;div&#039;); actionWrapper.className = isAppLayout ? &#039;absolute top-[-4px] right-[-4px] z-30&#039; : &#039;absolute top-2 right-2 z-30&#039;;

const menuBtn = document.createElement(&#039;button&#039;); const btnStyle = isAppLayout ? &#039;w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300 shadow-sm hover:bg-emerald-500 hover:text-white&#039; : &#039;w-7 h-7 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100/80 backdrop-blur-sm&#039;;

menuBtn.className = \\${btnStyle} flex items-center justify-center transition-all duration-200\; menuBtn.innerHTML = &#039;&lt;svg class=&quot;w-4 h-4&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot;&gt;&lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;1&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;19&quot; cy=&quot;12&quot; r=&quot;1&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;5&quot; cy=&quot;12&quot; r=&quot;1&quot;&gt;&lt;/circle&gt;&lt;/svg&gt;&#039;;

const dropdown = document.createElement(&#039;div&#039;); dropdown.className = &#039;hidden absolute right-0 top-6 w-28 bg-white dark:bg-[#1e293b] rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/10 overflow-hidden transform origin-top-right transition-all z-50 flex flex-col p-1 card-menu-dropdown&#039;;

dropdown.innerHTML = \ &lt;button class=&quot;menu-edit w-full text-left px-3 py-2 rounded-lg text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-emerald-50 dark:hover:bg-slate-700/50 hover:text-emerald-600 transition-colors flex items-center gap-2&quot;&gt; &lt;svg class=&quot;w-3.5 h-3.5&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z&quot;&gt;&lt;/path&gt;&lt;/svg&gt; 编辑 &lt;/button&gt; &lt;button class=&quot;menu-delete w-full text-left px-3 py-2 rounded-lg text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors flex items-center gap-2&quot;&gt; &lt;svg class=&quot;w-3.5 h-3.5&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; stroke-width=&quot;2&quot; d=&quot;M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16&quot;&gt;&lt;/path&gt;&lt;/svg&gt; 删除 &lt;/button&gt; \;

menuBtn.onclick = (e) =&gt; { e.stopPropagation(); document.querySelectorAll(&#039;.card-menu-dropdown&#039;).forEach(el =&gt; { if (el !== dropdown) el.classList.add(&#039;hidden&#039;); }); dropdown.classList.toggle(&#039;hidden&#039;); };

dropdown.querySelector(&#039;.menu-edit&#039;).onclick = (e) =&gt; { e.stopPropagation(); dropdown.classList.add(&#039;hidden&#039;); showEditDialog(link); };

dropdown.querySelector(&#039;.menu-delete&#039;).onclick = (e) =&gt; { e.stopPropagation(); dropdown.classList.add(&#039;hidden&#039;); removeCard(card); };

actionWrapper.appendChild(menuBtn); actionWrapper.appendChild(dropdown); card.appendChild(actionWrapper); }

if (!isEditMode) { card.onclick = () =&gt; { let url = link.url.startsWith(&#039;http&#039;) ? link.url : &#039;http://&#039; + link.url; window.open(url, &#039;_blank&#039;); }; }

card.addEventListener(&#039;dragstart&#039;, dragStart); card.addEventListener(&#039;dragover&#039;, dragOver); card.addEventListener(&#039;dragend&#039;, dragEnd); card.addEventListener(&#039;drop&#039;, drop);

if (!isEditMode &amp;&amp; link.tips) { card.classList.add(&#039;has-tooltip&#039;); card.setAttribute(&#039;data-tooltip&#039;, link.tips); } else if (!isEditMode &amp;&amp; !link.tips) { // 为没有描述的卡片添加URL作为悬停提示 card.classList.add(&#039;has-tooltip&#039;); card.setAttribute(&#039;data-tooltip&#039;, link.url); }

card.addEventListener(&#039;touchstart&#039;, touchStart, { passive: false });

container.appendChild(card);

if (!window.hasAddedCardMenuListener) { document.addEventListener(&#039;click&#039;, (e) =&gt; { if (!e.target.closest(&#039;.card-menu-dropdown&#039;) &amp;&amp; !e.target.closest(&#039;button&#039;)) { document.querySelectorAll(&#039;.card-menu-dropdown&#039;).forEach(el =&gt; el.classList.add(&#039;hidden&#039;)); } }); window.hasAddedCardMenuListener = true; } }

function updateCategorySelect() { const menu = document.getElementById(&#039;category-select-menu&#039;); menu.innerHTML = &#039;&#039;; Object.keys(categories).forEach(cat =&gt; { const item = document.createElement(&#039;div&#039;); item.className = &#039;px-4 py-2.5 text-sm text-slate-700 dark:text-slate-200 hover:bg-emerald-50 dark:hover:bg-slate-700 cursor-pointer transition-colors&#039;; item.textContent = cat; item.onclick = () =&gt; { document.getElementById(&#039;category-select-value&#039;).value = cat; document.getElementById(&#039;category-select-text&#039;).textContent = cat; menu.classList.add(&#039;hidden&#039;); }; menu.appendChild(item); }); }

async function addCard() { if (!await validateTokenOrRedirect()) return; const name = document.getElementById(&#039;name-input&#039;).value.trim(); const url = document.getElementById(&#039;url-input&#039;).value.trim(); const category = document.getElementById(&#039;category-select-value&#039;).value;

if (!name || !url || !category) { await customAlert(&#039;请填写必要信息 (名称, URL, 分类)&#039;); return; }

const newLink = { name, url, category, tips: document.getElementById(&#039;tips-input&#039;).value.trim(), icon: document.getElementById(&#039;icon-input&#039;).value.trim(), isPrivate: document.getElementById(&#039;private-checkbox&#039;).checked };

try { categories[category].links.push(newLink); await saveLinks(); if (isEditMode || !newLink.isPrivate || isLoggedIn) { renderCategories(); } hideAddDialog(); } catch (e) { await customAlert(&#039;添加失败: &#039; + e); } }

async function updateCard(oldLink) { if (!await validateTokenOrRedirect()) return;

const updatedLink = { name: document.getElementById(&#039;name-input&#039;).value.trim(), url: document.getElementById(&#039;url-input&#039;).value.trim(), tips: document.getElementById(&#039;tips-input&#039;).value.trim(), icon: document.getElementById(&#039;icon-input&#039;).value.trim(), category: document.getElementById(&#039;category-select-value&#039;).value, isPrivate: document.getElementById(&#039;private-checkbox&#039;).checked };

let found = false;

for (const cat in categories) { const idx = categories[cat].links.findIndex(l =&gt; l.url === oldLink.url);

if (idx !== -1) { found = true;

if (cat === updatedLink.category) { categories[cat].links[idx] = updatedLink; } else { categories[cat].links.splice(idx, 1);

if(!categories[updatedLink.category]) { categories[updatedLink.category] = { isHidden:false, links:[] }; } categories[updatedLink.category].links.push(updatedLink); } break; } }

if (!found) { if(!categories[updatedLink.category]) { categories[updatedLink.category] = { isHidden:false, links:[] }; } categories[updatedLink.category].links.push(updatedLink); }

await saveLinks(); renderCategories(); hideAddDialog(); }

async function removeCard(card) { if (!await validateTokenOrRedirect()) return; const url = card.getAttribute(&#039;data-url&#039;); for (const cat in categories) { const idx = categories[cat].links.findIndex(l =&gt; l.url === url); if (idx !== -1) { categories[cat].links.splice(idx, 1); break; } } card.remove(); await saveLinks(); }

// --- 拖拽辅助函数 --- function getCardState(card) { if(!card) return { category: null, index: -1 }; const section = card.closest(&#039;.section&#039;); const index = Array.from(section.querySelectorAll(&#039;.card&#039;)).indexOf(card); return { category: section.id, index: index }; }

// --- 拖拽(电脑端) --- let draggedCard = null; function dragStart(e) { if (!isEditMode) { e.preventDefault(); return; } draggedCard = this; this.classList.add(&#039;dragging&#039;); e.dataTransfer.effectAllowed = &quot;move&quot;; initialDragState = getCardState(this); } function dragOver(e) { if (!isEditMode) return; e.preventDefault(); const target = e.target.closest(&#039;.card&#039;); if (target &amp;&amp; target !== draggedCard) { const container = target.parentElement; const rect = target.getBoundingClientRect(); if (e.clientX &lt; rect.left + rect.width / 2) { container.insertBefore(draggedCard, target); } else { container.insertBefore(draggedCard, target.nextSibling); } } } function dragEnd() { this.classList.remove(&#039;dragging&#039;); } async function drop(e) { if (!isEditMode) return; e.preventDefault(); if (draggedCard) { const newState = getCardState(draggedCard); if (newState.category !== initialDragState.category || newState.index !== initialDragState.index) { updateCardCategory(draggedCard, newState.category); await saveCardOrder(); } draggedCard = null; } }

// 移动端拖拽 let mobileDragTimer = null; let isMobileDragging = false; let mobilePlaceholder = null; let mobileClone = null; let mobileTouchOffset = { x: 0, y: 0 }; let rafId = null; let lastTouchX = 0; let lastTouchY = 0; let lastSwapTime = 0; let activeContainer = null; let cloneWidth = 0; let cloneHeight = 0;

function touchStart(e) { if (!isEditMode) return; if (e.touches.length &gt; 1) return;

const card = e.target.closest(&#039;.card&#039;); if (!card) return;

const touch = e.touches[0]; const startX = touch.clientX; const startY = touch.clientY;

if (mobileDragTimer) clearTimeout(mobileDragTimer);

mobileDragTimer = setTimeout(() =&gt; { isMobileDragging = true; mobilePlaceholder = card; activeContainer = mobilePlaceholder.parentElement;

initialDragState = getCardState(mobilePlaceholder);

const rect = mobilePlaceholder.getBoundingClientRect(); cloneWidth = rect.width; cloneHeight = rect.height;

mobileTouchOffset.x = startX - rect.left; mobileTouchOffset.y = startY - rect.top;

lastTouchX = startX; lastTouchY = startY;

mobileClone = mobilePlaceholder.cloneNode(true);

Object.assign(mobileClone.style, { position: &#039;fixed&#039;, left: rect.left + &#039;px&#039;, top: rect.top + &#039;px&#039;, width: rect.width + &#039;px&#039;, height: rect.height + &#039;px&#039;, zIndex: &#039;9999&#039;, opacity: &#039;0.95&#039;, boxShadow: &#039;0 20px 25px -5px rgba(0, 0, 0, 0.2)&#039;, transform: &#039;scale(1.05)&#039;, transition: &#039;none&#039; }); mobileClone.classList.add(&#039;card-clone-dragging&#039;); mobileClone.classList.remove(&#039;group&#039;, &#039;hover:-translate-y-1&#039;, &#039;transition-all&#039;, &#039;duration-300&#039;); document.body.appendChild(mobileClone);

// 占位符样式 mobilePlaceholder.style.opacity = &#039;0.3&#039;; mobilePlaceholder.classList.add(&#039;border-dashed&#039;, &#039;border-2&#039;, &#039;border-emerald-400&#039;);

if (navigator.vibrate) navigator.vibrate(50);

updatePosition(); }, 500);

document.addEventListener(&#039;touchmove&#039;, handleTouchMove, { passive: false }); document.addEventListener(&#039;touchend&#039;, handleTouchEnd); document.addEventListener(&#039;touchcancel&#039;, handleTouchEnd);

function updatePosition() { if (!isMobileDragging || !mobileClone) return;

const x = lastTouchX - mobileTouchOffset.x; const y = lastTouchY - mobileTouchOffset.y;

mobileClone.style.left = x + &#039;px&#039;; mobileClone.style.top = y + &#039;px&#039;;

rafId = requestAnimationFrame(updatePosition); }

function handleTouchMove(moveEvent) { const moveTouch = moveEvent.touches[0];

if (!isMobileDragging) { const diffX = moveTouch.clientX - startX; const diffY = moveTouch.clientY - startY; const distance = Math.sqrt(diffX diffX + diffY diffY);

if (distance &gt; 10) { clearTimeout(mobileDragTimer); mobileDragTimer = null; }

return; }

if (isMobileDragging) { moveEvent.preventDefault();

lastTouchX = moveTouch.clientX; lastTouchY = moveTouch.clientY;

const now = Date.now(); if (now - lastSwapTime &gt; 30) { detectSort(moveTouch.clientX, moveTouch.clientY); } } }

function detectSort(fingerX, fingerY) { let elementBelow = document.elementFromPoint(fingerX, fingerY); if (!elementBelow) return;

let targetCard = elementBelow.closest(&#039;.card&#039;) || elementBelow.closest(&#039;.add-card-placeholder&#039;); let targetContainer = targetCard ? targetCard.parentElement : elementBelow.closest(&#039;.card-container&#039;);

if (!targetContainer) return;

if (activeContainer !== targetContainer) { activeContainer = targetContainer; const placeholderBtn = activeContainer.querySelector(&#039;.add-card-placeholder&#039;); if (placeholderBtn) { activeContainer.insertBefore(mobilePlaceholder, placeholderBtn); } else { activeContainer.appendChild(mobilePlaceholder); } lastSwapTime = Date.now(); return; }

const containerRect = activeContainer.getBoundingClientRect();

const cloneViewportCenterX = lastTouchX - mobileTouchOffset.x + (cloneWidth / 2); const cloneViewportCenterY = lastTouchY - mobileTouchOffset.y + (cloneHeight / 2);

const cloneRelX = cloneViewportCenterX - containerRect.left + activeContainer.scrollLeft; const cloneRelY = cloneViewportCenterY - containerRect.top + activeContainer.scrollTop;

const siblings = Array.from(activeContainer.children).filter(c =&gt; (c.classList.contains(&#039;card&#039;) || c.classList.contains(&#039;add-card-placeholder&#039;)) &amp;&amp; c !== mobilePlaceholder );

if (siblings.length === 0) return;

let closestElement = null; let minDistance = Infinity;

for (const child of siblings) { const childCenterX = child.offsetLeft + child.offsetWidth / 2; const childCenterY = child.offsetTop + child.offsetHeight / 2;

const dist = Math.hypot(cloneRelX - childCenterX, cloneRelY - childCenterY);

if (dist &lt; minDistance) { minDistance = dist; closestElement = child; } }

if (closestElement) { const positionsBefore = new Map(); const allChildren = Array.from(activeContainer.children).filter(el =&gt; el.classList.contains(&#039;card&#039;) || el.classList.contains(&#039;add-card-placeholder&#039;) ); allChildren.forEach(el =&gt; positionsBefore.set(el, el.getBoundingClientRect()));

const placeholderIndex = allChildren.indexOf(mobilePlaceholder); const targetIndex = allChildren.indexOf(closestElement);

if (targetIndex &gt; placeholderIndex) { activeContainer.insertBefore(mobilePlaceholder, closestElement.nextSibling); } else { activeContainer.insertBefore(mobilePlaceholder, closestElement); }

animateFlip(activeContainer, positionsBefore);

lastSwapTime = Date.now(); if(navigator.vibrate) navigator.vibrate(10); } }

function animateFlip(container, positionsBefore) { const siblings = Array.from(container.children); siblings.forEach(el =&gt; { if (el === mobilePlaceholder) return;

const rectAfter = el.getBoundingClientRect(); const rectBefore = positionsBefore.get(el);

if (rectBefore &amp;&amp; (rectBefore.left !== rectAfter.left || rectBefore.top !== rectAfter.top)) { const dx = rectBefore.left - rectAfter.left; const dy = rectBefore.top - rectAfter.top;

el.style.transition = &#039;none&#039;; el.style.transform = \translate(\${dx}px, \${dy}px)\; el.offsetHeight; el.style.transition = &#039;transform 0.2s cubic-bezier(0.2, 0, 0.2, 1)&#039;; el.style.transform = &#039;&#039;;

setTimeout(() =&gt; { if (el.style.transform === &#039;&#039;) { el.style.transition = &#039;&#039;; } }, 200); } }); }

function handleTouchEnd() { if (mobileDragTimer) { clearTimeout(mobileDragTimer); mobileDragTimer = null; } if (rafId) cancelAnimationFrame(rafId);

if (isMobileDragging) { // 离场动画 if (mobileClone &amp;&amp; mobilePlaceholder) { const rect = mobilePlaceholder.getBoundingClientRect(); mobileClone.style.transition = &#039;all 0.2s ease-out&#039;; mobileClone.style.left = rect.left + &#039;px&#039;; mobileClone.style.top = rect.top + &#039;px&#039;; mobileClone.style.opacity = &#039;0&#039;;

setTimeout(() =&gt; { if (mobileClone) mobileClone.remove(); if (mobilePlaceholder) { mobilePlaceholder.style.opacity = &#039;&#039;; mobilePlaceholder.classList.remove(&#039;border-dashed&#039;, &#039;border-2&#039;, &#039;border-emerald-400&#039;); }

// 保存排序 saveCardOrder();

mobilePlaceholder = null; mobileClone = null; }, 200); } else { if (mobileClone) mobileClone.remove(); if (mobilePlaceholder) mobilePlaceholder.style.opacity = &#039;&#039;; }

document.body.style.overflow = &#039;&#039;; }

isMobileDragging = false; cleanupListeners(); }

function cleanupListeners() { document.removeEventListener(&#039;touchmove&#039;, handleTouchMove); document.removeEventListener(&#039;touchend&#039;, handleTouchEnd); document.removeEventListener(&#039;touchcancel&#039;, handleTouchEnd); } }

function updateCardCategory(card, newCategory) { const url = card.getAttribute(&#039;data-url&#039;); let item = null; for (const cat in categories) { const idx = categories[cat].links.findIndex(l =&gt; l.url === url); if (idx !== -1) { item = categories[cat].links.splice(idx, 1)[0]; break; } } if(item) { item.category = newCategory; categories[newCategory].links.push(item); } }

async function saveCardOrder() { const newCategories = {}; const sections = document.querySelectorAll(&#039;.section&#039;); sections.forEach(sec =&gt; { const catName = sec.id; const oldCat = categories[catName]; newCategories[catName] = { isHidden: oldCat ? oldCat.isHidden : false, links: [] };

const cards = sec.querySelectorAll(&#039;.card&#039;); cards.forEach(c =&gt; { const url = c.getAttribute(&#039;data-url&#039;); const original = Object.values(categories).flatMap(x=&gt;x.links).find(l=&gt;l.url === url); if(original) { original.category = catName; newCategories[catName].links.push(original); } }); });

Object.keys(categories).forEach(k =&gt; delete categories[k]); Object.assign(categories, newCategories); await saveDataToServer(&#039;保存排序&#039;, categories); }

function applyTheme(isDark) { if (isDark) { document.documentElement.classList.add(&#039;dark&#039;); } else { document.documentElement.classList.remove(&#039;dark&#039;); } updateThemeSwitchUI(); }

function updateThemeSwitchUI() { const isDark = document.documentElement.classList.contains(&#039;dark&#039;); const checkbox = document.getElementById(&#039;theme-switch-checkbox&#039;); if(checkbox) checkbox.checked = isDark; }

function scrollToTop() { window.scrollTo({ top: 0, behavior: &#039;smooth&#039; }); }

// 认证和模式 async function toggleEditMode() { document.getElementById(&#039;profile-dropdown&#039;).classList.add(&#039;hidden&#039;); if (!isLoggedIn) { toggleLogin(); return; }

if (!isEditMode) { isEditMode = true; updateUIState();

renderCategories();

// 提示用户 // logAction(&#039;进入编辑模式&#039;, {}); } else { // 退出编辑模式 isEditMode = false; updateUIState(); renderCategories(); } }

async function toggleLogin() { if (!isLoggedIn) { toggleOverlay(&#039;password-dialog-overlay&#039;, true); document.getElementById(&#039;password-input&#039;).focus(); } else { if (await customConfirm(&#039;确定退出登录吗?&#039;)) { logout(); } } }

function showAddDialog() { toggleOverlay(&#039;dialog-overlay&#039;, true); document.getElementById(&#039;name-input&#039;).value = &#039;&#039;; document.getElementById(&#039;url-input&#039;).value = &#039;&#039;; document.getElementById(&#039;tips-input&#039;).value = &#039;&#039;; document.getElementById(&#039;icon-input&#039;).value = &#039;&#039;; document.getElementById(&#039;private-checkbox&#039;).checked = false;

document.getElementById(&#039;category-select-value&#039;).value = &#039;&#039;; document.getElementById(&#039;category-select-text&#039;).textContent = &#039;请选择分类&#039;;

const btn = document.getElementById(&#039;dialog-confirm-btn&#039;); const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); newBtn.onclick = addCard;

document.getElementById(&#039;dialog-cancel-btn&#039;).onclick = hideAddDialog; }

function showEditDialog(link) { toggleOverlay(&#039;dialog-overlay&#039;, true); document.getElementById(&#039;name-input&#039;).value = link.name; document.getElementById(&#039;url-input&#039;).value = link.url; document.getElementById(&#039;tips-input&#039;).value = link.tips || &#039;&#039;; document.getElementById(&#039;icon-input&#039;).value = link.icon || &#039;&#039;; document.getElementById(&#039;private-checkbox&#039;).checked = link.isPrivate;

document.getElementById(&#039;category-select-value&#039;).value = link.category; document.getElementById(&#039;category-select-text&#039;).textContent = link.category;

const btn = document.getElementById(&#039;dialog-confirm-btn&#039;); const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); newBtn.onclick = () =&gt; updateCard(link);

document.getElementById(&#039;dialog-cancel-btn&#039;).onclick = hideAddDialog; }

function hideAddDialog() { toggleOverlay(&#039;dialog-overlay&#039;, false); }

document.getElementById(&#039;password-confirm-btn&#039;).onclick = async () =&gt; { const pwd = document.getElementById(&#039;password-input&#039;).value; if(!pwd) return; try { const res = await fetch(&#039;/api/login&#039;, { method: &#039;POST&#039;, headers: {&#039;Content-Type&#039;: &#039;application/json&#039;}, body: JSON.stringify({password: pwd}) }); const data = await res.json(); if(data.valid) { localStorage.setItem(&#039;authToken&#039;, data.token); isLoggedIn = true; toggleOverlay(&#039;password-dialog-overlay&#039;, false); await loadLinks(); await customAlert(&#039;登录成功&#039;); } else { await customAlert(&#039;密码错误&#039;); } } catch(e) { await customAlert(&#039;Login Error&#039;); } }

async function fetchWithAuth(url, options = {}) { const token = localStorage.getItem(&#039;authToken&#039;); const headers = options.headers || {}; headers.Authorization = token; options.headers = headers;

let res = await fetch(url, options);

if (res.status === 401) { try { const refreshRes = await fetch(&#039;/api/refreshToken&#039;, { method: &#039;POST&#039;, credentials: &#039;include&#039; });

if (refreshRes.ok) { const refreshData = await refreshRes.json(); localStorage.setItem(&#039;authToken&#039;, refreshData.accessToken); headers.Authorization = refreshData.accessToken; options.headers = headers; res = await fetch(url, options); } else { throw new Error(&#039;Refresh token expired&#039;); } } catch (refreshError) { localStorage.removeItem(&#039;authToken&#039;); isLoggedIn = false; toggleOverlay(&#039;password-dialog-overlay&#039;, true); await customAlert(&#039;登录已过期,请重新登录&#039;); throw new Error(&#039;Unauthorized&#039;); } }

return res; };

document.getElementById(&#039;password-cancel-btn&#039;).onclick = () =&gt; { toggleOverlay(&#039;password-dialog-overlay&#039;, false); };

function showCategoryDialog(title, defaultVal = &#039;&#039;) { return new Promise(resolve =&gt; { toggleOverlay(&#039;category-dialog&#039;, true); document.getElementById(&#039;category-dialog-title&#039;).innerText = title; const input = document.getElementById(&#039;category-name-input&#039;); input.value = defaultVal; input.focus();

const close = (val) =&gt; { toggleOverlay(&#039;category-dialog&#039;, false); document.getElementById(&#039;category-confirm-btn&#039;).onclick = null; document.getElementById(&#039;category-cancel-btn&#039;).onclick = null; resolve(val); };

document.getElementById(&#039;category-confirm-btn&#039;).onclick = () =&gt; close(input.value.trim()); document.getElementById(&#039;category-cancel-btn&#039;).onclick = () =&gt; close(null); }); }

function customConfirm(msg) { return new Promise(resolve =&gt; { toggleOverlay(&#039;custom-confirm-overlay&#039;, true); document.getElementById(&#039;custom-confirm-message&#039;).innerText = msg;

const close = (val) =&gt; { toggleOverlay(&#039;custom-confirm-overlay&#039;, false); document.getElementById(&#039;custom-confirm-ok&#039;).onclick = null; document.getElementById(&#039;custom-confirm-cancel&#039;).onclick = null; resolve(val); }; document.getElementById(&#039;custom-confirm-ok&#039;).onclick = () =&gt; close(true); document.getElementById(&#039;custom-confirm-cancel&#039;).onclick = () =&gt; close(false); }); }

function customAlert(msg) { return new Promise(resolve =&gt; { toggleOverlay(&#039;custom-alert-overlay&#039;, true); document.getElementById(&#039;custom-alert-content&#039;).innerText = msg; document.getElementById(&#039;custom-alert-confirm&#039;).onclick = () =&gt; { toggleOverlay(&#039;custom-alert-overlay&#039;, false); resolve(); } }); }

function setupTooltipDelegation() { const tooltip = document.getElementById(&#039;custom-tooltip&#039;); let activeTarget = null;

document.body.addEventListener(&#039;mousemove&#039;, (e) =&gt; { const target = e.target.closest(&#039;.has-tooltip&#039;);

if (target) { const text = target.getAttribute(&#039;data-tooltip&#039;); if (text) { activeTarget = target; showTooltip(e, text); } else { hideTooltip(); } } else { if (activeTarget) { hideTooltip(); activeTarget = null; } } });

window.addEventListener(&#039;scroll&#039;, hideTooltip, { passive: true }); }

function showTooltip(e, text) { const tooltip = document.getElementById(&#039;custom-tooltip&#039;); tooltip.textContent = text; tooltip.classList.remove(&#039;hidden&#039;);

const offset = 12; let left = e.clientX + offset; let top = e.clientY + offset;

const tooltipRect = tooltip.getBoundingClientRect();

if (left + tooltipRect.width &gt; window.innerWidth) { left = e.clientX - tooltipRect.width - offset; } if (top + tooltipRect.height &gt; window.innerHeight) { top = e.clientY - tooltipRect.height - offset; }

tooltip.style.left = left + &#039;px&#039;; tooltip.style.top = top + &#039;px&#039;; }

function hideTooltip() { const tooltip = document.getElementById(&#039;custom-tooltip&#039;); if (tooltip &amp;&amp; !tooltip.classList.contains(&#039;hidden&#039;)) { tooltip.classList.add(&#039;hidden&#039;); } }

async function backupUserData() { try { const res = await fetchWithAuth(&#039;/api/backupData&#039;, { method: &#039;POST&#039;, headers: { &#039;Content-Type&#039;: &#039;application/json&#039; }, body: JSON.stringify({}), }); if(res.status === 401) { logout(); await customAlert(&#039;登录凭证已过期,请重新登录&#039;); return false; } const d = await res.json(); return d.success; } catch(e) { return false; } }

async function reloadCardsAsAdmin() { await loadLinks(); }

async function validateTokenOrRedirect() { const valid = await validateToken(); if(!valid) { logout(); await customAlert(&#039;登录凭证已过期,请重新登录&#039;); return false; } return true; }

async function validateToken() { const t = localStorage.getItem(&#039;authToken&#039;); if(!t) return false; try { const r = await fetchWithAuth(&#039;/api/validateToken&#039;); return r.status === 200; } catch(e) { return false; } }

function logout() { localStorage.removeItem(&#039;authToken&#039;); isLoggedIn = false; isEditMode = false; location.reload(); }

async function exportData() { if(!await validateTokenOrRedirect()) return; if(!await customConfirm(&quot;确定要导出数据吗?&quot;)) return;

try { const res = await fetchWithAuth(&quot;/api/exportData&quot;, { method: &quot;POST&quot; });

if (res.status === 401) { logout(); await customAlert(&#039;登录凭证已过期,请重新登录&#039;); return; }

if (!res.ok) throw new Error(&quot;Export failed&quot;); const data = await res.json(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: &quot;application/json&quot; }); const url = URL.createObjectURL(blob); const a = document.createElement(&quot;a&quot;); a.href = url; a.download = &quot;navexport&quot; + new Date().toISOString().split(&quot;T&quot;)[0] + &quot;.json&quot;; document.body.appendChild(a); a.click(); document.body.removeChild(a); } catch(e) { if(e.message !== &#039;Unauthorized&#039;) await customAlert(&quot;导出失败&quot;); } }

async function importData() { if(!await validateTokenOrRedirect()) return; if(!await customConfirm(&quot;确定要导入数据吗?导入将覆盖现有数据!&quot;)) return;

const fileInput = document.getElementById(&#039;import-file-input&#039;); fileInput.value = &#039;&#039;;

fileInput.onchange = async (e) =&gt; { const file = e.target.files[0]; if (!file) return;

try { const reader = new FileReader(); reader.onload = async (event) =&gt; { try { const data = JSON.parse(event.target.result); const res = await fetchWithAuth(&quot;/api/importData&quot;, { method: &quot;POST&quot;, headers: { &quot;Content-Type&quot;: &quot;application/json&quot; }, body: JSON.stringify(data) });

if (res.status === 401) { logout(); await customAlert(&#039;登录凭证已过期,请重新登录&#039;); return; }

if (!res.ok) throw new Error(&quot;Import failed&quot;);

await customAlert(&#039;数据导入成功!&#039;); location.reload(); } catch (error) { console.error(&quot;解析文件失败:&quot;, error); await customAlert(&#039;文件格式错误,请检查文件内容!&#039;); } }; reader.readAsText(file); } catch (error) { console.error(&quot;导入失败:&quot;, error); await customAlert(&#039;数据导入失败,请重试!&#039;); } }; fileInput.click(); }

&lt;/script&gt; &lt;/body&gt; &lt;/html&gt;

;

const DEFAULT_USER = &#039;testUser&#039;; const DEFAULT_IMGAPI = &#039;https://api.xinac.net/icon/?url=&#039;; let USEDEFAULTIMGAPI = true;

function base64UrlEncode(str) { return btoa(str).replace(/\+/g, &#039;-&#039;).replace(/\//g, &#039;_&#039;).replace(/=+$/, &#039;&#039;); }

function base64UrlEncodeUint8(arr) { const str = String.fromCharCode(...arr); return base64UrlEncode(str); }

function base64UrlDecode(str) { str = str.replace(/-/g, &#039;+&#039;).replace(/_/g, &#039;/&#039;); while (str.length % 4) str += &#039;=&#039;; return atob(str); }

async function createJWT(payload, secret) { const encoder = new TextEncoder(); const header = { alg: &#039;HS256&#039;, typ: &#039;JWT&#039; }; const headerEncoded = base64UrlEncode(JSON.stringify(header)); const payloadEncoded = base64UrlEncode(JSON.stringify(payload)); const toSign = encoder.encode(${headerEncoded}.${payloadEncoded});

const key = await crypto.subtle.importKey( &#039;raw&#039;, encoder.encode(secret), { name: &#039;HMAC&#039;, hash: &#039;SHA-256&#039; }, false, [&#039;sign&#039;] );

const signature = await crypto.subtle.sign(&#039;HMAC&#039;, key, toSign); const signatureEncoded = base64UrlEncodeUint8(new Uint8Array(signature));

return ${headerEncoded}.${payloadEncoded}.${signatureEncoded}; }

async function validateJWT(token, secret) { try { const encoder = new TextEncoder(); const parts = token.split(&#039;.&#039;); if (parts.length !== 3) return null;

const [headerEncoded, payloadEncoded, signature] = parts; const data = encoder.encode(${headerEncoded}.${payloadEncoded});

const key = await crypto.subtle.importKey( &#039;raw&#039;, encoder.encode(secret), { name: &#039;HMAC&#039;, hash: &#039;SHA-256&#039; }, false, [&#039;sign&#039;] );

const expectedSigBuffer = await crypto.subtle.sign(&#039;HMAC&#039;, key, data); const expectedSig = base64UrlEncodeUint8(new Uint8Array(expectedSigBuffer));

if (signature !== expectedSig) return null;

const payloadStr = base64UrlDecode(payloadEncoded); return JSON.parse(payloadStr); } catch (e) { return null; } }

function parseCookie(cookieHeader) { const cookies = {}; if (!cookieHeader) return cookies; cookieHeader.split(&#039;;&#039;).forEach(cookie =&gt; { const [name, value] = cookie.trim().split(&#039;=&#039;); cookies[name] = decodeURIComponent(value); }); return cookies; }

async function validateServerToken(authHeader, env) { if (!authHeader || !authHeader.startsWith(&#039;Bearer &#039;)) { return { isValid: false, status: 401, response: { error: &#039;Unauthorized&#039;, message: &#039;未登录&#039; } }; } const token = authHeader.slice(7);

const payload = await validateJWT(token, env.JWT_SECRET);

if (!payload) { return { isValid: false, status: 401, response: { error: &#039;Invalid&#039;, message: &#039;Token无效&#039; } }; }

if (payload.exp &lt; Math.floor(Date.now() / 1000)) { return { isValid: false, status: 401, response: { error: &#039;Expired&#039;, message: &#039;Token过期&#039; } }; }

if (payload.type !== &#039;access&#039;) { return { isValid: false, status: 403, response: { error: &#039;Forbidden&#039;, message: &#039;令牌类型错误&#039; } }; }

return { isValid: true, payload }; }

function normalizeCategories(categories) { for (const key in categories) { if (Array.isArray(categories[key])) { categories[key] = { isHidden: false, links: categories[key] }; } } return categories; }

const corsHeaders = { &#039;Access-Control-Allow-Origin&#039;: &#039;*&#039;, &#039;Access-Control-Allow-Methods&#039;: &#039;GET, POST, OPTIONS&#039;, &#039;Access-Control-Allow-Headers&#039;: &#039;Content-Type, Authorization, Cookie&#039;, &#039;Access-Control-Allow-Credentials&#039;: &#039;true&#039; };

async function fetchBestIcon(targetUrl) { const headers = { &#039;User-Agent&#039;: &#039;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&#039;, &#039;Accept&#039;: &#039;text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8&#039;, };

try { const controller = new AbortController(); const timeoutId = setTimeout(() =&gt; controller.abort(), 5000);

const response = await fetch(targetUrl, { headers: headers, redirect: &#039;follow&#039;, signal: controller.signal }); clearTimeout(timeoutId);

if (!response.ok) throw new Error(&#039;Site unreachable&#039;);

let iconUrl = null;

const rewriter = new HTMLRewriter() .on(&#039;link[rel=&quot;apple-touch-icon&quot;]&#039;, { element(e) { if (!iconUrl) { const href = e.getAttribute(&#039;href&#039;); if (href) iconUrl = href; } } }) .on(&#039;link[rel~=&quot;icon&quot;]&#039;, { element(e) { if (!iconUrl) { const href = e.getAttribute(&#039;href&#039;); if (href) iconUrl = href; } } });

await rewriter.transform(response).text();

let finalUrl; if (iconUrl) { finalUrl = new URL(iconUrl, targetUrl).toString(); } else { finalUrl = new URL(&#039;/favicon.ico&#039;, targetUrl).toString(); }

const iconResponse = await fetch(finalUrl, { headers: headers });

if (iconResponse.ok &amp;&amp; iconResponse.headers.get(&#039;content-type&#039;)?.includes(&#039;image&#039;)) { return iconResponse; }

throw new Error(&#039;Icon fetch failed&#039;);

} catch (e) { } return null; }

async function handleIconProxy(request, ctx) { const url = new URL(request.url); const targetUrl = url.searchParams.get(&#039;url&#039;);

if (!targetUrl) return new Response(&#039;Missing URL&#039;, { status: 400 });

const cacheKey = new Request(url.toString(), request); const cache = caches.default;

let response = await cache.match(cacheKey);

if (response) { response = new Response(response.body, response); response.headers.set(&#039;X-Icon-Cache-Status&#039;, &#039;HIT&#039;); } else { let upstreamResponse = null; if (USEDEFAULTIMGAPI) { const upstreamApi = ${DEFAULT_IMGAPI}${encodeURIComponent(targetUrl)}; upstreamResponse = await fetch(upstreamApi, { headers: { &#039;User-Agent&#039;: &#039;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&#039; } }); } else { upstreamResponse = await fetchBestIcon(targetUrl); } if (upstreamResponse) { response = new Response(upstreamResponse.body, upstreamResponse); response.headers.set(&#039;Cache-Control&#039;, &#039;public, max-age=604800, s-maxage=604800&#039;); response.headers.set(&#039;Access-Control-Allow-Origin&#039;, &#039;*&#039;); response.headers.set(&#039;X-Icon-Cache-Status&#039;, &#039;MISS&#039;); ctx.waitUntil(cache.put(cacheKey, response.clone())); } else { const defaultSVG = &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;32&quot; height=&quot;32&quot; viewBox=&quot;0 0 64 64&quot;&gt; &lt;path fill=&quot;#000000&quot; d=&quot;M62 32C62 15.432 48.568 2 32 2C15.861 2 2.703 14.746 2.031 30.72c-.008.196-.01.395-.014.592c-.005.23-.017.458-.017.688v.101C2 48.614 15.432 62 32 62s30-13.386 30-29.899l-.002-.049zM37.99 59.351c-.525-.285-1.029-.752-1.234-1.388c-.371-1.152-.084-2.046.342-3.086c.34-.833-.117-1.795.109-2.667c.441-1.697.973-3.536.809-5.359c-.102-1.119-.35-1.17-1.178-1.816c-.873-.685-.873-1.654-1.457-2.52c-.529-.787.895-3.777.498-3.959c-.445-.205-1.457.063-1.777-.362c-.344-.458-.584-.999-1.057-1.354c-.305-.229-1.654-.995-2.014-.941c-1.813.271-3.777-1.497-4.934-2.65c-.797-.791-1.129-1.678-1.713-2.593c-.494-.775-1.242-.842-1.609-1.803c-.385-1.004-.156-2.29-.273-3.346c-.127-1.135-.691-1.497-1.396-2.365c-1.508-1.863-2.063-4.643-4.924-4.643c-1.537 0-1.428 3.348-2.666 2.899c-1.4-.507-3.566 1.891-3.535 1.568c.164-1.674 1.883-2.488 2.051-2.987c.549-1.638-2.453-1.246-2.068-2.612c.188-.672 2.098-1.161 1.703-1.562c-.119-.122-1.58-1.147-1.508-1.198c.271-.19 1.449.412 1.193-.37c-.086-.26-.225-.499-.357-.74a28 28 0 0 1 1.92-1.975c1.014-.083 2.066-.02 2.447.054c2.416.476 3.256 1.699 5.672.794c1.162-.434 5.445.319 6.059 1.537c.334.666 1.578-.403 2.063-.475c.52-.078 1.695.723 2.053.232c.943-1.291-.604-1.827 1.223-.833c1.225.667 3.619-2.266 2.861 1.181c-.547 2.485-2.557 2.54-4.031 4.159c-1.451 1.594 2.871 2.028 2.982 3.468c.32 4.146 2.531-.338 1.939-1.812c-1.145-2.855 1.303-2.071 2.289-.257c.547 1.007.963.159 1.633-.192c.543-.283.688 1.25.805 1.517c.385.887 1.65 1.152 1.436 2.294c-.238 1.259-1.133.881-2.008 1.094c-.977.237.158 1.059.016 1.359c-.154.328-1.332.464-1.646.65c-.924.544-.359 1.605-1.082 2.175c-.496.392-.996.137-1.092.871c-.113.865-1.707 1.143-1.5 1.97c.057.227.516 1.923.227 2.013c-.133.043-1.184-1.475-1.471-1.627c-.568-.301-3.15-.055-3.482 1.654c-.215 1.105 1.563 2.85 2.016 1.328c.561-1.873.828 1.091.693 1.207c.268.234 1.836-.385 1.371.7c-.197.459.193 1.656.889 1.287c.291-.154 1.041.31 1.172.061a2.14 2.14 0 0 1 .742-.692c.701-.41 1.75-.025 2.518.02c.469.027 4.313 2.124 4.334 2.545c.084 1.575 2.99 1.37 3.436 1.933c1.199 1.526.83.751-.045 2.706c-.441.984-.057 2.191-1.125 2.904c-.514.342-1.141.171-1.598.655c-.412.437-.25.959-.5 1.464c-.301.601-4.346 4.236-4.613 5.115c-.133.441-1.34.825-.322 1.248c.592.174-1.311 1.973-.396 2.718c.223.181.369.334.479.471c-.457.122-.91.233-1.369.333M35.594 4.237c-.039.145.02.316.271.483c.566.375-.162 1.208-.943.671c-.779-.537-2.531.241-2.41.644c.119.403.66.563 1.496.242c.834-.322 1.178.048 1.318.43c.096.259 0 .403-.027.752c-.025.349-.996.107-1.803.162c-.809.054-1.67-.162-1.645-.619c.027-.456-.861-1.289-1.391-1.637c-.529-.348.232-1.1.934-.537c.699.564.727-.107 1.535-.321c.459-.122.275-.305.119-.479q1.29.047 2.546.209m3.517 8.869c.605.164 1.656.929 1.656 1.291c0 .363-.477.817-.688.765c-1.523-.371-2.807-1.874-3.514-2.697c-1.234-1.435-1.156-.205-3.111-.826c-.5-.16-1.293-1.711-.768-2.476s1.131-.886 1.615-.683c.484.2 1.898-.645 2.223.362c.322 1.007 1.211 2.292 2.02 2.636c.81.342-.04 1.464.567 1.628m.485 4.673c.242.483-1.455-.564-1.859-1.047c-.402-.482-1.01-1.571-.523-2.054c.484-.482 1.57 1.005 2.141 1.33c1.129.645-.001 1.289.241 1.771m-8.594-7.315c.117-.161.365.242.586.645s-.084.971-.586.885c-.502-.084-.281-1.136 0-1.53m0-4.052s.473 1.154 0 .966s-.496-.671 0-.966m.096 3.65c-.135-.321-.166-1.64.162-2.04c.484-.59 1.266.564.74 1.02c-.525.457-.768 1.343-.902 1.02m-6.077 1.415c-.879-.063-.898-.823-1.02-1.226s-.85.765-1.586 0s.172-1.771.01-2.376c-.162-.604 1.736 0 2.02 0s1.051 1.248 1.252 1.227c.203-.02 1.293.987 1.293.584c0-.402.166-1.088.93-1.168c1.172-.121.121 1.289.08 1.838c-.039.549.891 1.504 1.232 1.907c.344.403-.867.686-1.07.443c-.201-.242-.727 0-1.172.322c-.443.322-1.656-.443-2.221-.685c-.566-.241 1.131-.804.252-.866m3.141-6.354c.781.269 1.225.51 1.609 0c.371-.492.654 1.073.385 1.502c-.27.431-.781.324-.863 0c-.08-.32-1.912-1.771-1.131-1.502m1.131 4.859c-.268-.35-.295-.752 0-1.047c.297-.295.201-.644.729-.751c.26-.054.295.348.295.724s.324.859 0 1.448c-.323.589-.754-.026-1.024-.374m2.205-5.969c-.012.074-.061.118-.184.106a.6.6 0 0 1-.236-.095q.21-.008.42-.011M25.389 5.15c.619 0 .539.418 1.051.719c.512.3.242-1.552.592-.854c.35.697 1.389 1.664.889 1.851c-.43.163-2.234.859-2.396.739s-.377-.63-.809-.739c-.432-.107-.889-1.127-1.186-1.1c-.113.01-.123-.184-.049-.442a28 28 0 0 1 1.572-.455c.058.158.146.281.336.281m13.519 30.025c-.645.666-1.756-.464-2.523-.424s-1.152-.765-1.818-.684c-.668.079.182-.847 1.111-.362c.927.483 3.756.925 3.23 1.47m12.93-22.934c-.188.24-.402.408-.607.585c-.605.524-1.736.484-1.898.846s-.566 1.489-1.98 1.494s-1.01 2.131-1.131 2.738s-.443 1.325-.848.801s-.566-.323-1.816-1.853s-.77-2.375-.365-2.818c.404-.442.566-1.49 0-1.329s-.889-.202-.768-.703s.727-.867 0-1.402s-.324-2.445-.889-4.189c-.566-1.745-1.334-.51-2.586-.443s-1.455-.873-.889-1.303a27.95 27.95 0 0 1 13.777 7.576&quot;/&gt; &lt;/svg&gt;;

response = new Response(defaultSVG, { status: 200, headers: { &#039;Content-Type&#039;: &#039;image/svg+xml&#039;, &#039;Cache-Control&#039;: &#039;public, max-age=3600&#039; } }); response.headers.set(&#039;X-Icon-Cache-Status&#039;, &#039;DEFAULT&#039;); } response.headers.set(&#039;Access-Control-Allow-Origin&#039;, &#039;*&#039;);

}

return response; }

const MINBACKUPINTERVAL_MS = 10 60 1000;

async function handleSmartBackup(env, currentData) { try { const list = await env.CARDORDER.list({ prefix: backup${DEFAULTUSER} }); let keys = list.keys;

keys.sort((a, b) =&gt; a.name.localeCompare(b.name));

let shouldBackup = true;

if (keys.length &gt; 0) { const lastBackupMeta = keys[keys.length - 1].metadata; if (lastBackupMeta &amp;&amp; lastBackupMeta.timestamp) { const timeDiff = Date.now() - lastBackupMeta.timestamp; if (timeDiff &lt; MINBACKUPINTERVAL_MS) { shouldBackup = false; } } }

if (shouldBackup) { const now = Date.now(); const date = new Date(now + 8 3600 1000); const dateStr = date.toISOString().replace(/[:.]/g, &#039;-&#039;); const backupKey = backup${DEFAULTUSER}_${dateStr};

await env.CARD_ORDER.put(backupKey, currentData, { metadata: { timestamp: now } });

if (keys.length &gt;= 10) { const deleteCount = keys.length + 1 - 10; if(deleteCount &gt; 0) { const toDelete = keys.slice(0, deleteCount); for (const key of toDelete) { await env.CARD_ORDER.delete(key.name); } } } } } catch (e) { console.error(&quot;Smart backup failed:&quot;, e); } }

export default { async fetch(request, env, ctx) { const url = new URL(request.url);

if (request.method === &#039;OPTIONS&#039;) { return new Response(null, { headers: corsHeaders }); }

if (url.pathname === &#039;/api/icon&#039;) { return handleIconProxy(request, ctx); }

if (url.pathname === &#039;/&#039;) { return new Response(HTML_CONTENT, { headers: { &#039;Content-Type&#039;: &#039;text/html&#039; } }); }

if (url.pathname === &#039;/api/login&#039; &amp;&amp; request.method === &#039;POST&#039;) { try { const { password } = await request.json(); if (password !== env.ADMIN_PASSWORD) throw new Error(&#039;Password mismatch&#039;);

const currentTime = Math.floor(Date.now() / 1000);

const accessTokenPayload = { iat: currentTime, exp: currentTime + 7200, role: &#039;admin&#039;, type: &#039;access&#039; }; const accessToken = await createJWT(accessTokenPayload, env.JWT_SECRET);

const refreshTokenPayload = { iat: currentTime, exp: currentTime + 2592000, role: &#039;admin&#039;, type: &#039;refresh&#039; }; const refreshToken = await createJWT(refreshTokenPayload, env.JWT_SECRET);

const response = new Response(JSON.stringify({ valid: true, token: Bearer ${accessToken} }), { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039; } });

response.headers.append(&#039;Set-Cookie&#039;, refreshToken=${refreshToken}; HttpOnly; Secure; SameSite=Strict; Path=/api/refreshToken; Max-Age=2592000);

return response; } catch (e) { return new Response(JSON.stringify({ valid: false, error: &#039;Auth failed&#039; }), { status: 403, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039; } }); } }

if (url.pathname === &#039;/api/refreshToken&#039; &amp;&amp; request.method === &#039;POST&#039;) { try { const cookies = parseCookie(request.headers.get(&#039;Cookie&#039;)); const refreshToken = cookies.refreshToken;

if (!refreshToken) { return new Response(JSON.stringify({ error: &#039;Refresh token missing&#039; }), { status: 401, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039; } }); }

const payload = await validateJWT(refreshToken, env.JWT_SECRET); const currentTime = Math.floor(Date.now() / 1000);

if (!payload || payload.exp &lt; currentTime) { return new Response(JSON.stringify({ error: &#039;Refresh token expired&#039; }), { status: 401, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039; } }); }

if (payload.type !== &#039;refresh&#039;) { return new Response(JSON.stringify({ error: &#039;Invalid token type&#039; }), { status: 400, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039; } }); }

const newAccessTokenPayload = { iat: currentTime, exp: currentTime + 7200, role: &#039;admin&#039;, type: &#039;access&#039; }; const newAccessToken = await createJWT(newAccessTokenPayload, env.JWT_SECRET);

const newRefreshTokenPayload = { iat: currentTime, exp: currentTime + 2592000, role: &#039;admin&#039;, type: &#039;refresh&#039; }; const newRefreshToken = await createJWT(newRefreshTokenPayload, env.JWT_SECRET);

const response = new Response(JSON.stringify({ accessToken: Bearer ${newAccessToken} }), { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039; } });

response.headers.append(&#039;Set-Cookie&#039;, refreshToken=${newRefreshToken}; HttpOnly; Secure; SameSite=Strict; Path=/api/refreshToken; Max-Age=2592000);

return response; } catch (e) { return new Response(JSON.stringify({ error: &#039;Internal server error&#039; }), { status: 500, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039; } }); } }

if (url.pathname === &#039;/api/validateToken&#039;) { const validation = await validateServerToken(request.headers.get(&#039;Authorization&#039;), env); return new Response(JSON.stringify(validation.isValid ? { valid: true } : validation.response), { status: validation.status || 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039; } }); }

if (url.pathname === &#039;/api/getLinks&#039;) { const authToken = request.headers.get(&#039;Authorization&#039;); const dataStr = await env.CARDORDER.get(DEFAULTUSER);

if (dataStr) { const parsedData = JSON.parse(dataStr); const normalizedCategories = normalizeCategories(parsedData.categories || {}); let isAuthorized = false;

if (authToken) { const validation = await validateServerToken(authToken, env); if (validation.isValid) { isAuthorized = true; } }

if (isAuthorized) { return new Response(JSON.stringify(parsedData), { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); }

const filteredCategories = {}; for (const cat in normalizedCategories) { const catData = normalizedCategories[cat]; if (!catData.isHidden) { const publicLinks = (catData.links || []).filter(l =&gt; !l.isPrivate); if (publicLinks.length &gt; 0) { filteredCategories[cat] = { ...catData, links: publicLinks }; } } } return new Response(JSON.stringify({ categories: filteredCategories }), { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); } return new Response(JSON.stringify({ categories: {} }), { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); }

if (url.pathname === &#039;/api/saveData&#039; &amp;&amp; request.method === &#039;POST&#039;) { const validation = await validateServerToken(request.headers.get(&#039;Authorization&#039;), env); if (!validation.isValid) return new Response(JSON.stringify(validation.response), { status: validation.status, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} });

try { const { categories } = await request.json();

const currentData = await env.CARDORDER.get(DEFAULTUSER);

if (currentData) { ctx.waitUntil(handleSmartBackup(env, currentData)); }

await env.CARDORDER.put(DEFAULTUSER, JSON.stringify({ categories }));

return new Response(JSON.stringify({ success: true }), { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); } catch (e) { return new Response(JSON.stringify({ error: &#039;Bad Request&#039; }), { status: 400, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); } }

if (url.pathname === &#039;/api/backupData&#039; &amp;&amp; request.method === &#039;POST&#039;) { const validation = await validateServerToken(request.headers.get(&#039;Authorization&#039;), env); if (!validation.isValid) return new Response(JSON.stringify(validation.response), { status: validation.status, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} });

const sourceData = await env.CARDORDER.get(DEFAULTUSER);

if(sourceData) { const now = Date.now(); const date = new Date(now + 8 3600 1000); const dateStr = date.toISOString().replace(/[:.]/g, &#039;-&#039;); await env.CARDORDER.put(backup${DEFAULTUSER}${dateStr}, sourceData, { metadata: { timestamp: now } });

return new Response(JSON.stringify({ success: true }), { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); } return new Response(JSON.stringify({ success: false, error: &#039;User data not found&#039; }), { status: 404, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); }

if (url.pathname === &#039;/api/exportData&#039; &amp;&amp; request.method === &#039;POST&#039;) { const validation = await validateServerToken(request.headers.get(&#039;Authorization&#039;), env); if (!validation.isValid) return new Response(JSON.stringify(validation.response), { status: validation.status, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} });

const data = await env.CARDORDER.get(DEFAULTUSER); return new Response(data || &#039;{}&#039;, { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); }

if (url.pathname === &#039;/api/importData&#039; &amp;&amp; request.method === &#039;POST&#039;) { const validation = await validateServerToken(request.headers.get(&#039;Authorization&#039;), env); if (!validation.isValid) return new Response(JSON.stringify(validation.response), { status: validation.status, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} });

const body = await request.json();

const cleanData = { categories: body.categories || {} };

await env.CARDORDER.put(DEFAULTUSER, JSON.stringify(cleanData)); return new Response(JSON.stringify({ success: true }), { status: 200, headers: { ...corsHeaders, &#039;Content-Type&#039;: &#039;application/json&#039;} }); }

return new Response(&#039;Not Found&#039;, { status: 404, headers: corsHeaders }); } };