admin.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. <?php
  2. /**
  3. * BCJD 导航 - 管理面板
  4. *
  5. * 安全特性:
  6. * - CSRF Token 防护所有写操作
  7. * - 请求时动态获取 Token(不与页面静态绑定)
  8. */
  9. require_once __DIR__ . '/../init.php';
  10. use BCJD\Auth;
  11. $csrfToken = Auth::getCsrfToken();
  12. ?><!doctype html>
  13. <html lang="zh-CN">
  14. <head>
  15. <meta charset="utf-8" />
  16. <meta name="viewport" content="width=device-width,initial-scale=1" />
  17. <meta name="color-scheme" content="light dark" />
  18. <title>BCJD 导航 - 管理面板</title>
  19. <link rel="stylesheet" href="style.css" />
  20. </head>
  21. <body class="admin-page">
  22. <div class="wrap">
  23. <header>
  24. <h1>BCJD 导航 · 管理面板</h1>
  25. <div class="header-actions">
  26. <a href="index.php" class="btn">← 返回首页</a>
  27. <button id="logoutBtn">退出登录</button>
  28. </div>
  29. </header>
  30. <input type="hidden" id="csrfToken" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>" />
  31. <div class="tabs">
  32. <div class="tab active" data-tab="links">链接管理</div>
  33. <div class="tab" data-tab="add">添加链接</div>
  34. <div class="tab" data-tab="password">修改密码</div>
  35. </div>
  36. <!-- ===== 链接管理 ===== -->
  37. <div class="tab-content active" id="tab-links">
  38. <div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
  39. <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" />
  40. <button id="refreshLinks" class="btn-primary btn-sm">刷新</button>
  41. </div>
  42. <div id="linkList"></div>
  43. <div id="linkLoading" style="text-align:center;padding:30px;color:var(--muted)">加载中…</div>
  44. </div>
  45. <!-- ===== 添加链接 ===== -->
  46. <div class="tab-content" id="tab-add">
  47. <form id="addForm" autocomplete="off">
  48. <div class="form-row">
  49. <div class="form-group">
  50. <label for="addTitle">标题 *</label>
  51. <input type="text" id="addTitle" required placeholder="如:Monica" />
  52. </div>
  53. <div class="form-group">
  54. <label for="addUrl">链接地址 *</label>
  55. <input type="url" id="addUrl" required placeholder="如:https://monica.bcjd.net" />
  56. </div>
  57. </div>
  58. <div class="form-group">
  59. <label for="addDesc">描述</label>
  60. <input type="text" id="addDesc" placeholder="简短描述该服务" />
  61. </div>
  62. <div class="form-row">
  63. <div class="form-group">
  64. <label for="addGroup">分组名(英文标识)</label>
  65. <input type="text" id="addGroup" placeholder="如:apps / ops / sites" value="apps" />
  66. </div>
  67. <div class="form-group">
  68. <label for="addGroupLabel">分组显示名称</label>
  69. <input type="text" id="addGroupLabel" placeholder="如:应用" />
  70. </div>
  71. </div>
  72. <div class="form-row-3">
  73. <div class="form-group">
  74. <label for="addKeywords">搜索关键词</label>
  75. <input type="text" id="addKeywords" placeholder="空格分隔" />
  76. </div>
  77. <div class="form-group">
  78. <label for="addTagLabel">标签文字</label>
  79. <input type="text" id="addTagLabel" placeholder="如:App" />
  80. </div>
  81. <div class="form-group">
  82. <label for="addTagClass">标签样式</label>
  83. <select id="addTagClass">
  84. <option value="">默认</option>
  85. <option value="ok">绿色 (ok)</option>
  86. <option value="warn">红色 (warn)</option>
  87. </select>
  88. </div>
  89. </div>
  90. <div class="form-row">
  91. <div class="form-group">
  92. <label for="addMetaDomain">域名/元信息</label>
  93. <input type="text" id="addMetaDomain" placeholder="如:monica.bcjd.net" />
  94. </div>
  95. <div class="form-group">
  96. <label for="addIconSvg">SVG 图标代码</label>
  97. <input type="text" id="addIconSvg" placeholder="<svg>...</svg>" />
  98. </div>
  99. </div>
  100. <div class="checkbox-row">
  101. <input type="checkbox" id="addIsPrivate" />
  102. <label for="addIsPrivate" style="margin:0;cursor:pointer">🔒 私有链接(需登录才能看到)</label>
  103. </div>
  104. <div>
  105. <button type="submit" class="btn-primary">添加链接</button>
  106. </div>
  107. </form>
  108. </div>
  109. <!-- ===== 修改密码 ===== -->
  110. <div class="tab-content" id="tab-password">
  111. <form id="pwdForm" autocomplete="off">
  112. <div class="form-group">
  113. <label for="oldPwd">当前密码</label>
  114. <input type="password" id="oldPwd" required autocomplete="current-password" />
  115. </div>
  116. <div class="form-row">
  117. <div class="form-group">
  118. <label for="newPwd">新密码(至少6位)</label>
  119. <input type="password" id="newPwd" required minlength="6" autocomplete="new-password" />
  120. </div>
  121. <div class="form-group">
  122. <label for="confirmPwd">确认新密码</label>
  123. <input type="password" id="confirmPwd" required minlength="6" autocomplete="new-password" />
  124. </div>
  125. </div>
  126. <div>
  127. <button type="submit" class="btn-primary">更新密码</button>
  128. </div>
  129. </form>
  130. </div>
  131. </div>
  132. <!-- ===== 编辑模态框 ===== -->
  133. <div class="modal-overlay" id="editModal">
  134. <div class="modal-box">
  135. <h2>编辑链接</h2>
  136. <form id="editForm" autocomplete="off">
  137. <input type="hidden" id="editId" />
  138. <div class="form-row">
  139. <div class="form-group">
  140. <label for="editTitle">标题 *</label>
  141. <input type="text" id="editTitle" required />
  142. </div>
  143. <div class="form-group">
  144. <label for="editUrl">链接地址 *</label>
  145. <input type="url" id="editUrl" required />
  146. </div>
  147. </div>
  148. <div class="form-group">
  149. <label for="editDesc">描述</label>
  150. <input type="text" id="editDesc" />
  151. </div>
  152. <div class="form-row">
  153. <div class="form-group">
  154. <label for="editGroup">分组名</label>
  155. <input type="text" id="editGroup" />
  156. </div>
  157. <div class="form-group">
  158. <label for="editKeywords">关键词</label>
  159. <input type="text" id="editKeywords" />
  160. </div>
  161. </div>
  162. <div class="form-row-3">
  163. <div class="form-group">
  164. <label for="editTagLabel">标签</label>
  165. <input type="text" id="editTagLabel" />
  166. </div>
  167. <div class="form-group">
  168. <label for="editTagClass">标签样式</label>
  169. <select id="editTagClass">
  170. <option value="">默认</option>
  171. <option value="ok">绿色 (ok)</option>
  172. <option value="warn">红色 (warn)</option>
  173. </select>
  174. </div>
  175. <div class="form-group">
  176. <label for="editMetaDomain">域名</label>
  177. <input type="text" id="editMetaDomain" />
  178. </div>
  179. </div>
  180. <div class="form-group">
  181. <label for="editIconSvg">SVG 图标</label>
  182. <textarea id="editIconSvg" rows="3"></textarea>
  183. </div>
  184. <div class="checkbox-row">
  185. <input type="checkbox" id="editIsPrivate" />
  186. <label for="editIsPrivate" style="margin:0;cursor:pointer">🔒 私有链接</label>
  187. </div>
  188. <div class="btn-row">
  189. <button type="button" id="editCancel">取消</button>
  190. <button type="submit" class="btn-primary">保存</button>
  191. </div>
  192. </form>
  193. </div>
  194. </div>
  195. <!-- Toast -->
  196. <div class="toast" id="toast"></div>
  197. <script src="app.js"></script>
  198. <script>
  199. (function(){
  200. 'use strict';
  201. const $ = (s) => document.querySelector(s);
  202. const $$ = (s) => [...document.querySelectorAll(s)];
  203. function getCsrf(){ return $('#csrfToken').value; }
  204. // ===== API =====
  205. async function apiGet(action){
  206. const r = await fetch(`api.php?action=${action}`);
  207. if(!r.ok){ const e = await r.json(); throw new Error(e.error || '请求失败'); }
  208. return r.json();
  209. }
  210. async function apiPost(action, data){
  211. const fd = new URLSearchParams({csrf_token: getCsrf()});
  212. for(const [k,v] of Object.entries(data)) fd.append(k, v);
  213. const r = await fetch(`api.php?action=${action}`, {
  214. method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
  215. body: fd.toString(),
  216. });
  217. if(!r.ok){ const e = await r.json(); throw new Error(e.error || '请求失败'); }
  218. return r.json();
  219. }
  220. // ===== 登录检查 =====
  221. apiGet('check_login').then(d => { if(!d.logged_in) window.location.href = 'index.php'; });
  222. // ===== 退出登录 =====
  223. $('#logoutBtn').addEventListener('click', async () => {
  224. await apiPost('logout', {});
  225. window.location.href = 'index.php';
  226. });
  227. // ===== 选项卡 =====
  228. $$('.tab').forEach(tab => {
  229. tab.addEventListener('click', () => {
  230. $$('.tab').forEach(t => t.classList.remove('active'));
  231. $$('.tab-content').forEach(t => t.classList.remove('active'));
  232. tab.classList.add('active');
  233. const target = document.getElementById('tab-' + tab.dataset.tab);
  234. if(target) target.classList.add('active');
  235. });
  236. });
  237. // ===== 加载链接列表 =====
  238. let allLinks = [];
  239. async function loadLinks(){
  240. const list = $('#linkList');
  241. const loading = $('#linkLoading');
  242. loading.style.display = '';
  243. list.innerHTML = '';
  244. try {
  245. const data = await apiGet('get_links');
  246. allLinks = [];
  247. for(const g of (data.groups || [])){
  248. for(const l of (g.links || [])){
  249. allLinks.push(l);
  250. }
  251. }
  252. renderLinks(allLinks);
  253. loading.style.display = 'none';
  254. } catch(e){
  255. loading.textContent = '加载失败:' + e.message;
  256. }
  257. }
  258. function renderLinks(links){
  259. const list = $('#linkList');
  260. if(links.length === 0){
  261. list.innerHTML = '<div style="text-align:center;padding:30px;color:var(--muted)">暂无链接</div>';
  262. return;
  263. }
  264. list.innerHTML = links.map(l => `
  265. <div class="link-item" data-id="${l.id}">
  266. <div class="info">
  267. <div class="title">${esc(l.title)}</div>
  268. <div class="url">${esc(l.url)}</div>
  269. </div>
  270. <span class="badge">${esc(l.group_name)}</span>
  271. ${l.is_private ? '<span class="badge private">🔒 私有</span>' : '<span class="badge">公开</span>'}
  272. <div class="item-actions">
  273. <button class="btn-sm edit-btn" data-id="${l.id}">编辑</button>
  274. <button class="btn-sm btn-danger delete-btn" data-id="${l.id}">删除</button>
  275. </div>
  276. </div>
  277. `).join('');
  278. list.querySelectorAll('.edit-btn').forEach(btn => {
  279. btn.addEventListener('click', () => openEdit(parseInt(btn.dataset.id)));
  280. });
  281. list.querySelectorAll('.delete-btn').forEach(btn => {
  282. btn.addEventListener('click', () => deleteLink(parseInt(btn.dataset.id)));
  283. });
  284. }
  285. // ===== 筛选 =====
  286. $('#filterLinks').addEventListener('input', () => {
  287. const v = $('#filterLinks').value.toLowerCase().trim();
  288. if(!v){ renderLinks(allLinks); return; }
  289. const filtered = allLinks.filter(l =>
  290. (l.title + ' ' + l.url + ' ' + l.group_name + ' ' + (l.keywords || '') + ' ' + (l.description || '')).toLowerCase().includes(v)
  291. );
  292. renderLinks(filtered);
  293. });
  294. $('#refreshLinks').addEventListener('click', loadLinks);
  295. // ===== 删除链接 =====
  296. async function deleteLink(id){
  297. if(!confirm('确定要删除此链接吗?')) return;
  298. try {
  299. const data = await apiPost('delete_link', {id});
  300. if(data.success){ toast('已删除'); loadLinks(); }
  301. else { toast(data.error || '删除失败', 'error'); }
  302. } catch(e){ toast(e.message, 'error'); }
  303. }
  304. // ===== 编辑链接 =====
  305. function openEdit(id){
  306. const l = allLinks.find(x => x.id === id);
  307. if(!l) return;
  308. $('#editId').value = l.id;
  309. $('#editTitle').value = l.title;
  310. $('#editUrl').value = l.url;
  311. $('#editDesc').value = l.description || '';
  312. $('#editGroup').value = l.group_name;
  313. $('#editKeywords').value = l.keywords || '';
  314. $('#editTagLabel').value = l.tag_label || '';
  315. $('#editTagClass').value = l.tag_class || '';
  316. $('#editMetaDomain').value = l.meta_domain || '';
  317. $('#editIconSvg').value = l.icon_svg || '';
  318. $('#editIsPrivate').checked = !!l.is_private;
  319. $('#editModal').classList.add('active');
  320. }
  321. $('#editCancel').addEventListener('click', () => $('#editModal').classList.remove('active'));
  322. $('#editModal').addEventListener('click', (e) => { if(e.target === $('#editModal')) $('#editModal').classList.remove('active'); });
  323. $('#editForm').addEventListener('submit', async (e) => {
  324. e.preventDefault();
  325. try {
  326. const result = await apiPost('update_link', {
  327. id: $('#editId').value,
  328. title: $('#editTitle').value,
  329. url: $('#editUrl').value,
  330. description: $('#editDesc').value,
  331. group_name: $('#editGroup').value,
  332. keywords: $('#editKeywords').value,
  333. tag_label: $('#editTagLabel').value,
  334. tag_class: $('#editTagClass').value,
  335. meta_domain: $('#editMetaDomain').value,
  336. icon_svg: $('#editIconSvg').value,
  337. is_private: $('#editIsPrivate').checked ? 1 : 0,
  338. });
  339. if(result.success){ toast('已更新'); $('#editModal').classList.remove('active'); loadLinks(); }
  340. else { toast(result.error || '更新失败', 'error'); }
  341. } catch(e){ toast(e.message, 'error'); }
  342. });
  343. // ===== 添加链接 =====
  344. $('#addForm').addEventListener('submit', async (e) => {
  345. e.preventDefault();
  346. try {
  347. const result = await apiPost('add_link', {
  348. title: $('#addTitle').value,
  349. url: $('#addUrl').value,
  350. description: $('#addDesc').value,
  351. group_name: $('#addGroup').value || 'default',
  352. group_label: $('#addGroupLabel').value,
  353. keywords: $('#addKeywords').value,
  354. tag_label: $('#addTagLabel').value,
  355. tag_class: $('#addTagClass').value,
  356. meta_domain: $('#addMetaDomain').value,
  357. icon_svg: $('#addIconSvg').value,
  358. is_private: $('#addIsPrivate').checked ? 1 : 0,
  359. });
  360. if(result.success){
  361. toast('链接已添加');
  362. $('#addForm').reset();
  363. $$('.tab').forEach(t => t.classList.remove('active'));
  364. $$('.tab-content').forEach(t => t.classList.remove('active'));
  365. document.querySelector('[data-tab="links"]').classList.add('active');
  366. document.getElementById('tab-links').classList.add('active');
  367. loadLinks();
  368. } else { toast(result.error || '添加失败', 'error'); }
  369. } catch(e){ toast(e.message, 'error'); }
  370. });
  371. // ===== 修改密码 =====
  372. $('#pwdForm').addEventListener('submit', async (e) => {
  373. e.preventDefault();
  374. const oldPwd = $('#oldPwd').value;
  375. const newPwd = $('#newPwd').value;
  376. const confirmPwd = $('#confirmPwd').value;
  377. if(newPwd !== confirmPwd){ toast('两次输入的新密码不一致', 'error'); return; }
  378. if(newPwd.length < 6){ toast('新密码至少6位', 'error'); return; }
  379. try {
  380. const result = await apiPost('update_password', {
  381. old_password: oldPwd, new_password: newPwd, confirm_password: confirmPwd,
  382. });
  383. if(result.success){ toast('密码已更新'); $('#pwdForm').reset(); }
  384. else { toast(result.error || '密码更新失败', 'error'); }
  385. } catch(e){ toast(e.message, 'error'); }
  386. });
  387. // ===== 启动 =====
  388. loadLinks();
  389. })();
  390. </script>
  391. </body>
  392. </html>