Spaces:
Running
Running
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>叶兴阳双语音标有声读物 PDF 下载站</title> | |
| <style> | |
| :root { | |
| --primary: #6366f1; --primary-dark: #4f46e5; --primary-light: #818cf8; | |
| --accent: #f59e0b; --accent-light: #fbbf24; | |
| --bg: #f8fafc; --card-bg: #ffffff; | |
| --text: #1e293b; --text-secondary: #64748b; --text-muted: #94a3b8; | |
| --border: #e2e8f0; --hover: #f1f5f9; | |
| --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); | |
| --shadow-lg: 0 10px 40px rgba(0,0,0,0.1), 0 4px 12px rgba(0,0,0,0.05); | |
| --radius: 16px; --radius-sm: 12px; --selected: #e0e7ff; | |
| } | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{ | |
| font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei','PingFang SC',sans-serif; | |
| background:var(--bg);color:var(--text);min-height:100vh;line-height:1.6;font-size:15px; | |
| } | |
| /* === Navbar === */ | |
| .navbar{ | |
| background:rgba(255,255,255,0.92); | |
| backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px); | |
| border-bottom:1px solid var(--border);padding:0 2rem; | |
| display:flex;align-items:center;height:68px; | |
| position:sticky;top:0;z-index:100;gap:1.5rem; | |
| } | |
| .navbar .brand{ | |
| display:flex;align-items:center;gap:0.65rem; | |
| font-size:1.05rem;font-weight:700;color:var(--primary-dark); | |
| white-space:nowrap;letter-spacing:-0.01em;text-decoration:none; | |
| } | |
| .navbar .brand-icon{ | |
| width:38px;height:38px;border-radius:10px; | |
| background:linear-gradient(135deg,#4f46e5,#7c3aed); | |
| display:flex;align-items:center;justify-content:center;font-size:1.2rem; | |
| color:white;box-shadow:0 2px 8px rgba(99,102,241,0.3);flex-shrink:0; | |
| } | |
| .tabs{ | |
| display:flex;gap:0.2rem;overflow-x:auto;flex:1; | |
| -ms-overflow-style:none;scrollbar-width:none;align-items:center; | |
| } | |
| .tabs::-webkit-scrollbar{display:none} | |
| .tab-btn{ | |
| text-decoration:none;padding:0.45rem 0.9rem;border-radius:10px;border:none; | |
| background:transparent;cursor:pointer;font-size:0.82rem; | |
| white-space:nowrap;color:var(--text-secondary); | |
| transition:all 0.2s cubic-bezier(0.4,0,0.2,1); | |
| font-family:inherit;font-weight:500;display:inline-flex;align-items:center;gap:0.3rem; | |
| } | |
| .tab-btn:hover{background:var(--hover);color:var(--primary-dark)} | |
| .tab-btn.active{ | |
| background:linear-gradient(135deg,#4f46e5,#6366f1); | |
| color:white;font-weight:600;box-shadow:0 2px 8px rgba(79,70,229,0.35); | |
| } | |
| /* === Hero === */ | |
| .hero{ | |
| background:linear-gradient(135deg,#312e81 0%,#4f46e5 30%,#6366f1 60%,#7c3aed 100%); | |
| color:white;padding:2.75rem 1.5rem 2.25rem;text-align:center;position:relative;overflow:hidden; | |
| } | |
| .hero::before{ | |
| content:'';position:absolute;inset:0; | |
| background:url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none'%3E%3Cg fill='%23ffffff' fill-opacity='0.04'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); | |
| pointer-events:none; | |
| } | |
| .hero h1{ | |
| font-size:2.2rem;font-weight:800;margin-bottom:0.5rem;letter-spacing:-0.5px; | |
| position:relative;background:linear-gradient(to right,#ffffff,#e0e7ff); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text; | |
| } | |
| .hero .subtitle{opacity:0.88;font-size:0.95rem;position:relative;font-weight:400;margin-bottom:0.25rem} | |
| .hero .stats-row{display:flex;justify-content:center;gap:2.5rem;margin-top:1.5rem;position:relative;flex-wrap:wrap} | |
| .hero .stat-item{text-align:center} | |
| .hero .stat-num{font-size:1.85rem;font-weight:800;line-height:1.1} | |
| .hero .stat-label{font-size:0.78rem;opacity:0.72;margin-top:0.2rem;text-transform:uppercase;letter-spacing:1px} | |
| .hero .contact-bar{ | |
| margin-top:1.4rem;position:relative;display:inline-block; | |
| background:rgba(255,255,255,0.14);backdrop-filter:blur(8px); | |
| padding:0.55rem 1.4rem;border-radius:28px; | |
| font-size:0.9rem;cursor:pointer;border:1px solid rgba(255,255,255,0.2); | |
| transition:all 0.2s; | |
| } | |
| .hero .contact-bar:hover{background:rgba(255,255,255,0.22)} | |
| .hero .contact-bar strong{color:var(--accent);font-size:1.05rem;letter-spacing:0.5px} | |
| .hero .backup-bar{ | |
| margin-top:0.85rem;position:relative;display:inline-block; | |
| background:rgba(245,158,11,0.15);backdrop-filter:blur(6px); | |
| padding:0.45rem 1.15rem;border-radius:20px; | |
| font-size:0.82rem;border:1px solid rgba(245,158,11,0.25); | |
| } | |
| .hero .backup-bar a{color:var(--accent);font-weight:600;text-decoration:none} | |
| .hero .backup-bar a:hover{text-decoration:underline} | |
| /* === Content === */ | |
| .container{max-width:1200px;margin:0 auto;padding:1.25rem 1.25rem 2.5rem} | |
| /* 分类卡片 */ | |
| .cat-card{ | |
| background:var(--card-bg);border-radius:var(--radius-sm);box-shadow:var(--shadow); | |
| padding:1rem 1.25rem;margin-bottom:1rem;display:flex;align-items:center; | |
| justify-content:space-between;flex-wrap:wrap;gap:0.75rem;border:1px solid var(--border); | |
| } | |
| .cat-card .cat-info{display:flex;align-items:center;gap:0.75rem} | |
| .cat-card .cat-icon{ | |
| width:44px;height:44px;border-radius:12px; | |
| background:linear-gradient(135deg,#fef3c7,#fde68a);display:flex; | |
| align-items:center;justify-content:center;font-size:1.35rem;flex-shrink:0; | |
| } | |
| .cat-card h2{font-size:1.15rem;font-weight:700} | |
| .cat-card .tags{display:flex;gap:0.4rem} | |
| .cat-card .tag{ | |
| font-size:0.76rem;padding:0.18rem 0.6rem;border-radius:8px; | |
| background:#fef3c7;color:#92400e;font-weight:500; | |
| } | |
| .cat-card .count-badge{ | |
| font-size:0.82rem;padding:0.35rem 0.95rem;border-radius:20px; | |
| background:linear-gradient(135deg,var(--accent),#d97706);color:white;font-weight:600; | |
| box-shadow:0 2px 8px rgba(245,158,11,0.3); | |
| } | |
| /* 搜索 */ | |
| .search-area{ | |
| margin-bottom:0.75rem;position:relative; | |
| } | |
| .search-area .search-icon{position:absolute;left:0.75rem;top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:1rem;pointer-events:none} | |
| .search-area input{ | |
| width:100%;padding:0.65rem 1rem 0.65rem 2.6rem; | |
| border:1.5px solid var(--border);border-radius:14px;font-size:0.92rem; | |
| outline:none;background:white;transition:all 0.2s;font-family:inherit; | |
| } | |
| .search-area input:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(99,102,241,0.08)} | |
| /* 表格 */ | |
| .file-card{background:var(--card-bg);border-radius:var(--radius-sm);box-shadow:var(--shadow);overflow:hidden;border:1px solid var(--border)} | |
| .file-table{width:100%;border-collapse:collapse;font-size:0.92rem} | |
| .file-table th{ | |
| background:#f8fafc;padding:0.7rem 1rem;text-align:left;font-weight:600; | |
| color:var(--text-secondary);border-bottom:2px solid var(--border); | |
| font-size:0.78rem;text-transform:uppercase;letter-spacing:0.5px; | |
| } | |
| .file-table td{padding:0.6rem 1rem;border-bottom:1px solid #f1f5f9;vertical-align:middle} | |
| .file-table tbody tr{transition:all 0.15s} | |
| .file-table tbody tr:hover{background:var(--hover)} | |
| .file-table .file-link{color:var(--primary-dark);text-decoration:none;font-weight:500;transition:color 0.15s;word-break:break-all;font-size:0.92rem} | |
| .file-table .file-link:hover{color:var(--primary);text-decoration:underline} | |
| .file-table .file-link:visited{color:#7c3aed} | |
| /* 操作按钮组 */ | |
| .actions{display:flex;gap:0.35rem;align-items:center;flex-wrap:nowrap;justify-content:flex-end} | |
| .action-btn{ | |
| display:inline-flex;align-items:center;gap:0.2rem;padding:0.32rem 0.7rem; | |
| border-radius:8px;border:1px solid var(--border);background:white; | |
| cursor:pointer;font-size:0.78rem;transition:all 0.15s;font-family:inherit; | |
| font-weight:500;white-space:nowrap;text-decoration:none;color:var(--text-secondary); | |
| } | |
| .action-btn:hover{transform:translateY(-1px);box-shadow:0 1px 3px rgba(0,0,0,0.06)} | |
| .action-btn.btn-dl:hover{background:#eff6ff;color:#2563eb;border-color:#bfdbfe} | |
| .action-btn.btn-view:hover{background:#f0fdf4;color:#16a34a;border-color:#bbf7d0} | |
| .action-btn.btn-src:hover{background:#fffbeb;color:#d97706;border-color:#fde68a} | |
| /* 分页 */ | |
| .pager-wrap{ | |
| display:flex;align-items:center;justify-content:center;gap:0.3rem; | |
| padding:0.85rem;border-top:1px solid var(--border);flex-wrap:wrap; | |
| } | |
| .page-btn{ | |
| min-width:38px;height:38px;padding:0 0.6rem;border:1px solid var(--border); | |
| border-radius:10px;background:white;cursor:pointer;font-size:0.84rem; | |
| transition:all 0.15s;font-family:inherit;font-weight:500; | |
| display:inline-flex;align-items:center;justify-content:center; | |
| } | |
| .page-btn:hover:not(:disabled):not(.active){background:var(--primary-light);color:white;border-color:var(--primary-light)} | |
| .page-btn:disabled{opacity:0.25;cursor:not-allowed} | |
| .page-btn.active{background:var(--primary);color:white;border-color:var(--primary);box-shadow:0 2px 8px rgba(99,102,241,0.3)} | |
| .page-btn.arrow{font-size:0.95rem} | |
| .page-btn.ellipsis{pointer-events:none;border:none;background:transparent} | |
| .page-info{font-size:0.82rem;color:var(--text-muted);padding:0 0.6rem} | |
| .count-info{text-align:center;padding:0.6rem 1rem;font-size:0.83rem;color:var(--text-muted)} | |
| /* 加载 */ | |
| .loading-wrap{text-align:center;padding:4rem 2rem;color:var(--text-secondary)} | |
| .spinner{width:36px;height:36px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin 0.7s linear infinite;margin:0 auto 1rem} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .empty-state{text-align:center;padding:3rem 2rem;color:var(--text-muted)} | |
| .empty-state .empty-icon{font-size:2.5rem;margin-bottom:0.5rem;display:block} | |
| /* 页脚 */ | |
| .footer{text-align:center;padding:2rem;color:var(--text-muted);font-size:0.83rem;border-top:1px solid var(--border);margin-top:2rem} | |
| .footer .brand-name{font-weight:700;color:var(--text-secondary);font-size:0.92rem;margin-bottom:0.35rem} | |
| .footer a{color:var(--primary);text-decoration:none;font-weight:500} | |
| .footer a:hover{text-decoration:underline} | |
| /* 响应式 */ | |
| @media(max-width:768px){ | |
| .navbar{padding:0 0.75rem;height:56px} | |
| .navbar .brand{font-size:0.9rem} | |
| .navbar .brand-icon{width:30px;height:30px;border-radius:8px;font-size:1rem} | |
| .hero{padding:1.75rem 1rem} | |
| .hero h1{font-size:1.35rem} | |
| .hero .stats-row{gap:1rem} | |
| .hero .stat-num{font-size:1.35rem} | |
| .tab-btn{padding:0.35rem 0.6rem;font-size:0.73rem} | |
| .container{padding:0.75rem} | |
| .file-table{font-size:0.8rem} | |
| .file-table td,.file-table th{padding:0.45rem 0.6rem} | |
| .actions{flex-direction:column;align-items:flex-start} | |
| .action-btn{width:100%;justify-content:center} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- 导航栏 --> | |
| <nav class="navbar"> | |
| <a class="brand" href="/"> | |
| <span class="brand-icon">📚</span> | |
| <span>叶兴阳有声读物</span> | |
| </a> | |
| <div class="tabs" id="tabsContainer"></div> | |
| </nav> | |
| <!-- Hero --> | |
| <div class="hero"> | |
| <h1>叶兴阳双语音标有声读物 PDF 下载站</h1> | |
| <p class="subtitle">美式排版 · 英式排版 — 长篇名著 · 短篇精选 · 杂书经典 · 古典中文</p> | |
| <div class="stats-row"> | |
| <div class="stat-item"><div class="stat-num" id="statSources">-</div><div class="stat-label">分类</div></div> | |
| <div class="stat-item"><div class="stat-num" id="statTotal">-</div><div class="stat-label">PDF 文件</div></div> | |
| <div class="stat-item"><div class="stat-num" id="statCurrent">-</div><div class="stat-label">当前分类</div></div> | |
| </div> | |
| <div class="contact-bar" onclick="copyQQ()"> | |
| 💬 如需定制书籍,请联系 QQ:<strong>704100972</strong> | |
| </div> | |
| <div class="backup-bar"> | |
| 💡 阅读或下载遇到问题?请访问 <a href="https://huggingface.co/quejing" target="_blank" rel="noopener">HuggingFace / quejing</a> 直接下载 | |
| </div> | |
| </div> | |
| <div class="container"> | |
| <!-- 分类卡片 --> | |
| <div class="cat-card" id="catCard"> | |
| <div class="cat-info"> | |
| <div class="cat-icon" id="catIcon">📜</div> | |
| <div> | |
| <h2 id="catTitle">加载中...</h2> | |
| <div class="tags" id="catTags"></div> | |
| </div> | |
| </div> | |
| <span class="count-badge" id="catCount"></span> | |
| </div> | |
| <!-- 搜索 --> | |
| <div class="search-area"> | |
| <span class="search-icon">🔍</span> | |
| <input type="text" id="searchInput" placeholder="输入文件名关键词搜索..." oninput="onSearch(this.value)"> | |
| </div> | |
| <!-- 文件表格 --> | |
| <div class="file-card" id="fileContainer"> | |
| <div class="loading-wrap"><div class="spinner"></div><p>正在加载数据...</p></div> | |
| </div> | |
| </div> | |
| <!-- 页脚 --> | |
| <div class="footer"> | |
| <p class="brand-name">叶兴阳双语音标有声读物</p> | |
| <p>数据来源于 <a href="https://huggingface.co/quejing" target="_blank" rel="noopener">HuggingFace / quejing</a> · 本站仅提供索引链接,版权归原作者所有</p> | |
| </div> | |
| <script> | |
| // ==================== 全局状态 ==================== | |
| const DATA_DIR = 'data/'; | |
| const PAGE_SIZE = 50; | |
| let indexData = null; | |
| let currentIdx = 0; | |
| let currentPage = 1; | |
| let loadedChunks = {}; | |
| let chunkInFlight = {}; | |
| const CAT_ICONS = { | |
| '长篇·美式': '📖', '长篇·英式': '📗', | |
| '短篇·美式': '📘', '短篇·英式': '📙', | |
| '杂书名著·美式': '📚', '杂书名著·英式': '📛', | |
| '古典中文·美式': '🏯', '古典中文·英式': '🏯', | |
| }; | |
| const TAG_MAP = { | |
| normal:'普通', zashu:'杂书名著', zhongwen:'古典中文', | |
| meishi:'美式', yingshi:'英式', long:'长篇', short:'短篇' | |
| }; | |
| // ==================== 路由解析 ==================== | |
| function parseRoute() { | |
| const params = new URLSearchParams(window.location.search); | |
| const page = parseInt(params.get('page')) || 1; | |
| // 优先从 pathname 解析: /category/<id> | |
| const pathMatch = window.location.pathname.match(/^\/category\/(.+)$/); | |
| if (pathMatch) return { type: 'path', value: pathMatch[1], page: page }; | |
| // 回退 hash: #ES6%code | |
| const hash = window.location.hash.slice(1); | |
| if (hash) return { type: 'hash', value: decodeURIComponent(hash), page: page }; | |
| return null; | |
| } | |
| function updateUrl(catId, catName, page) { | |
| // 同时更新 path 和 hash,保持兼容 | |
| const pathUrl = '/category/' + catId; | |
| const query = (page && page > 1) ? '?page=' + page : ''; | |
| const hashUrl = '#' + encodeURIComponent(catName); | |
| const fullUrl = pathUrl + query + hashUrl; | |
| if (window.location.pathname !== pathUrl || window.location.search !== query) { | |
| history.pushState(null, '', fullUrl); | |
| } else { | |
| history.replaceState(null, '', fullUrl); | |
| } | |
| } | |
| // ==================== 初始化 ==================== | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| // 改用 API 接口加载索引 | |
| try { | |
| const resp = await fetch('/api/sources'); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | |
| indexData = await resp.json(); | |
| } catch (err) { | |
| document.getElementById('fileContainer').innerHTML = | |
| `<div class="empty-state"><span class="empty-icon">⚠️</span>索引加载失败: ${err.message}<br><small>请确认数据已生成</small></div>`; | |
| return; | |
| } | |
| const totalPdfs = indexData.sources.reduce((s, d) => s + d.total, 0); | |
| document.getElementById('statSources').textContent = indexData.sources.length; | |
| document.getElementById('statTotal').textContent = totalPdfs.toLocaleString(); | |
| if (indexData.sources.length > 0) { | |
| document.getElementById('statCurrent').textContent = indexData.sources[0].total.toLocaleString(); | |
| } | |
| // 渲染 Tab 导航 — 使用干净 URL: /category/<id> | |
| const tabsEl = document.getElementById('tabsContainer'); | |
| tabsEl.innerHTML = indexData.sources.map((src, i) => | |
| `<a class="tab-btn" href="/category/${src.id}" data-idx="${i}">${escHtml(src.name)}</a>` | |
| ).join(''); | |
| // Tab 点击拦截 — SPA 路由,不刷新页面 | |
| tabsEl.addEventListener('click', e => { | |
| const a = e.target.closest('.tab-btn'); | |
| if (!a) return; | |
| e.preventDefault(); | |
| const idx = parseInt(a.dataset.idx); | |
| if (!isNaN(idx) && idx !== currentIdx) switchSource(idx); | |
| }); | |
| // 解析当前路由 | |
| const route = parseRoute(); | |
| if (route) { | |
| let idx = -1; | |
| if (route.type === 'path') { | |
| idx = indexData.sources.findIndex(d => d.id === route.value); | |
| } else { | |
| idx = indexData.sources.findIndex(d => d.name === route.value); | |
| } | |
| if (idx >= 0) currentIdx = idx; | |
| if (route.page > 1) currentPage = route.page; | |
| } | |
| // 监听浏览器前进/后退 | |
| window.addEventListener('popstate', () => { | |
| const r = parseRoute(); | |
| if (r) { | |
| let idx = -1; | |
| if (r.type === 'path') idx = indexData.sources.findIndex(d => d.id === r.value); | |
| else idx = indexData.sources.findIndex(d => d.name === r.value); | |
| if (idx >= 0 && idx !== currentIdx) { | |
| currentIdx = idx; | |
| currentPage = r.page; | |
| applyTab(); | |
| } else if (idx >= 0 && r.page !== currentPage) { | |
| currentPage = r.page; | |
| renderCurrent(); | |
| document.getElementById('catCard')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| } | |
| }); | |
| // 旧版 hash 监听(兼容直接修改 hash 的场景) | |
| window.addEventListener('hashchange', () => { | |
| const h = decodeURIComponent(window.location.hash.slice(1)); | |
| const idx = indexData.sources.findIndex(d => d.name === h); | |
| if (idx >= 0 && idx !== currentIdx) { currentIdx = idx; applyTab(); } | |
| }); | |
| applyTab(); | |
| }); | |
| function applyTab() { | |
| document.querySelectorAll('.tab-btn').forEach((b, i) => b.classList.toggle('active', i === currentIdx)); | |
| currentPage = 1; | |
| const inp = document.getElementById('searchInput'); | |
| if (inp) inp.value = ''; | |
| renderCurrent(); | |
| } | |
| function switchSource(idx) { | |
| if (!indexData || idx === currentIdx) return; | |
| currentIdx = idx; | |
| currentPage = 1; | |
| const inp = document.getElementById('searchInput'); | |
| if (inp) inp.value = ''; | |
| const src = indexData.sources[idx]; | |
| updateUrl(src.id, src.name); | |
| applyTab(); | |
| } | |
| // ==================== 获取数据 ==================== | |
| function getSourceId() { return indexData.sources[currentIdx].id; } | |
| async function getFullFileList() { | |
| const src = indexData.sources[currentIdx]; | |
| const sid = src.id; | |
| // 优先使用 API 一次性获取 | |
| const searchTerm = (document.getElementById('searchInput')?.value || '').toLowerCase().trim(); | |
| let url; | |
| if (searchTerm) { | |
| url = '/api/search/' + sid + '?q=' + encodeURIComponent(searchTerm); | |
| } else if (!loadedChunks[sid]) { | |
| url = '/api/files/' + sid; | |
| } else { | |
| // 已缓存,直接使用 | |
| const allFiles = []; | |
| for (let ci = 0; ci < src.chunks.length; ci++) { | |
| if (loadedChunks[sid][ci]) allFiles.push(...loadedChunks[sid][ci]); | |
| } | |
| return allFiles; | |
| } | |
| if (chunkInFlight[sid]) { | |
| // 正在加载中,返回已缓存数据 | |
| const allFiles = []; | |
| for (let ci = 0; ci < src.chunks.length; ci++) { | |
| if (loadedChunks[sid][ci]) allFiles.push(...loadedChunks[sid][ci]); | |
| } | |
| return allFiles; | |
| } | |
| chunkInFlight[sid] = true; | |
| try { | |
| const resp = await fetch(url); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | |
| const files = await resp.json(); | |
| // 缓存结果(非搜索时) | |
| if (!searchTerm) { | |
| if (!loadedChunks[sid]) loadedChunks[sid] = {}; | |
| loadedChunks[sid][0] = files; // 全量缓存在 chunk 0 | |
| } | |
| return files; | |
| } catch (err) { | |
| console.error('加载数据失败:', err); | |
| // 回退到分片加载 | |
| return fallbackChunkLoad(src); | |
| } finally { | |
| delete chunkInFlight[sid]; | |
| } | |
| } | |
| async function fallbackChunkLoad(src) { | |
| const sid = src.id; | |
| if (!loadedChunks[sid]) loadedChunks[sid] = {}; | |
| const missingChunks = []; | |
| for (let ci = 0; ci < src.chunks.length; ci++) { | |
| if (!loadedChunks[sid][ci]) missingChunks.push(ci); | |
| } | |
| if (missingChunks.length > 0) { | |
| const promises = missingChunks.map(async ci => { | |
| if (chunkInFlight[sid + '_' + ci]) return; | |
| chunkInFlight[sid + '_' + ci] = true; | |
| try { | |
| const resp = await fetch(DATA_DIR + src.chunks[ci].f); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | |
| loadedChunks[sid][ci] = await resp.json(); | |
| } catch (err) { | |
| console.error('加载分片失败:', err); | |
| loadedChunks[sid][ci] = []; | |
| } finally { delete chunkInFlight[sid + '_' + ci]; } | |
| }); | |
| await Promise.all(promises); | |
| } | |
| const allFiles = []; | |
| for (let ci = 0; ci < src.chunks.length; ci++) { | |
| if (loadedChunks[sid][ci]) allFiles.push(...loadedChunks[sid][ci]); | |
| } | |
| return allFiles; | |
| } | |
| // ==================== 搜索 ==================== | |
| function onSearch(val) { | |
| clearTimeout(onSearch._t); | |
| stateSearchQ = val.trim().toLowerCase(); | |
| onSearch._t = setTimeout(() => { currentPage = 1; renderCurrent(); }, 250); | |
| } | |
| // ==================== 渲染 ==================== | |
| let lastRenderData = []; // 缓存当前渲染的文件列表,避免重复请求 | |
| async function renderCurrent() { | |
| const src = indexData.sources[currentIdx]; | |
| // 更新分类卡片 | |
| document.getElementById('catIcon').innerHTML = CAT_ICONS[src.name] || '📜'; | |
| document.getElementById('catTitle').textContent = src.name; | |
| document.getElementById('catCount').innerHTML = '<strong>' + src.total.toLocaleString() + '</strong> 个 PDF'; | |
| document.getElementById('statCurrent').textContent = src.total.toLocaleString(); | |
| document.getElementById('catTags').innerHTML = [ | |
| `<span class="tag">${TAG_MAP[src.genre] || src.genre}</span>`, | |
| `<span class="tag">${TAG_MAP[src.layout] || src.layout}</span>`, | |
| `<span class="tag">${TAG_MAP[src.length] || src.length}</span>`, | |
| ].join(''); | |
| const container = document.getElementById('fileContainer'); | |
| container.innerHTML = '<div class="loading-wrap"><div class="spinner"></div><p>正在加载数据...</p></div>'; | |
| try { | |
| const allFiles = await getFullFileList(); | |
| lastRenderData = allFiles; // 缓存 | |
| // API 搜索模式返回的是已过滤结果,不再二次过滤 | |
| const searchTerm = (document.getElementById('searchInput')?.value || '').toLowerCase().trim(); | |
| let filteredFiles; | |
| if (searchTerm && !chunkInFlight[src.id]) { | |
| // 缓存命中的情况下,需要客户端过滤 | |
| filteredFiles = allFiles.filter(f => f[0].toLowerCase().includes(searchTerm)); | |
| } else { | |
| filteredFiles = allFiles; | |
| } | |
| const totalPages = Math.ceil(filteredFiles.length / PAGE_SIZE) || 1; | |
| if (currentPage > totalPages) currentPage = totalPages; | |
| const start = (currentPage - 1) * PAGE_SIZE; | |
| const pageFiles = filteredFiles.slice(start, start + PAGE_SIZE); | |
| let html = ''; | |
| if (filteredFiles.length === 0) { | |
| html = '<div class="empty-state"><span class="empty-icon">📄</span>没有匹配的文件</div>'; | |
| } else { | |
| html = `<table class="file-table"> | |
| <thead><tr><th style="width:60%">文件名</th><th style="text-align:right">操作</th></tr></thead><tbody>`; | |
| for (let i = 0; i < pageFiles.length; i++) { | |
| const f = pageFiles[i]; | |
| const fn = f[0], url = f[1]; | |
| const previewUrl = '/reader?url=' + encodeURIComponent(url) + '&name=' + encodeURIComponent(fn); | |
| html += `<tr> | |
| <td><a class="file-link" href="${escUrl(url)}" target="_blank" rel="noopener" title="${escHtml(fn)}">${escHtml(fn)}</a></td> | |
| <td><div class="actions"> | |
| <a class="action-btn btn-dl" href="${escUrl(url)}" target="_blank" rel="noopener" title="直接下载">⬇ 下载</a> | |
| <a class="action-btn btn-view" href="${escUrl(previewUrl)}" target="_blank" rel="noopener" title="在线预览">📖 预览</a> | |
| <a class="action-btn btn-src" href="${escUrl(url)}" target="_blank" rel="noopener" title="查看原地址">🔗 原地址</a> | |
| </div></td> | |
| </tr>`; | |
| } | |
| html += '</tbody></table>'; | |
| // 分页 | |
| html += '<div class="count-info">共 <strong>' + filteredFiles.length.toLocaleString() + '</strong> 个文件</div>'; | |
| html += '<div class="pager-wrap">'; | |
| html += `<button class="page-btn arrow" onclick="goPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>←</button>`; | |
| const range = 2; | |
| let s = Math.max(1, currentPage - range); | |
| let e = Math.min(totalPages, s + range * 2); | |
| if (s > 1) { | |
| html += `<button class="page-btn num-btn" onclick="goPage(1)">1</button>`; | |
| if (s > 2) html += '<span class="page-btn ellipsis">…</span>'; | |
| } | |
| for (let p = s; p <= e; p++) { | |
| html += `<button class="page-btn num-btn ${p === currentPage ? 'active' : ''}" onclick="goPage(${p})">${p}</button>`; | |
| } | |
| if (e < totalPages) { | |
| if (e < totalPages - 1) html += '<span class="page-btn ellipsis">…</span>'; | |
| html += `<button class="page-btn num-btn" onclick="goPage(${totalPages})">${totalPages}</button>`; | |
| } | |
| html += `<button class="page-btn arrow" onclick="goPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>→</button>`; | |
| html += `<span class="page-info">${currentPage} / ${totalPages}</span>`; | |
| html += '</div>'; | |
| } | |
| container.innerHTML = html; | |
| } catch (err) { | |
| container.innerHTML = `<div class="empty-state"><span class="empty-icon">⚠️</span>数据加载失败: ${err.message}</div>`; | |
| } | |
| } | |
| function goPage(page) { | |
| if (!indexData || !lastRenderData.length) return; | |
| const searchTerm = (document.getElementById('searchInput')?.value || '').toLowerCase().trim(); | |
| const filtered = searchTerm | |
| ? lastRenderData.filter(f => f[0].toLowerCase().includes(searchTerm)) | |
| : lastRenderData; | |
| const total = Math.ceil(filtered.length / PAGE_SIZE) || 1; | |
| if (page < 1 || page > total) return; | |
| currentPage = page; | |
| const src = indexData.sources[currentIdx]; | |
| updateUrl(src.id, src.name, page); | |
| renderCurrent(); | |
| const el = document.getElementById('catCard'); | |
| if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| let stateSearchQ = ''; | |
| // ==================== 工具 ==================== | |
| function copyQQ() { | |
| navigator.clipboard.writeText('704100972').then(() => { | |
| alert('QQ号 704100972 已复制到剪贴板'); | |
| }).catch(() => { | |
| prompt('QQ号:704100972(请手动复制)', '704100972'); | |
| }); | |
| } | |
| function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } | |
| function escUrl(s) { return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); } | |
| function escAttr(s) { return escUrl(s).replace(/'/g, "'"); } | |
| </script> | |
| </body> | |
| </html> | |