• Evan

    OP
    博主
  • #1
  • 已编辑

现在这个版本是我自己在用的,作为静态版本已经算是不错的选择了。颜值方面还算可以,功能简单易用,主打一个手机看着也清楚。
不过遗憾的是即使部署在服务器端,也解决不了数据存储问题,现在的数据是存储在浏览器中,换一个浏览器或电脑,就只能导入数据。这个就很麻烦。
我本来想,让它们链接服务器上的数据库,直接解决这个问题。
自己非技术,试过node.js+mysql,也试过直接做成wp的主题,还试过直接链接mysql。
基本都是在数据库这里卡着了,我也不弄了。

我把我自己用的最后一版开放,有兴趣的可以试着解决一下,对于搞技术的不难。

Image description

文件结构:

EvanNav/
├── index.html
├── script.js
├── styles.css
├── favicon.ico
└── logo.png

1. index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Website Favorites</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
    <link rel="stylesheet" href="styles.css">
    <link rel="icon" href="favicon.ico" type="image/x-icon">
</head>
<body>
    <!-- 前台界面 -->
    <div class="frontend">
        <div class="nav-header">
            <div class="logo-container">
                <img id="website-logo" src="" alt="My Website Favorites Logo">
                <h1 id="website-title">My Website Favorites</h1>
            </div>
        </div>

        <!-- 调整后台管理按钮的位置 -->
        <div class="admin-button-container">
            <button class="btn-primary admin-button-small" onclick="showAdmin()">管理后台</button>
        </div>

        <div class="nav-categories" id="category-filters">
            <button class="category-btn active" data-category="all">全部</button>
        </div>

        <ul class="nav-list" id="nav-links"></ul>

        <!-- 分页 -->
        <div class="pagination-container">
            <div class="pagination-controls">
                <button class="btn-secondary" id="prev-page" onclick="prevPage()" disabled>上页</button>
            </div>

            <div class="pagination-controls">
                <button class="btn-secondary" id="next-page" onclick="nextPage()">下页</button>
            </div>
        </div>
    </div>

    <!-- 后台界面 -->
    <div class="backend">
        <!-- 登录界面 -->
        <div class="login-form">
            <h2 style="margin-bottom: 1.5rem; font-size: 1.5rem; color: #1f2937;">管理员登录</h2>
            <div class="form-group">
                <input type="password" id="admin-password" placeholder="输入管理密码" style="width: 100%;">
            </div>
            <button class="btn-primary" onclick="login()" style="width: 100%;">登录</button>
        </div>

        <!-- 管理界面 -->
        <div class="admin-panel" style="display:none;">
            <div class="page-title">
                <h2>链接管理</h2>
                <div class="action-buttons">
                    <button class="btn-secondary" onclick="showFrontend()">返回前台</button>
                </div>
            </div>

            <div style="margin-bottom: 2rem;">
                <h3 style="margin-bottom: 0.8rem; font-size: 1.2rem; color: #1f2937;">网站设置</h3>
                <div class="form-group">
                    <input type="text" id="website-logo-input" placeholder="输入网站LOGO URL(可选)" style="width: 100%;">
                </div>
                <div class="form-group">
                    <input type="text" id="website-title-input" placeholder="输入网站标题" style="width: 100%;">
                </div>
                <button class="btn-primary" onclick="saveWebsiteSettings()">保存设置</button>
            </div>

            <div style="margin-bottom: 2rem;">
                <h3 style="margin-bottom: 0.8rem; font-size: 1.2rem; color: #1f2937;">分类管理</h3>
                <div class="form-group">
                    <input type="text" id="new-category" placeholder="输入新分类名称" style="width: 100%;">
                </div>
                <button class="btn-primary" onclick="addNewCategory()">添加分类</button>
                <div class="categories-list" id="categories-list">
                    <!-- 分类列表将在这里动态生成 -->
                </div>
            </div>

            <div style="margin-bottom: 2rem;">
                <h3 style="margin-bottom: 0.8rem; font-size: 1.2rem; color: #1f2937;">页脚信息</h3>
                <div class="form-group">
                    <textarea id="footer-text" placeholder="输入页脚信息" style="width: 100%; min-height: 100px;"></textarea>
                </div>
                <button class="btn-primary" onclick="saveFooterInfo()">保存页脚信息</button>
            </div>

            <div class="links-table-container">
                <table class="links-table">
                    <thead>
                        <tr>
                            <th>名称</th>
                            <th>网址</th>
                            <th>分类</th>
                            <th>简介</th>
                            <th>状态</th>
                            <th>Logo</th>
                            <th>操作</th>
                        </tr>
                    </thead>
                    <tbody id="links-list"></tbody>
                </table>
            </div>

            <div class="pagination-controls">
                <button class="btn-secondary" id="admin-prev-page" onclick="prevPage()" disabled>上页</button>
                <button class="btn-secondary" id="admin-next-page" onclick="nextPage()">下页</button>
            </div>

            <div class="action-buttons" style="margin-top: 2rem;">
                <button class="btn-primary" onclick="addNewLink()">+ 新增链接</button>
                <button class="btn-secondary" onclick="exportLinks()">导出链接</button>
                <input type="file" id="import-file" style="display: none;" onchange="importLinks(this.files[0])">
                <button class="btn-secondary" onclick="document.getElementById('import-file').click()">导入链接</button>
            </div>

            <div class="password-change">
                <h3 style="margin-bottom: 0.8rem; font-size: 1.2rem; color: #1f2937;">修改密码</h3>
                <div class="form-group">
                    <input type="password" id="old-password" placeholder="输入旧密码" style="width: 100%;">
                </div>
                <div class="form-group">
                    <input type="password" id="new-password" placeholder="输入新密码" style="width: 100%;">
                </div>
                <div class="form-group">
                    <input type="password" id="confirm-password" placeholder="确认新密码" style="width: 100%;">
                </div>
                <button class="btn-success" onclick="changePassword()">修改密码</button>
            </div>
        </div>
    </div>

    <div class="footer" id="footer-info">
        <p>© 2025 My Website Favorites. Designer: evan.xin</p>
    </div>

    <script src="script.js"></script>
