From 5bf2c1e80dc6dbdd15524f0c0ac5a925ccb0583b Mon Sep 17 00:00:00 2001 From: Snowz <372492339@qq.com> Date: Sun, 14 Dec 2025 16:51:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=89=8D=E7=AB=AF):=20=E5=AE=9E=E7=8E=B0S?= =?UTF-8?q?eed=E6=8A=93=E5=8F=96=E7=9A=84=E5=BC=82=E6=AD=A5=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=B8=8E=E6=8F=90=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将链接按钮改为带加载状态的交互按钮 - 添加异步请求逻辑,包含防抖和加载指示 - 实现三种状态的Toast提示(成功/无新增/错误) - 添加相关CSS样式和交互逻辑 --- README.md | 16 +++++++++ public/app.js | 86 +++++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 5 +-- public/styles.css | 40 ++++++++++++++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d372ec1..272000b 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,22 @@ - 生效方式:修改 `.env` 后重启服务 - 相关代码位置:`server.js:19`(环境变量读取)、`server.js:419`(远程/本地加载逻辑) +## 前端交互更新(Seed抓取) + +- 禁止跳转到 `/api/seed`,前端通过 `fetch` 异步触发 Seed 抓取 +- 成功无新增:黄色提示 `已执行Seed抓取,无新增数据` +- 成功有新增:绿色提示 `已执行Seed抓取,发现 ${新增数量} 条新数据` +- 错误:红色提示 `请求失败,请稍后重试` +- Toast:右下角非模态,自动隐藏 3 秒;颜色由代码控制 +- 加载:请求超过 500ms 时显示按钮内微型旋转指示,结束后自动隐藏 +- 防抖:点击 300ms 防抖,避免重复请求与误触 +- 代码位置: + - 触发按钮:`public/index.html:46`(`id="seedTrigger"`) + - 异步调用与提示:`public/app.js:275`(`runSeed`)、`public/app.js:97`(`toastBR`) + - 加载指示逻辑:`public/app.js:258`(`showSeedSpinner`,500ms 延迟显示) + - 计算新增数量:`public/app.js:267`(`calcNewCount`) + - 样式:`public/styles.css:614`(`.toast-br`)、`public/styles.css:636`(`.btn-spinner`)、`public/styles.css:648`(`.btn.is-loading`) + ## 常见问题(FAQ) - 修改 `seed.txt` 是否需要重启?不需要,`GET /api/seed` 会重新读取。 diff --git a/public/app.js b/public/app.js index 8cd0849..d6aac67 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,7 @@ const elBackTop = document.getElementById('backTop') const elSearchHint = document.getElementById('searchHint') const listContainer = document.querySelector('.list-container') + const elSeedTrigger = document.getElementById('seedTrigger') // Mobile Sidebar Logic const toggleSidebar = document.getElementById('toggleSidebar') @@ -86,6 +87,32 @@ toastTimer = setTimeout(() => t.classList.remove('show'), 1800) } + let toastBrTimer + /** + * 底部右侧Toast提示(非模态) + * @param {'success'|'info'|'error'} type 提示类型 + * @param {string} msg 提示文本 + * @param {number} duration 显示时长毫秒 + */ + const toastBR = (type, msg, duration = 3000) => { + let t = document.querySelector('.toast-br') + if (!t) { + t = document.createElement('div') + t.className = 'toast-br' + document.body.appendChild(t) + } + t.textContent = msg + const map = { + success: 'rgba(52,199,89,0.9)', // 绿色 + info: 'rgba(255,204,0,0.9)', // 黄色(无变化) + error: 'rgba(255,59,48,0.9)' // 红色 + } + t.style.background = map[type] || map.info + t.classList.add('show') + clearTimeout(toastBrTimer) + toastBrTimer = setTimeout(() => t.classList.remove('show'), duration) + } + /** * 渲染缓存条目 * @param {Array<{type:string,url:string,size:number,mtime:number}>} items 列表 @@ -220,6 +247,65 @@ load(true) }) + // Seed触发:异步调用并提示新增数量 + let seedBusy = false + let seedDebounceTimer + let seedSpinnerTimer + /** + * 显示/隐藏Seed按钮上的加载指示器 + * @param {boolean} show 是否显示 + */ + const showSeedSpinner = show => { + if (!elSeedTrigger) return + elSeedTrigger.classList.toggle('is-loading', !!show) + } + /** + * 计算新增条目数量 + * @param {{results:Array<{skipped:boolean,error?:string}>}} data 接口返回数据 + * @returns {number} + */ + const calcNewCount = data => { + const arr = Array.isArray(data?.results) ? data.results : [] + return arr.filter(x => !x.skipped && !x.error).length + } + /** + * 触发Seed抓取(带防抖、加载延时与错误处理) + * @returns {Promise} + */ + const runSeed = async () => { + if (seedBusy) return + seedBusy = true + if (elSeedTrigger) elSeedTrigger.disabled = true + // 超过500ms才显示加载指示 + seedSpinnerTimer = setTimeout(() => showSeedSpinner(true), 500) + try { + const resp = await fetch('/api/seed', { method: 'GET' }) + const data = await resp.json() + const newCount = calcNewCount(data) + if (newCount > 0) { + toastBR('success', `已执行Seed抓取,发现 ${newCount} 条新数据`) + } else { + toastBR('info', '已执行Seed抓取,无新增数据') + } + // 刷新列表 + load(true) + } catch (e) { + toastBR('error', '请求失败,请稍后重试') + } finally { + clearTimeout(seedSpinnerTimer) + showSeedSpinner(false) + seedBusy = false + if (elSeedTrigger) elSeedTrigger.disabled = false + } + } + if (elSeedTrigger) { + elSeedTrigger.addEventListener('click', () => { + if (seedBusy) return + clearTimeout(seedDebounceTimer) + seedDebounceTimer = setTimeout(runSeed, 300) + }) + } + // 虚拟滚动/无限加载 const io = new IntersectionObserver(entries => { entries.forEach(e => { diff --git a/public/index.html b/public/index.html index c5d84ed..d9cd984 100644 --- a/public/index.html +++ b/public/index.html @@ -43,9 +43,10 @@ diff --git a/public/styles.css b/public/styles.css index 5d1b2c4..f6f223b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -609,3 +609,43 @@ html, body { padding: 12px 16px; } } + +/* Toast (Bottom Right, Non-modal) */ +.toast-br { + position: fixed; + bottom: 24px; + right: 24px; + transform: translateY(0); + background: rgba(0,0,0,0.8); + color: #fff; + padding: 10px 16px; + border-radius: 20px; + font-size: 13px; + box-shadow: var(--shadow-md); + opacity: 0; + transition: all 0.3s ease; + pointer-events: none; + z-index: 2000; + backdrop-filter: blur(10px); +} +.toast-br.show { + opacity: 1; +} + +/* Button inner loading spinner (shown when .btn has .is-loading) */ +.btn-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + display: none; +} +.btn.is-loading .btn-spinner { + display: inline-block; +} +.btn.is-loading { + opacity: 0.9; + cursor: not-allowed; +}