├── LICENSE
├── README.md
├── schema.sql
└── worker.js
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 一只会飞的旺旺
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 拾光集 - 精品网址导航站
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | 一个优雅的书签收藏与分享平台,基于Cloudflare Workers构建
11 |
12 |
13 |
14 | 特性 •
15 | 快速开始 •
16 | 部署指南 •
17 | 技术栈 •
18 | 贡献
19 |
20 |
21 | ## ✨ 特性
22 |
23 | - 📱 **响应式设计** - 完美适配各种设备屏幕
24 | - 📋 **分类浏览** - 按类别组织书签,简单直观
25 | - 🔍 **站内搜索** - 快速查找需要的网站
26 | - 📝 **书签提交** - 用户可申请添加新书签
27 | - 🛡️ **审核机制** - 管理员审批流程保证内容质量
28 | - 🔒 **安全认证** - 支持KV存储保存管理员凭据
29 | - 📊 **后台管理** - 完整的书签管理界面
30 | - 📤 **导入导出** - 支持批量导入导出书签
31 |
32 | ## 🖼️ 预览图
33 |
34 | 
35 |
36 |
37 | 
38 |
39 |
40 | ## 🚀 快速开始
41 |
42 | ### 在线体验
43 |
44 | 访问示例网站:https://nav.wangwangit.com
45 |
46 | 管理员入口:https://nav.wangwangit.com/admin
47 |
48 | > **注意**: 示例站点仅供演示,请勿提交敏感信息
49 |
50 | ## 📦 部署指南
51 |
52 | ### 步骤 1: 创建DB数据库和KV键值对
53 |
54 | 创建D1数据库,输入名称**book** ,点击创建!
55 |
56 | 
57 |
58 |
59 | 点击控制台,分别创建表**sites**,**pending_sites**,看下图表,添加字段,注意设置**主密钥**!
60 |
61 | **表名称:** `sites`
62 |
63 | | 列名称 | 类型 | 默认值 | 主密钥 |
64 | | :------------ | :------ | :----- | :-------- |
65 | | id | Integer | | 主密钥 |
66 | | name | text | | |
67 | | url | text | | |
68 | | logo | text | | |
69 | | desc | text | | |
70 | | catelog | text | | |
71 | | create_time | text | | |
72 | | update_time | text | | |
73 |
74 | 
75 |
76 | **表名称:** `pending_sites`
77 |
78 | | 列名称 | 类型 | 默认值 | 主密钥 |
79 | | :---------- | :------- | :----- | :-------- |
80 | | id | integer | | 主密钥 |
81 | | name | text | | |
82 | | url | text | | |
83 | | logo | text | | |
84 | | desc | text | | |
85 | | catelog | text | | |
86 | | create_time | text | | |
87 | 
88 |
89 |
90 |
91 | 创建KV,名称**NAV_AUTH**,根据自己实际情况修改密钥的值,这是后续用于登陆后台管理的账号密码!
92 |
93 | 
94 |
95 |
96 | 
97 |
98 |
99 | ### 步骤 2: 创建workers
100 |
101 | 看下图,点击hello worker创建一个workers,输入自定义名称进行创建!
102 |
103 | 
104 |
105 |
106 | 然后点击编辑代码,将本项目中的worker.js代码复制粘贴进去,替换原有代码,点击部署!
107 |
108 | 
109 |
110 |
111 | 然后前往设置中,绑定KV和DB
112 |
113 | 
114 |
115 |
116 | 然后访问页面即可,此时由于没有添加书签,访问首页会提示没有数据,可以前往admin后台登陆之后,添加一个书签,即可看到页面!
117 |
118 | 
119 |
120 |
121 | 页面提示这个信息!
122 |
123 | 
124 |
125 |
126 | 网址后面拼接 /admin 即可进入后台页面,输入在DB中设置的密码,然后进行添加!
127 |
128 | 
129 |
130 |
131 | 再回到首页,就能看到网站了!
132 |
133 | 
134 |
135 |
136 |
137 |
138 | ## 🔧 技术栈
139 |
140 | - [Cloudflare Workers](https://workers.cloudflare.com/) - 边缘计算平台
141 | - [Cloudflare D1](https://developers.cloudflare.com/d1/) - 边缘SQL数据库
142 | - [Cloudflare KV](https://developers.cloudflare.com/workers/runtime-apis/kv/) - 键值存储
143 | - [TailwindCSS](https://tailwindcss.com/) - 实用程序优先的CSS框架
144 |
145 | ## 💻 项目结构
146 |
147 | ```
148 | nav/
149 | ├── worker.js # 主应用代码
150 | ├── schema.sql # 数据库结构
151 | └── README.md # 项目文档
152 | ```
153 |
154 | ## 🛠️ 自定义开发
155 |
156 | ### 修改样式和主题
157 |
158 | 编辑`worker.js`中的TailwindCSS配置部分:
159 |
160 | ```js
161 | tailwind.config = {
162 | theme: {
163 | extend: {
164 | colors: {
165 | primary: {
166 | // 修改主色调
167 | 500: '#7209b7',
168 | },
169 | // ...其他颜色配置
170 | },
171 | }
172 | }
173 | }
174 | ```
175 |
176 | ### 添加自定义功能
177 |
178 | 本项目使用Cloudflare Workers的单文件结构,所有逻辑都在`worker.js`中。主要模块:
179 |
180 | - `api`: API请求处理
181 | - `admin`: 管理后台逻辑
182 | - `handleRequest`: 前端页面渲染
183 |
184 | ## 🌟 贡献
185 |
186 | 欢迎贡献代码,提交问题或者建议!
187 |
188 | 1. Fork 仓库
189 | 2. 创建你的功能分支 (`git checkout -b feature/amazing-feature`)
190 | 3. 提交更改 (`git commit -m '添加一些令人惊叹的功能'`)
191 | 4. 推送到分支 (`git push origin feature/amazing-feature`)
192 | 5. 开启一个Pull Request
193 |
194 | ## 📄 许可证
195 |
196 | 本项目采用 MIT 许可证 - 详情参见 [LICENSE](LICENSE) 文件
197 |
198 | ## 📞 联系方式
199 |
200 | 项目作者 - [@一只会飞的旺旺](https://github.com/wangwangit)
201 |
202 | 项目链接: [https://github.com/wangwangit/nav](https://github.com/wangwangit/nav)
203 |
204 | ---
205 |
206 | 如果你喜欢这个项目,别忘了给它一个⭐️!
207 |
--------------------------------------------------------------------------------
/schema.sql:
--------------------------------------------------------------------------------
1 | -- 网站配置表
2 | CREATE TABLE IF NOT EXISTS sites (
3 | id INTEGER PRIMARY KEY AUTOINCREMENT,
4 | name TEXT NOT NULL,
5 | url TEXT NOT NULL,
6 | logo TEXT,
7 | desc TEXT,
8 | catelog TEXT NOT NULL,
9 | create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
10 | update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
11 | );
12 |
13 | -- 待审核网站表
14 | CREATE TABLE IF NOT EXISTS pending_sites (
15 | id INTEGER PRIMARY KEY AUTOINCREMENT,
16 | name TEXT NOT NULL,
17 | url TEXT NOT NULL,
18 | logo TEXT,
19 | desc TEXT,
20 | catelog TEXT NOT NULL,
21 | create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
22 | );
--------------------------------------------------------------------------------
/worker.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 备用随机 SVG 图标 - 优化设计
3 | */
4 | export const fallbackSVGIcons = [
5 | `
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | `,
14 | `
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | `,
24 | `
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | `,
33 | ];
34 |
35 | function getRandomSVG() {
36 | return fallbackSVGIcons[Math.floor(Math.random() * fallbackSVGIcons.length)];
37 | }
38 |
39 | /**
40 | * 渲染单个网站卡片(优化版)
41 | */
42 | function renderSiteCard(site) {
43 | const logoHTML = site.logo
44 | ? ` `
45 | : getRandomSVG();
46 |
47 | return `
48 |
49 |
${site.id}
50 |
${site.name || '未命名'}
51 |
${site.catelog}
52 |
${logoHTML}
53 |
${site.desc || '暂无描述'}
54 |
${site.url}
55 |
56 |
57 |
58 |
59 |
60 |
61 |
已复制!
62 |
63 | `;
64 | }
65 |
66 |
67 | /**
68 | * 处理 API 请求
69 | */
70 | const api = {
71 | async handleRequest(request, env, ctx) {
72 | const url = new URL(request.url);
73 | const path = url.pathname.replace('/api', ''); // 去掉 "/api" 前缀
74 | const method = request.method;
75 | const id = url.pathname.split('/').pop(); // 获取最后一个路径段,作为 id (例如 /api/config/1)
76 | try {
77 | if (path === '/config') {
78 | switch (method) {
79 | case 'GET':
80 | return await this.getConfig(request, env, ctx, url);
81 | case 'POST':
82 | return await this.createConfig(request, env, ctx);
83 | default:
84 | return this.errorResponse('Method Not Allowed', 405)
85 | }
86 | }
87 | if (path === '/config/submit' && method === 'POST') {
88 | return await this.submitConfig(request, env, ctx);
89 | }
90 | if (path === `/config/${id}` && /^\d+$/.test(id)) {
91 | switch (method) {
92 | case 'PUT':
93 | return await this.updateConfig(request, env, ctx, id);
94 | case 'DELETE':
95 | return await this.deleteConfig(request, env, ctx, id);
96 | default:
97 | return this.errorResponse('Method Not Allowed', 405)
98 | }
99 | }
100 | if (path === `/pending/${id}` && /^\d+$/.test(id)) {
101 | switch (method) {
102 | case 'PUT':
103 | return await this.approvePendingConfig(request, env, ctx, id);
104 | case 'DELETE':
105 | return await this.rejectPendingConfig(request, env, ctx, id);
106 | default:
107 | return this.errorResponse('Method Not Allowed', 405)
108 | }
109 | }
110 | if (path === '/config/import' && method === 'POST') {
111 | return await this.importConfig(request, env, ctx);
112 | }
113 | if (path === '/config/export' && method === 'GET') {
114 | return await this.exportConfig(request, env, ctx);
115 | }
116 | if (path === '/pending' && method === 'GET') {
117 | return await this.getPendingConfig(request, env, ctx, url);
118 | }
119 | return this.errorResponse('Not Found', 404);
120 | } catch (error) {
121 | return this.errorResponse(`Internal Server Error: ${error.message}`, 500);
122 | }
123 | },
124 | async getConfig(request, env, ctx, url) {
125 | const catalog = url.searchParams.get('catalog');
126 | const page = parseInt(url.searchParams.get('page') || '1', 10);
127 | const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
128 | const keyword = url.searchParams.get('keyword');
129 | const offset = (page - 1) * pageSize;
130 | try {
131 | let query = `SELECT * FROM sites ORDER BY create_time DESC LIMIT ? OFFSET ?`;
132 | let countQuery = `SELECT COUNT(*) as total FROM sites`;
133 | let queryBindParams = [pageSize, offset];
134 | let countQueryParams = [];
135 |
136 | if (catalog) {
137 | query = `SELECT * FROM sites WHERE catelog = ? ORDER BY create_time DESC LIMIT ? OFFSET ?`;
138 | countQuery = `SELECT COUNT(*) as total FROM sites WHERE catelog = ?`
139 | queryBindParams = [catalog, pageSize, offset];
140 | countQueryParams = [catalog];
141 | }
142 |
143 | if (keyword) {
144 | const likeKeyword = `%${keyword}%`;
145 | query = `SELECT * FROM sites WHERE name LIKE ? OR url LIKE ? OR catelog LIKE ? ORDER BY create_time DESC LIMIT ? OFFSET ?`;
146 | countQuery = `SELECT COUNT(*) as total FROM sites WHERE name LIKE ? OR url LIKE ? OR catelog LIKE ?`;
147 | queryBindParams = [likeKeyword, likeKeyword, likeKeyword, pageSize, offset];
148 | countQueryParams = [likeKeyword, likeKeyword, likeKeyword];
149 |
150 | if (catalog) {
151 | query = `SELECT * FROM sites WHERE catelog = ? AND (name LIKE ? OR url LIKE ? OR catelog LIKE ?) ORDER BY create_time DESC LIMIT ? OFFSET ?`;
152 | countQuery = `SELECT COUNT(*) as total FROM sites WHERE catelog = ? AND (name LIKE ? OR url LIKE ? OR catelog LIKE ?)`;
153 | queryBindParams = [catalog, likeKeyword, likeKeyword, likeKeyword, pageSize, offset];
154 | countQueryParams = [catalog, likeKeyword, likeKeyword, likeKeyword];
155 | }
156 | }
157 |
158 | const { results } = await env.NAV_DB.prepare(query).bind(...queryBindParams).all();
159 | const countResult = await env.NAV_DB.prepare(countQuery).bind(...countQueryParams).first();
160 | const total = countResult ? countResult.total : 0;
161 |
162 | return new Response(
163 | JSON.stringify({
164 | code: 200,
165 | data: results,
166 | total,
167 | page,
168 | pageSize
169 | }),
170 | { headers: { 'Content-Type': 'application/json' } }
171 | );
172 |
173 | } catch (e) {
174 | return this.errorResponse(`Failed to fetch config data: ${e.message}`, 500)
175 | }
176 | },
177 | async getPendingConfig(request, env, ctx, url) {
178 | const page = parseInt(url.searchParams.get('page') || '1', 10);
179 | const pageSize = parseInt(url.searchParams.get('pageSize') || '10', 10);
180 | const offset = (page - 1) * pageSize;
181 | try {
182 | const { results } = await env.NAV_DB.prepare(`
183 | SELECT * FROM pending_sites ORDER BY create_time DESC LIMIT ? OFFSET ?
184 | `).bind(pageSize, offset).all();
185 | const countResult = await env.NAV_DB.prepare(`
186 | SELECT COUNT(*) as total FROM pending_sites
187 | `).first();
188 | const total = countResult ? countResult.total : 0;
189 | return new Response(
190 | JSON.stringify({
191 | code: 200,
192 | data: results,
193 | total,
194 | page,
195 | pageSize
196 | }),
197 | {headers: {'Content-Type': 'application/json'}}
198 | );
199 | } catch (e) {
200 | return this.errorResponse(`Failed to fetch pending config data: ${e.message}`, 500);
201 | }
202 | },
203 | async approvePendingConfig(request, env, ctx, id) {
204 | try {
205 | const { results } = await env.NAV_DB.prepare('SELECT * FROM pending_sites WHERE id = ?').bind(id).all();
206 | if(results.length === 0) {
207 | return this.errorResponse('Pending config not found', 404);
208 | }
209 | const config = results[0];
210 | await env.NAV_DB.prepare(`
211 | INSERT INTO sites (name, url, logo, desc, catelog)
212 | VALUES (?, ?, ?, ?, ?)
213 | `).bind(config.name, config.url, config.logo, config.desc, config.catelog).run();
214 | await env.NAV_DB.prepare('DELETE FROM pending_sites WHERE id = ?').bind(id).run();
215 |
216 | return new Response(JSON.stringify({
217 | code: 200,
218 | message: 'Pending config approved successfully'
219 | }),{
220 | headers: {
221 | 'Content-Type': 'application/json'
222 | }
223 | })
224 | }catch(e) {
225 | return this.errorResponse(`Failed to approve pending config : ${e.message}`, 500);
226 | }
227 | },
228 | async rejectPendingConfig(request, env, ctx, id) {
229 | try{
230 | await env.NAV_DB.prepare('DELETE FROM pending_sites WHERE id = ?').bind(id).run();
231 | return new Response(JSON.stringify({
232 | code: 200,
233 | message: 'Pending config rejected successfully',
234 | }), {headers: {'Content-Type': 'application/json'}});
235 | } catch(e) {
236 | return this.errorResponse(`Failed to reject pending config: ${e.message}`, 500);
237 | }
238 | },
239 | async submitConfig(request, env, ctx) {
240 | try{
241 | const config = await request.json();
242 | const { name, url, logo, desc, catelog } = config;
243 |
244 | if (!name || !url || !catelog ) {
245 | return this.errorResponse('Name, URL and Catelog are required', 400);
246 | }
247 | await env.NAV_DB.prepare(`
248 | INSERT INTO pending_sites (name, url, logo, desc, catelog)
249 | VALUES (?, ?, ?, ?, ?)
250 | `).bind(name, url, logo, desc, catelog).run();
251 |
252 | return new Response(JSON.stringify({
253 | code: 201,
254 | message: 'Config submitted successfully, waiting for admin approve',
255 | }), {
256 | status: 201,
257 | headers: { 'Content-Type': 'application/json' },
258 | })
259 | } catch(e) {
260 | return this.errorResponse(`Failed to submit config : ${e.message}`, 500);
261 | }
262 | },
263 |
264 | async createConfig(request, env, ctx) {
265 | try{
266 | const config = await request.json();
267 | const { name, url, logo, desc, catelog } = config;
268 |
269 | if (!name || !url || !catelog ) {
270 | return this.errorResponse('Name, URL and Catelog are required', 400);
271 | }
272 | const insert = await env.NAV_DB.prepare(`
273 | INSERT INTO sites (name, url, logo, desc, catelog)
274 | VALUES (?, ?, ?, ?, ?)
275 | `).bind(name, url, logo, desc, catelog).run();
276 |
277 | return new Response(JSON.stringify({
278 | code: 201,
279 | message: 'Config created successfully',
280 | insert
281 | }), {
282 | status: 201,
283 | headers: { 'Content-Type': 'application/json' },
284 | })
285 | } catch(e) {
286 | return this.errorResponse(`Failed to create config : ${e.message}`, 500);
287 | }
288 | },
289 |
290 | async updateConfig(request, env, ctx, id) {
291 | try {
292 | const config = await request.json();
293 | const { name, url, logo, desc, catelog } = config;
294 |
295 | const update = await env.NAV_DB.prepare(`
296 | UPDATE sites
297 | SET name = ?, url = ?, logo = ?, desc = ?, catelog = ?, update_time = CURRENT_TIMESTAMP
298 | WHERE id = ?
299 | `).bind(name, url, logo, desc, catelog, id).run();
300 | return new Response(JSON.stringify({
301 | code: 200,
302 | message: 'Config updated successfully',
303 | update
304 | }), { headers: { 'Content-Type': 'application/json' }});
305 | } catch (e) {
306 | return this.errorResponse(`Failed to update config: ${e.message}`, 500);
307 | }
308 | },
309 |
310 | async deleteConfig(request, env, ctx, id) {
311 | try{
312 | const del = await env.NAV_DB.prepare('DELETE FROM sites WHERE id = ?').bind(id).run();
313 | return new Response(JSON.stringify({
314 | code: 200,
315 | message: 'Config deleted successfully',
316 | del
317 | }), {headers: {'Content-Type': 'application/json'}});
318 | } catch(e) {
319 | return this.errorResponse(`Failed to delete config: ${e.message}`, 500);
320 | }
321 | },
322 | async importConfig(request, env, ctx) {
323 | try {
324 | const jsonData = await request.json();
325 |
326 | if (!Array.isArray(jsonData)) {
327 | return this.errorResponse('Invalid JSON data. Must be an array of site configurations.', 400);
328 | }
329 |
330 | const insertStatements = jsonData.map(item =>
331 | env.NAV_DB.prepare(`
332 | INSERT INTO sites (name, url, logo, desc, catelog)
333 | VALUES (?, ?, ?, ?, ?)
334 | `).bind(item.name, item.url, item.logo, item.desc, item.catelog)
335 | )
336 |
337 | // 使用 Promise.all 来并行执行所有插入操作
338 | await Promise.all(insertStatements.map(stmt => stmt.run()));
339 |
340 | return new Response(JSON.stringify({
341 | code: 201,
342 | message: 'Config imported successfully'
343 | }), {
344 | status: 201,
345 | headers: {'Content-Type': 'application/json'}
346 | });
347 | } catch (error) {
348 | return this.errorResponse(`Failed to import config : ${error.message}`, 500);
349 | }
350 | },
351 |
352 | async exportConfig(request, env, ctx) {
353 | try{
354 | const { results } = await env.NAV_DB.prepare('SELECT * FROM sites ORDER BY create_time DESC').all();
355 | return new Response(JSON.stringify({
356 | code: 200,
357 | data: results
358 | }),{
359 | headers: {
360 | 'Content-Type': 'application/json',
361 | 'Content-Disposition': 'attachment; filename="config.json"'
362 | }
363 | });
364 | } catch(e) {
365 | return this.errorResponse(`Failed to export config: ${e.message}`, 500)
366 | }
367 | },
368 | errorResponse(message, status) {
369 | return new Response(JSON.stringify({code: status, message: message}), {
370 | status: status,
371 | headers: { 'Content-Type': 'application/json' },
372 | });
373 | }
374 | };
375 |
376 |
377 | /**
378 | * 处理后台管理页面请求
379 | */
380 | const admin = {
381 | async handleRequest(request, env, ctx) {
382 | const url = new URL(request.url);
383 |
384 | if (url.pathname === '/admin') {
385 | const params = url.searchParams;
386 | const name = params.get('name');
387 | const password = params.get('password');
388 |
389 | // 从KV中获取凭据
390 | const storedUsername = await env.NAV_AUTH.get("admin_username");
391 | const storedPassword = await env.NAV_AUTH.get("admin_password");
392 |
393 | if (name === storedUsername && password === storedPassword) {
394 | return this.renderAdminPage();
395 | } else if (name || password) {
396 | return new Response('未授权访问', {
397 | status: 403,
398 | headers: { 'Content-Type': 'text/html; charset=utf-8' }
399 | });
400 | } else {
401 | return this.renderLoginPage();
402 | }
403 | }
404 |
405 | if (url.pathname.startsWith('/static')) {
406 | return this.handleStatic(request, env, ctx);
407 | }
408 |
409 | return new Response('页面不存在', {status: 404});
410 | },
411 | async handleStatic(request, env, ctx) {
412 | const url = new URL(request.url);
413 | const filePath = url.pathname.replace('/static/', '');
414 |
415 | let contentType = 'text/plain';
416 | if (filePath.endsWith('.css')) {
417 | contentType = 'text/css';
418 | } else if (filePath.endsWith('.js')) {
419 | contentType = 'application/javascript';
420 | }
421 |
422 | try {
423 | const fileContent = await this.getFileContent(filePath)
424 | return new Response(fileContent, {
425 | headers: { 'Content-Type': contentType }
426 | });
427 | } catch (e) {
428 | return new Response('Not Found', {status: 404});
429 | }
430 |
431 | },
432 | async getFileContent(filePath) {
433 | const fileContents = {
434 | 'admin.html': `
435 |
436 |
437 |
438 |
439 | 书签管理页面
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 | 导入
450 | 导出
451 |
452 |
453 |
454 |
455 |
456 |
457 |
458 |
459 | 添加
460 |
461 |
462 |
463 |
464 | 书签列表
465 | 待审核列表
466 |
467 |
468 |
469 |
470 |
471 |
472 | ID
473 | Name
474 | URL
475 | Logo
476 | Description
477 | Catelog
478 | Actions
479 |
480 |
481 |
482 |
483 |
484 |
485 |
490 |
491 |
492 |
493 |
494 |
495 |
496 |
497 | ID
498 | Name
499 | URL
500 | Logo
501 | Description
502 | Catelog
503 | Actions
504 |
505 |
506 |
507 |
508 |
509 |
510 |
515 |
516 |
517 |
518 |
519 |
520 |
521 | `,
522 | 'admin.css': `body {
523 | font-family: 'Noto Sans SC', sans-serif;
524 | margin: 0;
525 | padding: 20px;
526 | background-color: #f8f9fa; /* 更柔和的背景色 */
527 | color: #212529; /* 深色文字 */
528 | }
529 | .modal {
530 | display: none;
531 | position: fixed;
532 | z-index: 1000;
533 | left: 0;
534 | top: 0;
535 | width: 100%;
536 | height: 100%;
537 | overflow: auto;
538 | background-color: rgba(0, 0, 0, 0.5); /* 半透明背景 */
539 | }
540 | .modal-content {
541 | background-color: #fff; /* 模态框背景白色 */
542 | margin: 10% auto;
543 | padding: 20px;
544 | border: 1px solid #dee2e6; /* 边框 */
545 | width: 60%;
546 | border-radius: 8px;
547 | position: relative;
548 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); /* 阴影效果 */
549 | }
550 | .modal-close {
551 | color: #6c757d; /* 关闭按钮颜色 */
552 | position: absolute;
553 | right: 10px;
554 | top: 0;
555 | font-size: 28px;
556 | font-weight: bold;
557 | cursor: pointer;
558 | transition: color 0.2s;
559 | }
560 |
561 | .modal-close:hover,
562 | .modal-close:focus {
563 | color: #343a40; /* 悬停时颜色加深 */
564 | text-decoration: none;
565 | cursor: pointer;
566 | }
567 | .modal-content form {
568 | display: flex;
569 | flex-direction: column;
570 | }
571 |
572 | .modal-content form label {
573 | margin-bottom: 5px;
574 | font-weight: 500; /* 字重 */
575 | color: #495057; /* 标签颜色 */
576 | }
577 | .modal-content form input {
578 | margin-bottom: 10px;
579 | padding: 10px;
580 | border: 1px solid #ced4da; /* 输入框边框 */
581 | border-radius: 4px;
582 | font-size: 1rem;
583 | outline: none;
584 | transition: border-color 0.2s;
585 | }
586 | .modal-content form input:focus {
587 | border-color: #80bdff; /* 焦点边框颜色 */
588 | box-shadow:0 0 0 0.2rem rgba(0,123,255,.25);
589 | }
590 | .modal-content form input:focus {
591 | border-color: #80bdff; /* 焦点边框颜色 */
592 | box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
593 | }
594 | .modal-content button[type='submit'] {
595 | margin-top: 10px;
596 | background-color: #007bff; /* 提交按钮颜色 */
597 | color: #fff;
598 | border: none;
599 | padding: 10px 15px;
600 | border-radius: 4px;
601 | cursor: pointer;
602 | font-size: 1rem;
603 | transition: background-color 0.3s;
604 | }
605 |
606 | .modal-content button[type='submit']:hover {
607 | background-color: #0056b3; /* 悬停时颜色加深 */
608 | }
609 | .container {
610 | max-width: 1200px;
611 | margin: 20px auto;
612 | background-color: #fff;
613 | padding: 20px;
614 | border-radius: 8px;
615 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
616 | }
617 | h1 {
618 | text-align: center;
619 | margin-bottom: 20px;
620 | color: #343a40;
621 | }
622 | .tab-wrapper {
623 | margin-top: 20px;
624 | }
625 | .tab-buttons {
626 | display: flex;
627 | margin-bottom: 10px;
628 | }
629 | .tab-button {
630 | background-color: #e9ecef;
631 | border: 1px solid #dee2e6;
632 | padding: 10px 15px;
633 | border-radius: 4px 4px 0 0;
634 | cursor: pointer;
635 | color: #495057; /* tab按钮文字颜色 */
636 | transition: background-color 0.2s, color 0.2s;
637 | }
638 | .tab-button.active {
639 | background-color: #fff;
640 | border-bottom: 1px solid #fff;
641 | color: #212529; /* 选中tab颜色 */
642 | }
643 | .tab-button:hover {
644 | background-color: #f0f0f0;
645 | }
646 | .tab-content {
647 | display: none;
648 | border: 1px solid #dee2e6;
649 | padding: 10px;
650 | border-top: none;
651 | }
652 | .tab-content.active {
653 | display: block;
654 | }
655 |
656 | .import-export {
657 | display: flex;
658 | gap: 10px;
659 | margin-bottom: 20px;
660 | justify-content: flex-end;
661 | }
662 |
663 | .add-new {
664 | display: flex;
665 | gap: 10px;
666 | margin-bottom: 20px;
667 | }
668 | .add-new > input {
669 | flex: 1;
670 | }
671 | input[type="text"] {
672 | padding: 10px;
673 | border: 1px solid #ced4da;
674 | border-radius: 4px;
675 | font-size: 1rem;
676 | outline: none;
677 | margin-bottom: 5px;
678 | transition: border-color 0.2s;
679 | }
680 | input[type="text"]:focus {
681 | border-color: #80bdff; /* 焦点边框颜色 */
682 | box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
683 | }
684 | button {
685 | background-color: #6c63ff; /* 主色调 */
686 | color: #fff;
687 | border: none;
688 | padding: 10px 15px;
689 | border-radius: 4px;
690 | cursor: pointer;
691 | font-size: 1rem;
692 | transition: background-color 0.3s;
693 | }
694 | button:hover {
695 | background-color: #534dc4;
696 | }
697 |
698 | .table-wrapper {
699 | overflow-x: auto;
700 | }
701 | table {
702 | width: 100%;
703 | border-collapse: collapse;
704 | margin-bottom: 20px;
705 | }
706 | th, td {
707 | border: 1px solid #dee2e6;
708 | padding: 10px;
709 | text-align: left;
710 | color: #495057; /* 表格文字颜色 */
711 | }
712 | th {
713 | background-color: #f2f2f2;
714 | font-weight: 600;
715 | }
716 | tr:nth-child(even) {
717 | background-color: #f9f9f9;
718 | }
719 |
720 | .actions {
721 | display: flex;
722 | gap: 5px;
723 | }
724 | .actions button {
725 | padding: 5px 8px;
726 | font-size: 0.8rem;
727 | }
728 | .edit-btn {
729 | background-color: #17a2b8; /* 编辑按钮颜色 */
730 | }
731 |
732 | .del-btn {
733 | background-color: #dc3545; /* 删除按钮颜色 */
734 | }
735 | .pagination {
736 | text-align: center;
737 | margin-top: 20px;
738 | }
739 | .pagination button {
740 | margin: 0 5px;
741 | background-color: #e9ecef; /* 分页按钮颜色 */
742 | color: #495057;
743 | border: 1px solid #ced4da;
744 | }
745 | .pagination button:hover {
746 | background-color: #dee2e6;
747 | }
748 |
749 | .success {
750 | background-color: #28a745;
751 | color: #fff;
752 | }
753 | .error {
754 | background-color: #dc3545;
755 | color: #fff;
756 | }
757 | `,
758 | 'admin.js': `
759 | const configTableBody = document.getElementById('configTableBody');
760 | const prevPageBtn = document.getElementById('prevPage');
761 | const nextPageBtn = document.getElementById('nextPage');
762 | const currentPageSpan = document.getElementById('currentPage');
763 | const totalPagesSpan = document.getElementById('totalPages');
764 |
765 | const pendingTableBody = document.getElementById('pendingTableBody');
766 | const pendingPrevPageBtn = document.getElementById('pendingPrevPage');
767 | const pendingNextPageBtn = document.getElementById('pendingNextPage');
768 | const pendingCurrentPageSpan = document.getElementById('pendingCurrentPage');
769 | const pendingTotalPagesSpan = document.getElementById('pendingTotalPages');
770 |
771 | const messageDiv = document.getElementById('message');
772 |
773 | const addBtn = document.getElementById('addBtn');
774 | const addName = document.getElementById('addName');
775 | const addUrl = document.getElementById('addUrl');
776 | const addLogo = document.getElementById('addLogo');
777 | const addDesc = document.getElementById('addDesc');
778 | const addCatelog = document.getElementById('addCatelog');
779 |
780 | const importBtn = document.getElementById('importBtn');
781 | const importFile = document.getElementById('importFile');
782 | const exportBtn = document.getElementById('exportBtn');
783 |
784 | const tabButtons = document.querySelectorAll('.tab-button');
785 | const tabContents = document.querySelectorAll('.tab-content');
786 |
787 | tabButtons.forEach(button => {
788 | button.addEventListener('click', () => {
789 | const tab = button.dataset.tab;
790 | tabButtons.forEach(b => b.classList.remove('active'));
791 | button.classList.add('active');
792 | tabContents.forEach(content => {
793 | content.classList.remove('active');
794 | if(content.id === tab) {
795 | content.classList.add('active');
796 | }
797 | })
798 | });
799 | });
800 |
801 |
802 | // 添加搜索框
803 | const searchInput = document.createElement('input');
804 | searchInput.type = 'text';
805 | searchInput.placeholder = '搜索书签(名称,URL,分类)';
806 | searchInput.id = 'searchInput';
807 | searchInput.style.marginBottom = '10px';
808 | document.querySelector('.add-new').parentNode.insertBefore(searchInput, document.querySelector('.add-new'));
809 |
810 |
811 | let currentPage = 1;
812 | let pageSize = 10;
813 | let totalItems = 0;
814 | let allConfigs = []; // 保存所有配置数据
815 | let currentSearchKeyword = ''; // 保存当前搜索关键词
816 |
817 | let pendingCurrentPage = 1;
818 | let pendingPageSize = 10;
819 | let pendingTotalItems = 0;
820 | let allPendingConfigs = []; // 保存所有待审核配置数据
821 |
822 | // 创建编辑模态框
823 | const editModal = document.createElement('div');
824 | editModal.className = 'modal';
825 | editModal.style.display = 'none';
826 | editModal.innerHTML = \`
827 |
828 | ×
829 |
编辑站点
830 |
844 |
845 | \`;
846 | document.body.appendChild(editModal);
847 |
848 | const modalClose = editModal.querySelector('.modal-close');
849 | modalClose.addEventListener('click', () => {
850 | editModal.style.display = 'none';
851 | });
852 |
853 | const editForm = document.getElementById('editForm');
854 | editForm.addEventListener('submit', function (e) {
855 | e.preventDefault();
856 | const id = document.getElementById('editId').value;
857 | const name = document.getElementById('editName').value;
858 | const url = document.getElementById('editUrl').value;
859 | const logo = document.getElementById('editLogo').value;
860 | const desc = document.getElementById('editDesc').value;
861 | const catelog = document.getElementById('editCatelog').value;
862 |
863 | fetch(\`/api/config/\${id}\`, {
864 | method: 'PUT',
865 | headers: {
866 | 'Content-Type': 'application/json'
867 | },
868 | body: JSON.stringify({
869 | name,
870 | url,
871 | logo,
872 | desc,
873 | catelog
874 | })
875 | }).then(res => res.json())
876 | .then(data => {
877 | if (data.code === 200) {
878 | showMessage('修改成功', 'success');
879 | fetchConfigs();
880 | editModal.style.display = 'none'; // 关闭弹窗
881 | } else {
882 | showMessage(data.message, 'error');
883 | }
884 | }).catch(err => {
885 | showMessage('网络错误', 'error');
886 | })
887 | });
888 |
889 |
890 | function fetchConfigs(page = currentPage, keyword = currentSearchKeyword) {
891 | let url = \`/api/config?page=\${page}&pageSize=\${pageSize}\`;
892 | if(keyword) {
893 | url = \`/api/config?page=\${page}&pageSize=\${pageSize}&keyword=\${keyword}\`
894 | }
895 | fetch(url)
896 | .then(res => res.json())
897 | .then(data => {
898 | if (data.code === 200) {
899 | totalItems = data.total;
900 | currentPage = data.page;
901 | totalPagesSpan.innerText = Math.ceil(totalItems / pageSize);
902 | currentPageSpan.innerText = currentPage;
903 | allConfigs = data.data; // 保存所有数据
904 | renderConfig(allConfigs);
905 | updatePaginationButtons();
906 | } else {
907 | showMessage(data.message, 'error');
908 | }
909 | }).catch(err => {
910 | showMessage('网络错误', 'error');
911 | })
912 | }
913 | function renderConfig(configs) {
914 | configTableBody.innerHTML = '';
915 | if (configs.length === 0) {
916 | configTableBody.innerHTML = '没有配置数据 ';
917 | return
918 | }
919 | configs.forEach(config => {
920 | const row = document.createElement('tr');
921 | row.innerHTML = \`
922 | \${config.id}
923 | \${config.name}
924 | \${config.url}
925 | \${config.logo ? \` \` : 'N/A'}
926 | \${config.desc || 'N/A'}
927 | \${config.catelog}
928 |
929 | 编辑
930 | 删除
931 |
932 | \`;
933 | configTableBody.appendChild(row);
934 | });
935 | bindActionEvents();
936 | }
937 |
938 | function bindActionEvents() {
939 | document.querySelectorAll('.edit-btn').forEach(btn => {
940 | btn.addEventListener('click', function() {
941 | const id = this.dataset.id;
942 | handleEdit(id);
943 | })
944 | });
945 |
946 | document.querySelectorAll('.del-btn').forEach(btn => {
947 | btn.addEventListener('click', function() {
948 | const id = this.dataset.id;
949 | handleDelete(id)
950 | })
951 | })
952 | }
953 |
954 | function handleEdit(id) {
955 | const row = document.querySelector(\`#configTableBody tr:nth-child(\${Array.from(configTableBody.children).findIndex(tr => tr.querySelector('.edit-btn[data-id="'+ id +'"]')) + 1})\`);
956 | if (!row) return showMessage('找不到数据','error');
957 | const name = row.querySelector('td:nth-child(2)').innerText;
958 | const url = row.querySelector('td:nth-child(3) a').innerText;
959 | const logo = row.querySelector('td:nth-child(4) img')?.src || '';
960 | const desc = row.querySelector('td:nth-child(5)').innerText === 'N/A' ? '' : row.querySelector('td:nth-child(5)').innerText;
961 | const catelog = row.querySelector('td:nth-child(6)').innerText;
962 |
963 |
964 | // 填充表单数据
965 | document.getElementById('editId').value = id;
966 | document.getElementById('editName').value = name;
967 | document.getElementById('editUrl').value = url;
968 | document.getElementById('editLogo').value = logo;
969 | document.getElementById('editDesc').value = desc;
970 | document.getElementById('editCatelog').value = catelog;
971 | editModal.style.display = 'block';
972 | }
973 | function handleDelete(id) {
974 | if(!confirm('确认删除?')) return;
975 | fetch(\`/api/config/\${id}\`, {
976 | method: 'DELETE'
977 | }).then(res => res.json())
978 | .then(data => {
979 | if (data.code === 200) {
980 | showMessage('删除成功', 'success');
981 | fetchConfigs();
982 | } else {
983 | showMessage(data.message, 'error');
984 | }
985 | }).catch(err => {
986 | showMessage('网络错误', 'error');
987 | })
988 | }
989 | function showMessage(message, type) {
990 | messageDiv.innerText = message;
991 | messageDiv.className = type;
992 | messageDiv.style.display = 'block';
993 | setTimeout(() => {
994 | messageDiv.style.display = 'none';
995 | }, 3000);
996 | }
997 |
998 | function updatePaginationButtons() {
999 | prevPageBtn.disabled = currentPage === 1;
1000 | nextPageBtn.disabled = currentPage >= Math.ceil(totalItems/pageSize)
1001 | }
1002 |
1003 | prevPageBtn.addEventListener('click', () => {
1004 | if(currentPage > 1) {
1005 | fetchConfigs(currentPage -1);
1006 | }
1007 | });
1008 | nextPageBtn.addEventListener('click', () => {
1009 | if (currentPage < Math.ceil(totalItems/pageSize)) {
1010 | fetchConfigs(currentPage + 1);
1011 | }
1012 | });
1013 |
1014 | addBtn.addEventListener('click', () => {
1015 | const name = addName.value;
1016 | const url = addUrl.value;
1017 | const logo = addLogo.value;
1018 | const desc = addDesc.value;
1019 | const catelog = addCatelog.value;
1020 | if(!name || !url || !catelog) {
1021 | showMessage('名称,URL,分类 必填', 'error');
1022 | return;
1023 | }
1024 | fetch('/api/config', { method: 'POST',
1025 | headers: {
1026 | 'Content-Type': 'application/json'
1027 | },
1028 | body: JSON.stringify({
1029 | name,
1030 | url,
1031 | logo,
1032 | desc,
1033 | catelog
1034 | })
1035 | }).then(res => res.json())
1036 | .then(data => {
1037 | if(data.code === 201) {
1038 | showMessage('添加成功', 'success');
1039 | addName.value = '';
1040 | addUrl.value = '';
1041 | addLogo.value = '';
1042 | addDesc.value = '';
1043 | addCatelog.value = '';
1044 | fetchConfigs();
1045 | }else {
1046 | showMessage(data.message, 'error');
1047 | }
1048 | }).catch(err => {
1049 | showMessage('网络错误', 'error');
1050 | })
1051 | });
1052 |
1053 | importBtn.addEventListener('click', () => {
1054 | importFile.click();
1055 | });
1056 | importFile.addEventListener('change', function(e) {
1057 | const file = e.target.files[0];
1058 | if (file) {
1059 | const reader = new FileReader();
1060 | reader.onload = function(event) {
1061 | try {
1062 | const jsonData = JSON.parse(event.target.result);
1063 | fetch('/api/config/import', {
1064 | method: 'POST',
1065 | headers: {
1066 | 'Content-Type': 'application/json'
1067 | },
1068 | body: JSON.stringify(jsonData)
1069 | }).then(res => res.json())
1070 | .then(data => {
1071 | if(data.code === 201) {
1072 | showMessage('导入成功', 'success');
1073 | fetchConfigs();
1074 | } else {
1075 | showMessage(data.message, 'error');
1076 | }
1077 | }).catch(err => {
1078 | showMessage('网络错误', 'error');
1079 | })
1080 |
1081 | } catch (error) {
1082 | showMessage('JSON格式不正确', 'error');
1083 | }
1084 | }
1085 | reader.readAsText(file);
1086 | }
1087 | })
1088 | exportBtn.addEventListener('click', () => {
1089 | fetch('/api/config/export')
1090 | .then(res => res.blob())
1091 | .then(blob => {
1092 | const url = window.URL.createObjectURL(blob);
1093 | const a = document.createElement('a');
1094 | a.href = url;
1095 | a.download = 'config.json';
1096 | document.body.appendChild(a);
1097 | a.click();
1098 | window.URL.revokeObjectURL(url);
1099 | document.body.removeChild(a);
1100 | }).catch(err => {
1101 | showMessage('网络错误', 'error');
1102 | })
1103 | })
1104 |
1105 | // 搜索功能
1106 | searchInput.addEventListener('input', () => {
1107 | currentSearchKeyword = searchInput.value.trim();
1108 | currentPage = 1; // 搜索时重置为第一页
1109 | fetchConfigs(currentPage,currentSearchKeyword);
1110 | });
1111 |
1112 |
1113 | function fetchPendingConfigs(page = pendingCurrentPage) {
1114 | fetch(\`/api/pending?page=\${page}&pageSize=\${pendingPageSize}\`)
1115 | .then(res => res.json())
1116 | .then(data => {
1117 | if (data.code === 200) {
1118 | pendingTotalItems = data.total;
1119 | pendingCurrentPage = data.page;
1120 | pendingTotalPagesSpan.innerText = Math.ceil(pendingTotalItems/ pendingPageSize);
1121 | pendingCurrentPageSpan.innerText = pendingCurrentPage;
1122 | allPendingConfigs = data.data;
1123 | renderPendingConfig(allPendingConfigs);
1124 | updatePendingPaginationButtons();
1125 | } else {
1126 | showMessage(data.message, 'error');
1127 | }
1128 | }).catch(err => {
1129 | showMessage('网络错误', 'error');
1130 | })
1131 | }
1132 |
1133 | function renderPendingConfig(configs) {
1134 | pendingTableBody.innerHTML = '';
1135 | if(configs.length === 0) {
1136 | pendingTableBody.innerHTML = '没有待审核数据 ';
1137 | return
1138 | }
1139 | configs.forEach(config => {
1140 | const row = document.createElement('tr');
1141 | row.innerHTML = \`
1142 | \${config.id}
1143 | \${config.name}
1144 | \${config.url}
1145 | \${config.logo ? \` \` : 'N/A'}
1146 | \${config.desc || 'N/A'}
1147 | \${config.catelog}
1148 |
1149 | 批准
1150 | 拒绝
1151 |
1152 | \`;
1153 | pendingTableBody.appendChild(row);
1154 | });
1155 | bindPendingActionEvents();
1156 | }
1157 | function bindPendingActionEvents() {
1158 | document.querySelectorAll('.approve-btn').forEach(btn => {
1159 | btn.addEventListener('click', function() {
1160 | const id = this.dataset.id;
1161 | handleApprove(id);
1162 | })
1163 | });
1164 | document.querySelectorAll('.reject-btn').forEach(btn => {
1165 | btn.addEventListener('click', function() {
1166 | const id = this.dataset.id;
1167 | handleReject(id);
1168 | })
1169 | })
1170 | }
1171 |
1172 | function handleApprove(id) {
1173 | if (!confirm('确定批准吗?')) return;
1174 | fetch(\`/api/pending/\${id}\`, {
1175 | method: 'PUT',
1176 | }).then(res => res.json())
1177 | .then(data => {
1178 | if (data.code === 200) {
1179 | showMessage('批准成功', 'success');
1180 | fetchPendingConfigs();
1181 | fetchConfigs();
1182 | } else {
1183 | showMessage(data.message, 'error')
1184 | }
1185 | }).catch(err => {
1186 | showMessage('网络错误', 'error');
1187 | })
1188 | }
1189 | function handleReject(id) {
1190 | if (!confirm('确定拒绝吗?')) return;
1191 | fetch(\`/api/pending/\${id}\`, {
1192 | method: 'DELETE'
1193 | }).then(res => res.json())
1194 | .then(data => {
1195 | if(data.code === 200) {
1196 | showMessage('拒绝成功', 'success');
1197 | fetchPendingConfigs();
1198 | } else {
1199 | showMessage(data.message, 'error');
1200 | }
1201 | }).catch(err => {
1202 | showMessage('网络错误', 'error');
1203 | })
1204 | }
1205 | function updatePendingPaginationButtons() {
1206 | pendingPrevPageBtn.disabled = pendingCurrentPage === 1;
1207 | pendingNextPageBtn.disabled = pendingCurrentPage >= Math.ceil(pendingTotalItems/ pendingPageSize)
1208 | }
1209 |
1210 | pendingPrevPageBtn.addEventListener('click', () => {
1211 | if (pendingCurrentPage > 1) {
1212 | fetchPendingConfigs(pendingCurrentPage - 1);
1213 | }
1214 | });
1215 | pendingNextPageBtn.addEventListener('click', () => {
1216 | if (pendingCurrentPage < Math.ceil(pendingTotalItems/pendingPageSize)) {
1217 | fetchPendingConfigs(pendingCurrentPage + 1)
1218 | }
1219 | });
1220 |
1221 | fetchConfigs();
1222 | fetchPendingConfigs();
1223 | `
1224 | }
1225 | return fileContents[filePath]
1226 | },
1227 |
1228 | async renderAdminPage() {
1229 | const html = await this.getFileContent('admin.html');
1230 | return new Response(html, {
1231 | headers: {'Content-Type': 'text/html; charset=utf-8'}
1232 | });
1233 | },
1234 |
1235 | async renderLoginPage() {
1236 | const html = `
1237 |
1238 |
1239 |
1240 |
1241 | 管理员登录
1242 |
1243 |
1322 |
1323 |
1324 |
1325 |
管理员登录
1326 |
1338 |
返回首页
1339 |
1340 |
1341 |
1357 |
1358 | `;
1359 |
1360 | return new Response(html, {
1361 | headers: { 'Content-Type': 'text/html; charset=utf-8' }
1362 | });
1363 | }
1364 | };
1365 |
1366 |
1367 | /**
1368 | * 优化后的主逻辑:处理请求,返回优化后的 HTML
1369 | */
1370 | async function handleRequest(request, env, ctx) {
1371 | const url = new URL(request.url);
1372 | const catalog = url.searchParams.get('catalog');
1373 |
1374 | let sites = [];
1375 | try {
1376 | const { results } = await env.NAV_DB.prepare('SELECT * FROM sites ORDER BY create_time').all();
1377 | sites = results;
1378 | } catch (e) {
1379 | return new Response(`Failed to fetch data: ${e.message}`, { status: 500 });
1380 | }
1381 |
1382 | if (!sites || sites.length === 0) {
1383 | return new Response('No site configuration found.', { status: 404 });
1384 | }
1385 |
1386 | // 获取所有分类
1387 | const catalogs = Array.from(new Set(sites.map(s => s.catelog)));
1388 |
1389 | // 根据 URL 参数筛选站点
1390 | const currentCatalog = catalog || catalogs[0];
1391 | const currentSites = catalog ? sites.filter(s => s.catelog === currentCatalog) : sites;
1392 |
1393 | // 优化后的 HTML
1394 | const html = `
1395 |
1396 |
1397 |
1398 |
1399 |
1400 | 拾光集 - 精品网址导航
1401 |
1402 |
1403 |
1404 |
1456 |
1536 |
1537 |
1538 |
1539 |
1540 |
1541 |
1542 |
1543 |
1544 |
1545 |
1546 |
1547 |
1548 |
1549 |
1550 |
1551 |
1552 |
1553 |
1554 |
1555 |
1556 |
1557 |
1558 |
1559 |
1560 |
1561 |
1562 |
1563 |
1625 |
1626 |
1627 |
1628 |
1629 |
1630 |
1631 |
1632 |
1633 |
拾光集
1634 |
分享优质网站,构建更美好的网络世界
1635 |
1636 |
1637 |
1638 |
1639 |
1640 |
1641 |
1642 |
1643 |
1644 |
1645 | ${catalog ? `${currentCatalog} · ${currentSites.length} 个网站` : `全部收藏 · ${sites.length} 个网站`}
1646 |
1647 |
1648 | 点击卡片访问网站,鼠标悬停可复制链接
1649 |
1650 |
1651 |
1652 |
1653 |
1654 | ${currentSites.map(site => `
1655 |
1688 | `).join('')}
1689 |
1690 |
1691 |
1692 |
1693 |
1705 |
1706 |
1707 |
1708 |
1709 |
1710 |
1711 |
1712 |
1713 |
1714 |
1715 |
1768 |
1769 |
1989 |
1990 |
1991 | `;
1992 |
1993 | return new Response(html, {
1994 | headers: { 'content-type': 'text/html; charset=utf-8' }
1995 | });
1996 | }
1997 |
1998 |
1999 | // 导出主模块
2000 | export default {
2001 | async fetch(request, env, ctx) {
2002 | const url = new URL(request.url);
2003 |
2004 | if (url.pathname.startsWith('/api')) {
2005 | return api.handleRequest(request, env, ctx);
2006 | } else if (url.pathname === '/admin' || url.pathname.startsWith('/static')) {
2007 | return admin.handleRequest(request, env, ctx);
2008 | } else {
2009 | return handleRequest(request, env, ctx);
2010 | }
2011 | },
2012 | };
--------------------------------------------------------------------------------