</body>
</html>

2. script.js

let links = JSON.parse(localStorage.getItem('nav-links')) || Array.from({length: 12}, (_, i) => ({
    name: "Evan's Space", 
    url: "https://www.evan.xin", 
    category: "博客", 
    description: "keep it real", 
    status: "normal",
    logo: "https://www.evan.xin/logo.png"
}));
let categories = JSON.parse(localStorage.getItem('categories')) || ["博客", "工具", "资源", "娱乐"];
const PART1 = "admin";
const PART2 = "123";
const ENCRYPTED_ADMIN_PASSWORD = btoa(PART1 + PART2);
const SALT = "rainbow_salt_2023";
let currentPage = 1;
const itemsPerPage = 10;
let totalPages = Math.ceil(links.length / itemsPerPage);

function renderFrontend(category = "all") {
    const container = document.getElementById('nav-links');
    const filteredLinks = category === "all" 
        ? links 
        : links.filter(link => link.category === category);

    const startIndex = (currentPage - 1) * itemsPerPage;
    const endIndex = startIndex + itemsPerPage;
    const paginatedLinks = filteredLinks.slice(startIndex, endIndex);

    container.innerHTML = paginatedLinks.map(link => {
        const encodeHTML = (str) => str.replace(/[&<>'"]/g, 
            tag => ({
                '&': '&amp;',
                '<': '&lt;',
                '>': '&gt;',
                "'": '&#39;',
                '"': '&quot;'
            }[tag]));
            
        return `
            <li class="nav-item">
                <a href="${link.url}" class="nav-link" target="_blank">
                    ${link.logo ? `<img src="${link.logo}" class="link-logo" alt="${encodeHTML(link.name)}">` : ''}
                    <div class="link-info">
                        <div class="link-name">${encodeHTML(link.name)}</div>
                        <div class="link-desc">${encodeHTML(link.description)}</div>
                    </div>
                </a>
                <span class="status-badge ${link.status === 'normal' ? 'status-normal' : 'status-error'}">
                    ${link.status === 'normal' ? '正常' : '维护'}
                </span>
            </li>
        `;
    }).join('');

    updateCategoryFilters(category);
    updatePaginationButtons();
}

function updateCategoryFilters(activeCategory = "all") {
    const container = document.getElementById('category-filters');
    container.innerHTML = `
        <button class="category-btn ${activeCategory === 'all' ? 'active' : ''}" data-category="all">全部</button>
    `;

    categories.forEach(cat => {
        container.innerHTML += `
            <button class="category-btn ${activeCategory === cat ? 'active' : ''}" data-category="${cat}">
                ${cat} (${links.filter(link => link.category === cat).length})
            </button>
        `;
    });

    document.querySelectorAll('.category-btn').forEach(btn => {
        btn.addEventListener('click', function() {
            document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
            this.classList.add('active');
            currentPage = 1;
            renderFrontend(this.dataset.category);
        });
    });
}

function showAdmin() {
    document.querySelector('.frontend').style.display = 'none';
    document.querySelector('.backend').style.display = 'block';
    renderAdmin();
    renderCategories();
    loadFooterInfo();
    loadWebsiteSettings();
}

function showFrontend() {
    document.querySelector('.backend').style.display = 'none';
    document.querySelector('.frontend').style.display = 'block';
    renderFrontend();
    loadWebsiteLogo();
    loadWebsiteTitle();
}

// 密码
async function encryptPassword(password) {
    const encoder = new TextEncoder();
    const data = encoder.encode(SALT + password);
    const hashBuffer = await crypto.subtle.digest('SHA-256', data);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

async function login() {
    const password = document.getElementById('admin-password').value;
    const encryptedPassword = await encryptPassword(password);
    const storedPassword = localStorage.getItem('admin-password') || await encryptPassword(atob(ENCRYPTED_ADMIN_PASSWORD));
    
    if(encryptedPassword === storedPassword) {
        document.querySelector('.login-form').style.display = 'none';
        document.querySelector('.admin-panel').style.display = 'block';
    } else {
        alert('密码错误!');
    }
}

function renderAdmin() {
    const tbody = document.getElementById('links-list');
    const startIndex = (currentPage - 1) * itemsPerPage;
    const endIndex = startIndex + itemsPerPage;
    const paginatedLinks = links.slice(startIndex, endIndex);

    tbody.innerHTML = paginatedLinks.map((link, index) => {
        const encodeHTML = (str) => str.replace(/[&<>'"]/g, 
            tag => ({
                '&': '&amp;',
                '<': '&lt;',
                '>': '&gt;',
                "'": '&#39;',
                '"': '&quot;'
            }[tag]));
            
        return `
            <tr>
                <td>
                    <div class="form-group">
                        <input value="${encodeHTML(link.name)}" placeholder="名称">
                    </div>
                </td>
                <td>
                    <div class="form-group">
                        <input value="${encodeHTML(link.url)}" placeholder="网址">
                    </div>
                </td>
                <td>
                    <div class="form-group">
                        <select>
                            ${categories.map(cat => 
                                `<option value="${encodeHTML(cat)}" ${link.category === cat ? 'selected' : ''}>${encodeHTML(cat)}</option>`
                            ).join('')}
                        </select>
                    </div>
                </td>
                <td>
                    <div class="form-group">
                        <textarea>${encodeHTML(link.description)}</textarea>
                    </div>
                </td>
                <td>
                    <div class="form-group">
                        <select onchange="updateStatus(${startIndex + index}, this.value)">
                            <option value="normal" ${link.status === 'normal' ? 'selected' : ''}>正常</option>
                            <option value="error" ${link.status !== 'normal' ? 'selected' : ''}>维护</option>
                        </select>
                    </div>
                </td>
                <td>
                    <div class="form-group">
                        <input type="text" placeholder="https://example.com/logo.png" value="${encodeHTML(link.logo || '')}">
                    </div>
                </td>
                <td>
                    <div class="form-group">
                        <button class="btn-primary" onclick="saveLink(${startIndex + index})">保存</button>
                        <button class="btn-danger" onclick="deleteLink(${startIndex + index})">删除</button>
                    </div>
                </td>
            </tr>
        `;
    }).join('');


    updatePaginationButtons();
}

function renderCategories() {
    const container = document.getElementById('categories-list');
    container.innerHTML = categories.map((cat, index) => {
        const encodeHTML = (str) => str.replace(/[&<>'"]/g, 
            tag => ({
                '&': '&amp;',
                '<': '&lt;',
                '>': '&gt;',
                "'": '&#39;',
                '"': '&quot;'
            }[tag]));
            
        return `
            <div class="category-item">
                <span>${encodeHTML(cat)}</span>
                <button onclick="deleteCategory(${index})">✕</button>
            </div>
        `;
    }).join('');
}

function loadFooterInfo() {
    const footerInfo = localStorage.getItem('footer-info') || '© 2025 My Website Favorites. Designer: evan.xin';
    document.getElementById('footer-text').value = footerInfo;
    document.getElementById('footer-info').innerHTML = `<p>${footerInfo}</p>`;
}

function loadWebsiteSettings() {
    const websiteLogo = localStorage.getItem('website-logo') || '';
    const websiteTitle = localStorage.getItem('website-title') || 'My Website Favorites';
    document.getElementById('website-logo-input').value = websiteLogo;
    document.getElementById('website-title-input').value = websiteTitle;
}

function saveWebsiteSettings() {
    const websiteLogo = document.getElementById('website-logo-input').value;
    const websiteTitle = document.getElementById('website-title-input').value;
    localStorage.setItem('website-logo', websiteLogo);
    localStorage.setItem('website-title', websiteTitle);
    loadWebsiteLogo();
    loadWebsiteTitle();
}

function loadWebsiteLogo() {
    const websiteLogo = localStorage.getItem('website-logo') || '';
    const logoImg = document.getElementById('website-logo');
    
    if (websiteLogo) {
        logoImg.src = websiteLogo;
        logoImg.style.display = 'block';
    } else {
        logoImg.style.display = 'none';
    }
}

function loadWebsiteTitle() {
    const websiteTitle = localStorage.getItem('website-title') || 'My Website Favorites';
    document.querySelector('.logo-container h1').textContent = websiteTitle;
    document.title = websiteTitle;
}

function saveFooterInfo() {
    const footerInfo = document.getElementById('footer-text').value;
    localStorage.setItem('footer-info', footerInfo);
    document.getElementById('footer-info').innerHTML = `<p>${footerInfo}</p>`;
}

function addNewLink() {
    links.push({ 
        name: "新链接", 
        url: "https://", 
        category: categories[0], 
        description: "", 
        status: "normal",
        logo: ""
    });
    localStorage.setItem('nav-links', JSON.stringify(links));
    currentPage = Math.ceil(links.length / itemsPerPage);
    renderAdmin();
}

function saveLink(index) {
    const row = document.querySelectorAll('#links-list tr')[index - ((currentPage - 1) * itemsPerPage)];
    if (!row) return;

    // 验证输入内容
    const name = row.querySelector('td:first-child input').value.trim();
    const url = row.querySelector('td:nth-child(2) input').value.trim();
    const category = row.querySelector('td:nth-child(3) select').value;
    const description = row.querySelector('td:nth-child(4) textarea').value.trim();
    const status = row.querySelector('td:nth-child(5) select').value;
    const logo = row.querySelector('td:nth-child(6) input').value.trim();

    if (!name || !url || !category) {
        alert('名称、网址和分类不能为空!');
        return;
    }

    links[index] = {
        name,
        url,
        category,
        description,
        status,
        logo
    };
    localStorage.setItem('nav-links', JSON.stringify(links));
    renderFrontend();
    renderAdmin();
}

function deleteLink(index) {
    if(confirm('确认删除该链接?')) {
        links.splice(index, 1);
        localStorage.setItem('nav-links', JSON.stringify(links));
        currentPage = Math.max(1, Math.min(currentPage, Math.ceil(links.length / itemsPerPage)));
        renderAdmin();
        renderFrontend();
    }
}

function updateStatus(index, status) {
    links[index].status = status;
    localStorage.setItem('nav-links', JSON.stringify(links));
    renderFrontend();
    renderAdmin();
}

function addNewCategory() {
    const categoryName = document.getElementById('new-category').value.trim();
    if (!categoryName) {
        alert('分类名称不能为空!');
        return;
    }

    // 验证输入内容
    if (!/^[a-zA-Z0-9\u4e00-\u9fa5]+$/.test(categoryName)) {
        alert('分类名称只能包含字母、数字和中文!');
        return;
    }

    if (!categories.includes(categoryName)) {
        categories.push(categoryName);
        localStorage.setItem('categories', JSON.stringify(categories));
        renderCategories();
        document.getElementById('new-category').value = '';
        renderFrontend();
    } else {
        alert('该分类已存在!');
    }
}

function deleteCategory(index) {
    if(confirm('确认删除该分类?此操作不会删除链接,只会将链接分类重置为第一个分类')) {
        const deletedCategory = categories[index];
        categories.splice(index, 1);
        localStorage.setItem('categories', JSON.stringify(categories));
        
        // 更新链接分类
        links.forEach(link => {
            if(link.category === deletedCategory) {
                link.category = categories[0] || "未分类";
            }
        });
        localStorage.setItem('nav-links', JSON.stringify(links));
        
        renderCategories();
        renderAdmin();
        renderFrontend();
    }
}

function prevPage() {
    if (currentPage > 1) {
        currentPage--;
        renderFrontend();
        renderAdmin();
    }
}

function nextPage() {
    if (currentPage * itemsPerPage < links.length) {
        currentPage++;
        renderFrontend();
        renderAdmin();
    }
}

function updatePaginationButtons() {
    document.getElementById('prev-page').disabled = currentPage === 1;
    document.getElementById('next-page').disabled = currentPage * itemsPerPage >= links.length;
    document.getElementById('admin-prev-page').disabled = currentPage === 1;
    document.getElementById('admin-next-page').disabled = currentPage * itemsPerPage >= links.length;
}

async function changePassword() {
    const oldPassword = document.getElementById('old-password').value;
    const newPassword = document.getElementById('new-password').value;
    const confirmPassword = document.getElementById('confirm-password').value;

    if (!oldPassword || !newPassword || !confirmPassword) {
        alert('所有字段不能为空!');
        return;
    }

    if (newPassword !== confirmPassword) {
        alert('新密码和确认密码不一致!');
        return;
    }

    const encryptedOldPassword = await encryptPassword(oldPassword);
    const storedPassword = localStorage.getItem('admin-password') || await encryptPassword(atob(ENCRYPTED_ADMIN_PASSWORD));

    if (encryptedOldPassword !== storedPassword) {
        alert('旧密码错误!');
        return;
    }

    localStorage.setItem('admin-password', await encryptPassword(newPassword));
    alert('密码修改成功!');
    document.getElementById('old-password').value = '';
    document.getElementById('new-password').value = '';
    document.getElementById('confirm-password').value = '';
}

function exportLinks() {
    const data = JSON.stringify(links, null, 2);
    const blob = new Blob([data], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'links_export.json';
    a.click();
    URL.revokeObjectURL(url);
}

function importLinks(file) {
    if (!file || file.type !== 'application/json') {
        alert('请选择有效的JSON文件!');
        return;
    }

    const reader = new FileReader();
    reader.onload = function(e) {
        try {
            const importedLinks = JSON.parse(e.target.result);
            if (!Array.isArray(importedLinks)) {
                throw new Error('导入数据格式不正确!');
            }

            importedLinks.forEach(link => {
                if (!link.name || !link.url || !link.category) {
                    throw new Error('导入数据格式不正确!');
                }
            });

            links = importedLinks;
            localStorage.setItem('nav-links', JSON.stringify(links));
            renderAdmin();
            renderFrontend();
            alert('链接导入成功!');
        } catch (error) {
            alert('导入失败,请检查文件格式是否正确。');
        }
    };
    reader.readAsText(file);
}

renderFrontend();
loadWebsiteLogo();
loadWebsiteTitle();

3. styles.css(下面需要回复查看,主要还是想多置顶,哈哈。看有没有人可以解决数据存储问题)

此内容需要 回复 并刷新页面可见

OK,所有的文件都在这里了,有兴趣的可以在此基础上再优化和完善数据存储。

后台密码:admin123

  • dfjk

    Dev
  • #3

搞不定数据库你就直接存成一个 JSON 文件呗

    • Evan

      OP
      博主
    • #4
    • 已编辑

    #3 dfjk

    哈哈,非技术。数据库是真不会,AI给的都太复杂

    #2 LuckyFixate

    哈哈yct07

    • alay

      YEAR
    • #5

    这个数据库不复杂啊!

      • Evan

        OP
        博主
      • #6

      #5 alay

      知道,但我不是技术,不懂。。弄个静态还行。哈哈。纯AI

      厉害厉害

      谢谢分享 这种的我以前也叫AI弄过 也是你一样的问题 后来添加的 只能缓存在本地 更换地方就没有了

      • Evan

        OP
        博主
      • #9

      在弄josn,后台还是有些问题,其他ok

      感谢分享,看看。

      感谢分享

      • Evan

        OP
        博主
      • #12
      • 已编辑

      已经可以部署在服务器了,哈哈哈哈!

      Image description

      这下我就可以用手机的时候不在再点什么收藏夹了,直接打开网址就用yct10

      Evan 更改标题为「EvanNav版个人私密收藏夹开放啦!(已经实现NODE.JS部署)

      你用AI搞成这样,很厉害了,我之前搞的一半的简单的已放弃了。

        • Evan

          OP
          博主
        • #15

        #14 Jensfrank

        昨天几乎摸鱼一天➕一晚上,今天上午才搞定。静态的好搞,部署在服务器就真是头大。不会用node.js 纯粹试试试试试试出来的。。。改了无数版。( ˶´⚰︎`˵ )

          • Evan

            OP
            博主
          • #17
          • 已编辑

          #16 sepbigo

          说不清ac11

          我自己都懵地,你可以看看网上的教程🙈

          程序要重新写哈,不是我上传的这个。我上传的这个是静态版,没办法部署🙈

          #15 Evan 静态的是好弄些,已经很不错了,慢慢更新吧,自用书签不多的话也可以用了。

            • Evan

              OP
              博主
            • #19

            #18 Jensfrank

            嗯。我这还再琢磨后台拖拽排列功能呢!哈哈哈。
            刚刚实现导入数据的一键保存,之前只能一个一个保存ac10

              #19 Evan 这个也是实用的功能,不然调整顺序有点麻烦