跳到主要内容

supabase数据库的使用

最后更新于:
AI 摘要

image-20260729070840.avif

supabase数据库的使用

记录于2026年7月29日

supabase是边缘专业数据库,自身就快,10ms,还不限制函数,直接就能调用。

1s3存储(免费,不限制函数)
2kv存储(快,免费,只能用pages-edge函数
3blob存储(普通,免费,只能用pages-任意函数)
4supabase存储(最快,免费,不限制函数)
5cos存储(最快,收费,不限制函数)

版权

@w3c佬的自助申请友链

@hyde与@w3c的讨论过程

附件

2026.7.29-w3c自动申请友链

https://openlist.onedayxyy.cn/%E8%BD%AF%E4%BB%B6/2026.7.29-w3c%E8%87%AA%E5%8A%A8%E7%94%B3%E8%AF%B7%E5%8F%8B%E9%93%BE

image-20260729071348.avif

思路

  1. 领取supabase免费数据库(哈哈领数据库也算一步,一般人,在这一步就卡住了)
  2. 创建数据库
  3. 前端页面改造
  4. 边缘函数部署

细节

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

image-20260729060554.avifimage-20260729060614.avifimage-20260729060641.avif

1、领取

https://gpdbnext.console.aliyun.com/gpdb/cn-hangzhou/supabase

在阿里云领,免费版1G

注意下

南方可以领广东地域,那个链接是杭州可以切换一下,别领错了。

细节

在上面切换

左上角

image-20260729060819.avif

2、创建

创建网络交换机

细节

转有网络创建无法选择的

按照界面写的区域创建

点开

点开这个创建F区

image-20260729060957.avifimage-20260729061017.avif

必须按照它提示的区创建,才能被选

重新创建交换机,注意里面有个选区

这里确认很容易踩坑

默认创建的,区对不上不能被选

深圳需要f区

image-20260729061132.avif

下面的的ip6选系统

ip4随便勾

这些只是交换机内部用到的,对数据库没有影响和差异,创建了就行

oneblogfriendslink

登录数据库:

image-20260729061327.avif

创建表

把友链那几个字段创建一下

 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);

细节

image-20260729061434.avif

执行试试

执行成功了

应该可以了

看看里面有没有表格了

有,只是没数据

image-20260729061529.avifimage-20260729061550.avif

测试

测试细节:

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

image-20260729061629.avif

咋测试阿

晚点我发这个给你

现在手机不方便

image-20260729061721.avif

3、博客前端 & eo边缘函数

细节

剩下就是博客前端 & eo边缘函数的配置了。

@爱洗脚的one哥

0删除 方便后续合并 友链 动静合并显示

内置一个极简 审核页

image-20260729062536.avifimage-20260729062808.avif

---

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

image-20260729062943.avif

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测试效果

hyde测试效果ok了

细节

用ai写了一个函数

image-20260729063125.avif

拿白木测试下

image-20260729063517955

可以了哦

image-20260729063459.avif

---

继续迭代:

批量通过/拒绝,筛选状态和搜索

image-20260729064343.avif

---

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

image-20260729064424.avif

---

太完美了

image-20260729064757.avif

one亲测效果

自己已经创建好supabase数据库的效果:

但是手里没 边缘函数代码,友链前端还没克隆hyde开源项目,这里予以暂停测试。

image-20260729061918.avif

image-20260729062015.avif

FAQ

讨论

 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插件

找到了wp的插件

https://github.com/liseezn/see-friends

细节

image-20260729064210.avifimage-20260729064716.avif

最后

  1. @w3c佬的自助申请友链效果的确不错,按这个方式,也可以把博客文章、评论、配置等等一切都存到数据库里去,事实上这已经算是ssr或者动态博客/前后端分类博客了,完全依赖数据库,这个思想可以学习下。
  2. 而one使用的是hugo+eo+cnb+eo边缘函数+博客后台,底层任然是以md为核心(所见即所得),完全不依赖数据库,也就是传统的ssg博客了。
  3. 这个阿里免费的supabase边缘专业数据库可能速度也快,很适合ssr(后期如果astro彻底使用了astro ssr,可以优先考虑使用这个数据库),但目前自己的ssg如果上了这个数据库,反而引入了更多的第三方服务(目前已经有twikoo评论、eo部署的站点统计等),这对想上手ssg的博客道友来说增加了很大的成本。
  4. 另外:博客一般是给自己看的,且小众个人站点一般评论都很少,友链也是。one博客通过用户的评论留言,自己在博客后台手动填写完全足够了哈哈哈。

PS:

见RZ群里一些小伙伴关于博客友链是否要双向添加问题讨论哈哈,这里做下个人看法说明:

  1. 个人加友链的原则,在某个博客群里,有些道友经常活跃发言的,我和他经常有互动的,如果他有博客,我一般也会加的;
  2. 某个大佬的博客的确太漂亮了,我也会加他的友链的;
  3. 有其他大佬申请加我友链的,我也会加下对方的友链哈哈;

一句话,率性而为,想加就加,随缘,并不是所谓的见人就加哈哈😂,对方加不加我友链,无所谓哈哈哈。

结束。

最新文章

文档导航