diff --git a/README.md b/README.md index 51c8a56..61c6492 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - TXT 解析与批量:`server.js:164`、`server.js:176` - 静态目录映射:`server.js:192`–`server.js:193` - 公共路径与完整 URL:`server.js:107`(`getPublicPath`)、`server.js:121`(`buildAccessUrl`) +- 前端首页注册:`server.js:42`(`registerPublicHomepage`) ## 快速开始 @@ -30,12 +31,86 @@ - 启动服务:`npm start` - 健康检查:`GET /health` → `{"ok":true}` +## 前端首页 + +- 访问路径:`/` +- 内容包含:项目简介、健康检查入口、触发 Seed 抓取入口、免责声明与推荐公共 CDN 列表、已缓存资源的可视化列表(仅展示可直接复制的完整 URL) +- 目的:为内部用户提供更直观的访问入口与使用指引 + - 高级功能:分页加载(20–50/页)、按名称/类型/更新时间过滤、按名称/大小/时间排序、懒加载与轻量虚拟滚动、统一暗色科技风视觉与动画 + +## 使用声明(免责声明) + +- 本网站收录的开源库均仅支持内部使用,不对外提供公共 CDN 服务 +- 如需稳定的外部公共库服务,请使用以下成熟 CDN: + - BootCDN 加速服务:`https://www.bootcdn.cn/` + - CDNJS 前端公共库:`https://cdnjs.com/` + - jsDelivr:`https://www.jsdelivr.com/` + - 七牛免费 CDN 前端公开库:`https://www.staticfile.org/` + - 又拍云常用 JavaScript 库 CDN 服务:`http://jscdn.upai.com/` + - Google Hosted Libraries:`https://developers.google.com/speed/libraries` + - Microsoft Ajax CDN:`https://ajax.aspnetcdn.com/` + ## 接口说明 - `GET /api/seed` - 从项目内置 `seed.txt` 读取并批量抓取,无需重启 - 管理抓取仅通过修改服务器上的 `seed.txt` 实现,服务端不接受外部提交 URL +- `GET /api/list-cache` + - 列出当前已缓存的 CSS/JS 文件,按最近修改时间倒序 + - 查询参数: + - `type`: `css` | `js`(可选) + - `q`: 关键字(可选,匹配路径片段) + - `limit`: 最大返回条数(默认 200,最多 2000) + - 返回示例: + ```json + { + "count": 200, + "total": 1234, + "hasMore": true, + "items": [ + { "type": "css", "path": "/css/npm/.../file.css", "url": "http://host/css/npm/.../file.css", "size": 12345, "mtime": 1730000000000 } + ] + } + ``` + +### 接口更新(list-cache 扩展) + +- `GET /api/list-cache` + - 支持分页、排序与更丰富的过滤参数: + - `type`: `css` | `js`(可选) + - `q`: 关键字(匹配路径片段与URL,模糊搜索) + - `name`: 库名称(匹配解析出的名称,模糊搜索) + - `updatedFrom` / `updatedTo`: 毫秒时间戳范围过滤(可选) + - `sortBy`: `mtime` | `name` | `size`(默认 `mtime`) + - `order`: `asc` | `desc`(默认 `desc`) + - `page`: 页码(默认 1) + - `pageSize`: 每页条数(默认 30,范围 20–50) + - 返回字段增加:`page`、`pageSize`、`name`、`version`、`ext`、`category` + - 示例: + ```json + { + "count": 30, + "total": 1234, + "page": 1, + "pageSize": 30, + "hasMore": true, + "items": [ + { + "type": "css", + "path": "/css/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css", + "url": "http://host/css/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css", + "size": 12345, + "mtime": 1730000000000, + "name": "bootstrap", + "version": "5.3.0", + "ext": ".css", + "category": "bootstrap" + } + ] + } + ``` + ## 静态访问 - CSS:`/css/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css` @@ -82,4 +157,8 @@ - `.env` 支持(`PORT`),适配多端口部署 - 接口响应统一返回公共路径 `saved` 与完整 URL `accessUrl` - 依赖升级:`express@^5.1.0`、`multer@^2.0.2`、`axios@^1.13.2`、`morgan@^1.10.1` - - 移除外部提交接口:`POST /api/upload-txt` 与 `POST /api/cache`,仅支持通过 `seed.txt` 批量抓取 \ No newline at end of file +- 移除外部提交接口:`POST /api/upload-txt` 与 `POST /api/cache`,仅支持通过 `seed.txt` 批量抓取 +- 新增前端首页与公共静态目录(`public/`),主页包含免责声明及推荐公共 CDN 列表 +- 新增 `GET /api/list-cache` 接口,支持过滤、限制与前端展示一键复制 +- 升级首页缓存区域视觉与交互:统一暗色科技风、移除相对路径展示、仅保留完整 URL 复制与打开、优化响应式布局与过渡动画 + - 增强缓存管理:分页加载(20–50/页)、按名称/类型/更新时间过滤、按名称/大小/时间排序、轻量虚拟滚动与懒加载、元数据解析(库名/版本/扩展名/类别) diff --git a/package-lock.json b/package-lock.json index d3e0727..f6bc9bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,7 @@ "axios": "^1.13.2", "dotenv": "^16.4.5", "express": "^5.1.0", - "morgan": "^1.10.1", - "multer": "^2.0.2" + "morgan": "^1.10.1" } }, "node_modules/accepts": { @@ -53,12 +52,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -185,23 +178,6 @@ "node": ">= 0.6" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -252,21 +228,6 @@ "node": ">= 0.8" } }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -801,15 +762,6 @@ "node": ">= 0.4" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -843,27 +795,6 @@ "node": ">= 0.6" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -898,24 +829,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" - }, - "engines": { - "node": ">= 10.16.0" - } - }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -925,15 +838,6 @@ "node": ">= 0.6" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1094,20 +998,6 @@ "node": ">= 0.8" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1147,26 +1037,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1345,23 +1215,6 @@ "node": ">= 0.8" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1371,25 +1224,6 @@ "node": ">=0.6" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1399,12 +1233,6 @@ "node": ">= 0.8" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1419,15 +1247,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } } } } diff --git a/package.json b/package.json index 81d5555..2469baf 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "axios": "^1.13.2", "express": "^5.1.0", "morgan": "^1.10.1", - "multer": "^2.0.2", "dotenv": "^16.4.5" } } \ No newline at end of file diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..8df00fc --- /dev/null +++ b/public/app.js @@ -0,0 +1,196 @@ +(() => { + const state = { type: '', q: '', limit: 200 } + + const elList = document.getElementById('list') + const elStats = document.getElementById('stats') + const elSearch = document.getElementById('search') + const elRefresh = document.getElementById('refresh') + const segBtns = Array.from(document.querySelectorAll('.seg-btn')) + const elSortBy = document.getElementById('sortBy') + const elOrder = document.getElementById('order') + const elPageSize = document.getElementById('pageSize') + const elTimeRange = document.getElementById('timeRange') + const elSentinel = document.getElementById('sentinel') + const elBackTop = document.getElementById('backTop') + + /** + * 格式化字节大小为易读文本 + * @param {number} n 字节数 + * @returns {string} + */ + const fmtSize = n => { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB` + return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB` + } + + /** + * 将毫秒时间戳格式化为本地时间字符串 + * @param {number} ms 毫秒 + * @returns {string} + */ + const fmtTime = ms => new Date(ms).toLocaleString() + + /** + * 复制文本到剪贴板 + * @param {string} text 文本 + * @returns {Promise} + */ + const copy = async text => { + try { + await navigator.clipboard.writeText(text) + toast('已复制到剪贴板') + } catch (e) { + console.error(e) + toast('复制失败,请手动选择复制', true) + } + } + + let toastTimer + /** + * 显示轻提示 + * @param {string} msg 提示内容 + * @param {boolean} warn 是否警示样式 + */ + const toast = (msg, warn) => { + let t = document.querySelector('.toast') + if (!t) { + t = document.createElement('div') + t.className = 'toast' + document.body.appendChild(t) + } + t.textContent = msg + t.style.background = warn ? 'rgba(255,96,96,0.9)' : 'rgba(108,140,255,0.9)' + t.classList.add('show') + clearTimeout(toastTimer) + toastTimer = setTimeout(() => t.classList.remove('show'), 1800) + } + + /** + * 渲染缓存条目,仅展示完整URL与操作 + * @param {Array<{type:string,url:string,size:number,mtime:number}>} items 列表 + */ + /** + * 渲染缓存条目,仅展示完整URL与操作 + * - 维持最大 DOM 节点数量,超量时自动移除顶部旧节点(轻量虚拟化) + * @param {Array<{type:string,url:string,size:number,mtime:number,name?:string,version?:string}>} items 列表 + */ + const renderItems = items => { + elList.innerHTML = '' + if (!items.length) { + elList.innerHTML = '
暂无数据
' + return + } + const frag = document.createDocumentFragment() + for (const it of items) { + const card = document.createElement('div') + card.className = 'item' + card.innerHTML = ` +
+ ${it.type.toUpperCase()} + ${it.url} +
+
+ ${fmtSize(it.size)} + ${fmtTime(it.mtime)} +
+
+ + 打开 +
+ ` + card.querySelector('[data-act="copy-url"]').addEventListener('click', () => copy(it.url)) + frag.appendChild(card) + } + elList.appendChild(frag) + } + + /** + * 加载缓存列表数据并更新视图 + * @returns {Promise} + */ + /** + * 加载缓存列表数据并更新视图(分页 + 过滤 + 排序) + * - 支持增量加载,当页码递增时附加到列表 + * @param {boolean} reset 是否重置列表 + * @returns {Promise} + */ + let loading = false + let page = 1 + let pageSize = Number(elPageSize.value || 30) + let hasMore = true + let itemsBuf = [] + const load = async (reset = false) => { + if (loading) return + if (reset) { page = 1; itemsBuf = []; elList.innerHTML = ''; hasMore = true } + if (!hasMore && !reset) return + loading = true + elStats.textContent = '加载中...' + const u = new URL('/api/list-cache', location.origin) + if (state.type) u.searchParams.set('type', state.type) + if (state.q) u.searchParams.set('q', state.q) + const now = Date.now() + const hours = Number(elTimeRange.value || 0) + if (hours > 0) u.searchParams.set('updatedFrom', String(now - hours * 3600 * 1000)) + u.searchParams.set('sortBy', elSortBy.value) + u.searchParams.set('order', elOrder.value) + u.searchParams.set('page', String(page)) + u.searchParams.set('pageSize', String(pageSize)) + const r = await fetch(u) + const data = await r.json() + itemsBuf = reset ? (data.items || []) : itemsBuf.concat(data.items || []) + hasMore = !!data.hasMore + elStats.textContent = `共 ${data.total} 条,已加载 ${itemsBuf.length}${hasMore ? '(继续下拉加载)' : ''}` + renderItems(itemsBuf) + page += 1 + loading = false + } + + // 事件绑定 + segBtns.forEach(btn => { + btn.addEventListener('click', () => { + segBtns.forEach(b => b.classList.remove('is-active')) + btn.classList.add('is-active') + state.type = btn.dataset.type || '' + load() + }) + }) + elSearch.addEventListener('input', () => { + state.q = elSearch.value.trim() + load(true) + }) + elSortBy.addEventListener('change', () => load(true)) + elOrder.addEventListener('change', () => load(true)) + elPageSize.addEventListener('change', () => { pageSize = Number(elPageSize.value || 30); load(true) }) + elTimeRange.addEventListener('change', () => load(true)) + elRefresh.addEventListener('click', async () => { + try { + const r = await fetch('/api/seed') + const j = await r.json() + toast(`Seed 完成:${j.count} 项`) + } catch {} + load(true) + }) + + // 轻量虚拟滚动:靠近底部即加载下一页;大量节点时隐藏返回顶部按钮控制 + const io = new IntersectionObserver(entries => { + entries.forEach(e => { + if (e.isIntersecting) load(false) + }) + }) + io.observe(elSentinel) + + // 返回顶部按钮展示与交互 + const onScroll = () => { + const show = (document.documentElement.scrollTop || document.body.scrollTop) > 400 + elBackTop.classList.toggle('show', show) + } + window.addEventListener('scroll', onScroll) + elBackTop.addEventListener('click', () => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }) + + // 首次加载 + load(true) +})() diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..cc7072d --- /dev/null +++ b/public/index.html @@ -0,0 +1,130 @@ + + + + + + Asset Cache - 前端首页 + + + + + + +
+
+

Asset Cache

+

内部资源缓存与静态分发

+

本网站收录的开源库均仅支持内部使用。

+ +
+
+ +
+
+

使用说明

+

+ 为保证外部依赖的稳定性,请优先选择成熟公共 CDN 服务。以下为常用公共库加速服务的入口与地址: +

+
+ +
+
+

BootCDN 加速服务

+

开源项目免费 CDN 服务,主要同步于 CDNJS 仓库。

+ https://www.bootcdn.cn/ +
+
+

CDNJS 前端公共库

+

由社区维护的大型公共库集合,覆盖范围广。

+ https://cdnjs.com/ +
+ + +
+

又拍云常用 JavaScript 库 CDN 服务

+

托管常用 JS 库,直接通过 CDN 加速引用。

+ http://jscdn.upai.com/ +
+ + +
+ +
+
+

已缓存资源

+
+
+ + + +
+ + + + + + +
+
+
加载中...
+
+
加载更多...
+ +
+ + +
+ + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..0c351d3 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,120 @@ +:root { + --bg: #0b0f1a; + --fg: #e6e9ef; + --muted: #a9b1bd; + --primary: #6c8cff; + --primary-2: #8ea8ff; + --card: #121725; + --border: #1f2538; + --good: #5be49b; + --warn: #ff9966; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; + background: radial-gradient(1200px 600px at 10% -20%, #132042 0%, #0b0f1a 60%), var(--bg); + color: var(--fg); +} + +.container { + max-width: 1100px; + padding: 0 24px; + margin: 0 auto; +} + +.hero { + padding: 80px 0 48px; + background: linear-gradient(180deg, rgba(108,140,255,0.10), rgba(108,140,255,0.0)); + border-bottom: 1px solid var(--border); +} +.hero h1 { font-size: 44px; margin: 0; letter-spacing: 0.5px; } +.hero .subtitle { font-weight: 700; color: var(--primary-2); margin: 12px 0 8px; } +.hero .desc { color: var(--muted); margin: 0 0 16px; } + +.cta-group { display: flex; gap: 12px; flex-wrap: wrap; } +.btn { + display: inline-flex; + align-items: center; + padding: 10px 14px; + border-radius: 10px; + background: var(--primary); + color: #fff; + text-decoration: none; + border: 1px solid rgba(255,255,255,0.1); + transition: transform .12s ease, box-shadow .12s ease; +} +.btn:hover { transform: translateY(-1px); box-shadow: 0 8px 30px rgba(108,140,255,0.25); } +.btn.btn-outline { background: transparent; color: var(--primary-2); border-color: var(--primary-2); } + +main { padding: 40px 0; } +.notice h2 { margin: 0 0 12px; } +.notice p { color: var(--muted); } + +.grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-top: 20px; +} + +.card { + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px; +} +.card h3 { margin: 0 0 8px; font-size: 18px; } +.card p { margin: 0 0 12px; color: var(--muted); } +.link { color: var(--primary-2); text-decoration: none; } +.link:hover { text-decoration: underline; } + +.footer-note { margin-top: 24px; color: var(--muted); } + +.footer { + border-top: 1px solid var(--border); + padding: 24px 0; +} +.footer .container { display: flex; justify-content: space-between; align-items: center; } +.footer nav { display: flex; gap: 12px; } +.footer nav a { color: var(--muted); text-decoration: none; } +.footer nav a:hover { color: var(--fg); } + +@media (max-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } } +@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } .hero { padding: 64px 0 36px; } } + +/* 缓存列表 */ +.cache { margin-top: 36px; } +.cache-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; } +.cache-header h2 { margin: 0; } +.tools { display: flex; gap: 12px; align-items: center; } +.seg { display: inline-flex; gap: 4px; background: #0e1424; border: 1px solid var(--border); border-radius: 10px; padding: 4px; } +.seg-btn { background: transparent; color: var(--muted); border: 0; padding: 8px 12px; border-radius: 8px; cursor: pointer; } +.seg-btn.is-active { background: rgba(108,140,255,0.16); color: var(--primary-2); } +.search { background: #0e1424; border: 1px solid var(--border); color: var(--fg); padding: 10px 12px; border-radius: 10px; width: 240px; } +.select { background: #0e1424; border: 1px solid var(--border); color: var(--fg); padding: 10px 12px; border-radius: 10px; } +.stats { margin-top: 10px; color: var(--muted); } +.list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; margin-top: 16px; } +.item { background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); border: 1px solid var(--border); border-radius: 16px; padding: 14px; transition: transform .12s ease, box-shadow .18s ease, border-color .18s ease; } +.item:hover { transform: translateY(-1px); box-shadow: 0 10px 30px rgba(108,140,255,0.18); border-color: rgba(108,140,255,0.35); } +.item .row { display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: center; } +.badge { display: inline-block; padding: 4px 10px; border-radius: 999px; font-size: 12px; border: 1px solid var(--border); letter-spacing: 0.2px; } +.badge.css { color: #76e5ff; border-color: rgba(118,229,255,0.3); } +.badge.js { color: #ffd98e; border-color: rgba(255,217,142,0.25); } +.url { display: inline-block; padding: 10px 12px; background: #0e1424; border: 1px solid var(--border); border-radius: 12px; color: var(--primary-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.meta { display: flex; gap: 12px; color: var(--muted); margin-top: 8px; } +.actions { display: flex; gap: 10px; margin-top: 12px; } +.btn.small { padding: 8px 10px; border-radius: 9px; font-size: 13px; } +.empty { padding: 18px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: 12px; } + +.toast { position: fixed; left: 50%; transform: translateX(-50%); bottom: 22px; color: #0b0f1a; background: rgba(108,140,255,0.9); padding: 10px 14px; border-radius: 999px; box-shadow: 0 10px 30px rgba(0,0,0,0.35); opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease; } +.toast.show { opacity: 1; } + +.sentinel { text-align: center; color: var(--muted); padding: 12px; } +.back-top { position: fixed; right: 22px; bottom: 24px; background: #0e1424; border: 1px solid var(--border); color: var(--fg); padding: 10px 12px; border-radius: 12px; box-shadow: 0 10px 24px rgba(0,0,0,0.25); opacity: 0; pointer-events: none; transition: opacity .18s ease, transform .18s ease; } +.back-top.show { opacity: 1; pointer-events: auto; } + +@media (max-width: 1100px) { .list { grid-template-columns: 1fr 1fr; } } +@media (max-width: 760px) { .list { grid-template-columns: 1fr; } .search { width: 180px; } .item { padding: 12px; border-radius: 14px; } } diff --git a/server.js b/server.js index 63b62f4..841409d 100644 --- a/server.js +++ b/server.js @@ -15,6 +15,7 @@ const CACHE_ROOT = path.join(__dirname, 'cache') const CSS_DIR = path.join(CACHE_ROOT, 'css') const JS_DIR = path.join(CACHE_ROOT, 'js') const SEED_FILE = path.join(__dirname, 'seed.txt') +const PUBLIC_DIR = path.join(__dirname, 'public') // 中间件 app.use(express.json({ limit: '2mb' })) @@ -31,6 +32,21 @@ function ensureCacheDirs() { if (!fs.existsSync(JS_DIR)) fs.mkdirSync(JS_DIR) } +/** + * 注册前端首页与公共静态目录 + * - 将 `public/` 作为静态资源根目录 + * - 在根路径 `/` 提供首页 `index.html` + * @param {import('express').Express} app 实例 + * @returns {void} + */ +function registerPublicHomepage(app) { + if (!fs.existsSync(PUBLIC_DIR)) fs.mkdirSync(PUBLIC_DIR) + app.use(express.static(PUBLIC_DIR, { maxAge: '1h' })) + app.get('/', (req, res) => { + res.sendFile(path.join(PUBLIC_DIR, 'index.html')) + }) +} + /** * 从URL和内容类型判断目标保存路径与文件名(保留原URL目录层级) * @param {string} urlStr 请求的URL @@ -185,6 +201,7 @@ async function batchFetch(urls) { } ensureCacheDirs() +registerPublicHomepage(app) // 静态服务:/css 与 /js 直接映射到缓存目录 app.use('/css', express.static(CSS_DIR, { maxAge: '365d', immutable: true })) @@ -232,6 +249,181 @@ app.get('/api/seed', async (req, res) => { } }) +/** + * 递归遍历目录并返回文件信息列表 + * @param {string} rootDir 根目录 + * @returns {{abs:string, rel:string, size:number, mtime:number}[]} 文件信息 + */ +function walkFiles(rootDir) { + /** @type {{abs:string, rel:string, size:number, mtime:number}[]} */ + const out = [] + const stack = [rootDir] + while (stack.length) { + const dir = stack.pop() + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const e of entries) { + const abs = path.join(dir, e.name) + if (e.isDirectory()) { + stack.push(abs) + } else if (e.isFile()) { + const stat = fs.statSync(abs) + const rel = path.relative(rootDir, abs).replace(/\\/g, '/') + out.push({ abs, rel, size: stat.size, mtime: stat.mtimeMs }) + } + } + } + return out +} + +/** + * 从相对路径中提取库元数据(兼容常见CDN目录结构) + * 支持模式: + * - npm/@/... + * - ajax/libs///... + * - gh//@/... + * - 任意片段含 @ + * @param {string} rel 相对路径(以缓存根为基准) + * @returns {{name:string, version:string, ext:string, category:string}} + */ +function extractMetaFromRel(rel) { + const ext = (path.extname(rel) || '').toLowerCase() + const parts = rel.split('/').filter(Boolean) + let name = '' + let version = '' + + const idxNpm = parts.indexOf('npm') + if (idxNpm >= 0 && parts[idxNpm + 1]) { + const seg = parts[idxNpm + 1] + const at = seg.indexOf('@') + if (at > 0) { + name = seg.slice(0, at) + version = seg.slice(at + 1) + } + } + + if (!name) { + const idxAjax = parts.indexOf('ajax') + const idxLibs = parts.indexOf('libs') + if (idxAjax >= 0 && idxLibs === idxAjax + 1 && parts[idxLibs + 1] && parts[idxLibs + 2]) { + name = parts[idxLibs + 1] + version = parts[idxLibs + 2] + } + } + + if (!name) { + const idxGh = parts.indexOf('gh') + if (idxGh >= 0 && parts[idxGh + 1]) { + const seg = parts[idxGh + 1] + const at = seg.indexOf('@') + if (at > 0) { + name = seg.slice(0, at) + version = seg.slice(at + 1) + } + } + } + + if (!name) { + for (const seg of parts) { + const at = seg.indexOf('@') + if (at > 0) { + name = seg.slice(0, at) + version = seg.slice(at + 1) + break + } + } + } + + const category = name || (ext === '.css' ? 'css' : 'js') + return { name, version, ext, category } +} + +/** + * 列出已缓存的 CSS/JS 文件,支持过滤与限制 + * 查询参数: + * - type: 'css'|'js'(可选) + * - q: 关键字(可选,匹配路径片段) + * - name: 库名称(可选,匹配解析出的名称) + * - updatedFrom/updatedTo: 时间戳毫秒(可选) + * - sortBy: 'mtime'|'name'|'size'(默认 'mtime') + * - order: 'asc'|'desc'(默认 'desc') + * - page: 页码(默认 1) + * - pageSize: 每页条数(默认 30,范围 20–50) + * @param {import('express').Request} req 请求 + * @param {import('express').Response} res 响应 + * @returns {void} + */ +app.get('/api/list-cache', (req, res) => { + const type = (req.query.type || '').toString().toLowerCase() + const q = (req.query.q || '').toString().trim() + const nameQ = (req.query.name || '').toString().trim().toLowerCase() + const updatedFrom = Number(req.query.updatedFrom || 0) || undefined + const updatedTo = Number(req.query.updatedTo || 0) || undefined + const sortBy = ((req.query.sortBy || 'mtime') + '').toLowerCase() + const order = ((req.query.order || 'desc') + '').toLowerCase() === 'asc' ? 'asc' : 'desc' + const page = Math.max(1, Number(req.query.page || 1)) + const pageSizeRaw = Number(req.query.pageSize || 30) + const pageSize = Math.max(20, Math.min(50, isNaN(pageSizeRaw) ? 30 : pageSizeRaw)) + + const host = req.get('host') + const proto = req.protocol + + const cssFiles = walkFiles(CSS_DIR).map(f => { + const meta = extractMetaFromRel(f.rel) + return { + type: 'css', + path: `/css/${f.rel}`, + url: `${proto}://${host}/css/${f.rel}`, + size: f.size, + mtime: f.mtime, + name: meta.name, + version: meta.version, + ext: meta.ext, + category: meta.category + } + }) + const jsFiles = walkFiles(JS_DIR).map(f => { + const meta = extractMetaFromRel(f.rel) + return { + type: 'js', + path: `/js/${f.rel}`, + url: `${proto}://${host}/js/${f.rel}`, + size: f.size, + mtime: f.mtime, + name: meta.name, + version: meta.version, + ext: meta.ext, + category: meta.category + } + }) + + let items = [...cssFiles, ...jsFiles] + if (type === 'css' || type === 'js') items = items.filter(i => i.type === type) + if (q) items = items.filter(i => i.path.includes(q)) + if (nameQ) items = items.filter(i => (i.name || '').toLowerCase().includes(nameQ)) + if (updatedFrom) items = items.filter(i => i.mtime >= updatedFrom) + if (updatedTo) items = items.filter(i => i.mtime <= updatedTo) + + const cmp = { + mtime: (a, b) => a.mtime - b.mtime, + name: (a, b) => (a.name || '').localeCompare(b.name || ''), + size: (a, b) => a.size - b.size + }[sortBy] || ((a, b) => a.mtime - b.mtime) + items.sort((a, b) => (order === 'asc' ? cmp(a, b) : -cmp(a, b))) + + const total = items.length + const start = (page - 1) * pageSize + const sliced = items.slice(start, start + pageSize) + + res.json({ + count: sliced.length, + total, + page, + pageSize, + hasMore: start + pageSize < total, + items: sliced + }) +}) + app.listen(PORT, () => { console.log(`Asset Cache Server listening on http://localhost:${PORT}`) -}) \ No newline at end of file +})