|
@@ -0,0 +1,605 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+/**
|
|
|
|
|
+ * BCJD 导航 - 管理面板
|
|
|
|
|
+ *
|
|
|
|
|
+ * 安全特性:
|
|
|
|
|
+ * - CSRF Token 防护所有写操作
|
|
|
|
|
+ * - 请求时动态获取 Token(不与页面静态绑定)
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+require_once __DIR__ . '/../init.php';
|
|
|
|
|
+
|
|
|
|
|
+use BCJD\Auth;
|
|
|
|
|
+
|
|
|
|
|
+$csrfToken = Auth::getCsrfToken();
|
|
|
|
|
+?><!doctype html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="utf-8" />
|
|
|
|
|
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
|
|
|
+ <meta name="color-scheme" content="light dark" />
|
|
|
|
|
+ <title>BCJD 导航 - 管理面板</title>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ :root{
|
|
|
|
|
+ --bg: #0b1020;
|
|
|
|
|
+ --panel: rgba(255,255,255,.06);
|
|
|
|
|
+ --panel-2: rgba(255,255,255,.10);
|
|
|
|
|
+ --text: rgba(255,255,255,.92);
|
|
|
|
|
+ --muted: rgba(255,255,255,.65);
|
|
|
|
|
+ --border: rgba(255,255,255,.12);
|
|
|
|
|
+ --shadow: 0 10px 30px rgba(0,0,0,.35);
|
|
|
|
|
+ --accent: #6ea8ff;
|
|
|
|
|
+ --accent2: #7cf7d4;
|
|
|
|
|
+ --danger: #ff6b6b;
|
|
|
|
|
+ --ok: #3ddc97;
|
|
|
|
|
+ --max: 1120px;
|
|
|
|
|
+ --radius: 18px;
|
|
|
|
|
+ --radius-sm: 12px;
|
|
|
|
|
+ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
|
|
|
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", Arial, sans-serif;
|
|
|
|
|
+ }
|
|
|
|
|
+ html[data-theme="light"]{
|
|
|
|
|
+ --bg: #f6f7fb;
|
|
|
|
|
+ --panel: rgba(0,0,0,.04);
|
|
|
|
|
+ --panel-2: rgba(0,0,0,.06);
|
|
|
|
|
+ --text: rgba(0,0,0,.88);
|
|
|
|
|
+ --muted: rgba(0,0,0,.58);
|
|
|
|
|
+ --border: rgba(0,0,0,.10);
|
|
|
|
|
+ --shadow: 0 10px 30px rgba(0,0,0,.12);
|
|
|
|
|
+ --accent: #245bdb;
|
|
|
|
|
+ --accent2: #0aa37f;
|
|
|
|
|
+ --danger: #d64545;
|
|
|
|
|
+ --ok: #0a7f5f;
|
|
|
|
|
+ }
|
|
|
|
|
+ @media (prefers-color-scheme: light){
|
|
|
|
|
+ html:not([data-theme="dark"]){
|
|
|
|
|
+ --bg: #f6f7fb;
|
|
|
|
|
+ --panel: rgba(0,0,0,.04);
|
|
|
|
|
+ --panel-2: rgba(0,0,0,.06);
|
|
|
|
|
+ --text: rgba(0,0,0,.88);
|
|
|
|
|
+ --muted: rgba(0,0,0,.58);
|
|
|
|
|
+ --border: rgba(0,0,0,.10);
|
|
|
|
|
+ --shadow: 0 10px 30px rgba(0,0,0,.12);
|
|
|
|
|
+ --accent: #245bdb;
|
|
|
|
|
+ --accent2: #0aa37f;
|
|
|
|
|
+ --danger: #d64545;
|
|
|
|
|
+ --ok: #0a7f5f;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ *{ box-sizing:border-box; }
|
|
|
|
|
+ body{
|
|
|
|
|
+ margin:0;
|
|
|
|
|
+ min-height:100vh;
|
|
|
|
|
+ font-family: var(--sans);
|
|
|
|
|
+ color: var(--text);
|
|
|
|
|
+ background: var(--bg);
|
|
|
|
|
+ }
|
|
|
|
|
+ a{ color:var(--accent); text-decoration:none; }
|
|
|
|
|
+ a:hover{ text-decoration:underline; }
|
|
|
|
|
+ .wrap{
|
|
|
|
|
+ max-width: var(--max);
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ padding: 30px 18px 44px;
|
|
|
|
|
+ }
|
|
|
|
|
+ header{
|
|
|
|
|
+ display:flex;
|
|
|
|
|
+ justify-content:space-between;
|
|
|
|
|
+ align-items:center;
|
|
|
|
|
+ gap:18px;
|
|
|
|
|
+ margin-bottom: 24px;
|
|
|
|
|
+ }
|
|
|
|
|
+ header h1{ margin:0; font-size:22px; font-weight:800; }
|
|
|
|
|
+ .header-actions{ display:flex; gap:10px; align-items:center; }
|
|
|
|
|
+
|
|
|
|
|
+ button, .btn{
|
|
|
|
|
+ height: 40px; display:inline-flex; align-items:center; justify-content:center;
|
|
|
|
|
+ padding: 0 16px; border-radius:999px; border:1px solid var(--border);
|
|
|
|
|
+ background: var(--panel); box-shadow: var(--shadow); color: var(--text);
|
|
|
|
|
+ cursor:pointer; font-family: var(--sans); font-size:13px; font-weight:600;
|
|
|
|
|
+ transition: background .15s; white-space:nowrap;
|
|
|
|
|
+ }
|
|
|
|
|
+ button:hover, .btn:hover{ background: var(--panel-2); border-color: rgba(110,168,255,.28); }
|
|
|
|
|
+ .btn-primary{ background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
|
|
|
+ .btn-primary:hover{ opacity:.88; border-color:var(--accent); background:var(--accent); }
|
|
|
|
|
+ .btn-danger{ background: rgba(255,107,107,.15); border-color: rgba(255,107,107,.35); color: var(--danger); }
|
|
|
|
|
+ .btn-danger:hover{ background: rgba(255,107,107,.25); }
|
|
|
|
|
+ .btn-sm{ height:34px; padding:0 12px; font-size:12px; }
|
|
|
|
|
+
|
|
|
|
|
+ .tabs{
|
|
|
|
|
+ display:flex; gap:4px; margin-bottom: 24px;
|
|
|
|
|
+ border-bottom:1px solid var(--border); padding-bottom:0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .tab{
|
|
|
|
|
+ padding:10px 18px; border-radius:12px 12px 0 0; cursor:pointer;
|
|
|
|
|
+ font-size:14px; font-weight:600; color: var(--muted);
|
|
|
|
|
+ border:1px solid transparent; border-bottom:none; transition: all .12s; background:transparent;
|
|
|
|
|
+ }
|
|
|
|
|
+ .tab:hover{ color:var(--text); background:var(--panel); }
|
|
|
|
|
+ .tab.active{ color:var(--accent); border-color:var(--border); background:var(--panel); }
|
|
|
|
|
+ .tab-content{ display:none; }
|
|
|
|
|
+ .tab-content.active{ display:block; }
|
|
|
|
|
+
|
|
|
|
|
+ .link-item{
|
|
|
|
|
+ display:flex; align-items:center; gap:12px; padding:12px 14px;
|
|
|
|
|
+ border-radius:var(--radius-sm); border:1px solid var(--border);
|
|
|
|
|
+ background:var(--panel); margin-bottom:8px; transition:background .12s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .link-item:hover{ background:var(--panel-2); }
|
|
|
|
|
+ .link-item .info{ flex:1; min-width:0; }
|
|
|
|
|
+ .link-item .info .title{ font-weight:700; font-size:14px; }
|
|
|
|
|
+ .link-item .info .url{
|
|
|
|
|
+ font-size:12px; color:var(--muted); font-family:var(--mono);
|
|
|
|
|
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
|
|
|
+ }
|
|
|
|
|
+ .link-item .badge{
|
|
|
|
|
+ font-size:11px; padding:3px 8px; border-radius:999px;
|
|
|
|
|
+ border:1px solid var(--border); background:var(--panel-2); color:var(--muted); white-space:nowrap;
|
|
|
|
|
+ }
|
|
|
|
|
+ .link-item .badge.private{
|
|
|
|
|
+ border-color:rgba(255,107,107,.35); background:rgba(255,107,107,.08); color:var(--danger);
|
|
|
|
|
+ }
|
|
|
|
|
+ .link-item .item-actions{ display:flex; gap:6px; flex-shrink:0; }
|
|
|
|
|
+
|
|
|
|
|
+ .form-group{ margin-bottom:16px; }
|
|
|
|
|
+ .form-group label{
|
|
|
|
|
+ display:block; font-size:13px; font-weight:600; margin-bottom:6px; color:var(--muted);
|
|
|
|
|
+ }
|
|
|
|
|
+ .form-group input, .form-group select, .form-group textarea{
|
|
|
|
|
+ width:100%; height:42px; padding:0 14px; border-radius:999px;
|
|
|
|
|
+ border:1px solid var(--border); background:var(--panel); color:var(--text);
|
|
|
|
|
+ font-size:14px; font-family:var(--sans); outline:none; transition:border-color .15s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .form-group textarea{
|
|
|
|
|
+ height:auto; min-height:60px; padding:10px 14px; border-radius:var(--radius-sm);
|
|
|
|
|
+ resize:vertical; font-family:var(--mono); font-size:13px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .form-group input:focus, .form-group select:focus, .form-group textarea:focus{ border-color:var(--accent); }
|
|
|
|
|
+ .form-group select option{ background:var(--bg); color:var(--text); }
|
|
|
|
|
+ .form-row{ display:grid; grid-template-columns:1fr 1fr; gap:14px; }
|
|
|
|
|
+ .form-row-3{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:14px; }
|
|
|
|
|
+ .checkbox-row{
|
|
|
|
|
+ display:flex; align-items:center; gap:10px; margin-bottom:16px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .checkbox-row input[type="checkbox"]{ width:18px; height:18px; accent-color:var(--accent); }
|
|
|
|
|
+
|
|
|
|
|
+ .toast{
|
|
|
|
|
+ display:none; position:fixed; bottom:30px; left:50%; transform:translateX(-50%);
|
|
|
|
|
+ padding:12px 24px; border-radius:999px; background:var(--panel-2);
|
|
|
|
|
+ border:1px solid var(--border); backdrop-filter:blur(10px); box-shadow:var(--shadow);
|
|
|
|
|
+ font-size:14px; z-index:999;
|
|
|
|
|
+ }
|
|
|
|
|
+ .toast.show{ display:block; }
|
|
|
|
|
+ .toast.success{ border-color:rgba(61,220,151,.35); color:var(--ok); }
|
|
|
|
|
+ .toast.error{ border-color:rgba(255,107,107,.35); color:var(--danger); }
|
|
|
|
|
+
|
|
|
|
|
+ .modal-overlay{
|
|
|
|
|
+ display:none; position:fixed; inset:0; background:rgba(0,0,0,.55);
|
|
|
|
|
+ backdrop-filter:blur(4px); z-index:1000; align-items:center; justify-content:center;
|
|
|
|
|
+ }
|
|
|
|
|
+ .modal-overlay.active{ display:flex; }
|
|
|
|
|
+ .modal-box{
|
|
|
|
|
+ width:520px; max-width:92vw; max-height:90vh; overflow-y:auto;
|
|
|
|
|
+ padding:28px; border-radius:var(--radius); border:1px solid var(--border);
|
|
|
|
|
+ background:var(--bg); box-shadow:0 20px 60px rgba(0,0,0,.5);
|
|
|
|
|
+ }
|
|
|
|
|
+ .modal-box h2{ margin:0 0 18px; font-size:20px; font-weight:800; }
|
|
|
|
|
+ .modal-box .btn-row{ display:flex; gap:10px; margin-top:20px; justify-content:flex-end; }
|
|
|
|
|
+
|
|
|
|
|
+ @media (max-width: 720px){
|
|
|
|
|
+ .form-row, .form-row-3{ grid-template-columns:1fr; }
|
|
|
|
|
+ .link-item{ flex-wrap:wrap; }
|
|
|
|
|
+ .link-item .item-actions{ width:100%; justify-content:flex-end; }
|
|
|
|
|
+ }
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+ <div class="wrap">
|
|
|
|
|
+ <header>
|
|
|
|
|
+ <h1>BCJD 导航 · 管理面板</h1>
|
|
|
|
|
+ <div class="header-actions">
|
|
|
|
|
+ <a href="index.php" class="btn">← 返回首页</a>
|
|
|
|
|
+ <button id="logoutBtn">退出登录</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <input type="hidden" id="csrfToken" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>" />
|
|
|
|
|
+
|
|
|
|
|
+ <div class="tabs">
|
|
|
|
|
+ <div class="tab active" data-tab="links">链接管理</div>
|
|
|
|
|
+ <div class="tab" data-tab="add">添加链接</div>
|
|
|
|
|
+ <div class="tab" data-tab="password">修改密码</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ===== 链接管理 ===== -->
|
|
|
|
|
+ <div class="tab-content active" id="tab-links">
|
|
|
|
|
+ <div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
|
|
|
|
+ <input id="filterLinks" placeholder="筛选链接…" style="height:40px;padding:0 14px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--text);font-size:13px;font-family:var(--sans);flex:1;min-width:200px;outline:none" />
|
|
|
|
|
+ <button id="refreshLinks" class="btn-primary btn-sm">刷新</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="linkList"></div>
|
|
|
|
|
+ <div id="linkLoading" style="text-align:center;padding:30px;color:var(--muted)">加载中…</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ===== 添加链接 ===== -->
|
|
|
|
|
+ <div class="tab-content" id="tab-add">
|
|
|
|
|
+ <form id="addForm" autocomplete="off">
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addTitle">标题 *</label>
|
|
|
|
|
+ <input type="text" id="addTitle" required placeholder="如:Monica" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addUrl">链接地址 *</label>
|
|
|
|
|
+ <input type="url" id="addUrl" required placeholder="如:https://monica.bcjd.net" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addDesc">描述</label>
|
|
|
|
|
+ <input type="text" id="addDesc" placeholder="简短描述该服务" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addGroup">分组名(英文标识)</label>
|
|
|
|
|
+ <input type="text" id="addGroup" placeholder="如:apps / ops / sites" value="apps" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addGroupLabel">分组显示名称</label>
|
|
|
|
|
+ <input type="text" id="addGroupLabel" placeholder="如:应用" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-row-3">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addKeywords">搜索关键词</label>
|
|
|
|
|
+ <input type="text" id="addKeywords" placeholder="空格分隔" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addTagLabel">标签文字</label>
|
|
|
|
|
+ <input type="text" id="addTagLabel" placeholder="如:App" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addTagClass">标签样式</label>
|
|
|
|
|
+ <select id="addTagClass">
|
|
|
|
|
+ <option value="">默认</option>
|
|
|
|
|
+ <option value="ok">绿色 (ok)</option>
|
|
|
|
|
+ <option value="warn">红色 (warn)</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addMetaDomain">域名/元信息</label>
|
|
|
|
|
+ <input type="text" id="addMetaDomain" placeholder="如:monica.bcjd.net" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="addIconSvg">SVG 图标代码</label>
|
|
|
|
|
+ <input type="text" id="addIconSvg" placeholder="<svg>...</svg>" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="checkbox-row">
|
|
|
|
|
+ <input type="checkbox" id="addIsPrivate" />
|
|
|
|
|
+ <label for="addIsPrivate" style="margin:0;cursor:pointer">🔒 私有链接(需登录才能看到)</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <button type="submit" class="btn-primary">添加链接</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ===== 修改密码 ===== -->
|
|
|
|
|
+ <div class="tab-content" id="tab-password">
|
|
|
|
|
+ <form id="pwdForm" autocomplete="off">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="oldPwd">当前密码</label>
|
|
|
|
|
+ <input type="password" id="oldPwd" required autocomplete="current-password" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="newPwd">新密码(至少6位)</label>
|
|
|
|
|
+ <input type="password" id="newPwd" required minlength="6" autocomplete="new-password" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="confirmPwd">确认新密码</label>
|
|
|
|
|
+ <input type="password" id="confirmPwd" required minlength="6" autocomplete="new-password" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <button type="submit" class="btn-primary">更新密码</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ===== 编辑模态框 ===== -->
|
|
|
|
|
+ <div class="modal-overlay" id="editModal">
|
|
|
|
|
+ <div class="modal-box">
|
|
|
|
|
+ <h2>编辑链接</h2>
|
|
|
|
|
+ <form id="editForm" autocomplete="off">
|
|
|
|
|
+ <input type="hidden" id="editId" />
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editTitle">标题 *</label>
|
|
|
|
|
+ <input type="text" id="editTitle" required />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editUrl">链接地址 *</label>
|
|
|
|
|
+ <input type="url" id="editUrl" required />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editDesc">描述</label>
|
|
|
|
|
+ <input type="text" id="editDesc" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-row">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editGroup">分组名</label>
|
|
|
|
|
+ <input type="text" id="editGroup" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editKeywords">关键词</label>
|
|
|
|
|
+ <input type="text" id="editKeywords" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-row-3">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editTagLabel">标签</label>
|
|
|
|
|
+ <input type="text" id="editTagLabel" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editTagClass">标签样式</label>
|
|
|
|
|
+ <select id="editTagClass">
|
|
|
|
|
+ <option value="">默认</option>
|
|
|
|
|
+ <option value="ok">绿色 (ok)</option>
|
|
|
|
|
+ <option value="warn">红色 (warn)</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editMetaDomain">域名</label>
|
|
|
|
|
+ <input type="text" id="editMetaDomain" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label for="editIconSvg">SVG 图标</label>
|
|
|
|
|
+ <textarea id="editIconSvg" rows="3"></textarea>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="checkbox-row">
|
|
|
|
|
+ <input type="checkbox" id="editIsPrivate" />
|
|
|
|
|
+ <label for="editIsPrivate" style="margin:0;cursor:pointer">🔒 私有链接</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="btn-row">
|
|
|
|
|
+ <button type="button" id="editCancel">取消</button>
|
|
|
|
|
+ <button type="submit" class="btn-primary">保存</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Toast -->
|
|
|
|
|
+ <div class="toast" id="toast"></div>
|
|
|
|
|
+
|
|
|
|
|
+ <script>
|
|
|
|
|
+ (function(){
|
|
|
|
|
+ 'use strict';
|
|
|
|
|
+
|
|
|
|
|
+ const $ = (s) => document.querySelector(s);
|
|
|
|
|
+ const $$ = (s) => [...document.querySelectorAll(s)];
|
|
|
|
|
+ const esc = (s) => (s || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
|
|
|
+
|
|
|
|
|
+ function getCsrf(){ return $('#csrfToken').value; }
|
|
|
|
|
+
|
|
|
|
|
+ function toast(msg, type = 'success'){
|
|
|
|
|
+ const t = $('#toast');
|
|
|
|
|
+ t.textContent = msg;
|
|
|
|
|
+ t.className = 'toast show ' + type;
|
|
|
|
|
+ clearTimeout(t._timer);
|
|
|
|
|
+ t._timer = setTimeout(() => t.classList.remove('show'), 3000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== API =====
|
|
|
|
|
+ async function apiGet(action){
|
|
|
|
|
+ const r = await fetch(`api.php?action=${action}`);
|
|
|
|
|
+ if(!r.ok){ const e = await r.json(); throw new Error(e.error || '请求失败'); }
|
|
|
|
|
+ return r.json();
|
|
|
|
|
+ }
|
|
|
|
|
+ async function apiPost(action, data){
|
|
|
|
|
+ const fd = new URLSearchParams({csrf_token: getCsrf()});
|
|
|
|
|
+ for(const [k,v] of Object.entries(data)) fd.append(k, v);
|
|
|
|
|
+ const r = await fetch(`api.php?action=${action}`, {
|
|
|
|
|
+ method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
|
|
|
|
+ body: fd.toString(),
|
|
|
|
|
+ });
|
|
|
|
|
+ if(!r.ok){ const e = await r.json(); throw new Error(e.error || '请求失败'); }
|
|
|
|
|
+ return r.json();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 登录检查 =====
|
|
|
|
|
+ apiGet('check_login').then(d => { if(!d.logged_in) window.location.href = 'index.php'; });
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 退出登录 =====
|
|
|
|
|
+ $('#logoutBtn').addEventListener('click', async () => {
|
|
|
|
|
+ await apiPost('logout', {});
|
|
|
|
|
+ window.location.href = 'index.php';
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 选项卡 =====
|
|
|
|
|
+ $$('.tab').forEach(tab => {
|
|
|
|
|
+ tab.addEventListener('click', () => {
|
|
|
|
|
+ $$('.tab').forEach(t => t.classList.remove('active'));
|
|
|
|
|
+ $$('.tab-content').forEach(t => t.classList.remove('active'));
|
|
|
|
|
+ tab.classList.add('active');
|
|
|
|
|
+ const target = document.getElementById('tab-' + tab.dataset.tab);
|
|
|
|
|
+ if(target) target.classList.add('active');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 加载链接列表 =====
|
|
|
|
|
+ let allLinks = [];
|
|
|
|
|
+
|
|
|
|
|
+ async function loadLinks(){
|
|
|
|
|
+ const list = $('#linkList');
|
|
|
|
|
+ const loading = $('#linkLoading');
|
|
|
|
|
+ loading.style.display = '';
|
|
|
|
|
+ list.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await apiGet('get_links');
|
|
|
|
|
+ allLinks = [];
|
|
|
|
|
+ for(const g of (data.groups || [])){
|
|
|
|
|
+ for(const l of (g.links || [])){
|
|
|
|
|
+ allLinks.push(l);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ renderLinks(allLinks);
|
|
|
|
|
+ loading.style.display = 'none';
|
|
|
|
|
+ } catch(e){
|
|
|
|
|
+ loading.textContent = '加载失败:' + e.message;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderLinks(links){
|
|
|
|
|
+ const list = $('#linkList');
|
|
|
|
|
+ if(links.length === 0){
|
|
|
|
|
+ list.innerHTML = '<div style="text-align:center;padding:30px;color:var(--muted)">暂无链接</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ list.innerHTML = links.map(l => `
|
|
|
|
|
+ <div class="link-item" data-id="${l.id}">
|
|
|
|
|
+ <div class="info">
|
|
|
|
|
+ <div class="title">${esc(l.title)}</div>
|
|
|
|
|
+ <div class="url">${esc(l.url)}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="badge">${esc(l.group_name)}</span>
|
|
|
|
|
+ ${l.is_private ? '<span class="badge private">🔒 私有</span>' : '<span class="badge">公开</span>'}
|
|
|
|
|
+ <div class="item-actions">
|
|
|
|
|
+ <button class="btn-sm edit-btn" data-id="${l.id}">编辑</button>
|
|
|
|
|
+ <button class="btn-sm btn-danger delete-btn" data-id="${l.id}">删除</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `).join('');
|
|
|
|
|
+
|
|
|
|
|
+ list.querySelectorAll('.edit-btn').forEach(btn => {
|
|
|
|
|
+ btn.addEventListener('click', () => openEdit(parseInt(btn.dataset.id)));
|
|
|
|
|
+ });
|
|
|
|
|
+ list.querySelectorAll('.delete-btn').forEach(btn => {
|
|
|
|
|
+ btn.addEventListener('click', () => deleteLink(parseInt(btn.dataset.id)));
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 筛选 =====
|
|
|
|
|
+ $('#filterLinks').addEventListener('input', () => {
|
|
|
|
|
+ const v = $('#filterLinks').value.toLowerCase().trim();
|
|
|
|
|
+ if(!v){ renderLinks(allLinks); return; }
|
|
|
|
|
+ const filtered = allLinks.filter(l =>
|
|
|
|
|
+ (l.title + ' ' + l.url + ' ' + l.group_name + ' ' + (l.keywords || '') + ' ' + (l.description || '')).toLowerCase().includes(v)
|
|
|
|
|
+ );
|
|
|
|
|
+ renderLinks(filtered);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $('#refreshLinks').addEventListener('click', loadLinks);
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 删除链接 =====
|
|
|
|
|
+ async function deleteLink(id){
|
|
|
|
|
+ if(!confirm('确定要删除此链接吗?')) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await apiPost('delete_link', {id});
|
|
|
|
|
+ if(data.success){ toast('已删除'); loadLinks(); }
|
|
|
|
|
+ else { toast(data.error || '删除失败', 'error'); }
|
|
|
|
|
+ } catch(e){ toast(e.message, 'error'); }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 编辑链接 =====
|
|
|
|
|
+ function openEdit(id){
|
|
|
|
|
+ const l = allLinks.find(x => x.id === id);
|
|
|
|
|
+ if(!l) return;
|
|
|
|
|
+ $('#editId').value = l.id;
|
|
|
|
|
+ $('#editTitle').value = l.title;
|
|
|
|
|
+ $('#editUrl').value = l.url;
|
|
|
|
|
+ $('#editDesc').value = l.description || '';
|
|
|
|
|
+ $('#editGroup').value = l.group_name;
|
|
|
|
|
+ $('#editKeywords').value = l.keywords || '';
|
|
|
|
|
+ $('#editTagLabel').value = l.tag_label || '';
|
|
|
|
|
+ $('#editTagClass').value = l.tag_class || '';
|
|
|
|
|
+ $('#editMetaDomain').value = l.meta_domain || '';
|
|
|
|
|
+ $('#editIconSvg').value = l.icon_svg || '';
|
|
|
|
|
+ $('#editIsPrivate').checked = !!l.is_private;
|
|
|
|
|
+ $('#editModal').classList.add('active');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $('#editCancel').addEventListener('click', () => $('#editModal').classList.remove('active'));
|
|
|
|
|
+ $('#editModal').addEventListener('click', (e) => { if(e.target === $('#editModal')) $('#editModal').classList.remove('active'); });
|
|
|
|
|
+
|
|
|
|
|
+ $('#editForm').addEventListener('submit', async (e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await apiPost('update_link', {
|
|
|
|
|
+ id: $('#editId').value,
|
|
|
|
|
+ title: $('#editTitle').value,
|
|
|
|
|
+ url: $('#editUrl').value,
|
|
|
|
|
+ description: $('#editDesc').value,
|
|
|
|
|
+ group_name: $('#editGroup').value,
|
|
|
|
|
+ keywords: $('#editKeywords').value,
|
|
|
|
|
+ tag_label: $('#editTagLabel').value,
|
|
|
|
|
+ tag_class: $('#editTagClass').value,
|
|
|
|
|
+ meta_domain: $('#editMetaDomain').value,
|
|
|
|
|
+ icon_svg: $('#editIconSvg').value,
|
|
|
|
|
+ is_private: $('#editIsPrivate').checked ? 1 : 0,
|
|
|
|
|
+ });
|
|
|
|
|
+ if(result.success){ toast('已更新'); $('#editModal').classList.remove('active'); loadLinks(); }
|
|
|
|
|
+ else { toast(result.error || '更新失败', 'error'); }
|
|
|
|
|
+ } catch(e){ toast(e.message, 'error'); }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 添加链接 =====
|
|
|
|
|
+ $('#addForm').addEventListener('submit', async (e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await apiPost('add_link', {
|
|
|
|
|
+ title: $('#addTitle').value,
|
|
|
|
|
+ url: $('#addUrl').value,
|
|
|
|
|
+ description: $('#addDesc').value,
|
|
|
|
|
+ group_name: $('#addGroup').value || 'default',
|
|
|
|
|
+ group_label: $('#addGroupLabel').value,
|
|
|
|
|
+ keywords: $('#addKeywords').value,
|
|
|
|
|
+ tag_label: $('#addTagLabel').value,
|
|
|
|
|
+ tag_class: $('#addTagClass').value,
|
|
|
|
|
+ meta_domain: $('#addMetaDomain').value,
|
|
|
|
|
+ icon_svg: $('#addIconSvg').value,
|
|
|
|
|
+ is_private: $('#addIsPrivate').checked ? 1 : 0,
|
|
|
|
|
+ });
|
|
|
|
|
+ if(result.success){
|
|
|
|
|
+ toast('链接已添加');
|
|
|
|
|
+ $('#addForm').reset();
|
|
|
|
|
+ $$('.tab').forEach(t => t.classList.remove('active'));
|
|
|
|
|
+ $$('.tab-content').forEach(t => t.classList.remove('active'));
|
|
|
|
|
+ document.querySelector('[data-tab="links"]').classList.add('active');
|
|
|
|
|
+ document.getElementById('tab-links').classList.add('active');
|
|
|
|
|
+ loadLinks();
|
|
|
|
|
+ } else { toast(result.error || '添加失败', 'error'); }
|
|
|
|
|
+ } catch(e){ toast(e.message, 'error'); }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 修改密码 =====
|
|
|
|
|
+ $('#pwdForm').addEventListener('submit', async (e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ const oldPwd = $('#oldPwd').value;
|
|
|
|
|
+ const newPwd = $('#newPwd').value;
|
|
|
|
|
+ const confirmPwd = $('#confirmPwd').value;
|
|
|
|
|
+
|
|
|
|
|
+ if(newPwd !== confirmPwd){ toast('两次输入的新密码不一致', 'error'); return; }
|
|
|
|
|
+ if(newPwd.length < 6){ toast('新密码至少6位', 'error'); return; }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await apiPost('update_password', {
|
|
|
|
|
+ old_password: oldPwd, new_password: newPwd, confirm_password: confirmPwd,
|
|
|
|
|
+ });
|
|
|
|
|
+ if(result.success){ toast('密码已更新'); $('#pwdForm').reset(); }
|
|
|
|
|
+ else { toast(result.error || '密码更新失败', 'error'); }
|
|
|
|
|
+ } catch(e){ toast(e.message, 'error'); }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 启动 =====
|
|
|
|
|
+ loadLinks();
|
|
|
|
|
+
|
|
|
|
|
+ })();
|
|
|
|
|
+ </script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|