快捷菜单
常用功能一站直达
更多功能请点顶栏「快捷菜单」

记录于2026年7月29日。
supabase是边缘专业数据库,自身就快,10ms,还不限制函数,直接就能调用。
1s3存储(免费,不限制函数)
2kv存储(快,免费,只能用pages-edge函数
3blob存储(普通,免费,只能用pages-任意函数)
4supabase存储(最快,免费,不限制函数)
5cos存储(最快,收费,不限制函数)@w3c佬的自助申请友链
@hyde与@w3c的讨论过程
2026.7.29-w3c自动申请友链。

细节
领了数据库后,登陆点这个其实已经有步骤了



https://gpdbnext.console.aliyun.com/gpdb/cn-hangzhou/supabase
在阿里云领,免费版1G
注意下
南方可以领广东地域,那个链接是杭州可以切换一下,别领错了。
细节
在上面切换
左上角

细节
转有网络创建无法选择的
按照界面写的区域创建
点开
点开这个创建F区


必须按照它提示的区创建,才能被选
重新创建交换机,注意里面有个选区
这里确认很容易踩坑
默认创建的,区对不上不能被选
深圳需要f区

下面的的ip6选系统
ip4随便勾
这些只是交换机内部用到的,对数据库没有影响和差异,创建了就行
oneblogfriendslink


登录数据库:

把友链那几个字段创建一下
1CREATE TABLE site_config (
2 id BIGSERIAL PRIMARY KEY,
3 title TEXT NOT NULL,
4 description TEXT,
5 site_url TEXT NOT NULL,
6 img_url TEXT,
7 tags TEXT[] DEFAULT '{}',
8 created_at TIMESTAMPTZ DEFAULT NOW(),
9 updated_at TIMESTAMPTZ DEFAULT NOW()
10
11);细节

执行试试
执行成功了
应该可以了
看看里面有没有表格了
有,只是没数据


测试细节:
表创建后,可以用这个密钥测试写入

咋测试阿
晚点我发这个给你
现在手机不方便

细节
剩下就是博客前端 & eo边缘函数的配置了。
@爱洗脚的one哥
0删除 方便后续合并 友链 动静合并显示
内置一个极简 审核页


---
前端页面弄好,数据咋对接起来,目前还是你的数据

hyde-Supabase-friends.js
1// (边缘函数之 Supabase + friends 完整版:友链CRUD+审核+搜索+三主题+钉钉/飞书消息推送)
2// 必填环境变量(控制台配置,无硬编码密钥)
3// SUPABASE_URL
4// SUPABASE_KEY
5// ADMIN_USER
6// ADMIN_PASS
7// PUSH_SWITCH on / off 推送总开关
8// PUSH_PLATFORM 推送平台名称 (dingtalk/feishu)
9// PUSH_TOKEN 平台webhook/token/密钥
10// PUSH_KEYWORD 钉钉关键词(如果机器人配置了关键词校验)
11// PUSH_SECRET 钉钉加签密钥(SEC开头,如果机器人配置了签名校验)
12// === 1. 全局配置 ===
13const API_PREFIX = "/api";
14const SUPABASE_URL = env.SUPABASE_URL;
15const SUPABASE_KEY = env.SUPABASE_KEY;
16const ADMIN_USER = env.ADMIN_USER;
17const ADMIN_PASS = env.ADMIN_PASS;
18// 推送配置
19const PUSH_SWITCH = (env.PUSH_SWITCH || "off").toLowerCase();
20const PUSH_PLATFORM = (env.PUSH_PLATFORM || "").toLowerCase();
21const PUSH_TOKEN = env.PUSH_TOKEN || "";
22const PUSH_KEYWORD = env.PUSH_KEYWORD || "";
23const PUSH_SECRET = env.PUSH_SECRET || "";
24// 支持的推送平台列表(仅保留钉钉和飞书)
25const SUPPORT_PUSH_PLATFORMS = [
26 "dingtalk",
27 "feishu",
28];
29
30// 环境变量校验
31if (!SUPABASE_URL || !SUPABASE_KEY || !ADMIN_USER || !ADMIN_PASS) {
32 throw new Error(
33 "缺少基础环境变量:SUPABASE_URL/SUPABASE_KEY/ADMIN_USER/ADMIN_PASS",
34 );
35}
36// 推送配置校验(开关开启时必须配置平台和token)
37if (PUSH_SWITCH === "on") {
38 if (!SUPPORT_PUSH_PLATFORMS.includes(PUSH_PLATFORM)) {
39 throw new Error(
40 `PUSH_PLATFORM 平台不支持,可选:${SUPPORT_PUSH_PLATFORMS.join(",")}`,
41 );
42 }
43 if (!PUSH_TOKEN) {
44 throw new Error("推送开关开启时,必须配置 PUSH_TOKEN");
45 }
46}
47
48const ADMIN_PREFIX = `${API_PREFIX}/admin`;
49// === 2. 基础工具函数 ===
50function json(data, status = 200, extraHeaders = {}) {
51 const headers = new Headers({
52 "Content-Type": "application/json; charset=UTF-8",
53 "Access-Control-Allow-Origin": "*",
54 "X-Powered-By": "W3C",
55 });
56 for (const [k, v] of Object.entries(extraHeaders)) {
57 headers.set(k, v);
58 }
59 return new Response(JSON.stringify(data, null, 2), { status, headers });
60}
61
62// === 2.1 会话令牌工具(替代浏览器原生 Basic 弹窗登录) ===
63const SESSION_COOKIE = "hyde_admin_token";
64// 令牌签名密钥:基于管理员密码派生,修改密码后旧令牌自动失效
65const SESSION_SECRET = `hyde-edge-${ADMIN_PASS}`;
66// 登录页 / 登录接口 / 退出接口为公开路径,无需鉴权
67const PUBLIC_ADMIN_PATHS = new Set([
68 `${ADMIN_PREFIX}/login`,
69 `${ADMIN_PREFIX}/logout`,
70]);
71
72/**
73 * HMAC-SHA256 签名
74 * @param {string} data 待签名数据
75 * @param {string} secret 签名密钥
76 * @returns {Promise<string>} Base64 签名
77 */
78async function hmacSign(data, secret) {
79 const encoder = new TextEncoder();
80 const key = await crypto.subtle.importKey(
81 "raw",
82 encoder.encode(secret),
83 { name: "HMAC", hash: "SHA-256" },
84 false,
85 ["sign"],
86 );
87 const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
88 const bytes = new Uint8Array(sig);
89 let binary = "";
90 for (let i = 0; i < bytes.length; i++) {
91 binary += String.fromCharCode(bytes[i]);
92 }
93 return btoa(binary);
94}
95
96/**
97 * 创建会话令牌(payload.signature)
98 * @param {number} maxAgeSec 有效期(秒)
99 * @returns {Promise<string>} 签名令牌
100 */
101async function createSessionToken(maxAgeSec) {
102 const payload = { exp: Date.now() + maxAgeSec * 1000 };
103 const payloadStr = btoa(JSON.stringify(payload));
104 const sig = await hmacSign(payloadStr, SESSION_SECRET);
105 return `${payloadStr}.${sig}`;
106}
107
108/**
109 * 校验会话令牌有效性(签名 + 过期时间)
110 * @param {string} token 待校验令牌
111 * @returns {Promise<boolean>} 是否有效
112 */
113async function verifySessionToken(token) {
114 if (!token || typeof token !== "string") return false;
115 const parts = token.split(".");
116 if (parts.length !== 2) return false;
117 const [payloadStr, sig] = parts;
118 const expectedSig = await hmacSign(payloadStr, SESSION_SECRET);
119 // 长度不符直接拒绝
120 if (sig.length !== expectedSig.length) return false;
121 // 常量时间比较,缓解计时攻击
122 let diff = 0;
123 for (let i = 0; i < sig.length; i++) {
124 diff |= sig.charCodeAt(i) ^ expectedSig.charCodeAt(i);
125 }
126 if (diff !== 0) return false;
127 try {
128 const payload = JSON.parse(atob(payloadStr));
129 if (typeof payload.exp !== "number" || Date.now() > payload.exp)
130 return false;
131 return true;
132 } catch (_) {
133 return false;
134 }
135}
136
137/**
138 * 从请求头解析 Cookie
139 * @param {Request} request
140 * @returns {Record<string, string>}
141 */
142function parseCookies(request) {
143 const cookieHeader = request.headers.get("Cookie") || "";
144 const cookies = {};
145 cookieHeader.split(";").forEach((pair) => {
146 const idx = pair.indexOf("=");
147 if (idx < 0) return;
148 const key = pair.slice(0, idx).trim();
149 const val = pair.slice(idx + 1).trim();
150 if (key) cookies[key] = decodeURIComponent(val);
151 });
152 return cookies;
153}
154
155/**
156 * 构造写入会话 Cookie 的 Set-Cookie 头值
157 * HttpOnly 防 XSS 读取;SameSite=Lax 防基础 CSRF;Secure 仅 HTTPS 下生效
158 * @param {string} token 令牌
159 * @param {number} maxAgeSec 有效期(秒)
160 * @returns {string}
161 */
162function buildSessionCookie(token, maxAgeSec) {
163 return [
164 `${SESSION_COOKIE}=${encodeURIComponent(token)}`,
165 "Path=/api/admin",
166 `Max-Age=${maxAgeSec}`,
167 "HttpOnly",
168 "SameSite=Lax",
169 "Secure",
170 ].join("; ");
171}
172
173/**
174 * 构造清除会话 Cookie 的 Set-Cookie 头值
175 * @returns {string}
176 */
177function buildClearCookie() {
178 return [
179 `${SESSION_COOKIE}=`,
180 "Path=/api/admin",
181 "Max-Age=0",
182 "HttpOnly",
183 "SameSite=Lax",
184 "Secure",
185 ].join("; ");
186}
187
188/**
189 * 管理后台鉴权:校验会话令牌
190 * - 浏览器页面请求(GET 且接受 HTML)未登录时重定向到登录页
191 * - 接口请求未登录时返回 401 JSON,由前端处理跳转
192 * @param {Request} request
193 * @param {string} pathname
194 * @returns {Promise<Response|null>} 未通过则返回响应,通过返回 null
195 */
196async function checkAdminAuth(request, pathname) {
197 if (!pathname.startsWith(ADMIN_PREFIX)) return null;
198 if (PUBLIC_ADMIN_PATHS.has(pathname)) return null;
199 const cookies = parseCookies(request);
200 const token = cookies[SESSION_COOKIE] || "";
201 if (await verifySessionToken(token)) return null;
202 const accept = request.headers.get("Accept") || "";
203 if (request.method === "GET" && accept.includes("text/html")) {
204 return Response.redirect(
205 new URL("/api/admin/login", request.url).toString(),
206 302,
207 );
208 }
209 return json(
210 { success: false, code: "UNAUTHORIZED", message: "未登录或会话已过期,请重新登录" },
211 401,
212 );
213}
214
215// === 3.1 钉钉加签工具函数 ===
216/**
217 * 钉钉机器人加签(HMAC-SHA256)
218 * @param {string} secret 加签密钥
219 * @returns {Promise<{timestamp: number, sign: string}>} 时间戳和签名
220 */
221async function dingTalkSign(secret) {
222 const timestamp = Date.now();
223 const stringToSign = `${timestamp}\n${secret}`;
224 const encoder = new TextEncoder();
225 const data = encoder.encode(stringToSign);
226 const secretBuffer = encoder.encode(secret);
227 const key = await crypto.subtle.importKey(
228 "raw",
229 secretBuffer,
230 { name: "HMAC", hash: "SHA-256" },
231 false,
232 ["sign"]
233 );
234 const signature = await crypto.subtle.sign("HMAC", key, data);
235 const bytes = new Uint8Array(signature);
236 let binary = "";
237 for (let i = 0; i < bytes.length; i++) {
238 binary += String.fromCharCode(bytes[i]);
239 }
240 return { timestamp, sign: btoa(binary) };
241}
242
243// === 3.2 消息推送核心函数(钉钉/飞书适配)
244/**
245 * 统一推送入口
246 * @param {string} title 推送标题
247 * @param {string} content 推送正文内容
248 * @returns {Promise<boolean>} 是否推送成功
249 */
250async function sendPushNotice(title, content) {
251 // 推送总开关关闭,直接返回
252 if (PUSH_SWITCH !== "on") return false;
253 const platform = PUSH_PLATFORM;
254 const token = PUSH_TOKEN;
255 let pushUrl = "";
256 let pushBody = null;
257 let pushHeaders = { "Content-Type": "application/json" };
258
259 // 钉钉加签(如果配置了密钥)
260 let dingSignResult = null;
261 if (platform === "dingtalk" && PUSH_SECRET) {
262 dingSignResult = await dingTalkSign(PUSH_SECRET);
263 }
264
265 switch (platform) {
266 // 钉钉
267 case "dingtalk":
268 pushUrl = token;
269 // 如果配置了加签密钥,添加timestamp和sign参数
270 if (dingSignResult) {
271 const separator = pushUrl.includes("?") ? "&" : "?";
272 pushUrl += `${separator}timestamp=${dingSignResult.timestamp}&sign=${encodeURIComponent(dingSignResult.sign)}`;
273 }
274 // 如果配置了关键词,在消息开头添加关键词(钉钉关键词校验)
275 const dingContent = PUSH_KEYWORD ? `${PUSH_KEYWORD}\n【${title}】\n${content}` : `【${title}】\n${content}`;
276 pushBody = {
277 msgtype: "text",
278 text: { content: dingContent },
279 };
280 break;
281 // 飞书(卡片式消息)
282 case "feishu":
283 pushUrl = token;
284 pushBody = {
285 msg_type: "interactive",
286 card: {
287 header: {
288 title: {
289 content: title,
290 tag: "plain_text",
291 },
292 },
293 elements: [
294 {
295 tag: "div",
296 text: {
297 tag: "lark_md",
298 content: content.replace(/\n/g, "\n\n"),
299 },
300 },
301 {
302 tag: "action",
303 actions: [
304 {
305 tag: "button",
306 text: {
307 content: "访问后台管理",
308 tag: "lark_md",
309 },
310 url: "https://seasir.top/api/admin",
311 type: "default",
312 value: {},
313 },
314 ],
315 },
316 ],
317 },
318 };
319 pushHeaders = { "Content-Type": "application/json" };
320 break;
321 default:
322 console.error("不支持的推送平台");
323 return false;
324 }
325
326 try {
327 const fetchOpt = { method: "POST", headers: pushHeaders };
328 if (pushBody !== null) {
329 // 钉钉/飞书均使用 JSON 请求体
330 fetchOpt.body = JSON.stringify(pushBody);
331 }
332 const res = await fetch(pushUrl, fetchOpt);
333 // 解析响应体,检查钉钉/飞书平台的错误码
334 let responseBody = "";
335 try {
336 responseBody = await res.text();
337 } catch (parseErr) {
338 console.error("推送响应解析失败:", parseErr);
339 }
340 // 钉钉/飞书平台返回200但响应体包含错误码
341 if (platform === "dingtalk" || platform === "feishu") {
342 try {
343 const jsonRes = JSON.parse(responseBody);
344 // 钉钉错误码:0表示成功,其他表示失败
345 if (jsonRes.errcode !== undefined && jsonRes.errcode !== 0) {
346 console.error(`推送失败 [${platform}]:errcode=${jsonRes.errcode}, errmsg=${jsonRes.errmsg || jsonRes.message}`);
347 return false;
348 }
349 } catch (e) {
350 // 非JSON响应,按HTTP状态判断
351 }
352 }
353 // 按HTTP状态判断
354 if (!res.ok) {
355 console.error(`推送失败 [${platform}]:status=${res.status}, body=${responseBody}`);
356 return false;
357 }
358 console.log(`推送成功 [${platform}]:${title}`);
359 return true;
360 } catch (e) {
361 console.error("推送请求异常:", e);
362 return false;
363 }
364}
365
366// === 4. Supabase 请求封装 ===
367async function fetchApi(path, init = {}) {
368 const method = (init?.method || "GET").toUpperCase();
369 const url = new URL(path, SUPABASE_URL);
370 const headers = new Headers(init?.headers || {});
371 headers.set("apikey", SUPABASE_KEY);
372 headers.set("Authorization", `Bearer ${SUPABASE_KEY}`);
373 if (!headers.has("Content-Type"))
374 headers.set("Content-Type", "application/json");
375 if ((method === "GET" || method === "HEAD") && !headers.has("Accept")) {
376 headers.set("Accept", "application/json");
377 }
378 return fetch(
379 new Request(url.toString(), { method, headers, body: init?.body }),
380 );
381}
382
383async function apiJson(path) {
384 const resp = await fetchApi(path);
385 if (!resp.ok) return null;
386 return resp.json();
387}
388
389async function readJson(request) {
390 try {
391 return { body: await request.json() };
392 } catch (_) {
393 return { error: json({ success: false, message: "无效 JSON" }, 400) };
394 }
395}
396
397async function patchFriend(id, patch) {
398 const resp = await fetchApi(
399 `/rest/v1/friends?id=eq.${encodeURIComponent(id)}`,
400 {
401 method: "PATCH",
402 headers: { Prefer: "return=minimal" },
403 body: JSON.stringify(patch),
404 },
405 );
406 return resp.ok ? null : json({ success: false, message: "更新失败" }, 500);
407}
408
409async function fullUpdateFriend(id, data) {
410 const weightNum = Number(data.weight);
411 const payload = {
412 title: data.title,
413 desc: data.desc,
414 siteUrl: data.siteUrl,
415 imgUrl: data.imgUrl || "",
416 email: data.email || "",
417 message: data.message || "",
418 tags: Array.isArray(data.tags) ? data.tags : [],
419 status: data.status || "pending",
420 weight: Number.isFinite(weightNum) ? weightNum : 0,
421 updatedAt: Date.now(),
422 };
423 const resp = await fetchApi(
424 `/rest/v1/friends?id=eq.${encodeURIComponent(id)}`,
425 {
426 method: "PATCH",
427 headers: { Prefer: "return=minimal" },
428 body: JSON.stringify(payload),
429 },
430 );
431 return resp.ok
432 ? null
433 : json({ success: false, message: "编辑保存失败" }, 500);
434}
435
436async function createFriend(data, clientIp = "") {
437 const weightNum = Number(data.weight);
438 const payload = [
439 {
440 title: data.title,
441 desc: data.desc,
442 siteUrl: data.siteUrl,
443 imgUrl: data.imgUrl || "",
444 email: data.email || "",
445 message: data.message || "",
446 tags: Array.isArray(data.tags) ? data.tags : [],
447 status: data.status || "pending",
448 weight: Number.isFinite(weightNum) ? weightNum : 0,
449 ip: clientIp,
450 createdAt: Date.now(),
451 updatedAt: Date.now(),
452 },
453 ];
454 const resp = await fetchApi(`/rest/v1/friends`, {
455 method: "POST",
456 headers: { Prefer: "return=minimal" },
457 body: JSON.stringify(payload),
458 });
459 return resp.ok
460 ? null
461 : json({ success: false, message: "已有重复友链,请更换" }, 500);
462}
463
464async function deleteFriend(id) {
465 const resp = await fetchApi(
466 `/rest/v1/friends?id=eq.${encodeURIComponent(id)}`,
467 {
468 method: "DELETE",
469 headers: { Prefer: "return=minimal" },
470 },
471 );
472 return resp.ok ? null : json({ success: false, message: "删除失败" }, 500);
473}
474
475function mapFriendBase(a) {
476 const weightNum = Number(a.weight);
477 return {
478 title: a.title,
479 desc: a.desc,
480 siteUrl: a.siteUrl,
481 imgUrl: a.imgUrl,
482 tags: a.tags || [],
483 weight: Number.isFinite(weightNum) ? weightNum : 0,
484 };
485}
486
487async function readFriends() {
488 const rows = await apiJson(
489 `/rest/v1/friends?select=title,desc,siteUrl,imgUrl,tags,weight&status=eq.approved&order="weight".desc,createdAt.asc`,
490 );
491 if (rows === null) return [];
492 return (rows || []).map((a) => mapFriendBase(a));
493}
494
495async function readAllFriends() {
496 const resp = await fetchApi(
497 `/rest/v1/friends?select=id,title,desc,"siteUrl","imgUrl",email,message,tags,status,ip,weight,"createdAt","updatedAt"&order="weight".desc,"createdAt".desc`,
498 );
499 if (!resp.ok) return null;
500 const rows = await resp.json();
501 return (rows || []).map((a) => ({
502 ...mapFriendBase(a),
503 id: a.id,
504 email: a.email,
505 message: a.message,
506 status: a.status,
507 ip: a.ip,
508 weight: typeof a.weight === "number" ? a.weight : Number(a.weight) || 0,
509 createdAt: a.createdAt,
510 updatedAt: a.updatedAt,
511 }));
512}
513
514// === 5.1 独立登录页面(替代浏览器原生 Basic 弹窗) ===
515function loginHtml() {
516 return `<!DOCTYPE html>
517<html lang="zh-CN">
518<head>
519<meta charset="UTF-8">
520<meta name="viewport" content="width=device-width, initial-scale=1.0">
521<title>管理后台登录 · Hyde 友链审核</title>
522<link rel="icon" href="https://seasir.top/favicon/favicon.ico" type="image/x-icon" />
523<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f5fa" />
524<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1b1b2a" />
525<meta name="color-scheme" content="light dark" />
526<style>
527* { margin: 0; padding: 0; box-sizing: border-box; }
528/* 沿用站点品牌色相 270(OKLCH),与 Firefly 主题保持一致,避免 AI 紫色渐变 */
529:root {
530 --hue: 270;
531 --page-bg: oklch(0.96 0.008 var(--hue));
532 --card-bg: oklch(1 0 0);
533 --card-shadow: 0 16px 48px oklch(0.25 0.02 var(--hue) / 0.12);
534 --text-primary: oklch(0.25 0.02 var(--hue));
535 --text-secondary: oklch(0.50 0.02 var(--hue));
536 --text-muted: oklch(0.60 0.015 var(--hue));
537 --border-color: oklch(0.90 0.005 var(--hue));
538 --input-bg: oklch(0.97 0.005 var(--hue));
539 --input-border: oklch(0.88 0.006 var(--hue));
540 --input-focus: oklch(0.58 0.16 var(--hue));
541 --focus-ring: oklch(0.58 0.16 var(--hue) / 0.20);
542 --primary: oklch(0.55 0.17 var(--hue));
543 --primary-hover: oklch(0.49 0.17 var(--hue));
544 --error-bg: oklch(0.96 0.03 25);
545 --error-text: oklch(0.52 0.20 25);
546 --error-border: oklch(0.85 0.06 25);
547 --error-ring: oklch(0.52 0.20 25 / 0.18);
548 --link: oklch(0.55 0.17 var(--hue));
549 --link-hover: oklch(0.49 0.17 var(--hue));
550}
551html.dark {
552 --page-bg: oklch(0.16 0.014 var(--hue));
553 --card-bg: oklch(0.23 0.015 var(--hue));
554 --card-shadow: 0 16px 48px oklch(0 0 0 / 0.5);
555 --text-primary: oklch(0.95 0.005 var(--hue));
556 --text-secondary: oklch(0.72 0.015 var(--hue));
557 --text-muted: oklch(0.62 0.02 var(--hue));
558 --border-color: oklch(0.33 0.015 var(--hue));
559 --input-bg: oklch(0.19 0.012 var(--hue));
560 --input-border: oklch(0.33 0.015 var(--hue));
561 --input-focus: oklch(0.75 0.14 var(--hue));
562 --focus-ring: oklch(0.75 0.14 var(--hue) / 0.24);
563 --primary: oklch(0.72 0.14 var(--hue));
564 --primary-hover: oklch(0.78 0.14 var(--hue));
565 --error-bg: oklch(0.30 0.06 25);
566 --error-text: oklch(0.82 0.14 25);
567 --error-border: oklch(0.40 0.10 25);
568 --error-ring: oklch(0.82 0.14 25 / 0.22);
569 --link: oklch(0.75 0.14 var(--hue));
570 --link-hover: oklch(0.80 0.14 var(--hue));
571}
572html, body { height: 100%; }
573body {
574 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
575 background: var(--page-bg);
576 min-height: 100vh;
577 display: flex;
578 align-items: center;
579 justify-content: center;
580 padding: 24px;
581 color: var(--text-primary);
582 -webkit-font-smoothing: antialiased;
583 transition: background 0.3s var(--ease-standard, ease), color 0.3s ease;
584}
585.login-card {
586 position: relative;
587 width: 100%;
588 max-width: 420px;
589 background: var(--card-bg);
590 border: 1px solid var(--border-color);
591 border-radius: 20px;
592 box-shadow: var(--card-shadow);
593 padding: 40px 36px 32px;
594 animation: cardIn 0.5s cubic-bezier(0.16, 1, 0.3, 1);
595 transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
596}
597@keyframes cardIn {
598 from { opacity: 0; transform: translateY(20px); }
599 to { opacity: 1; transform: translateY(0); }
600}
601.brand { text-align: center; margin-bottom: 28px; }
602.brand h1 { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
603.brand p { font-size: 13px; color: var(--text-secondary); }
604.form-group { margin-bottom: 18px; }
605.form-label {
606 display: block; font-size: 13px; font-weight: 600;
607 color: var(--text-primary); margin-bottom: 8px;
608}
609.input-wrap { position: relative; }
610.input-icon {
611 position: absolute; left: 14px; top: 50%;
612 transform: translateY(-50%);
613 color: var(--text-muted); pointer-events: none; display: flex;
614}
615.input-icon svg { width: 18px; height: 18px; }
616.form-input {
617 width: 100%; height: 46px;
618 padding: 0 14px 0 42px;
619 border: 1px solid var(--input-border);
620 border-radius: 10px;
621 background: var(--input-bg);
622 color: var(--text-primary);
623 font-size: 14px;
624 transition: border-color 0.2s, box-shadow 0.2s, background-color 0.3s ease, color 0.3s ease;
625 outline: none;
626}
627.form-input::placeholder { color: var(--text-muted); }
628.form-input:focus {
629 border-color: var(--input-focus);
630 box-shadow: 0 0 0 3px var(--focus-ring);
631}
632.form-input.has-error {
633 border-color: var(--error-text);
634 box-shadow: 0 0 0 3px var(--error-ring);
635}
636.password-toggle {
637 position: absolute; right: 12px; top: 50%;
638 transform: translateY(-50%);
639 background: none; border: none; cursor: pointer;
640 color: var(--text-muted); padding: 4px; display: flex; border-radius: 6px;
641}
642.password-toggle:hover { color: var(--text-secondary); }
643.password-toggle svg { width: 18px; height: 18px; }
644.form-input.password { padding-right: 42px; }
645.field-error {
646 font-size: 12px; color: var(--error-text);
647 margin-top: 6px; display: none;
648}
649.field-error.visible { display: block; }
650.alert {
651 background: var(--error-bg); color: var(--error-text);
652 border: 1px solid var(--error-border);
653 padding: 10px 14px; border-radius: 10px;
654 font-size: 13px; margin-bottom: 18px;
655 display: none; align-items: center; gap: 8px;
656}
657.alert.visible { display: flex; }
658.alert svg { width: 16px; height: 16px; flex-shrink: 0; }
659.form-row {
660 display: flex; align-items: center; justify-content: space-between;
661 margin-bottom: 24px; font-size: 13px;
662}
663.checkbox-wrap {
664 display: flex; align-items: center; gap: 8px;
665 cursor: pointer; user-select: none; color: var(--text-secondary);
666}
667.checkbox-wrap input {
668 width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer;
669}
670.link { color: var(--link); text-decoration: none; cursor: pointer; }
671.link:hover { color: var(--link-hover); text-decoration: underline; }
672.submit-btn {
673 width: 100%; height: 48px; border: none; border-radius: 10px;
674 background: var(--primary); color: #fff;
675 font-size: 15px; font-weight: 600; cursor: pointer;
676 transition: background 0.2s, transform 0.1s;
677 display: flex; align-items: center; justify-content: center; gap: 8px;
678}
679.submit-btn:hover:not(:disabled) { background: var(--primary-hover); }
680.submit-btn:active:not(:disabled) { transform: scale(0.99); }
681.submit-btn:disabled { opacity: 0.7; cursor: not-allowed; }
682.spinner {
683 width: 18px; height: 18px;
684 border: 2px solid rgba(255,255,255,0.3);
685 border-top-color: #fff; border-radius: 50%;
686 animation: spin 0.7s linear infinite; display: inline-block;
687}
688@keyframes spin { to { transform: rotate(360deg); } }
689.card-footer {
690 text-align: center; margin-top: 24px; padding-top: 20px;
691 border-top: 1px solid var(--border-color);
692 font-size: 12px; color: var(--text-muted);
693}
694.modal-overlay {
695 position: fixed; inset: 0; background: rgba(0,0,0,0.5);
696 display: none; align-items: center; justify-content: center;
697 padding: 24px; z-index: 1000;
698}
699.modal-overlay.visible { display: flex; }
700.modal {
701 background: var(--card-bg); border-radius: 16px;
702 padding: 28px; max-width: 380px; width: 100%;
703 box-shadow: var(--card-shadow); animation: cardIn 0.3s ease;
704}
705.modal h3 { font-size: 17px; margin-bottom: 12px; }
706.modal p { font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 8px; }
707.modal code {
708 background: var(--input-bg); padding: 2px 6px; border-radius: 4px;
709 font-size: 13px; color: var(--primary);
710}
711.modal-btn {
712 width: 100%; height: 42px; margin-top: 16px; border: none;
713 border-radius: 10px; background: var(--primary); color: #fff;
714 font-size: 14px; font-weight: 600; cursor: pointer;
715}
716.modal-btn:hover { background: var(--primary-hover); }
717/* 主题切换按钮:固定在登录卡片右上角 */
718.theme-toggle {
719 position: absolute; top: 16px; right: 16px;
720 width: 36px; height: 36px;
721 display: flex; align-items: center; justify-content: center;
722 border: 1px solid var(--border-color);
723 border-radius: 10px;
724 background: var(--input-bg);
725 color: var(--text-secondary);
726 cursor: pointer;
727 transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
728}
729.theme-toggle:hover { color: var(--text-primary); border-color: var(--input-focus); }
730.theme-toggle svg { width: 18px; height: 18px; }
731.theme-toggle .icon-moon { display: none; }
732.theme-toggle .icon-sun { display: block; }
733html.dark .theme-toggle .icon-moon { display: block; }
734html.dark .theme-toggle .icon-sun { display: none; }
735@media (max-width: 480px) {
736 .login-card { padding: 32px 24px 24px; border-radius: 16px; }
737 .brand h1 { font-size: 20px; }
738}
739</style>
740</head>
741<body>
742 <div class="login-card">
743 <button type="button" class="theme-toggle" id="themeToggle" aria-label="切换亮色/暗色模式" title="切换亮色/暗色模式">
744 <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
745 <circle cx="12" cy="12" r="5"/>
746 <line x1="12" y1="1" x2="12" y2="3"/>
747 <line x1="12" y1="21" x2="12" y2="23"/>
748 <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
749 <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
750 <line x1="1" y1="12" x2="3" y2="12"/>
751 <line x1="21" y1="12" x2="23" y2="12"/>
752 <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
753 <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
754 </svg>
755 <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
756 <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
757 </svg>
758 </button>
759 <div class="brand">
760 <h1>管理后台登录</h1>
761 <p>Hyde 友链审核系统</p>
762 </div>
763
764 <div class="alert" id="alert" role="alert">
765 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
766 <circle cx="12" cy="12" r="10"/>
767 <line x1="12" y1="8" x2="12" y2="12"/>
768 <line x1="12" y1="16" x2="12.01" y2="16"/>
769 </svg>
770 <span id="alertText"></span>
771 </div>
772
773 <form id="loginForm" novalidate>
774 <div class="form-group">
775 <label class="form-label" for="username">用户名 / 邮箱</label>
776 <div class="input-wrap">
777 <span class="input-icon">
778 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
779 <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
780 <circle cx="12" cy="7" r="4"/>
781 </svg>
782 </span>
783 <input type="text" id="username" class="form-input" placeholder="请输入用户名或邮箱" autocomplete="username" maxlength="100" />
784 </div>
785 <div class="field-error" id="usernameError"></div>
786 </div>
787
788 <div class="form-group">
789 <label class="form-label" for="password">密码</label>
790 <div class="input-wrap">
791 <span class="input-icon">
792 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
793 <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
794 <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
795 </svg>
796 </span>
797 <input type="password" id="password" class="form-input password" placeholder="请输入密码" autocomplete="current-password" maxlength="100" />
798 <button type="button" class="password-toggle" id="pwdToggle" aria-label="显示或隐藏密码">
799 <svg id="eyeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
800 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
801 <circle cx="12" cy="12" r="3"/>
802 </svg>
803 </button>
804 </div>
805 <div class="field-error" id="passwordError"></div>
806 </div>
807
808 <div class="form-row">
809 <label class="checkbox-wrap">
810 <input type="checkbox" id="remember" />
811 <span>记住我</span>
812 </label>
813 <span class="link" id="forgotLink">忘记密码?</span>
814 </div>
815
816 <button type="submit" class="submit-btn" id="submitBtn">
817 <span class="spinner" id="spinner" style="display:none"></span>
818 <span id="btnText">登 录</span>
819 </button>
820 </form>
821
822 <div class="card-footer">© Hyde 友链审核系统 · 请使用管理员账号登录</div>
823 </div>
824
825 <div class="modal-overlay" id="forgotModal">
826 <div class="modal">
827 <h3>忘记密码?</h3>
828 <p>管理员账号密码通过环境变量配置,无法在页面端找回。</p>
829 <p>如需重置,请在边缘函数环境变量中修改:</p>
830 <p><code>ADMIN_USER</code> / <code>ADMIN_PASS</code></p>
831 <button class="modal-btn" id="closeModal">我已知晓</button>
832 </div>
833 </div>
834
835 <script>
836 (function() {
837 // 主题:与后台保持一致(读取 localStorage 中的 themeMode)
838 var savedTheme = localStorage.getItem("themeMode") || "system";
839 function isDark(mode) {
840 return mode === "dark" || (mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
841 }
842 function applyTheme(mode) {
843 var dark = isDark(mode);
844 document.documentElement.classList.toggle("dark", dark);
845 // 同步浏览器地址栏 / 状态栏主题色
846 var metas = document.querySelectorAll('meta[name="theme-color"]');
847 metas.forEach(function(m) { m.setAttribute("content", dark ? "#1b1b2a" : "#f6f5fa"); });
848 }
849 applyTheme(savedTheme);
850 window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function() {
851 if ((localStorage.getItem("themeMode") || "system") === "system") applyTheme("system");
852 });
853 // 后台在其它标签页切换主题时,同步刷新登录页主题
854 window.addEventListener("storage", function(e) {
855 if (e.key === "themeMode") applyTheme(localStorage.getItem("themeMode") || "system");
856 });
857 // 主题切换按钮:在 亮色 ↔ 暗色 间切换,并持久化到 localStorage
858 document.getElementById("themeToggle").addEventListener("click", function() {
859 var current = localStorage.getItem("themeMode") || "system";
860 // 当前实际为暗色则切到亮色,否则切到暗色
861 var next = isDark(current) ? "light" : "dark";
862 localStorage.setItem("themeMode", next);
863 applyTheme(next);
864 });
865
866 var form = document.getElementById("loginForm");
867 var usernameInput = document.getElementById("username");
868 var passwordInput = document.getElementById("password");
869 var usernameError = document.getElementById("usernameError");
870 var passwordError = document.getElementById("passwordError");
871 var alertBox = document.getElementById("alert");
872 var alertText = document.getElementById("alertText");
873 var submitBtn = document.getElementById("submitBtn");
874 var btnText = document.getElementById("btnText");
875 var spinner = document.getElementById("spinner");
876 var pwdToggle = document.getElementById("pwdToggle");
877 var eyeIcon = document.getElementById("eyeIcon");
878 var forgotLink = document.getElementById("forgotLink");
879 var forgotModal = document.getElementById("forgotModal");
880 var closeModal = document.getElementById("closeModal");
881
882 var EYE_SHOW = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>';
883 var EYE_HIDE = '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>';
884
885 function showError(input, errorEl, msg) {
886 input.classList.add("has-error");
887 errorEl.textContent = msg;
888 errorEl.classList.add("visible");
889 }
890 function clearError(input, errorEl) {
891 input.classList.remove("has-error");
892 errorEl.classList.remove("visible");
893 }
894 function showAlert(msg) { alertText.textContent = msg; alertBox.classList.add("visible"); }
895 function hideAlert() { alertBox.classList.remove("visible"); }
896
897 usernameInput.addEventListener("input", function() { clearError(usernameInput, usernameError); hideAlert(); });
898 passwordInput.addEventListener("input", function() { clearError(passwordInput, passwordError); hideAlert(); });
899
900 // 密码显示 / 隐藏
901 pwdToggle.addEventListener("click", function() {
902 if (passwordInput.type === "password") {
903 passwordInput.type = "text";
904 eyeIcon.innerHTML = EYE_HIDE;
905 } else {
906 passwordInput.type = "password";
907 eyeIcon.innerHTML = EYE_SHOW;
908 }
909 });
910
911 // 忘记密码弹窗
912 forgotLink.addEventListener("click", function() { forgotModal.classList.add("visible"); });
913 closeModal.addEventListener("click", function() { forgotModal.classList.remove("visible"); });
914 forgotModal.addEventListener("click", function(e) { if (e.target === forgotModal) forgotModal.classList.remove("visible"); });
915
916 function validate() {
917 var ok = true;
918 var u = usernameInput.value.trim();
919 var p = passwordInput.value;
920 if (!u) { showError(usernameInput, usernameError, "请输入用户名或邮箱"); ok = false; }
921 else { clearError(usernameInput, usernameError); }
922 if (!p) { showError(passwordInput, passwordError, "请输入密码"); ok = false; }
923 else { clearError(passwordInput, passwordError); }
924 return ok;
925 }
926
927 function setLoading(loading) {
928 submitBtn.disabled = loading;
929 spinner.style.display = loading ? "inline-block" : "none";
930 btnText.textContent = loading ? "登录中..." : "登 录";
931 }
932
933 form.addEventListener("submit", function(e) {
934 e.preventDefault();
935 hideAlert();
936 if (!validate()) return;
937 setLoading(true);
938 fetch("/api/admin/login", {
939 method: "POST",
940 headers: { "Content-Type": "application/json" },
941 body: JSON.stringify({
942 username: usernameInput.value.trim(),
943 password: passwordInput.value,
944 remember: document.getElementById("remember").checked
945 })
946 }).then(function(res) {
947 return res.json().then(function(data) {
948 if (res.ok && data.success) {
949 submitBtn.disabled = true;
950 spinner.style.display = "none";
951 btnText.textContent = "登录成功,正在跳转...";
952 window.location.href = "/api/admin";
953 } else {
954 setLoading(false);
955 showAlert(data.message || "登录失败,请稍后重试");
956 }
957 });
958 }).catch(function() {
959 setLoading(false);
960 showAlert("网络异常,请检查网络后重试");
961 });
962 });
963
964 // 用户名回车跳到密码,密码回车提交
965 usernameInput.addEventListener("keypress", function(e) {
966 if (e.key === "Enter") { e.preventDefault(); passwordInput.focus(); }
967 });
968 // 自动聚焦用户名
969 usernameInput.focus();
970 })();
971 </script>
972</body>
973</html>`;
974}
975
976function adminHtml() {
977 const pushStatusText =
978 PUSH_SWITCH === "on"
979 ? `✅ 推送已开启 | 平台:${PUSH_PLATFORM}`
980 : "❌ 推送已关闭";
981 // 主题svg图标
982 const themeSvg = `<svg t="1785146130734" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1730" width="24" height="24"><path d="M554.666667 839.111111v113.777778h-71.111111v-113.777778h71.111111z m220.885333-120.945778l80.455111 80.455111-50.275555 50.275556-80.455112-80.455111 50.275556-50.275556z m-512.881778 0l50.275556 50.275556-80.455111 80.455111-50.275556-50.275556 80.455111-80.455111zM512 256c141.383111 0 256 114.616889 256 256s-114.616889 256-256 256-256-114.616889-256-256 114.616889-256 256-256z m0 71.111111c-102.115556 0-184.888889 82.773333-184.888889 184.888889s82.773333 184.888889 184.888889 184.888889 184.888889-82.773333 184.888889-184.888889-82.773333-184.888889-184.888889-184.888889z m448 149.333333v71.111112h-113.777778v-71.111112h113.777778z m-768 0v71.111112h-113.777778v-71.111112h113.777778z m40.490667-301.340444l80.455111 80.455111-50.275556 50.275556-80.455111-80.455111 50.275556-50.275556z m573.240889 0l50.275555 50.275556-80.455111 80.455111-50.275556-50.275556 80.455112-80.455111zM554.666667 71.111111v113.777778h-71.111111V71.111111h71.111111z" fill="currentColor" p-id="1731"></path></svg>`;
983 return `<!DOCTYPE html>
984<!DOCTYPE html>
985<html lang="zh-CN">
986<head>
987<meta charset="UTF-8">
988<meta name="viewport" content="width=device-width, initial-scale=1.0">
989<title>Hyde友链审核管理后台</title>
990<link rel="icon" href="https://seasir.top/favicon/favicon.ico" type="image/x-icon" />
991<link rel="preconnect" href="https://unpkg.com" crossorigin />
992<link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css" media="print" onload="this.media='all'" />
993<link rel="stylesheet" href="//unpkg.com/element-plus/theme-chalk/dark/css-vars.css" media="print" onload="this.media='all'" />
994<script defer src="//unpkg.com/vue@3/dist/vue.global.prod.js"></script>
995<script defer src="//unpkg.com/element-plus"></script>
996<script defer src="//unpkg.com/element-plus/dist/locale/zh-cn.min.js"></script>
997<style>
998* {margin:0;padding:0;box-sizing:border-box;}
999#app {padding:24px;margin:0 auto;}
1000.push-tip {margin-bottom:16px;padding:12px 16px;border-radius:8px;background:var(--el-fill-color-lighter);}
1001.header-bar {display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:12px;}
1002.header-theme-btn {padding:6px;cursor:pointer;color:var(--el-text-color-primary);border-radius:6px;border:1px solid var(--el-border-color);background:transparent;display:flex;align-items:center;justify-content:center;}
1003.header-theme-btn:hover {background:var(--el-fill-color-lighter);}
1004.filter-wrap {display:flex;gap:12px;align-items:center;margin-bottom:16px;flex-wrap:wrap;}
1005.search-input {flex:1;min-width:280px;}
1006.batch-bar {display:flex;gap:10px;align-items:center;padding:8px 12px;border-radius:6px;flex-wrap:wrap;}
1007.table-box {border-radius:8px;overflow:hidden;border:1px solid var(--el-border-color);}
1008.form-dialog .el-form-item {margin-bottom:18px;}
1009.avatar-img {width:40px;height:40px;border-radius:6px;object-fit:cover;}
1010.header-title {font-size:22px;font-weight:600;}
1011.header-actions {display:flex;gap:10px;align-items:center;}
1012.filter-select {width:100px;}
1013.table-link {color:var(--el-color-primary);word-break:break-all;}
1014.theme-option-item {padding:10px 16px;cursor:pointer;border-radius:6px;}
1015.theme-option-item:hover {background:var(--el-fill-color-lighter);}
1016.theme-option-item.active {background:var(--el-color-primary-light-3);color:var(--el-color-primary);}
1017.tags-ellipsis {display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1018.op-cell {display:flex;align-items:center;justify-content:center;height:100%;gap:4px;flex-wrap:nowrap;}
1019.pagination-wrap {display:flex;justify-content:space-between;align-items:center;margin-top:16px;padding-right:8px;gap:16px;}
1020.el-button--small {
1021 border-radius: calc(var(--el-border-radius-base) - 1px);
1022 padding: 5px 4px;
1023 font-size: 12px;
1024}
1025@media (max-width: 768px) {
1026 /* 筛选区保持单行,搜索框自适应,下拉框固定窄宽 */
1027 .filter-wrap {flex-wrap:nowrap;}
1028 .filter-wrap .search-input {flex:1;min-width:0;}
1029 .pagination-wrap {justify-content:center;flex-wrap:wrap;}
1030 .el-pagination {flex-wrap:wrap;justify-content:center;}
1031 .el-pagination__jump {display: none;}
1032 /* 弹窗宽度铺满屏幕,左右留边距 */
1033 .el-dialog {
1034 width: calc(100% - 24px);
1035 margin: 0 auto ;
1036 top: 10vh ;
1037 transform: translateY(0);
1038 }
1039 /* 弹窗内边距缩小 */
1040 .el-dialog__body {
1041 padding: 12px 16px;
1042 }
1043 .el-dialog__header {
1044 padding: 12px 16px ;
1045 }
1046 .el-dialog__footer {
1047 padding: 12px 16px;
1048 display: flex;
1049 flex-wrap: wrap;
1050 gap: 10px;
1051 justify-content: flex-end;
1052 }
1053 /* 表单标签取消固定宽度,文字居上 */
1054 .form-dialog .el-form-item__label {
1055 width: auto;
1056 text-align: left;
1057 padding-bottom: 6px;
1058 display: block;
1059 }
1060 /* 表单项间距缩小 */
1061 .form-dialog .el-form-item {
1062 margin-bottom: 14px;
1063 }
1064 /* 输入框、下拉框100%宽度 */
1065 .form-dialog .el-input,
1066 .form-dialog .el-select {
1067 width: 100%;
1068 }
1069}
1070
1071</style>
1072</head>
1073<body>
1074<div id="app">
1075 <div class="push-tip">${pushStatusText}</div>
1076 <div class="header-bar">
1077 <h1 class="header-title">友链审核管理</h1>
1078 <div class="header-actions">
1079 <el-button type="success" @click="openAddDialog">新增友链</el-button>
1080 <el-button @click="logout">退出登录</el-button>
1081 <button class="header-theme-btn" @click="themeDialogVisible = true">
1082 ${themeSvg}
1083 </button>
1084 </div>
1085 </div>
1086
1087 <div class="filter-wrap">
1088 <el-input class="search-input" v-model="searchKeyword" placeholder="搜索标题/描述/链接/标签/IP/邮箱/权重" prefix-icon="Search"></el-input>
1089 <el-select v-model="filterStatus" placeholder="筛选状态" class="filter-select">
1090 <el-option label="全部状态" value=""></el-option>
1091 <el-option label="待审核" value="pending"></el-option>
1092 <el-option label="已通过" value="approved"></el-option>
1093 <el-option label="已拒绝" value="rejected"></el-option>
1094 </el-select>
1095 </div>
1096
1097 <div class="table-box">
1098 <el-table :data="pagedTableData" border stripe v-loading="loading" @selection-change="handleSelectionChange" empty-text="暂无友链数据">
1099 <el-table-column type="selection" width="40"></el-table-column>
1100 <el-table-column label="头像" width="85">
1101 <template #default="scope">
1102 <img v-if="scope.row.imgUrl" class="avatar-img" :src="scope.row.imgUrl" @error="$event.target.style.display='none'" />
1103 <span v-else>-</span>
1104 </template>
1105 </el-table-column>
1106 <el-table-column label="状态" width="110">
1107 <template #default="scope">
1108 <el-tag :type="statusTypeMap[scope.row.status] || 'info'" size="small">
1109 {{ statusMap[scope.row.status] }}
1110 </el-tag>
1111 </template>
1112 </el-table-column>
1113 <el-table-column label="站点名称" prop="title" min-width="130"></el-table-column>
1114
1115 <el-table-column label="描述" prop="desc" min-width="200"></el-table-column>
1116 <el-table-column label="站点链接" prop="siteUrl" min-width="240">
1117 <template #default="scope">
1118 <a :href="scope.row.siteUrl" target="_blank" class="table-link">{{ scope.row.siteUrl }}</a>
1119 </template>
1120 </el-table-column>
1121 <el-table-column label="标签" min-width="160">
1122 <template #default="scope">
1123 <span class="tags-ellipsis" v-if="scope.row.tags && scope.row.tags.length > 0">{{ scope.row.tags.join(", ") }}</span>
1124 <span v-else>-</span>
1125 </template>
1126 </el-table-column>
1127 <el-table-column label="权重" prop="weight" width="80" align="center">
1128 <template #default="scope">
1129 <span v-if="normalizeWeight(scope.row.weight) !== null">{{ normalizeWeight(scope.row.weight) }}</span>
1130 <span v-else>-</span>
1131 </template>
1132 </el-table-column>
1133 <el-table-column label="IP" prop="ip" width="150"></el-table-column>
1134 <el-table-column label="邮箱" prop="email" min-width="200"></el-table-column>
1135 <el-table-column label="留言" prop="message" min-width="200"></el-table-column>
1136 <el-table-column label="申请时间" prop="createdAt" width="170">
1137 <template #default="scope">{{ formatTime(scope.row.createdAt) }}</template>
1138 </el-table-column>
1139 <el-table-column label="更新时间" prop="updatedAt" width="170">
1140 <template #default="scope">{{ formatTime(scope.row.updatedAt) }}</template>
1141 </el-table-column>
1142 <el-table-column label="操作" width="100" fixed="right">
1143 <template #default="scope">
1144 <div class="op-cell">
1145 <!-- 永久显示通过按钮 -->
1146 <el-button text type="success" size="small" @click="singlePass(scope.row.id)">通过</el-button>
1147
1148 <el-dropdown @command="(cmd) => handleRowCommand(cmd, scope.row)">
1149 <el-button text size="small">更多</el-button>
1150 <template #dropdown>
1151 <el-dropdown-menu>
1152 <el-dropdown-item command="edit">编辑</el-dropdown-item>
1153 <el-dropdown-item command="reject">拒绝</el-dropdown-item>
1154 <el-dropdown-item command="delete">删除</el-dropdown-item>
1155 </el-dropdown-menu>
1156 </template>
1157 </el-dropdown>
1158 </div>
1159 </template>
1160 </el-table-column>
1161 </el-table>
1162 </div>
1163
1164 <div class="pagination-wrap">
1165 <div class="batch-bar">
1166 <span>已选中 {{ selectedIds.length }} 条</span>
1167 <el-dropdown @command="handleBatchCommand" :disabled="selectedIds.length === 0">
1168 <el-button type="primary" size="small" :disabled="selectedIds.length === 0">批量操作</el-button>
1169 <template #dropdown>
1170 <el-dropdown-menu>
1171 <el-dropdown-item command="batchPass">批量通过</el-dropdown-item>
1172 <el-dropdown-item command="batchReject">批量拒绝</el-dropdown-item>
1173 <el-dropdown-item command="batchDelete">批量删除</el-dropdown-item>
1174 </el-dropdown-menu>
1175 </template>
1176 </el-dropdown>
1177 </div>
1178 <el-pagination
1179 v-model:current-page="currentPage"
1180 v-model:page-size="pageSize"
1181 :total="filterTableData.length"
1182 :page-sizes="[15, 20, 30]"
1183 layout="total, sizes, prev, pager, next, jumper"
1184 background
1185 ></el-pagination>
1186 </div>
1187
1188<!-- 新增友链表单 -->
1189 <el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" :close-on-click-modal="false">
1190 <el-form class="form-dialog" :model="formData" label-width="100px">
1191 <el-form-item label="站点名称">
1192 <el-input v-model="formData.title" maxlength="50" placeholder="1-50字符"></el-input>
1193 </el-form-item>
1194 <el-form-item label="站点描述">
1195 <el-input v-model="formData.desc" type="textarea" :rows="3" maxlength="200" placeholder="1-200字符"></el-input>
1196 </el-form-item>
1197 <el-form-item label="站点链接">
1198 <el-input v-model="formData.siteUrl" placeholder="https://example.com"></el-input>
1199 </el-form-item>
1200 <el-form-item label="头像链接">
1201 <el-input v-model="formData.imgUrl" placeholder="头像链接,必填"></el-input>
1202 </el-form-item>
1203 <el-form-item label="联系邮箱">
1204 <el-input v-model="formData.email" placeholder="用于站长联系(可选)"></el-input>
1205 </el-form-item>
1206 <el-form-item label="留言">
1207 <el-input v-model="formData.message" type="textarea" :rows="3" placeholder="留言内容(可选)"></el-input>
1208 </el-form-item>
1209 <el-form-item label="标签">
1210 <el-input v-model="formData.tagsStr" placeholder="多个标签用英文逗号分隔"></el-input>
1211 </el-form-item>
1212 <el-form-item label="审核状态">
1213 <el-select v-model="formData.status">
1214 <el-option label="待审核" value="pending"></el-option>
1215 <el-option label="已通过" value="approved"></el-option>
1216 <el-option label="已拒绝" value="rejected"></el-option>
1217 </el-select>
1218 </el-form-item>
1219 <el-form-item label="权重">
1220 <el-input-number v-model="formData.weight" :min="0" :step="1" step-strictly controls-position="right" placeholder="数字越大排序越靠前"></el-input-number>
1221 </el-form-item>
1222 </el-form>
1223 <template #footer>
1224 <el-button @click="dialogVisible = false">取消</el-button>
1225 <el-button type="primary" @click="submitForm">保存</el-button>
1226 </template>
1227 </el-dialog>
1228
1229 <el-dialog v-model="themeDialogVisible" title="选择界面主题" width="360px" :close-on-click-modal="true">
1230 <div class="theme-option-item" :class="{active: themeMode === 'system'}" @click="selectTheme('system')">
1231 跟随系统
1232 </div>
1233 <div class="theme-option-item" :class="{active: themeMode === 'light'}" @click="selectTheme('light')">
1234 浅色模式
1235 </div>
1236 <div class="theme-option-item" :class="{active: themeMode === 'dark'}" @click="selectTheme('dark')">
1237 暗黑模式
1238 </div>
1239 </el-dialog>
1240
1241 <!-- 删除二次确认弹窗:确认按钮 5s 倒计时(单条 / 批量复用) -->
1242 <el-dialog v-model="deleteConfirmVisible" :title="deleteMode === 'batch' ? '批量删除' : '危险操作'" width="420px" :close-on-click-modal="false" @close="stopDeleteCountdown">
1243 <span>{{ deleteConfirmText }}</span>
1244 <template #footer>
1245 <el-button @click="cancelDelete">取消</el-button>
1246 <el-button type="danger" :disabled="deleteCountdown > 0" @click="confirmDelete">
1247 {{ deleteCountdown > 0 ? '确认删除 (' + deleteCountdown + 's)' : '确认删除' }}
1248 </el-button>
1249 </template>
1250 </el-dialog>
1251</div>
1252
1253<script>
1254document.addEventListener("DOMContentLoaded", () => {
1255const { createApp, ref, reactive, computed, onMounted, watch, onUnmounted } = Vue;
1256const { ElMessage, ElMessageBox } = ElementPlus;
1257const ADMIN_API = "${ADMIN_PREFIX}/friends";
1258// 统一 fetch 封装:会话过期(401)时自动跳转登录页
1259let isRedirectingToLogin = false;
1260const adminFetch = async (url, opts) => {
1261 const res = await fetch(url, opts);
1262 if (res.status === 401 && !isRedirectingToLogin) {
1263 isRedirectingToLogin = true;
1264 ElMessage.error("登录已过期,正在跳转登录页...");
1265 setTimeout(() => { window.location.href = "/api/admin/login"; }, 1200);
1266 }
1267 return res;
1268};
1269
1270const statusMap = Object.freeze({
1271 pending: "待审核",
1272 approved: "已通过",
1273 rejected: "已拒绝"
1274});
1275const statusTypeMap = Object.freeze({
1276 approved: "success",
1277 pending: "warning",
1278 rejected: "danger"
1279});
1280
1281createApp({
1282 setup() {
1283 const loading = ref(false);
1284 const tableData = ref([]);
1285 const searchKeyword = ref("");
1286 const filterStatus = ref("");
1287 const selectedIds = ref([]);
1288 const currentPage = ref(1);
1289 const pageSize = ref(15);
1290
1291 const dialogVisible = ref(false);
1292 const dialogTitle = ref("新增友链");
1293 const editRowId = ref("");
1294 // 补全所有表单字段,消除赋值undefined报错
1295 const formData = reactive({
1296 title: "",
1297 desc: "",
1298 siteUrl: "",
1299 imgUrl: "",
1300 email: "",
1301 message: "",
1302 tagsStr: "",
1303 status: "pending",
1304 weight: 0
1305 });
1306
1307 const themeDialogVisible = ref(false);
1308 const themeMode = ref(localStorage.getItem("themeMode") || "system");
1309 const systemDarkMedia = window.matchMedia("(prefers-color-scheme: dark)");
1310
1311 // 移动端检测
1312 const isMobile = ref(window.innerWidth <= 768);
1313 let resizeTimer = null;
1314 const resizeHandler = () => {
1315 clearTimeout(resizeTimer);
1316 resizeTimer = setTimeout(() => {
1317 isMobile.value = window.innerWidth <= 768;
1318 }, 150);
1319 };
1320
1321 const formatTime = (timestamp) => {
1322 if (!timestamp) return "-";
1323 const d = new Date(timestamp);
1324 const pad = n => String(n).padStart(2, "0");
1325 return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
1326 };
1327
1328 const normalizeWeight = (w) => {
1329 if (w === null || w === undefined || w === "") return null;
1330 const n = Number(w);
1331 return Number.isFinite(n) ? n : null;
1332 };
1333
1334 const filterTableData = computed(() => {
1335 let list = tableData.value;
1336 const kw = debouncedKeyword.value.trim().toLowerCase();
1337 if (filterStatus.value) {
1338 list = list.filter(item => item.status === filterStatus.value);
1339 }
1340 if (kw) {
1341 list = list.filter(item => {
1342 const tagText = Array.isArray(item.tags) ? item.tags.join(",") : "";
1343 const weightText = item.weight != null ? String(item.weight) : "";
1344 const text = [item.title, item.desc, item.siteUrl, item.ip, item.email, tagText, weightText].join(" ").toLowerCase();
1345 return text.includes(kw);
1346 });
1347 }
1348 return list;
1349 });
1350
1351 const pagedTableData = computed(() => {
1352 const start = (currentPage.value - 1) * pageSize.value;
1353 const end = start + pageSize.value;
1354 return filterTableData.value.slice(start, end);
1355 });
1356
1357 watch([searchKeyword, filterStatus], () => {
1358 currentPage.value = 1;
1359 });
1360
1361 // 搜索防抖:250ms,避免快速输入时频繁重算
1362 const debouncedKeyword = ref("");
1363 let searchDebounceTimer = null;
1364 watch(searchKeyword, (val) => {
1365 clearTimeout(searchDebounceTimer);
1366 searchDebounceTimer = setTimeout(() => {
1367 debouncedKeyword.value = val;
1368 }, 250);
1369 });
1370
1371 watch([filterTableData, pageSize], ([newData]) => {
1372 const totalPages = Math.ceil(newData.length / pageSize.value);
1373 if (currentPage.value > totalPages && totalPages > 0) {
1374 currentPage.value = totalPages;
1375 } else if (totalPages === 0) {
1376 currentPage.value = 1;
1377 }
1378 });
1379
1380 const applyTheme = (mode) => {
1381 const html = document.documentElement;
1382 html.classList.remove("dark");
1383 if (mode === "dark") {
1384 html.classList.add("dark");
1385 } else if (mode === "system") {
1386 if (systemDarkMedia.matches) html.classList.add("dark");
1387 }
1388 localStorage.setItem("themeMode", mode);
1389 };
1390 const selectTheme = (mode) => {
1391 themeMode.value = mode;
1392 applyTheme(mode);
1393 themeDialogVisible.value = false;
1394 ElMessage.success("主题切换完成");
1395 };
1396 const initTheme = () => {
1397 applyTheme(themeMode.value);
1398 systemDarkMedia.addEventListener("change", () => {
1399 if (themeMode.value === "system") applyTheme("system");
1400 });
1401 };
1402
1403 const loadList = async () => {
1404 loading.value = true;
1405
1406 try {
1407 const res = await adminFetch(ADMIN_API);
1408 const json = await res.json();
1409 if (json.success) {
1410 const items = json.items || [];
1411 items.sort((a, b) => (Number(b.weight) || 0) - (Number(a.weight) || 0));
1412 tableData.value = items;
1413 } else {
1414 ElMessage.error(json.message || "数据加载失败");
1415 }
1416 } catch (err) {
1417 ElMessage.error("网络请求失败");
1418 console.error(err);
1419 } finally {
1420 loading.value = false;
1421 }
1422 };
1423
1424 const handleSelectionChange = (val) => {
1425 selectedIds.value = val.map(item => item.id);
1426 };
1427
1428 const openAddDialog = () => {
1429 dialogTitle.value = "新增友链";
1430 editRowId.value = "";
1431 formData.title = "";
1432 formData.desc = "";
1433 formData.siteUrl = "";
1434 formData.imgUrl = "";
1435 formData.email = "";
1436 formData.message = "";
1437 formData.tagsStr = "";
1438 formData.status = "pending";
1439 formData.weight = 0;
1440 dialogVisible.value = true;
1441 };
1442
1443 const logout = () => {
1444 ElMessageBox.confirm("确定要退出登录吗?", "退出确认", {
1445 confirmButtonText: "确认退出",
1446 cancelButtonText: "取消",
1447 type: "warning"
1448 }).then(() => {
1449 window.location.href = "/api/admin/logout";
1450 }).catch(() => {});
1451 };
1452
1453 const openEditDialog = (row) => {
1454 dialogTitle.value = "编辑友链";
1455 editRowId.value = row.id;
1456 formData.title = row.title || "";
1457 formData.desc = row.desc || "";
1458 formData.siteUrl = row.siteUrl || "";
1459 formData.imgUrl = row.imgUrl || "";
1460 formData.email = row.email || "";
1461 formData.message = row.message || "";
1462 formData.tagsStr = Array.isArray(row.tags) ? row.tags.join(",") : "";
1463 formData.status = row.status || "pending";
1464 formData.weight = (() => {
1465 const n = Number(row.weight);
1466 return Number.isFinite(n) ? n : 0;
1467 })();
1468 dialogVisible.value = true;
1469 };
1470
1471 const submitForm = async () => {
1472 const { title, desc, siteUrl, imgUrl, email, message, tagsStr, status, weight } = formData;
1473 if (!title || title.length > 50) return ElMessage.warning("标题必填,长度1-50字符");
1474 if (!desc || desc.length > 200) return ElMessage.warning("描述必填,长度1-200字符");
1475 if (!siteUrl) return ElMessage.warning("站点链接必填");
1476 try { new URL(siteUrl); } catch { return ElMessage.warning("站点链接格式不正确"); }
1477 if (!imgUrl) return ElMessage.warning("头像地址必填");
1478 const w = Number(weight);
1479 if (!Number.isFinite(w)) return ElMessage.warning("权重必须是数字");
1480 const tags = tagsStr.split(",").map(s => s.trim()).filter(s => s);
1481 const payload = { title, desc, siteUrl, imgUrl, email, message, tags, status, weight: w };
1482
1483 try {
1484 if (editRowId.value) {
1485 payload.id = editRowId.value;
1486 const res = await adminFetch(ADMIN_API, {
1487 method: "PATCH",
1488 headers: { "Content-Type": "application/json" },
1489 body: JSON.stringify(payload)
1490 });
1491 const json = await res.json();
1492 if (json.success) {
1493 ElMessage.success("编辑成功");
1494 dialogVisible.value = false;
1495 loadList();
1496 } else {
1497 ElMessage.error(json.message || "编辑失败");
1498 }
1499 } else {
1500 const res = await adminFetch(ADMIN_API, {
1501 method: "POST",
1502 headers: { "Content-Type": "application/json" },
1503 body: JSON.stringify(payload)
1504 });
1505 const json = await res.json();
1506 if (json.success) {
1507 ElMessage.success("新增成功");
1508 dialogVisible.value = false;
1509 loadList();
1510 } else {
1511 ElMessage.error(json.message || "新增失败");
1512 }
1513 }
1514 } catch (err) {
1515 ElMessage.error("操作失败,网络异常");
1516 console.error(err);
1517 }
1518 };
1519
1520 const singlePass = (id) => {
1521 ElMessageBox.confirm("确定通过这条友链的审核?", "通过确认", {
1522 confirmButtonText: "确认通过",
1523 cancelButtonText: "取消",
1524 type: "success"
1525 }).then(() => updateSingleStatus(id, "approved"))
1526 .catch(() => ElMessage.info("已取消"));
1527 };
1528 const singleReject = (id) => {
1529 ElMessageBox.confirm("确定拒绝这条友链的审核?", "拒绝确认", {
1530 confirmButtonText: "确认拒绝",
1531 cancelButtonText: "取消",
1532 type: "warning"
1533 }).then(() => updateSingleStatus(id, "rejected"))
1534 .catch(() => ElMessage.info("已取消"));
1535 };
1536 const updateSingleStatus = async (id, status) => {
1537 try {
1538 const res = await adminFetch(ADMIN_API, {
1539 method: "PUT",
1540 headers: { "Content-Type": "application/json" },
1541 body: JSON.stringify({ id, status })
1542 });
1543 const json = await res.json();
1544 if (json.success) {
1545 ElMessage.success("操作成功");
1546 loadList();
1547 } else {
1548 ElMessage.error(json.message || "操作失败");
1549 }
1550 } catch {
1551 ElMessage.error("网络异常");
1552 }
1553 };
1554
1555 // 删除二次确认:确认按钮 3s 倒计时(单条 / 批量复用)
1556 const deleteConfirmVisible = ref(false);
1557 const deleteMode = ref("single");
1558 const pendingDeleteId = ref("");
1559 const deleteCountdown = ref(0);
1560 let deleteCountdownTimer = null;
1561
1562 const deleteConfirmText = computed(() => {
1563 return deleteMode.value === "batch"
1564 ? "确定永久删除选中的 " + selectedIds.value.length + " 条友链?删除后无法恢复!"
1565 : "确定永久删除这条友链?删除后无法恢复!";
1566 });
1567
1568 const startDeleteCountdown = () => {
1569 deleteCountdown.value = 3;
1570 if (deleteCountdownTimer) clearInterval(deleteCountdownTimer);
1571 deleteCountdownTimer = setInterval(() => {
1572 deleteCountdown.value -= 1;
1573 if (deleteCountdown.value <= 0) {
1574 clearInterval(deleteCountdownTimer);
1575 deleteCountdownTimer = null;
1576 deleteCountdown.value = 0;
1577 }
1578 }, 1000);
1579 };
1580
1581 const stopDeleteCountdown = () => {
1582 if (deleteCountdownTimer) {
1583 clearInterval(deleteCountdownTimer);
1584 deleteCountdownTimer = null;
1585 }
1586 deleteCountdown.value = 0;
1587 };
1588
1589 const singleDelete = (id) => {
1590 deleteMode.value = "single";
1591 pendingDeleteId.value = id;
1592 deleteConfirmVisible.value = true;
1593 startDeleteCountdown();
1594 };
1595
1596 const cancelDelete = () => {
1597 deleteConfirmVisible.value = false;
1598 stopDeleteCountdown();
1599 ElMessage.info("已取消删除");
1600 };
1601
1602 const confirmDelete = async () => {
1603 if (deleteCountdown.value > 0) return;
1604 deleteConfirmVisible.value = false;
1605 stopDeleteCountdown();
1606 // 批量删除
1607 if (deleteMode.value === "batch") {
1608 const results = await Promise.allSettled(
1609 selectedIds.value.map(id =>
1610 adminFetch(ADMIN_API, {
1611 method: "DELETE",
1612 headers: { "Content-Type": "application/json" },
1613 body: JSON.stringify({ id })
1614 }).then(r => r.json())
1615 )
1616 );
1617 const success = results.filter(r => r.status === "fulfilled" && r.value.success).length;
1618 const fail = selectedIds.value.length - success;
1619 ElMessage.success("批量删除完成:成功" + success + "条,失败" + fail + "条");
1620 selectedIds.value = [];
1621 loadList();
1622 return;
1623 }
1624 // 单条删除
1625 const id = pendingDeleteId.value;
1626 try {
1627 const res = await adminFetch(ADMIN_API, {
1628 method: "DELETE",
1629 headers: { "Content-Type": "application/json" },
1630 body: JSON.stringify({ id })
1631 });
1632 const json = await res.json();
1633 if (json.success) {
1634 ElMessage.success("删除成功");
1635 loadList();
1636 } else {
1637 ElMessage.error(json.message || "删除失败");
1638 }
1639 } catch {
1640 ElMessage.error("网络异常");
1641 }
1642 };
1643
1644 const batchPass = async () => {
1645 if (selectedIds.value.length === 0) return ElMessage.warning("请先勾选友链");
1646 const tipText = "确定批量通过选中的 " + selectedIds.value.length + " 条友链?";
1647 ElMessageBox.confirm(tipText, "批量操作", {
1648 confirmButtonText: "确认",
1649 cancelButtonText: "取消",
1650 type: "info"
1651 }).then(async () => {
1652 const results = await Promise.allSettled(
1653 selectedIds.value.map(id =>
1654 adminFetch(ADMIN_API, {
1655 method: "PUT",
1656 headers: { "Content-Type": "application/json" },
1657 body: JSON.stringify({ id, status: "approved" })
1658 }).then(r => r.json())
1659 )
1660 );
1661 const success = results.filter(r => r.status === "fulfilled" && r.value.success).length;
1662 const fail = selectedIds.value.length - success;
1663 ElMessage.success("批量完成:成功" + success + "条,失败" + fail + "条");
1664 selectedIds.value = [];
1665 loadList();
1666 }).catch(() => ElMessage.info("已取消"));
1667 };
1668 const batchReject = async () => {
1669 if (selectedIds.value.length === 0) return ElMessage.warning("请先勾选友链");
1670 const tipText = "确定批量拒绝选中的 " + selectedIds.value.length + " 条友链?";
1671 ElMessageBox.confirm(tipText, "批量操作", {
1672 confirmButtonText: "确认",
1673 cancelButtonText: "取消",
1674 type: "info"
1675 }).then(async () => {
1676 const results = await Promise.allSettled(
1677 selectedIds.value.map(id =>
1678 adminFetch(ADMIN_API, {
1679 method: "PUT",
1680 headers: { "Content-Type": "application/json" },
1681 body: JSON.stringify({ id, status: "rejected" })
1682 }).then(r => r.json())
1683 )
1684 );
1685 const success = results.filter(r => r.status === "fulfilled" && r.value.success).length;
1686 const fail = selectedIds.value.length - success;
1687 ElMessage.success("批量完成:成功" + success + "条,失败" + fail + "条");
1688 selectedIds.value = [];
1689 loadList();
1690 }).catch(() => ElMessage.info("已取消"));
1691 };
1692 const batchDelete = () => {
1693 if (selectedIds.value.length === 0) return ElMessage.warning("请先勾选友链");
1694 deleteMode.value = "batch";
1695 deleteConfirmVisible.value = true;
1696 startDeleteCountdown();
1697 };
1698
1699 const handleRowCommand = (command, row) => {
1700 switch (command) {
1701 case "edit":
1702 openEditDialog(row);
1703 break;
1704 case "reject":
1705 singleReject(row.id);
1706 break;
1707 case "delete":
1708 singleDelete(row.id);
1709 break;
1710 }
1711 };
1712
1713 const handleBatchCommand = (command) => {
1714 switch (command) {
1715 case "batchPass":
1716 batchPass();
1717 break;
1718 case "batchReject":
1719 batchReject();
1720 break;
1721 case "batchDelete":
1722 batchDelete();
1723 break;
1724 }
1725 };
1726
1727 onMounted(() => {
1728 initTheme();
1729 loadList();
1730 window.addEventListener("resize", resizeHandler);
1731 });
1732
1733 onUnmounted(() => {
1734 window.removeEventListener("resize", resizeHandler);
1735 stopDeleteCountdown();
1736 clearTimeout(searchDebounceTimer);
1737 clearTimeout(resizeTimer);
1738 });
1739
1740 return {
1741 isMobile,
1742 loading,
1743 tableData,
1744 pagedTableData,
1745 filterTableData,
1746 searchKeyword,
1747 filterStatus,
1748 selectedIds,
1749 currentPage,
1750 pageSize,
1751 statusMap,
1752 statusTypeMap,
1753 dialogVisible,
1754 dialogTitle,
1755 formData,
1756 themeMode,
1757 themeDialogVisible,
1758 selectTheme,
1759 formatTime,
1760 normalizeWeight,
1761 handleSelectionChange,
1762 openAddDialog,
1763 logout,
1764 openEditDialog,
1765 submitForm,
1766 singlePass,
1767 singleReject,
1768 singleDelete,
1769 deleteConfirmVisible,
1770 deleteMode,
1771 deleteConfirmText,
1772 deleteCountdown,
1773 cancelDelete,
1774 confirmDelete,
1775 stopDeleteCountdown,
1776 batchPass,
1777 batchReject,
1778 batchDelete,
1779 handleRowCommand,
1780 handleBatchCommand
1781 };
1782 }
1783}).use(ElementPlus, { locale: ElementPlusLocaleZhCn }).mount("#app");
1784});
1785</script>
1786</body>
1787</html>`;
1788}
1789
1790// === 6. 路由处理逻辑 ===
1791addEventListener("fetch", (event) => {
1792 event.respondWith(handleRequest(event.request));
1793});
1794async function handleRequest(request) {
1795 const url = new URL(request.url);
1796 let pathname = url.pathname;
1797 if (pathname.length > 1 && pathname.endsWith("/"))
1798 pathname = pathname.slice(0, -1);
1799 if (request.method === "OPTIONS") {
1800 return new Response(null, {
1801 status: 204,
1802 headers: {
1803 "Access-Control-Allow-Origin": "*",
1804 "Access-Control-Allow-Methods":
1805 "GET, POST, PUT, PATCH, DELETE, OPTIONS",
1806 "Access-Control-Allow-Headers": "Content-Type, Authorization",
1807 "Access-Control-Max-Age": "86400",
1808 "X-Powered-By": "W3C",
1809 },
1810 });
1811 }
1812 const adminAuthRes = await checkAdminAuth(request, pathname);
1813 if (adminAuthRes) return adminAuthRes;
1814 try {
1815 // 公开友链接口
1816 if (request.method === "GET" && pathname === `${API_PREFIX}/friends`) {
1817 const apps = await readFriends();
1818 return json({ items: apps });
1819 }
1820 // 游客提交友链(新增pending,自动推送通知)
1821 if (request.method === "POST" && pathname === `${API_PREFIX}/friends`) {
1822 const parsed = await readJson(request);
1823 if (parsed.error) return parsed.error;
1824 const { title, desc, siteUrl, imgUrl, email, message } = parsed.body || {};
1825 if (!title || typeof title !== "string" || title.length > 50)
1826 return json({ success: false, message: "title 无效 (1-50字符)" }, 400);
1827 if (!desc || typeof desc !== "string" || desc.length > 200)
1828 return json({ success: false, message: "desc 无效 (1-200字符)" }, 400);
1829 if (!siteUrl || typeof siteUrl !== "string")
1830 return json({ success: false, message: "siteUrl 无效" }, 400);
1831 try {
1832 new URL(siteUrl);
1833 } catch (_) {
1834 return json({ success: false, message: "siteUrl 不是有效 URL" }, 400);
1835 }
1836 if (!imgUrl || typeof imgUrl !== "string")
1837 return json({ success: false, message: "imgUrl 不能为空" }, 400);
1838 const clientIp = request.eo?.clientIp || "";
1839 const now = Date.now();
1840 const existResp = await fetchApi(
1841 `/rest/v1/friends?select=id&siteUrl=eq.${encodeURIComponent(siteUrl)}&limit=1`,
1842 );
1843 const existRows = existResp.ok ? await existResp.json() : [];
1844 if (existRows?.length) {
1845 const fail = await patchFriend(existRows[0].id, {
1846 title,
1847 desc,
1848 siteUrl,
1849 imgUrl: imgUrl || "",
1850 email: email || "",
1851 message: message || "",
1852 updatedAt: now,
1853 });
1854 if (fail) return fail;
1855 } else {
1856 const ins = await fetchApi(`/rest/v1/friends`, {
1857 method: "POST",
1858 headers: { Prefer: "return=minimal" },
1859 body: JSON.stringify([
1860 {
1861 title,
1862 desc,
1863 siteUrl,
1864 imgUrl: imgUrl || "",
1865 email: email || "",
1866 message: message || "",
1867 tags: [],
1868 status: "pending",
1869 ip: clientIp,
1870 createdAt: now,
1871 updatedAt: now,
1872 },
1873 ]),
1874 });
1875 if (!ins.ok) return json({ success: false, message: "提交失败" }, 500);
1876 // 新增友链申请,触发推送通知(await确保推送完成)
1877 await sendPushNotice(
1878 "新友链申请待审核",
1879 `站点名称:${title}\n站点描述:${desc}\n站点链接:${siteUrl}\n头像链接:${imgUrl}\n联系邮箱:${email || "-"}\n留言:${message || "-"}\n当前状态:待审核`,
1880 );
1881 }
1882 return json({ success: true });
1883 }
1884 // 登录页面(GET 返回登录 HTML;已登录则直接跳转后台)
1885 if (request.method === "GET" && pathname === `${ADMIN_PREFIX}/login`) {
1886 const cookies = parseCookies(request);
1887 const token = cookies[SESSION_COOKIE] || "";
1888 if (await verifySessionToken(token)) {
1889 return Response.redirect(
1890 new URL(ADMIN_PREFIX, request.url).toString(),
1891 302,
1892 );
1893 }
1894 return new Response(loginHtml(), {
1895 status: 200,
1896 headers: {
1897 "Content-Type": "text/html; charset=UTF-8",
1898 "X-Powered-By": "W3C",
1899 "Cache-Control": "no-store",
1900 },
1901 });
1902 }
1903 // 登录接口(POST 校验账号密码,成功则下发会话 Cookie)
1904 if (request.method === "POST" && pathname === `${ADMIN_PREFIX}/login`) {
1905 const parsed = await readJson(request);
1906 if (parsed.error) return parsed.error;
1907 const { username, password, remember } = parsed.body || {};
1908 if (!username || !password) {
1909 return json({ success: false, message: "请输入用户名和密码" }, 400);
1910 }
1911 // 统一失败提示,避免泄露用户名是否存在
1912 if (username !== ADMIN_USER || password !== ADMIN_PASS) {
1913 return json({ success: false, message: "用户名或密码错误" }, 401);
1914 }
1915 // 记住我:30 天;否则:12 小时
1916 const maxAge = remember ? 30 * 24 * 3600 : 12 * 3600;
1917 const token = await createSessionToken(maxAge);
1918 return json({ success: true }, 200, {
1919 "Set-Cookie": buildSessionCookie(token, maxAge),
1920 });
1921 }
1922 // 退出登录(清除 Cookie 后跳转登录页)
1923 if (request.method === "GET" && pathname === `${ADMIN_PREFIX}/logout`) {
1924 return new Response(null, {
1925 status: 302,
1926 headers: {
1927 Location: new URL("/api/admin/login", request.url).toString(),
1928 "Set-Cookie": buildClearCookie(),
1929 "X-Powered-By": "W3C",
1930 },
1931 });
1932 }
1933 // 管理后台页面
1934 if (request.method === "GET" && pathname === ADMIN_PREFIX) {
1935 return new Response(adminHtml(), {
1936 status: 200,
1937 headers: {
1938 "Content-Type": "text/html; charset=UTF-8",
1939 "X-Powered-By": "W3C",
1940 },
1941 });
1942 }
1943 // 获取全部友链
1944 if (request.method === "GET" && pathname === `${ADMIN_PREFIX}/friends`) {
1945 const apps = await readAllFriends();
1946 if (apps === null)
1947 return json({ success: false, message: "读取失败" }, 500);
1948 return json({ success: true, items: apps });
1949 }
1950 // 修改审核状态(通过/拒绝)
1951 if (request.method === "PUT" && pathname === `${ADMIN_PREFIX}/friends`) {
1952 const parsed = await readJson(request);
1953 if (parsed.error) return parsed.error;
1954 const { id, status } = parsed.body || {};
1955 if (!id) return json({ success: false, message: "id 不能为空" }, 400);
1956 if (!status || !["approved", "rejected", "pending"].includes(status))
1957 return json({ success: false, message: "status 无效" }, 400);
1958 const fail = await patchFriend(id, { status, updatedAt: Date.now() });
1959 if (fail) return fail;
1960 // 可扩展:审核通过/拒绝推送通知
1961 return json({ success: true, data: { id, status } });
1962 }
1963 // 后台手动新增友链
1964 if (request.method === "POST" && pathname === `${ADMIN_PREFIX}/friends`) {
1965 const parsed = await readJson(request);
1966 if (parsed.error) return parsed.error;
1967 const body = parsed.body;
1968 if (
1969 !body.title ||
1970 typeof body.title !== "string" ||
1971 body.title.length > 50
1972 )
1973 return json({ success: false, message: "title 无效 (1-50字符)" }, 400);
1974 if (!body.desc || typeof body.desc !== "string" || body.desc.length > 200)
1975 return json({ success: false, message: "desc 无效 (1-200字符)" }, 400);
1976 if (!body.siteUrl)
1977 return json({ success: false, message: "siteUrl 不能为空" }, 400);
1978 try {
1979 new URL(body.siteUrl);
1980 } catch (_) {
1981 return json({ success: false, message: "siteUrl 不是有效URL" }, 400);
1982 }
1983 if (!body.imgUrl || typeof body.imgUrl !== "string")
1984 return json({ success: false, message: "imgUrl 不能为空" }, 400);
1985 const fail = await createFriend(body, request.eo?.clientIp || "");
1986 if (fail) return fail;
1987 // 后台手动新增也推送(await确保推送完成)
1988 const statusMap = { pending: "待审核", approved: "已通过", rejected: "已拒绝" };
1989 await sendPushNotice(
1990 "管理员新增友链",
1991 `站点名称:${body.title}\n站点描述:${body.desc}\n站点链接:${body.siteUrl}\n头像链接:${body.imgUrl}\n联系邮箱:${body.email || "-"}\n留言:${body.message || "-"}\n当前状态:${statusMap[body.status] || body.status}`,
1992 );
1993 return json({ success: true });
1994 }
1995 // 编辑友链
1996 if (request.method === "PATCH" && pathname === `${ADMIN_PREFIX}/friends`) {
1997 const parsed = await readJson(request);
1998 if (parsed.error) return parsed.error;
1999 const { id, ...data } = parsed.body || {};
2000 if (!id) return json({ success: false, message: "id不能为空" }, 400);
2001 const fail = await fullUpdateFriend(id, data);
2002 if (fail) return fail;
2003 return json({ success: true });
2004 }
2005 // 删除友链
2006 if (request.method === "DELETE" && pathname === `${ADMIN_PREFIX}/friends`) {
2007 const parsed = await readJson(request);
2008 if (parsed.error) return parsed.error;
2009 const { id } = parsed.body || {};
2010 if (!id) return json({ success: false, message: "id不能为空" }, 400);
2011 const fail = await deleteFriend(id);
2012 if (fail) return fail;
2013 return json({ success: true });
2014 }
2015 // 未匹配友链路由的请求(如 /api/dynamic.json、/api/allPostMeta.json 等 Astro 端点)
2016 // 透传回源,由 EdgeOne Pages 源站(Astro 构建产物)响应
2017 // 说明:fetch(request) 子请求访问 EdgeOne 节点缓存/回源,不会再次触发边缘函数,无递归风险
2018 return fetch(request);
2019 } catch (err) {
2020 return json(
2021 {
2022 success: false,
2023 message: err instanceof Error ? err.message : String(err),
2024 uuid: request.eo?.uuid || "",
2025 },
2026 500,
2027 );
2028 }
2029}hyde测试效果ok了
细节
用ai写了一个函数

拿白木测试下

可以了哦

---
继续迭代:
批量通过/拒绝,筛选状态和搜索

---
引入饿了么ui样式,好看多了佬 @W3C

---
太完美了

自己已经创建好supabase数据库的效果:
但是手里没 边缘函数代码,友链前端还没克隆hyde开源项目,这里予以暂停测试。


1@W3C 有搜索即时通知就完美了
2一旦有人提交友链立马推送到微信钉钉飞书等
3
4比较容易
5邮箱,公众号,企业微信,叮叮,都可以
6
7也可以全放开,默认通过
8自己设置了重复提交=修改内容
9但是 审核 是仪式
10
11加了一个搜索,max最大宽度撑满,还有跟随系统模式
12
13没部署eo 的进审核,博客框架不是XXX进审核
14
15
16友链的目的是交换,提升站点权重
17如果是单项的,那么只会拖垮自己站点权重
18所以啊
19最重要的是如何检测是否对方加了
20我的友链都是双向的,我就是不会如何检测,所以一个月查一次,把我删了我就移除
21
22
23好像有自动检测机制的方案
24每隔一段时间自动检测
25
26我这有(还没公开)
27我这还每天检查他们的评分
28还检测框架,和部署平台
29有全套api
30输入域名就知道所有情况
31什么时候换框架的,什么时候构建的,什么时候证书到期,友链数量,是否备案,什么换的平台,什么时候弄了重定向
32页面大小,速度
33还有历史页面快照
34过往某个时间点,页面是什么样子,也缓存了
35一年前弄的,应该有一年了找到了wp的插件
https://github.com/liseezn/see-friends
细节



PS:
见RZ群里一些小伙伴关于博客友链是否要双向添加问题讨论哈哈,这里做下个人看法说明:
- 个人加友链的原则,在某个博客群里,有些道友经常活跃发言的,我和他经常有互动的,如果他有博客,我一般也会加的;
- 某个大佬的博客的确太漂亮了,我也会加他的友链的;
- 有其他大佬申请加我友链的,我也会加下对方的友链哈哈;
一句话,率性而为,想加就加,随缘,并不是所谓的见人就加哈哈😂,对方加不加我友链,无所谓哈哈哈。
结束。
精选 · 友链 · 更多

One的公众号
爱折腾博客的小白