index.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. <?php require_once __DIR__ . '/../init.php'; ?><!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8" />
  5. <meta name="viewport" content="width=device-width,initial-scale=1" />
  6. <meta name="color-scheme" content="light dark" />
  7. <title>BCJD 导航</title>
  8. <link rel="stylesheet" href="style.css" />
  9. </head>
  10. <body class="index-page">
  11. <div class="wrap">
  12. <header>
  13. <div class="brand">
  14. <h1>BCJD 服务导航</h1>
  15. <div class="sub">统一入口 · 快速跳转 · 显示时区:<span class="mono">UTC+8</span></div>
  16. </div>
  17. <div class="toolbar">
  18. <div class="pill" id="clock">--:--:-- (UTC+8)</div>
  19. <button id="themeBtn" title="切换主题">主题:跟随系统</button>
  20. <button id="loginBtn" title="登录">登录</button>
  21. <button id="adminBtn" title="管理" style="display:none">管理</button>
  22. </div>
  23. </header>
  24. <div class="search" role="search" aria-label="Search">
  25. <span class="mono" style="opacity:.7">/</span>
  26. <input id="q" placeholder="搜索服务…(回车打开第一个)" autocomplete="off" />
  27. <kbd>Enter</kbd>
  28. </div>
  29. <!-- 加载状态 -->
  30. <div id="loading" style="text-align:center;padding:40px;color:var(--muted);font-size:14px">加载中…</div>
  31. <div class="board" id="board" style="display:none">
  32. <!-- 由 JS 动态渲染 -->
  33. <div class="empty" id="emptyState">没有找到匹配的服务。</div>
  34. </div>
  35. <footer>
  36. <div>提示:按 <span class="mono">/</span> 聚焦搜索;按 <span class="mono">Enter</span> 打开第一个结果;按 <span class="mono">Esc</span> 清空搜索。</div>
  37. <div class="mono">Updated (UTC+8): <span id="ts">--</span></div>
  38. </footer>
  39. </div>
  40. <!-- ===== 登录模态框 ===== -->
  41. <div class="modal-overlay" id="loginModal">
  42. <div class="modal-box">
  43. <h2>登录</h2>
  44. <p>请输入站点密码以解锁全部链接。</p>
  45. <label for="loginPwd">密码</label>
  46. <input type="password" id="loginPwd" placeholder="输入密码" autocomplete="current-password" />
  47. <div class="btn-row">
  48. <button id="loginCancel">取消</button>
  49. <button id="loginConfirm" class="btn-primary">登录</button>
  50. </div>
  51. <div class="modal-error" id="loginError"></div>
  52. </div>
  53. </div>
  54. <script src="app.js"></script>
  55. <script>
  56. (function(){
  57. 'use strict';
  58. const $ = (s) => document.querySelector(s);
  59. const $$ = (s) => [...document.querySelectorAll(s)];
  60. const board = $("#board");
  61. const loading = $("#loading");
  62. const emptyState = $("#emptyState");
  63. const q = $("#q");
  64. const loginBtn = $("#loginBtn");
  65. const adminBtn = $("#adminBtn");
  66. const loginModal = $("#loginModal");
  67. const loginPwd = $("#loginPwd");
  68. const loginError = $("#loginError");
  69. const loginCancel = $("#loginCancel");
  70. const loginConfirm = $("#loginConfirm");
  71. let loggedIn = false;
  72. let allGroups = [];
  73. // ===== 时钟 =====
  74. const TZ = "Asia/Shanghai";
  75. const fmtClock = new Intl.DateTimeFormat("zh-CN", {
  76. timeZone: TZ, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
  77. });
  78. function formatYMDHMS_UTC8(d){
  79. const parts = new Intl.DateTimeFormat("zh-CN", {
  80. timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit",
  81. hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
  82. }).formatToParts(d);
  83. const get = (t) => parts.find(p => p.type === t)?.value || "";
  84. return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")}`;
  85. }
  86. function tick(){
  87. const d = new Date();
  88. $("#clock").textContent = `${fmtClock.format(d)} (UTC+8)`;
  89. $("#ts").textContent = formatYMDHMS_UTC8(d);
  90. }
  91. tick(); setInterval(tick, 1000);
  92. // ===== 主题切换 =====
  93. const themeBtn = $("#themeBtn");
  94. const root = document.documentElement;
  95. function getThemeLabel(){
  96. const cur = root.getAttribute("data-theme");
  97. if(cur === "dark") return "主题:深色";
  98. if(cur === "light") return "主题:浅色";
  99. return "主题:跟随系统";
  100. }
  101. function refreshThemeLabel(){ themeBtn.textContent = getThemeLabel(); }
  102. function setTheme(mode){
  103. if(!mode){ root.removeAttribute("data-theme"); localStorage.removeItem("theme"); refreshThemeLabel(); return; }
  104. root.setAttribute("data-theme", mode); localStorage.setItem("theme", mode); refreshThemeLabel();
  105. }
  106. const saved = localStorage.getItem("theme");
  107. if(saved === "dark" || saved === "light") setTheme(saved); else refreshThemeLabel();
  108. themeBtn.addEventListener("click", () => {
  109. const cur = root.getAttribute("data-theme");
  110. if(cur === "dark") setTheme("light"); else if(cur === "light") setTheme(null); else setTheme("dark");
  111. });
  112. // ===== API 请求 =====
  113. async function apiGet(action){
  114. const r = await fetch(`api.php?action=${action}`);
  115. if(!r.ok) { const e = await r.json(); throw new Error(e.error || '请求失败'); }
  116. return r.json();
  117. }
  118. async function apiPost(action, data){
  119. const fd = new URLSearchParams();
  120. for(const [k,v] of Object.entries(data)) fd.append(k, v);
  121. const r = await fetch(`api.php?action=${action}`, {
  122. method: 'POST',
  123. headers: {'Content-Type':'application/x-www-form-urlencoded'},
  124. body: fd.toString(),
  125. });
  126. if(!r.ok) { const e = await r.json(); throw new Error(e.error || '请求失败'); }
  127. return r.json();
  128. }
  129. // ===== 渲染链接 =====
  130. function renderLinks(groups){
  131. board.innerHTML = '';
  132. for(const g of groups){
  133. if(!g.links || g.links.length === 0) continue;
  134. const section = document.createElement('section');
  135. section.className = 'section-block';
  136. section.dataset.group = g.name;
  137. const header = document.createElement('div');
  138. header.className = 'section';
  139. header.innerHTML = `<h2>${esc(g.label || g.name)}</h2><div class="hint">${g.hint || ''}</div>`;
  140. const grid = document.createElement('div');
  141. grid.className = 'grid';
  142. for(const link of g.links){
  143. const card = document.createElement('a');
  144. card.className = 'card' + (link.is_private ? ' private' : '');
  145. card.href = link.url;
  146. card.target = '_blank';
  147. card.rel = 'noopener';
  148. card.dataset.group = g.name;
  149. card.dataset.keywords = (link.keywords || '') + ' ' + link.title + ' ' + (link.description || '');
  150. const tagClass = link.tag_class ? `tag ${link.tag_class}` : 'tag';
  151. card.innerHTML = `
  152. <div class="top">
  153. <div class="title">
  154. <div class="icon" aria-hidden="true">${link.icon_svg || ''}</div>
  155. <div style="min-width:0">
  156. <div class="name">${esc(link.title)}</div>
  157. <div class="meta">${esc(link.meta_domain)}</div>
  158. </div>
  159. </div>
  160. <div class="tags">
  161. ${link.tag_label ? `<span class="${tagClass}">${esc(link.tag_label)}</span>` : ''}
  162. ${link.is_private ? '<span class="tag" style="color:var(--danger);border-color:rgba(255,107,107,.3);background:rgba(255,107,107,.08)">🔒 私有</span>' : ''}
  163. </div>
  164. </div>
  165. <div class="desc">${esc(link.description)}</div>
  166. <div class="actions">
  167. <span class="go">打开 →</span>
  168. <span class="small">${link.url.replace(/^https?:\/\//,'').split('/')[0]}</span>
  169. </div>
  170. `;
  171. grid.appendChild(card);
  172. }
  173. section.appendChild(header);
  174. section.appendChild(grid);
  175. board.appendChild(section);
  176. }
  177. board.appendChild(emptyState);
  178. applyFilter();
  179. }
  180. // ===== 获取并渲染链接 =====
  181. async function loadLinks(){
  182. loading.style.display = '';
  183. board.style.display = 'none';
  184. try {
  185. const data = await apiGet('get_links');
  186. loggedIn = data.logged_in;
  187. allGroups = data.groups || [];
  188. renderLinks(allGroups);
  189. loading.style.display = 'none';
  190. board.style.display = '';
  191. loginBtn.textContent = loggedIn ? '已登录' : '登录';
  192. adminBtn.style.display = loggedIn ? '' : 'none';
  193. } catch(e){
  194. loading.textContent = '加载失败:' + e.message;
  195. }
  196. }
  197. // ===== 搜索 =====
  198. function norm(s){ return (s || "").toLowerCase().trim(); }
  199. function visibleCards(){ return $$(".card").filter(c => c.style.display !== "none"); }
  200. function updateGroups(){
  201. for(const group of $$(".section-block")){
  202. const key = group.dataset.group;
  203. const count = $$(".card").filter(c => c.dataset.group === key && c.style.display !== "none").length;
  204. group.style.display = count > 0 ? "" : "none";
  205. }
  206. }
  207. function applyFilter(){
  208. const v = norm(q.value);
  209. for(const c of $$(".card")){
  210. const hay = norm((c.dataset.keywords || "") + " " + c.textContent);
  211. c.style.display = (!v || hay.includes(v)) ? "" : "none";
  212. }
  213. updateGroups();
  214. emptyState.style.display = visibleCards().length ? "none" : "block";
  215. }
  216. q.addEventListener("input", applyFilter);
  217. q.addEventListener("keydown", (e) => {
  218. if(e.key === "Enter"){ const first = visibleCards()[0]; if(first) first.click(); }
  219. if(e.key === "Escape"){ q.value = ""; applyFilter(); q.blur(); }
  220. });
  221. document.addEventListener("keydown", (e) => {
  222. if(e.key === "/" && document.activeElement !== q){ e.preventDefault(); q.focus(); }
  223. });
  224. // ===== 登录逻辑 =====
  225. function showLoginModal(){
  226. loginModal.classList.add('active'); loginPwd.value = '';
  227. loginError.style.display = 'none'; loginPwd.focus();
  228. }
  229. function hideLoginModal(){ loginModal.classList.remove('active'); }
  230. loginBtn.addEventListener('click', () => {
  231. if(loggedIn){
  232. if(!confirm('确定要退出登录吗?')) return;
  233. apiPost('logout', {}).then(() => { loggedIn = false; loadLinks(); }).catch(e => alert(e.message));
  234. } else {
  235. showLoginModal();
  236. }
  237. });
  238. function doLogin(){
  239. const pwd = loginPwd.value;
  240. if(!pwd){ loginError.textContent = '请输入密码'; loginError.style.display = ''; return; }
  241. loginConfirm.disabled = true; loginConfirm.textContent = '登录中…';
  242. apiPost('login', {password: pwd}).then(data => {
  243. if(data.success){ window.location.href = 'admin.php'; }
  244. else { loginError.textContent = data.error || '密码错误'; loginError.style.display = ''; loginConfirm.disabled = false; loginConfirm.textContent = '登录'; }
  245. }).catch(e => {
  246. loginError.textContent = e.message; loginError.style.display = '';
  247. loginConfirm.disabled = false; loginConfirm.textContent = '登录';
  248. });
  249. }
  250. loginConfirm.addEventListener('click', doLogin);
  251. loginPwd.addEventListener('keydown', (e) => { if(e.key === 'Enter') doLogin(); });
  252. loginCancel.addEventListener('click', hideLoginModal);
  253. loginModal.addEventListener('click', (e) => { if(e.target === loginModal) hideLoginModal(); });
  254. // ===== 管理面板 =====
  255. adminBtn.addEventListener('click', () => { window.location.href = 'admin.php'; });
  256. // ===== 启动 =====
  257. loadLinks();
  258. })();
  259. </script>
  260. </body>
  261. </html>