MENU

文章目录

Typecho 后台增强插件 - AB Admin

2026 年 03 月 08 日 • 阅读: 1959 • 技术,开发

这可能是史上颜值最高的 Typecho 后台美化插件

目录

AB Admin (原Admin Beautify)

Typecho 后台美化插件
Admin Beautify

AB Store Web 插件主页: https://ab-store.lhl.one/plugin/c3238849

安装方法

  1. 前往 GitHub Releases / AB Store Web 下载最新版本
  2. 解压后将 AdminBeautify 文件夹上传至
/usr/plugins/
  1. 后台进入
    控制台 → 插件 → 启用 AdminBeautify

截图预览

首页插件管理个人设置
图片.png图片.png图片.png
顶部导航栏(原版)文章编辑页面文章管理
插件仓库移动端导航栏
AB插件仓库

常见问题

Q: 支持哪些版本的 Typecho?

本插件基于 Typecho 1.3.0 设计开发,建议使用该版本或更高版本。

Q: Typecho 1.2.1兼容吗?

已写 Typecho 1.2.1 兼容脚本(目前不够完善),请在 插件设置-兼容脚本 中开启,如果没有该脚本请点击同步Github按钮。

Q: 启用后后台样式异常怎么办?

请先清除浏览器缓存,确保加载最新的 CSS/JS 文件。如果仍有问题,请检查是否与其他后台美化插件冲突。

Q: 移动端显示效果如何?

插件内置了响应式设计,移动端会自动切换为顶部折叠菜单模式。

Q: 如何自定义登录页背景?

在插件设置中填入背景图片的 URL,并可调整虚化方式和虚化大小。

Q: 开了 Turnstile 人机验证之后,自己反而登不进去了怎么办?

别慌,插件为这个场景专门做了三层兜底。最快的一招:用 FTP 或主机文件管理器,在 usr/plugins/AdminBeautify/ 下新建一个空文件 turnstile-off —— 人机验证会立即全部跳过,登录后进后台把密钥改对,再把该文件删掉即可。详见下一节。

Cloudflare Turnstile 配置错误,进不去后台怎么办

「安全验证设置」用的是 Cloudflare Turnstile。密钥填错、或者部件的「域」没配对,会导致登录页谁也过不了验证,包括你自己。这一节讲清楚:为什么会锁死、插件已经替你做了什么、真出事时怎么把自己救回来。

为什么会把自己锁在门外

Turnstile 的校验在服务端完成:表单提交时,插件拿着浏览器产出的令牌去 challenges.cloudflare.com 换取结果。密钥不对,Cloudflare 一律返回失败 → 插件拦截登录 → 你进不了后台 → 也就改不了那条错误配置。这是个死循环,所以插件专门为它做了三层兜底。

第 0 步:先判断你是哪一种情况

现象大概率原因
页面上验证框显示「验证失败」或压根加载不出来站点密钥(Site Key)写错,或部件「域」里没有你的域名
验证框能正常打勾,但一提交就提示「安全验证未通过」密钥(Secret Key)写错,或两个密钥不是同一个部件的
之前一直好好的,突然全部过不了密钥被 Cloudflare 重置过;或令牌过期(令牌只有 5 分钟有效期且只能用一次,刷新页面拿新的即可)

第 1 层:自动熔断(不需要你做任何事)

插件会统计「服务器侧配置错」类的失败,只有 invalid-input-secret、bad-request 这两种。连续 3 次之后,插件会自动暂停人机验证、放行所有登录入口,让你能先进后台改配置。

为什么只认这两种错误码?因为它们是攻击者无论如何都伪造不出来的。像 invalid-input-response 这种随手就能构造的错误一律不计数 —— 否则等于给攻击者留了「刷几次就能把防护关掉」的后门。

只要密钥被改过(插件记的是密钥指纹),熔断会自动解除;也可以在插件设置页点「立即恢复验证」。

注意:如果你还没撑到第 3 次就放弃了(比如第 1 次失败就去查资料了),熔断还没触发,请直接走第 2 层。

第 2 层:紧急开关文件(推荐,最快最稳)

用 FTP / 主机控制面板的文件管理器,在插件目录下新建一个空文件:

usr/plugins/AdminBeautify/turnstile-off

文件名就叫 turnstile-off,内容留空。只要这个文件存在,人机验证立即全部跳过 —— 登录、注册、忘记密码三个入口一起放行,而且不需要清缓存、不需要动数据库、不需要进后台。

完整流程:

  1. 传空文件 usr/plugins/AdminBeautify/turnstile-off
  2. 正常登录后台 → 插件 → AdminBeautify →「安全验证设置」→ 核对密钥
  3. 修好并保存 → 退出登录 → 试一次登录,确认验证能过
  4. 确认无误后,把这个文件删掉,验证功能恢复
⚠️ 这个文件在插件目录里,升级插件时会被自动更新清掉(更新程序会先清空插件目录再写入新文件)。所以如果你配置还没修好就点了升级,可能又被锁在外面 —— 升级后记得再放一次,或者干脆先把配置改对。

第 3 层:直接改数据库

如果连文件都传不上去,就去改数据库。插件配置存在 options 表里(表前缀按你的安装而定,默认是 typecho_options),选项名是 plugin:AdminBeautify:

SELECT value FROM typecho_options WHERE name = 'plugin:AdminBeautify';

value 是一段 JSON(紧凑格式、值都是字符串)。找到这三项,把 "1" 改成 "0":

字段作用
turnstile_login登录页人机验证
turnstile_register注册页人机验证
turnstile_forgot忘记密码页人机验证
-- 关掉登录页的人机验证(其余两项按需照改)
UPDATE typecho_options
SET value = REPLACE(value, '"turnstile_login":"1"', '"turnstile_login":"0"')
WHERE name = 'plugin:AdminBeautify';

更省事的做法:直接把 turnstile_siteKey 与 turnstile_secretKey 两个值清空成 ""。两个密钥只要有一个为空,插件就视为「未启用」,不会拦截任何页面。

改完刷新登录页试试;如果没生效,清一下 OPcache 或后台可能装的缓存插件。

进后台之后:怎么看出到底错在哪

「安全验证设置」卡片的提示区会直接告诉你状态,不用猜:

  • ⛔ 紧急开关生效中 —— turnstile-off 文件还在,验证仍被全部跳过
  • ⛔ 人机验证已被自动暂停(时间) —— 触发了自动熔断,并写明是哪个错误码触发的,以及修复建议
  • ⚠ 上次安全验证失败(时间) —— 最近一次的失败原因与处理办法

常见错误码对照表:

错误码含义与处理
invalid-input-secretSecret Key 不对。Site Key 与 Secret Key 必须来自同一个 Turnstile 部件;被重置过就回 Cloudflare 控制台重新复制两个密钥
missing-token浏览器没把令牌提交上来。多半是组件还没加载完就点了提交,或页面 JS 被拦截;刷新页面重试
invalid-input-response令牌无效。检查 Turnstile 部件的「域」是否包含本站域名(本地 / 测试站要加上 127.0.0.1)
timeout-or-duplicate令牌过期或已被使用过。令牌只有 5 分钟有效期且只能用一次,刷新页面即可
bad-request请求格式不对,Secret Key 里可能混进了空格或换行
internal-errorCloudflare 侧临时故障,稍后重试

几条少踩坑的配置建议

  • 先关着配好,再开启。 两项密钥都留空 = 未启用,此时怎么折腾都不会拦住登录;确认密钥对了,再打开那三个开关。
  • 粘贴后检查首尾空格。 从 Cloudflare 复制密钥很容易带上换行。插件虽然会自动 trim(),但别依赖它。
  • 本地 / 测试站请用测试密钥。 Cloudflare 提供一对永远通过的测试密钥:站点密钥 1x00000000000000000000AA、密钥 1x0000000000000000000000000000000AA。注意规则:用了测试站点密钥,就必须配测试密钥,混搭真密钥会直接失败。
  • 一次只开一个入口。 先只勾「登录页」,跑通之后再考虑注册页与忘记密码页。
  • 自己先用无痕窗口试一遍,确认验证能过再放给别人用。

完整操作顺序(TL;DR)

  1. 传一个空文件 usr/plugins/AdminBeautify/turnstile-off
  2. 正常登录后台 → 插件 → AdminBeautify →「安全验证设置」→ 检查密钥的首尾空格、以及两个密钥是否同属一个部件
  3. 修好密钥并保存 → 退出登录 → 试一次登录,确认验证能过
  4. 确认无误后删掉 turnstile-off 文件
  5. 回到设置页,确认提示区没有「⛔」标记,收工

插件样式适配 (主题色/亮暗适配)

本节面向 其他 Typecho 插件的开发者,说明如何让你的插件原生兼容 Admin Beautify 的样式体系和暗色模式,而无需依赖兼容脚本。

一、暗色模式兼容

Admin Beautify 通过在 <html> 标签上切换 data-theme 属性来控制暗色模式:

data-theme="light"   // 亮色
data-theme="dark"    // 暗色

推荐做法: 在你的插件 CSS 中,使用 [data-theme="dark"] 选择器覆盖暗色样式,而不是依赖 @media (prefers-color-scheme: dark)(因为 Admin Beautify 支持手动切换,不一定跟随系统)。

/* 推荐:跟随 Admin Beautify 的暗色切换 */
[data-theme="dark"] .my-plugin-card {
    background: #1d1b20;
    color: #e6e1e5;
    border-color: rgba(255, 255, 255, .12);
}

/* 不推荐:仅跟随系统,无法响应手动切换 */
@media (prefers-color-scheme: dark) {
    .my-plugin-card { background: #1d1b20; }
}

二、CSS 自定义变量(Design Tokens)

Admin Beautify 在 :root 上注入了一套完整的 Material Design 3 色彩 Token,可直接在你的 CSS 中引用,颜色会随用户选择的主题色和亮/暗模式自动变化。

亮色模式变量

变量名用途示例值(默认紫色主题)
--md-primary主色(按钮、强调)#7D5260
--md-on-primary主色上的文字#FFFFFF
--md-primary-container主色容器背景#FFD8E4
--md-on-primary-container主色容器上的文字#21005D
--md-secondary次要色#625B71
--md-secondary-container次要色容器背景#E8DEF8
--md-tertiary第三色#7D5260
--md-surface页面/卡片背景#FFFBFE
--md-surface-container-lowest最低层容器#FFFFFF
--md-surface-container-low低层容器(卡片)#F7F2FA
--md-surface-container标准容器#F3EDF7
--md-surface-container-high高层容器#ECE6F0
--md-surface-container-highest最高层容器#E6E0E9
--md-on-surface主要文字色#1C1B1F
--md-on-surface-variant次要文字色#49454F
--md-outline边框/分割线#79747E
--md-outline-variant浅色边框#CAC4D0
--md-error错误色#B3261E
--md-error-container错误容器背景#F9DEDC

暗色模式变量(data-theme="dark" 时自动生效)

Admin Beautify 在暗色模式下会将上述变量自动替换为暗色值(如 --md-primary 在暗色下变为 #D0BCFF),无需你额外处理,直接使用变量即可。

三、实践示例

/* 一个完全兼容 Admin Beautify 的插件卡片 */
.my-plugin-card {
    background: var(--md-surface-container-low);
    border: 1px solid var(--md-outline-variant);
    border-radius: 16px;
    padding: 16px;
    color: var(--md-on-surface);
}

.my-plugin-card .title {
    color: var(--md-on-surface);
    font-weight: 600;
}

.my-plugin-card .subtitle {
    color: var(--md-on-surface-variant);
    font-size: 12px;
}

.my-plugin-card .primary-btn {
    background: var(--md-primary);
    color: var(--md-on-primary);
    border-radius: 20px;
    padding: 6px 16px;
    border: none;
    cursor: pointer;
}

/* 错误提示 */
.my-plugin-error {
    background: var(--md-error-container);
    color: var(--md-error);
    border-radius: 8px;
    padding: 8px 12px;
}

由于 Admin Beautify 在亮色/暗色切换时会同步更新所有 CSS 变量的值,上述写法自动支持暗色模式,无需额外的 [data-theme="dark"] 覆盖规则。

四、回退值(Fallback)

为了让你的插件在 未安装 Admin Beautify 的环境下也能正常显示,建议始终为 CSS 变量提供 fallback:

.my-plugin-card {
    /* var(变量, 回退值) */
    background: var(--md-surface-container-low, #f7f2fa);
    border-color: var(--md-outline-variant, #cac4d0);
    color: var(--md-on-surface, #1c1b1f);
}

[data-theme="dark"] .my-plugin-card {
    /* 无 Admin Beautify 时的暗色回退 */
    background: var(--md-surface-container-low, #211f26);
    border-color: var(--md-outline-variant, rgba(255,255,255,.12));
    color: var(--md-on-surface, #e6e1e5);
}

五、布局注意事项

注意点说明
避免固定宽度Admin Beautify 支持侧边栏和顶部导航两种布局,内容区宽度会变化,建议用max-width + width:100%
position:fixed 弹窗如需弹窗全屏覆盖,建议将弹窗 DOM 通过 JSappendChild 挂载到 document.body,否则可能被祖先元素的 transform / overflow:hidden 限制
z-index 层级Admin Beautify 侧边栏 z-index 约为1000,全局弹窗建议使用 z-index: 9999 以上
字体Admin Beautify 可选加载 Noto Sans SC,建议你的插件使用font-family: inherit 跟随页面字体

兼容性脚本开发

目录:/asset/compat,此目录存放用于兼容其他 Typecho 插件的 JS 脚本文件。

工作原理

  • AdminBeautify 会自动扫描本目录下的所有 .js 文件
  • 每个脚本的 元数据(名称、简介、适用插件等)将展示在 AdminBeautify 设置页面中
  • 用户可在设置页面中 单独启用/禁用 每个脚本
  • 每个 JS 文件应自行判断当前页面是否需要执行(通过 URL、DOM 等),避免影响其他页面
  • 脚本在每次页面加载时执行一次(含 AdminBeautify AJAX 导航,见下文)
  • 用户还可以在设置面板中添加 外部 JS 文件链接 来加载额外的兼容脚本

元数据格式(必须)

每个 JS 文件的 头部注释 中必须包含以下 @tag 元数据,AdminBeautify 会解析并展示:

/**
 * @name        脚本名称
 * @description 脚本简介说明
 * @plugins     Plugin1, Plugin2
 * @version     1.0.0
 * @author      作者名
 */
标签必填说明
@name推荐脚本名称(不填则使用文件名)
@description推荐一句话功能说明
@plugins推荐适用的插件名称,多个用逗号分隔
@version可选版本号
@author可选作者

脚本结构

脚本模板

AdminBeautify 使用 AJAX 进行后台页面导航,不会完整重载页面。脚本只在首次页面加载时执行一次,若不处理 ab:pageload 事件,从其他页面 AJAX 跳转过来时修复将不会生效。

ab:pageload 事件说明:

属性说明
触发时机history.pushState 之后,document.title 和 DOM 主内容区已完成替换
e.detail.url导航目标的完整 URL
监听方式document.addEventListener('ab:pageload', fn)
注意:ab:pageload 在每次 AJAX 导航后均会触发,包括 离开 目标页面时。修复函数应通过 URL 判断当前是否处于目标页面,并在离开时清理已注入的样式(保持幂等)。
/**
 * @name        MyPlugin 兼容
 * @description 修复 MyPlugin 在 AdminBeautify 下的排版问题
 * @plugins     MyPlugin
 * @version     1.0.0
 * @author      YourName
 */
(function () {
    'use strict';

    var STYLE_ID = 'ab-compat-myplugin';

    // ── 核心修复函数(幂等,可重复调用)
    function applyFix(url) {
        var isTarget = (url || '').indexOf('panel=MyPlugin') !== -1;

        if (!isTarget) {
            // 离开目标页面时清理已注入的样式
            var old = document.getElementById(STYLE_ID);
            if (old) old.remove();
            return;
        }

        // 注入修复样式(幂等:已存在则跳过)
        if (!document.getElementById(STYLE_ID)) {
            var style = document.createElement('style');
            style.id = STYLE_ID;
            style.textContent = '/* your CSS fixes */';
            document.head.appendChild(style);
        }

        // 其他 DOM 修复操作...
    }

    // ── 初始执行(首次页面加载)
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () {
            applyFix(window.location.href);
        });
    } else {
        applyFix(window.location.href);
    }

    // ── 监听 AdminBeautify AJAX 导航事件 (必须,否则脚本不会生效)
    // e.detail.url 是导航目标 URL;此时 document.title 和 DOM 主内容区已替换完毕。
    document.addEventListener('ab:pageload', function (e) {
        var url = (e && e.detail && e.detail.url) ? e.detail.url : window.location.href;
        applyFix(url);
    });
})();

注意事项

  1. 必须判断页面:每个脚本必须先检测当前 URL 或 DOM,确定是目标页面才执行
  2. 支持 AJAX 导航:监听 ab:pageload 事件以响应 AdminBeautify 的 AJAX 页面切换(推荐)
  3. 保持幂等:修复函数可能被多次调用(首次加载 + 每次 AJAX 导航),需避免重复注入;离开目标页面时应清理已注入的样式
  4. 使用 IIFE:用 (function(){ ... })() 包裹,避免全局变量污染
  5. 使用 'use strict':启用严格模式
  6. 兼容暗色模式:如有样式修复,注意处理 [data-theme="dark"]
  7. 可用 CSS 变量:AdminBeautify 已定义大量 MD3 CSS 变量,如 --md-primary、--md-surface 等

自定义卡片(概要页自建卡片)

适用版本:AdminBeautify v2.1.50+
投稿 / 获取别人写好的卡片配置:https://github.com/lhl77/Typecho-Plugin-AdminBeautify/issues/11

自定义卡片(方案 C)允许你用内置组件库 + JavaScript 在后台概要页(admin/index.php)里插入自己的卡片,
例如:友链概览、站点健康度、待办提醒、第三方服务的只读数据面板等。

插件内置了一套 MD3 组件库 ab.ui:磁贴指标、对齐行列表、大号数字、清单、徽标、按钮、提示条、空状态、卡片底部入口、
对话框 / 确认框、开关、分段标签、进度条、文本字段与下拉……
组件自带样式,深浅主题自动适配,你不需要写任何 CSS。

除了组件,卡片还能直接用一批工具接口:ab.data(服务端存储,支持全站共享 + TTL 缓存)、
ab.db(只读查库)、ab.ajax / ab.request(打插件自己的后台接口)、
ab.notify(轻提示)、ab.copy(剪贴板)、ab.format(字节 / 时间 / 相对时间)、
ab.chart(懒加载图表库)、ab.onCleanup / ab.interval(随卡片销毁自动收尾)。

卡片内容会在后台页面里以内联脚本执行,等价于给后台加了一小段插件 JS,因此请只粘贴自己信任的代码。

1. 快速开始

  1. 进入 控制台 → 插件 → AdminBeautify → 设置。
  2. 找到 概要页卡片设置 卡片 → 底部的 自定义卡片 区域。
  3. 把 自定义卡片 开关切到 开启(允许执行自定义 HTML / JS)。

    • 默认是 关闭,关闭时不会渲染自定义卡片,也不会执行任何自定义 JS。
  4. 点击 添加卡片,填写:
字段说明
图标Material Icons Round 字形名,如 link / insights / pending_actions
卡片标题显示在卡片标题栏
HTML 内容卡片正文的静态结构,用组件库时留空即可(JS 里动态生成)
JavaScript卡片渲染完成后执行的脚本(可留空)
  1. 右下角 保存设置,回到概要页查看效果。
💡 一张自定义卡片都没配置时,概要页会出现一张引导卡(大加号 +「开始管理你的自定义卡片」),
点击即可直达本设置页(链接带 &to=dashboardcards,会自动展开并高亮「概要页卡片设置」)。

2. 数据是怎么存的

自定义卡片保存在隐藏字段 dashboardCustomCards 中,是一个 JSON 数组:

[
  {
    "id": "c1m3k9x2ab",
    "icon": "link",
    "title": "友链概览",
    "html": "",
    "js": "(function(){ /* … */ })();"
  }
]
字段类型说明
idstring自动生成(前缀 c),用于卡片 key custom:<id> 和排序,不要手工重复
iconstringMaterial Icons Round 字形名,缺省 widgets
titlestring卡片标题,缺省 自定义卡片
htmlstring注入 .ab-custom-body 的静态 HTML(用组件库时可留空)
jsstring在卡片里执行的脚本源码

想用别人分享的配置?在 issue 里拿到 html / js 两段文本,在设置页 添加卡片 后分别粘贴即可。

⚠️ 注意区分两类"数据":
· 卡片配置本身(图标 / 标题 / HTML / JS):入口在插件设置项 dashboardCustomCards,但保存时 HTML / JS 代码会自动转存到数据表
{站点前缀}abadmin(键 card-cc-<id>-h / -j),设置项里只留 id / icon / title 元数据 ——
这样卡片数量与代码长度都不受 Typecho 设置项 64KB 上限约束(背景见 §18 最后两条 FAQ);
· 卡片运行时数据(计数、缓存、用户标记等)存在数据表 {站点前缀}abadmin 里,用 ab.data 读写,见 §8。

3. 卡片 DOM 结构

内置卡片与自定义卡片都挂在同一个网格容器里:

<div id="ab-dash-cards" class="ab-cards-grid ab-dash-cards">
  <div class="ab-card ab-dash-card" data-ab-card="umami">…</div>
  <div class="ab-card ab-dash-card ab-custom-card" data-ab-card="custom:c1m3k9x2ab">
    <h3>
      <span class="ab-card-header-icon">
        <span class="material-icons-round">link</span>
      </span>
      友链概览
    </h3>
    <div class="ab-custom-body">
      <!-- 你的组件 / HTML 内容 -->
    </div>
    <a class="ab-card-footer ab-ui-footer" href="…">   <!-- ab.ui.footer() -->
      <span class="ab-card-footer-text">管理友链</span>
      <span class="material-icons-round ab-card-footer-icon">arrow_forward</span>
    </a>
  </div>
  …
</div>

要点:

  • 标题栏 h3、图标块 .ab-card-header-icon、正文 .ab-custom-body 由插件生成,你只负责正文内容。
  • .ab-custom-body 已有基础排版(font-size:13px; line-height:1.7; padding:14px 20px 16px)。
  • 卡片本身是 flex 纵向布局,同行卡片等高拉伸。想让某个元素吸在卡片底部 → 用 ab.ui.footer()(见 §7.10)。
  • 网格列宽:repeat(auto-fit, minmax(340px, 1fr)) —— 卡片最窄约 340px,宽屏可能到 600px+,请用弹性布局而不是固定像素。

4. HTML 字段

  • 填的是HTML 片段(不需要 <html> / <body>),会作为 innerHTML 注入 .ab-custom-body。
  • 推荐留空:所有内容用 §7 的组件库在 JS 里生成,结构更稳、样式更统一、也不需要手动转义。
  • 需要静态图片等可以直接写 <img src="…">、<a href="…">。
  • 若一定要写内联 <style>:请给所有选择器加唯一前缀(例如 .my-card-*),避免污染后台其它元素。
  • <style> 会被自动搬到卡片根元素上(2.1.49 起)。原因:卡片脚本通常紧接着调用
    ab.ui.mount(body, …),而 mount 会清空 .ab-custom-body——样式若留在里面会被
    连着删掉,表现就是「写了 CSS 却完全不生效」。搬走后(<style> 放哪都是全局生效的)
    用 ab.ui.mount 反复重绘也不会丢样式;调试时可用 card.querySelector('style') 找到它。

5. JS 字段

插件执行方式等价于:

new Function('card', 'ab', def.js).call(card, card, abApi);
名称说明
this当前卡片的根元素(div.ab-card.ab-custom-card)
card同上,参数形式
ab迷你 API(2.1.50 起完整版):
• 数据:ab.data / ab.db / ab.ajax / ab.request
• 展示:ab.ui(组件库)
• 工具:ab.notify / ab.copy / ab.copyText / ab.format / ab.chart
• 生命周期:ab.onCleanup / ab.interval / ab.timeout / ab.cleanup
• 上下文:ab.config / ab.version / ab.esc
详见 §6

执行时机:卡片已注入并 appendChild 到 #ab-dash-cards 之后,因此可以直接 card.querySelector(...);
此时 document 已在 DOM Ready 之后。

约定:

  • 脚本请用 IIFE 包裹;不要在顶层写 let / const(统一 var),避免与外层作用域冲突。

    (function () { /* … */ })();
  • 抛出的异常会被插件捕获:控制台打印 [AB] 自定义卡片脚本出错:custom:<id>,
    卡片正文顶部也会插入一条红色错误块(包含错误信息),不影响其它卡片。

6. 可用 API(ab)

API签名说明
ab.uiobject内置组件库,见 §7(本说明书的主角)
ab.dataobject数据存储:save / get / getRow / list / remove(scope:'site' 可存全站共享)+ getCached(),见 §8
ab.dbobject只读查询:ab.db.read(table, query) 读数据库其它表(只能读,不能写),见 §8
ab.ajaxab.ajax(do, params, opts)请求 AdminBeautify 自己的 后台接口(/action/admin-beautify?do=…,自动带 token);ab.data / ab.db 内部就是它的 Promise 版
ab.requestab.request(do, params, method)ab.ajax 的 Promise 版(出错时 reject,err.code 为业务码)
ab.notifyab.notify(text, tone?, ms?)轻提示(右下角 snackbar):tone = info/success/warn/danger,见 §6.1
ab.copyab.copy(text) → Promise<bool>复制到剪贴板(含 execCommand 兜底);ab.copyText(text, tip?) 复制并提示,见 §6.2
ab.formatobject格式化:bytes / date / relative / duration / num,见 §6.3
ab.chartobject图表:load() / line(el, opts) / polar(el, opts)(懒加载本地 SVG 图表库),见 §6.4
ab.onCleanupab.onCleanup(fn)注册销毁回调(AJAX 切页时自动执行),见 §6.5
ab.interval / ab.timeoutab.interval(fn, ms)定时器(卡片销毁时自动清理,不用再手写幂等)
ab.cleanupab.cleanup()立即执行本卡片已注册的清理函数(一般不需要手动调)
ab.escab.esc(text) → stringHTML 转义;手写 HTML 拼接时使用
ab.configobject即 window.__AB_CONFIG__,见下表
ab.versionstring插件版本号(同 ab.config.pluginVersion,便于快速判断能力)
ab.ajax('stats', { range: '7' }, {
  method: 'GET',              // 默认 GET;POST 走表单编码
  onSuccess: function (json) { /* json.code === 0 时进入 */ },
  onError:   function (err)  { /* 网络 / 业务错误 */ }
});

ab.config 常用只读字段:

字段说明
pluginVersion插件版本号,例如 2.1.50
siteName站点名称
user当前登录用户:{uid, name, screenName, group}(group 形如 administrator / editor / contributor;不含邮箱、token 等任何凭据)
pluginUrl插件资源根地址
pluginSettingsUrl插件设置页地址(可拼 &to=<卡片id> 直达某个设置卡片)
ajaxUrl / ajaxTokenAdminBeautify 后台接口地址与 token(ab.ajax 已自动处理)
dashboardCardOrder当前卡片顺序字符串
darkMode / enableAnimation当前主题与动画开关(想跟主题联动的卡片可以用)
ab.ajax 只能访问 AdminBeautify 自己的接口。取其它插件的数据请用 ab.ui.getText / getDoc / getJSON
(同源请求,自动带后台登录态),见 §7.9。

6.1 轻提示 ab.notify

右下角 snackbar,和插件自身“已成功上传 / 已复制”提示是同一套样式,无需自己写提示层。

ab.notify('已保存');                          // 默认 info
ab.notify('已同步 3 条', 'success');
ab.notify('请求失败:超时', 'danger', 6000);   // 最后一个是显示毫秒数
参数默认说明
text—提示文字(纯文本,已自动转义)
toneinfoinfo / success / warn / danger(也接受 primary 与 warning/error 别名)
ms2600显示时长(毫秒)

6.2 剪贴板 ab.copy / ab.copyText

/* 静默复制:resolve 为 true/false(false = 两种方式都失败) */
ab.copy('https://example.com/a.png').then(function (ok) { /* … */ });

/* 复制 + 自动轻提示(成功/失败都会提示) */
ab.copyText('https://example.com/a.png', 'URL 已复制');

底层先用 navigator.clipboard(需 HTTPS / localhost),不可用时自动回退到
textarea + execCommand('copy') —— 后台如果跑在 http 或旧浏览器上也不会失败。

6.3 格式化 ab.format

列表 / 时间 / 体积类卡片不用再自己写转换:

调用结果
ab.format.bytes(1536)1.5 KB
ab.format.bytes(1048576, 0)1 MB(第二个参数是小数位)
ab.format.date(1789800000)2026-09-20 12:00(接受时间戳(秒/毫秒自动判断)、'2026-09-20 12:00:00'、Date)
ab.format.date(1789800000, 'MM/DD HH:mm')09/20 12:00(pattern:YYYY MM DD HH mm ss)
ab.format.relative(1789800000)刚刚 / 3 分钟前 / 2 小时前 / 昨天 12:00 / 5 天前 / 2026-09-20
ab.format.duration(3725)1 小时 2 分
ab.format.num(1234)1,234(同 ab.ui.num,true 时 1.2w)

6.4 图表 ab.chart

概要页那两张图表用的本地 SVG 图表库(assets/lib/ab-charts.v1.0.js)现在对卡片开放了,
按需懒加载(第一次调用才注入 <script>,全局只加载一次),不需要自己引资源。

/* 折线图:xData / yData 等长数组 */
ab.chart.line('#my-trend', {
  xData:     ['09/14', '09/15', '09/16'],
  yData:     [3, 7, 2],
  color:     ab.config.primaryColorHex     || '#6750a4',
  colorDark: ab.config.primaryColorDarkHex || '#d0bcff'
}).catch(function (e) { /* 库加载失败 */ });

/* 极坐标柱状图:data=[{name, value}] */
ab.chart.polar('#my-cats', { data: [{ name: '随笔', value: 12 }, { name: '技术', value: 30 }] });

/* 只要库本体(自己画) */
ab.chart.load().then(function (ABCharts) { /* ABCharts.line / ABCharts.polar */ });
  • 目标可以是选择器、DOM 元素,或组件返回值(如 ab.ui.el('div'))。
  • 两个函数都返回 Promise;渲染前会先清空目标容器。
  • 颜色建议取 ab.config.primaryColorHex,这样图表会跟着后台主题色走。

6.5 生命周期:ab.onCleanup / ab.interval / ab.timeout

开启 AJAX 无痕导航后,从其它页面切回概要页时卡片会被整体重建、脚本重跑。
旧卡片里的定时器 / window 监听如果不收尾,就会一直堆在后台页面里。

/* 写法 1:定时器用包装版,销毁时自动 clearInterval */
ab.interval(function () { reload(); }, 60000);

/* 写法 2:自己收尾(任何清理逻辑都行:解绑事件、断开 ResizeObserver…) */
var ro = new ResizeObserver(fit);
ro.observe(card);
ab.onCleanup(function () { ro.disconnect(); });
  • 注册的清理函数在页面被替换前(AJAX 切页)和手动 ab.cleanup() 时执行,每个只跑一次。
  • 监听器挂在 card 内部子元素上的不必清理(卡片整体被移除时一并回收);
    挂在 window / document / body 上的必须清理。

6.6 可用的后台接口(do=)速查

这些是 AdminBeautify 自身提供的只读接口,用 ab.request('名字', 参数) 或
ab.ajax('名字', 参数, { onSuccess }) 调用(已自动带登录态与 CSRF token)。

do=参数返回(json.data)
stats—{posts:{total,thisWeek,lastWeek}, comments:{…}, cats:{total,thisWeek,lastWeek}}
chart-datadays(天数,0 = 全部){frequency:[{date,count}], commentsByCat:[{name,count}]}
list-mediapage、per(≤100)、parent(-1 全部 / 0 未归档 / N 属于文章 N)、search{total,page,per,items:[{cid,name,url,mime,size,created}]}
get-media-urlscids(逗号分隔,如 1,2,3){'1':'https://…','2':'…'}(会兼容 PicUp 等插件的 CDN 路径)
info—{version,posts,comments,categories}(保留的旧接口)
/* 典型用法:站点概览 */
ab.request('stats').then(function (d) {
  console.log(d.posts.total, d.comments.thisWeek);
});

/* 最近 5 张图片 */
ab.request('list-media', { page: 1, per: 5, parent: -1 }).then(function (d) {
  d.items.forEach(function (it) { console.log(it.name, ab.format.bytes(it.size), it.url); });
});
写类接口(upload-media、save-settings、do-update 等)需要管理员 + token + POST,
卡片里不应调用;卡片自己的持久化数据请走 ab.data(见 §8)。

7. 内置组件库 ab.ui

所有组件自带 MD3 样式(浅色 / 深色自动适配),返回的都是真实 DOM 元素,可直接 appendChild、querySelector、监听事件。
排版用 ab.ui.mount(容器, 内容) 一次写入(会先清空容器)。

7.0 通用约定

概念说明
tone(色调)default / primary / success / warn / danger / info,缺省 default
item(数据项){ id, icon, label, value, hint, tone, href, onClick, skeleton, attrs }
可更新metric / row / hero / notice 返回的元素都带 .update(patch),用于拿到数据后就地刷新
可点击传了 href → 渲染成 <a>(支持新窗口/中键);只传 onClick → 挂 JS 跳转。两者都会加 .ab-ui-clickable(手型 + 悬浮阴影)

7.1 基础工具

API说明
ab.ui.el(tag, className, text)创建元素(文本走 textContent,天然安全)
ab.ui.icon(name, className)创建 Material Icons 图标 <span class="material-icons-round">
ab.ui.append(target, content)追加内容(元素 / 元素数组 / HTML 字符串);target 可以是选择器或元素
ab.ui.mount(target, content)清空 target 后写入内容
ab.ui.num(n, compact)数字格式化:1234 → "1,234";compact=true 时 12000 → "1.2w"
ab.ui.navigate(href)组件内部使用的跳转钩子(默认 location.href = href)。可覆盖它改成新标签页打开等:ab.ui.navigate = function (h) { window.open(h, '_blank'); };
metrics / rows / list 除了接收数据对象,也直接接收已经创建好的组件元素,
例如先把磁贴建好、拿到数据后再 update(),最后一起塞进容器(§15 就是这么做的)。

7.2 指标磁贴 metrics / metric

一排大数字卡片,适合 2~4 项概览。

var box = ab.ui.metrics([
  { icon: 'link',            label: '已启用友链', value: '—' },
  { icon: 'pending_actions', label: '待审核',     value: '—', id: 'pending', tone: 'warn' }
], { cols: 2 });                     // cols 可省略,默认按卡片宽度自动换行
ab.ui.mount(card.querySelector('.ab-custom-body'), box);

/* 拿到数据后就地刷新(也可以用 ab.ui.set('#pending', {...})) */
var tiles = box.children;            // 也可直接用元素引用:ab.ui.metric({...}).update({...})
ab.ui.setValue(tiles[1], 3);

产物:.ab-ui-metrics > .ab-ui-metric(.ab-ui-tone-*) > [.ab-ui-metric-icon, .ab-ui-metric-value, .ab-ui-metric-label, .ab-ui-metric-hint?]

item 字段说明
value主数值(缺省 —;传 null 也会显示 —,方便「加载中 / 无数据」)
label说明文字
hint更小的补充说明(可选)
skeleton: true数值位置渲染骨架屏(ab-ui-skeleton),数据到位后 .update({value}) 自动去掉

7.3 对齐行列表 rows / row

与内置「访问统计」卡片同款的图标胶囊 + 名称 + 右对齐数值列表,适合 3~6 项。

ab.ui.mount(body, ab.ui.rows([
  { icon: 'article',         label: '已发布', value: '12' },
  { icon: 'drafts',          label: '草稿',   value: '2' },
  { icon: 'pending_actions', label: '待审核', value: '3', tone: 'danger', href: '/admin/manage-posts.php' }
]));

产物:.ab-ui-rows > .ab-ui-row(.ab-ui-tone-*) > [.ab-ui-row-icon, .ab-ui-row-label, .ab-ui-row-value]

7.4 大号指标 hero

一张大数字区块,用于「最重要的那一个数」。

ab.ui.mount(body, ab.ui.hero({ icon: 'visibility', label: '今日访问', value: '1,286', hint: '较昨日 +12%', tone: 'primary' }));

产物:.ab-ui-hero(.ab-ui-tone-*) > [.ab-ui-hero-icon, .ab-ui-hero-value, .ab-ui-hero-label, .ab-ui-hero-hint?]

7.5 清单 list / listItem

带图标的可点击行(图标 + 主标题 + 副标题 + 右侧值)。

ab.ui.mount(body, ab.ui.list([
  { icon: 'add_link', label: '新增友链', desc: '打开友链管理页', href: manageUrl },
  { icon: 'rule',     label: '待审核',   value: '3 条', desc: '点击处理', href: manageUrl }
]));

产物:.ab-ui-list > .ab-ui-list-item > [.ab-ui-list-icon, .ab-ui-list-main(> .ab-ui-list-label + .ab-ui-list-desc), .ab-ui-list-value?]

7.6 徽标 / 按钮

var row = ab.ui.actions([
  ab.ui.badge('已同步', 'success'),
  ab.ui.badge('待审核 3', 'danger'),
  ab.ui.button({ text: '打开管理页', icon: 'open_in_new', href: manageUrl }),
  ab.ui.button({ text: '立即刷新', icon: 'refresh', tone: 'info', onClick: reload })
]);
ab.ui.mount(body, row);
  • ab.ui.badge(text, tone) → .ab-ui-badge
  • ab.ui.button({ text, icon, href, onClick, tone, block }) → .ab-ui-btn
    (传 href 时是 <a>,否则是 <button>;block:true 撑满一行)
  • ab.ui.actions([...]) → .ab-ui-actions(自动换行、自带间距的容器)

7.7 提示条 notice

var tip = ab.ui.notice({ text: '正在读取数据…', icon: 'hourglass_empty' });
ab.ui.mount(body, [metricsBox, tip]);

/* 加载完成后就地更新 */
tip.update({ text: '有 3 条待审核', tone: 'danger', icon: 'error_outline' });

产物:.ab-ui-notice(.ab-ui-tone-*) > [.ab-ui-notice-icon, .ab-ui-notice-text]

7.8 空状态 empty

ab.ui.mount(body, ab.ui.empty({
  icon: 'inbox', size: 'lg',
  text: '暂无数据', hint: '点击前往设置',
  href: manageUrl
}));

产物:.ab-ui-empty(.ab-ui-tone-primary)(.ab-ui-empty-lg) > [.ab-ui-empty-icon, .ab-ui-empty-text, .ab-ui-empty-hint?]
—— size:'lg' 会渲染 72px 的圆形大图标,适合做占位 / 引导。

7.9 同源请求 getText / getDoc / getJSON

自动带 credentials: 'same-origin' 与 X-Requested-With,失败时 reject(HTTP xxx)。

ab.ui.getText('/admin/extending.php?panel=Links%2Fmanage-links.php');  // → Promise<string>
ab.ui.getDoc(sameUrl).then(function (doc) { /* doc 是 Document,可直接 querySelectorAll */ });
ab.ui.getJSON('/action/xxx?do=list');                                  // → Promise<object>
取别的插件的数据,最稳的做法就是 getDoc + querySelectorAll 数元素,见 §15。

7.10 卡片底部入口 footer(ab-card-footer)

给卡片加一个吸底的操作入口(与内置「最近文章 / 回复」卡片的「查看全部文章 / 查看全部评论」同款样式):
主色文字、居中、顶部发丝分隔线、hover 底色 + 箭头右移动效、margin-top:auto 吸底。

ab.ui.footer(card, { text: '管理友链', href: manageUrl });
// 或纯 JS 行为:
ab.ui.footer(card, { text: '立即同步', icon: 'sync', onClick: syncNow });
选项默认值说明
text查看全部入口文字
href—传了就渲染 <a>(原生跳转,可新窗口/中键)
onClick—无 href 时渲染 <button> 并调用它
iconarrow_forward右侧图标(hover 时向右滑 3px)
同一个卡片重复调用只会保留一个 footer(自动替换旧的),所以脚本重复执行是安全的。

7.11 对话框 dialog / confirm

MD3 遮罩 + 圆角面板(--md-surface-container-high + 28px 圆角 + 三级阴影),
点遮罩 / 按 Esc 关闭。DOM 挂在 document.body 下,不受卡片宽度限制。

/* 确认框:返回 Promise<boolean> */
ab.ui.confirm({
  title: '清空缓存?',
  text: '下次进入概要页会重新拉取数据。',
  okText: '清空', tone: 'danger'
}).then(function (ok) {
  if (!ok) return;
  ab.data.clearCached('ab-links-count').then(function () { ab.notify('缓存已清空', 'success'); });
});

/* 通用对话框:自定义正文 + 任意个按钮 */
var dlg = ab.ui.dialog({
  title: '接口返回', icon: 'data_object',
  body: ab.ui.list([{ icon: 'bolt', label: '耗时', value: '128 ms' }]),
  actions: [
    { text: '复制 JSON', icon: 'content_copy', tone: 'info', value: 'copy' },
    { text: '关闭', primary: true }
  ],
  onClose: function (v) { if (v === 'copy') ab.copyText(JSON.stringify(lastData)); }
});
选项说明
title / icon标题与左侧图标(Material Icons 名)
text一段说明文字
body正文内容(元素 / 元素数组 / HTML 字符串,同 ab.ui.mount)
actions[{ text, icon, tone, primary, value, onClick }];不传时默认一个「知道了」
dismissible默认 true;传 false 时点遮罩不关(Esc 仍可关)
onClose(value)关闭时回调,value 是被点按钮的 value(点遮罩/Esc 关则是 null)

返回 { el, scrim, close(value) },可编程关闭。

ab.ui.confirm(options) 是它的语法糖:确定 → true,取消/遮罩/Esc → false。

7.12 开关 switch

var sw = ab.ui.switch({
  label: '只显示未读',
  checked: false,
  onChange: function (on) { render(on); }
});
ab.ui.mount(body, sw);
sw.setValue(true);            // 程序化设置(不触发 onChange)
var on = sw.getValue();

产物:.ab-ui-switch(.is-on) > [.ab-ui-switch-track, .ab-ui-switch-label]
—— 带 role="switch" + aria-checked,可 Tab 聚焦、Enter/空格 切换;
.update({ checked, label }) 可刷新。

7.13 分段标签 tabs

var tabs = ab.ui.tabs([
  { id: 'week',  label: '本周', icon: 'calendar_view_week' },
  { id: 'month', label: '本月' },
  { id: 'all',   label: '全部' }
], {
  active: 'week',
  onChange: function (id) { reload(id); }
});
ab.ui.mount(body, [tabs, contentHost]);
tabs.setActive('month', true);   // 程序化切换(第二个参数 = 是否触发 onChange)
tabs.getActive();                // 'month'

产物:.ab-ui-tabs > .ab-ui-tab(.is-active);标签文字里的 id 会原样回传给 onChange。

7.14 进度条 progress

var bar = ab.ui.progress({ label: '备份进度', value: 0, max: 100 });
ab.ui.mount(body, bar);
bar.update({ value: 42, label: '备份进度(42%)' });
bar.setValue(100);
bar.update({ tone: 'success', hideValue: false });   // tone: primary/…/success/warn/danger

产物:.ab-ui-progress > [.ab-ui-progress-head(> label + value), .ab-ui-progress-track > .ab-ui-progress-bar]
—— 数值变化只动 width,有过渡动画;hideValue: true 可隐藏右侧百分比。

7.15 文本字段 / 下拉 field / input / select

三个名字是同一个函数(按参数自动判断类型),用于卡片里的轻量输入(筛选、关键词等)。
注意:卡片里的输入没有并入设置表单,需要自己用 ab.data 或请求保存。

/* 文本 */
ab.ui.field({ label: '关键词', placeholder: '输入后回车', value: '',
  onInput: function (v) { filter(v); }, onChange: function (v) { /* 失焦/回车 */ } });

/* 多行文本 */
ab.ui.field({ label: '备注', type: 'textarea', value: '…', hint: '最多 200 字' });

/* 下拉(传 options 即为 select) */
ab.ui.select({ label: '时间范围', value: '7',
  options: [{ value: '7', label: '近 7 天' }, { value: '30', label: '近 30 天' }],
  onChange: function (v) { reload(v); } });

产物:.ab-ui-field > [.ab-ui-field-label?, .ab-ui-field-input, .ab-ui-field-hint?];
返回值带 .input(原生元素)、.getValue()、.setValue(v)。

8. 数据存储与只读查询

自定义卡片除了展示数据,还可以持久化自己的数据(例如"上次同步时间""打开次数""用户标记过的条目"),
以及只读查询站点数据库中的其它表。两者都由插件在服务端提供,前端用 ab.data / ab.db 调用。

🔐 安全前提:所有接口都要求 已登录的后台管理员 + 有效 CSRF token(封装会自动附带),
数据默认按登录用户隔离(传 scope:'site' 可改为全站共享,见 §8.5),写入只接受 POST。
前端拿不到超出这些限制的能力。

8.1 数据表

  • 表名:{站点前缀}abadmin(例如 typecho_abadmin),不需要你手动建表
  • 建表时机:进入 AdminBeautify 设置页 时自动检测并创建(CREATE TABLE IF NOT EXISTS,幂等);
    读写接口内部也会兜底确保表存在。同时兼容 MySQL / SQLite / PgSQL
  • 表结构:
字段类型说明
id自增主键
uidint所属用户(登录管理员的 uid,服务端写入,前端无法指定);0 表示全站共享(scope:'site' 的行)
card_keyvarchar(64)数据键,落库为 card-<自定义字符>
contentTEXTJSON 字符串(UTF-8,单条 ≤ 65535 字节)
created_atint创建时间(Unix 秒)
updated_atint更新时间(Unix 秒)

唯一索引 (uid, card_key):同一用户同一键只会有一行,写入天然是 upsert。


手动建表 SQL(MySQL,一般用不到)

CREATE TABLE IF NOT EXISTS `typecho_abadmin` (
  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `uid` INT(10) UNSIGNED NOT NULL DEFAULT 0,
  `card_key` VARCHAR(64) NOT NULL DEFAULT '',
  `content` TEXT NULL,
  `created_at` INT(10) UNSIGNED NOT NULL DEFAULT 0,
  `updated_at` INT(10) UNSIGNED NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `typecho_abadmin_uid_key` (`uid`,`card_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

SQLite / PgSQL 的建表语句由 Plugin.php::cardTableDdl() 生成,不需要手写。

8.2 数据键规则

规则说明
允许字符A-Za-z0-9_-
长度1 ~ 64
前缀不要自己写 card-,服务端会统一拼成 card-<你的键>
校验前端(ab.data)与后端(PHP)双重校验,非法直接拒绝、不发请求

8.3 ab.data:读写自己的卡片数据

API请求说明
ab.data.save(key, value)POST写入。不存在则新增,存在则覆盖;返回 {key, full_key, created, bytes, updated_at}
ab.data.get(key, fallback?)GET读取并返回解码后的 JSON;不存在时返回 null(或 fallback)
ab.data.getRow(key)GET读取完整信息 {exists, content, json, created_at, updated_at}
ab.data.list()GET列出自己的全部键 {items:[{key, created_at, updated_at}], count}(不含内容)
ab.data.remove(key)POST删除该键,返回 {deleted}
ab.data.getCached(key, ttl, loader, opts?)—带 TTL 的缓存:命中就不调 loader,未命中则调用并回写,见 §8.5
ab.data.clearCached(key, opts?)POST删掉缓存(强制下次重拉)
上列所有方法都接受末位 opts,形如 { scope: 'site' } 即可操作全站共享数据(见 §8.5)。
  • value 可以是任意 JSON 可序列化的值(对象 / 数组 / 字符串 / 数字 / null);
    传字符串时会被原样当作 JSON 存储(便于自己控制格式)。
  • 全部返回 Promise;失败会 reject,err.code 为服务端错误码(401 未登录 / 403 令牌或敏感列 / 400 参数非法 / 500 数据库异常)。
/* 例:给卡片加一个服务端持久化的「打开次数」,并支持一键重置 */
(function () {
  var body = card.querySelector('.ab-custom-body');
  var tile = ab.ui.metric({ icon: 'tag', label: '打开次数', value: '—', tone: 'primary' });
  ab.ui.mount(body, tile);
  ab.ui.footer(card, {
    text: '重置计数', icon: 'restart_alt',
    onClick: function () { ab.data.remove('open-count').then(function () { location.reload(); }); }
  });

  ab.data.get('open-count', 0).then(function (n) {
    var next = (Number(n) || 0) + 1;
    tile.update({ value: next });
    return ab.data.save('open-count', next);       // 不存在则新增,存在则覆盖
  }).catch(function (err) {
    tile.update({ value: '—', label: '存储不可用', tone: 'warn' });
    if (window.console) console.warn('[AB] 数据存储失败', err);
  });
})();

8.4 ab.db:只读查询其它表

只能读(SELECT),只能写结构化参数,不接受原始 SQL。

ab.db.read('contents', {
  columns: ['cid', 'title'],          // 可选,默认 ['*'](敏感列会被自动剔除)
  where: [                            // 可选,多个条件之间是 AND
    { field: 'type',    op: '=',  value: 'post' },
    { field: 'status',  op: 'IN', value: ['publish', 'hidden'] },
    { field: 'created', op: '>=', value: Math.floor(Date.now() / 1000) - 7 * 86400 }
  ],
  orderBy: { field: 'created', dir: 'DESC' },   // 也可直接写 'created'
  limit: 20,                          // 默认 50,上限 200
  offset: 0
}).then(function (res) {
  console.log(res.table, res.count, res.rows);    // rows: 对象数组
});
参数说明
table表名,不带站点前缀(带了也会自动剥离);只允许 A-Za-z0-9_
columns列名数组,或 '*'(默认);列名非法 → 直接报错
where[].op= != <> > >= < <= LIKE NOT LIKE IN NOT IN
where[].value标量;IN 时为数组(≤ 100 项且不能为空)
orderBy{field, dir} 或直接字段名(dir 只认 ASC / DESC)
limit / offsetlimit 默认 50、硬上限 200;offset 默认 0

返回值:{table, columns, rows, count, limit, offset}。

只读接口的安全边界(这些都是服务端强制的):

限制内容
语句只生成 SELECT,没有任何写入路径
参数绑定表名 / 列名 / 操作符 / 排序字段走白名单正则,值全部走参数绑定(不是字符串拼接)
禁止的表options(含站点 secret)、information_schema、performance_schema、mysql、sys、pg_catalog、sqlite_*
禁止的列password、passwd、authCode、salt(按"包含"匹配,password_hash 之类的变体同样会被拦)
columns: ['*']敏感列会在返回前被逐列剔除,所以 ab.db.read('users') 也拿不到 password / authCode
值体积单列值超过 8 KB 会被截断并追加 …[truncated];非法 UTF-8 值返回 [binary data: N bytes]
鉴权必须已登录管理员 + CSRF token;请当作"你自己的权限",不要把它做成给访客用的数据源
/* 例:统计最近 7 天发布的文章数(只读,不修改任何数据) */
ab.db.read('contents', {
  columns: ['cid'],
  where: [
    { field: 'type',    op: '=',  value: 'post' },
    { field: 'status',  op: '=',  value: 'publish' },
    { field: 'created', op: '>=', value: Math.floor(Date.now() / 1000) - 7 * 86400 }
  ]
}).then(function (res) {
  ab.ui.set(tile, { value: res.count });
}).catch(function (err) {
  ab.ui.set(tip, { text: '查询失败:' + err.message, tone: 'warn' });
});
想读别的插件自己的表?直接写表名(不带前缀)即可,只要不在禁止列表里。

8.5 全站共享(scope:'site')与 TTL 缓存

默认作用域是 user:每个管理员各存一份,互不影响(适合“我的阅读标记”这类个人数据)。
但很多卡片需要的是全站一份的缓存(Umami 汇总、GitHub star 数、某接口的返回值……)——
这种请用 scope:'site'(服务端存在 uid = 0 的行里,所有管理员读写同一份)。

/* 简写:site* 系列 */
ab.data.siteSave('site-stats', { posts: 128, comments: 456 });
ab.data.siteGet('site-stats').then(function (d) { /* … */ });
ab.data.siteRemove('site-stats');       // siteList() 列出全站共享键

/* 或用 opts 写法(get / getRow / list / remove 都支持) */
ab.data.get('site-stats', null, { scope: 'site' });

推荐直接用 getCached,把“读缓存 → 没有就拉 → 写回去”三步合并:

/* ab.data.getCached(key, ttl毫秒, loader, opts) */
ab.data.getCached('umami-overview', 10 * 60 * 1000, function () {
  return ab.ui.getJSON('/action/xxx?do=overview');   // 任何返回 Promise 的取数
}, { scope: 'site' }).then(function (data) {
  render(data);                                     // 10 分钟内切页回来都不会再发请求
});
  • loader 可以是函数(返回 Promise 或直接返回值)或直接传值;
  • 缓存载荷固定为 {t: 写入时间戳, d: 数据}(想手动判断过期就用 ab.data.get);
  • 缓存内容也受 §8.2 的键规则与 ≤ 65535 字节限制,只存摘要数据,不要塞整页 HTML;
  • 手动刷新:ab.data.clearCached(key, opts) 后再 getCached(或直接 getCached 前先 remove)。
两个作用域的键名不冲突(同一 key 可以同时存在 user 与 site 两份)。
卡片卸载时缓存不会被删——它就是要跨会话复用。

9. 自定义样式与主题变量

组件库已覆盖绝大多数场景;确有特殊需求时,插件也注入了完整的 MD3 变量表,直接引用即可自动适配深色主题:

变量(浅色名)用途
--md-primary主色(图标、链接)
--md-primary-container / --md-on-primary-container主色容器 + 其上文字
--md-surface-container-low / -high / -highest逐级加深的容器背景
--md-on-surface / --md-on-surface-variant主要 / 次要文字
--md-outline-variant发丝分隔线
--md-error-container / --md-on-error-container警示容器 + 其上文字
--md-approve-bg / --md-approve-color绿色「已通过」语义色

深色主题下这些变量会被插件自动重映射([data-theme="dark"] 内 --md-primary: var(--md-dark-primary) 等),
所以通常只写浅色那一套就够了:

.my-card-num { color: var(--md-on-surface); }

需要显式区分时才写:

.my-card-num { color: var(--md-on-surface, #1c1b1f); }
[data-theme=dark] .my-card-num { color: var(--md-dark-on-surface, #e6e1e5); }

推荐做法:数值加 font-variant-numeric: tabular-nums、圆角 10~16px、间距取 8/12/16px 的倍数;
不要给卡片设固定高度 / position:fixed / 大阴影,会破坏等高网格。

10. 排序

「概要页卡片设置」里的排序列表用这些 key:

key对应卡片
umami访问统计
freq更新频率
cat分类占比
posts最近文章
replies最近回复
custom:<id>你的自定义卡片

顺序保存在隐藏字段 dashboardCardOrder(逗号分隔)。未出现在该字符串中的卡片按默认顺序追加到末尾
(引导卡 custom:__empty__ 也是这样)。建议始终通过设置页的 ↑ ↓ 调整,不要手改隐藏字段。

11. 生命周期与注意事项

  • 自定义卡片在每次概要页加载时都会重新渲染并执行一次 JS。
  • 开启 AJAX 无刷新导航后,从其它后台页面切回概要页会再次执行,脚本必须可重复执行:

    • 事件监听挂在 card 或组件元素上(卡片会被整体重建,重复绑定风险最低);
    • 定时器 / window 监听用新 API 收尾(不要再手写 card.__myTimer 那套幂等):

      ab.interval(function () { refresh(); }, 60000);      // 卡片销毁时自动 clearInterval
      ab.timeout(function () { /* … */ }, 3000);
      
      var onResize = function () { fit(); };
      window.addEventListener('resize', onResize);
      ab.onCleanup(function () { window.removeEventListener('resize', onResize); });

      清理时机:AJAX 切页、页面被替换前(插件会主动调用);每个回调只跑一次。

    • ab.ui.mount / ab.ui.footer 本身是幂等的(清空重写 / 替换旧节点)。
  • 数据加载前先用占位(value: '—' 或 skeleton: true),避免布局跳动。
  • 请求尽量只读;不要在卡片脚本里做写操作(自己的持久化数据走 ab.data)。
  • 脚本抛错时:控制台会打印 [AB] 自定义卡片脚本出错:custom:<id>,卡片内也会显示一条红色错误块(不会再是一片空白)。

12. 安全须知

  • 自定义卡片的 HTML/JS 会在后台页面里执行,权限等同于管理员会话:

    • 可以读取后台页面 DOM、发起带凭据的同源请求;
    • 恶意脚本可以代替你执行后台操作。
  • 只粘贴你自己写的、或完全信任来源的代码。 从 issue / 论坛拿到的配置,请先读一遍再粘贴。
  • 不确定时把开关保持为 关闭;临时不需要时无需删除内容,关掉开关即可。
  • 自定义卡片只在后台渲染,不会影响前台访客。

13. 调试技巧

  1. 打开后台概要页 → F12 控制台。
  2. 找不到卡片?依次检查:

    • 「自定义卡片」开关是否为「开启」;
    • 保存设置后是否刷新过概要页;
    • 控制台是否有 [AB] 自定义卡片脚本出错:custom:<id>;
    • window.__AB_CONFIG__.dashboardCustomCardsEnabled 是否为 '1',
      window.__AB_CONFIG__.dashboardCustomCards.length 是否大于 0。
  3. 脚本报错看两处:控制台 [AB] 自定义卡片脚本出错:custom:<id> +
    卡片正文顶部会插入一条红色错误块(带错误信息)。
  4. 控制台里可以直接玩组件库(AdminBeautify.ui 就是 ab.ui):

    AdminBeautify.ui.version                       // '1.1'
    AdminBeautify.ui.mount('#ab-dash-cards [data-ab-card="umami"] .ab-custom-body',
      AdminBeautify.ui.hero({ icon: 'bolt', label: '试一下', value: '42' }))
    
    AdminBeautify.ui.confirm({ text: '试试确认框?' }).then(console.log)
    AdminBeautify.ui.progress({ label: '示例', value: 30 })
    AdminBeautify.notify('这是一条轻提示', 'success')
  5. 手动验证取数逻辑(以 Links Plus 为例):

    AdminBeautify.ui.getDoc('/admin/extending.php?panel=Links%2Fmanage-links.php')
      .then(function (doc) { console.log(doc.querySelectorAll('article.lp-link-card[data-state="2"]').length); });
    
    /* 插件自己的只读接口:stats / chart-data / list-media / get-media-urls */
    AdminBeautify.request('stats').then(console.log);
  6. 想临时改样式:在控制台给元素加规则,满意后再写进 HTML 字段的 <style>。
  7. 看卡片有没有留下未清理的定时器:

    AdminBeautify.cleanupCustomCards();      // 手动执行所有卡片的清理函数,返回执行条数
    document.querySelector('#ab-dash-cards [data-ab-card="custom:xxx"]').__abCleanups;

14. 示例一:最小可用卡片

图标:rocket_launch | 卡片标题:站点概览 | HTML 内容:留空

JavaScript:

(function () {
  var body = card.querySelector('.ab-custom-body');
  var user = ab.config.user || {};               // 当前登录用户(2.1.50+)

  /* 4 行、零 CSS:组件库自动排版 + 深色适配 */
  var rows = ab.ui.rows([
    { icon: 'person',   label: '当前用户', value: user.screenName || user.name || '—' },
    { icon: 'dns',      label: '插件版本', value: ab.version || '—' },
    { icon: 'language', label: '站点名称', value: ab.config.siteName || '—' },
    { icon: 'link',     label: '查看前台', value: '打开', href: '/' }
  ]);

  var actions = ab.ui.actions([
    ab.ui.button({ text: '后台首页', icon: 'dashboard', href: '/admin/' }),
    ab.ui.button({ text: '复制本页链接', icon: 'content_copy', tone: 'info',
      onClick: function () { ab.copyText(location.href, '链接已复制'); } })   // 复制 + 轻提示
  ]);

  ab.ui.mount(body, [rows, actions]);
})();

14.1 用到的新接口

接口作用
ab.config.user拿到当前登录用户的昵称 / 角色(无需多发请求)
ab.version插件版本,便于做能力判断(例如“旧版就不显示某按钮”)
ab.copyText(text, tip?)复制到剪贴板并自动弹一个底部轻提示(支持 http 环境的兜底方案)

15. 示例二:Links Plus 友链概览卡片

目标插件:Links Plus(插件目录 usr/plugins/Links,版本 v2.0.0)
效果:显示 已启用友链数量 与 待审核数量;卡片底部有 「管理友链」 入口;
有待审核时,待审核 磁贴本身可点击跳转到友链管理页。

15.1 原理

Links Plus v2.0.0 没有只读的数量接口,但它的管理页
admin/extending.php?panel=Links/manage-links.php 会把全部友链渲染成列表:

<article class="lp-link-card" data-state="1">…</article>
data-state含义
1已启用
2待审核
0已禁用

所以只要同源读取该管理页并数元素即可:零改动、无需给 Links Plus 加接口,也不写数据库。
(该页在只读授权模式下同样会渲染列表,数量依然可用。)

15.2 卡片配置

图标:link | 卡片标题:友链概览 | HTML 内容:留空

JavaScript:

(function () {
  /* ① 友链管理页地址(当前页在 /admin/ 下,取其目录) */
  var adminDir = location.pathname.replace(/[^\/]*$/, '');   // 形如 "/admin/"
  var manageUrl = adminDir + 'extending.php?panel=' + encodeURIComponent('Links/manage-links.php');

  var KEY  = 'ab-links-count';          // 缓存键(存在 abadmin 表里,全站共享)
  var TTL  = 5 * 60 * 1000;             // 5 分钟
  var OPTS = { scope: 'site' };         // 友链数量是站点级事实:所有管理员共用一份缓存

  /* ② 两块指标:先占位,拉到数据后就地刷新 */
  var enabledTile = ab.ui.metric({ icon: 'link',            label: '已启用友链', value: '—' });
  var pendingTile = ab.ui.metric({ icon: 'pending_actions', label: '待审核',     value: '—', id: 'ab-links-pending' });
  var tip         = ab.ui.notice({ text: '正在读取 Links Plus 数据…', icon: 'hourglass_empty' });

  ab.ui.mount(card.querySelector('.ab-custom-body'), [
    ab.ui.metrics([enabledTile, pendingTile], { cols: 2 }),
    tip,
    ab.ui.actions([
      ab.ui.button({ text: '重新读取', icon: 'refresh', tone: 'info', onClick: refresh })
    ])
  ]);

  /* ③ 底部入口:管理友链(组件库内置的 .ab-card-footer,自动吸底) */
  ab.ui.footer(card, { text: '管理友链', href: manageUrl });

  /* ④ 取数:同源读取管理页 → 解析各状态数量 */
  function fetchCounts() {
    return ab.ui.getDoc(manageUrl).then(function (doc) {
      var countOf = function (state) {
        return doc.querySelectorAll('article.lp-link-card[data-state="' + state + '"]').length;
      };
      return { enabled: countOf(1), pending: countOf(2) };
    });
  }

  function render(d, at) {
    enabledTile.update({ value: d.enabled });
    pendingTile.update({ value: d.pending, hint: ab.format.relative(at) });   // 缓存时间用友好格式展示

    if (d.pending > 0) {
      pendingTile.update({ tone: 'danger', href: manageUrl });                // 有积压 → 磁贴本身可点
      tip.update({
        tone: 'danger', icon: 'error_outline',
        text: '有 ' + d.pending + ' 条友链待审核,点击上方「待审核」前往处理'
      });
    } else {
      pendingTile.update({ tone: 'success' });
      tip.update({ tone: 'success', icon: 'check_circle', text: '审核已清空,共 ' + d.enabled + ' 条启用中的友链' });
    }
  }

  /* ⑤ getCached:命中缓存就不发请求,过期才调 fetchCounts() 并自动回写 */
  function load() {
    return ab.data.getCached(KEY, TTL, fetchCounts, OPTS).then(function (d) {
      // 额外读一下写入时间,用来显示「几分钟前更新」(不想显示可以省掉这次请求)
      return ab.data.getRow(KEY, OPTS).then(function (row) {
        render(d, (row && row.updated_at) ? row.updated_at : Date.now());
      });
    }).catch(function (err) {
      tip.update({ tone: 'warn', icon: 'warning_amber', text: '读取友链数据失败:' + ((err && err.message) || err) });
    });
  }

  function refresh() {
    ab.data.clearCached(KEY, OPTS)
      .then(load, load)
      .then(function () { ab.notify('已重新读取友链数据', 'success'); });
  }

  load();
})();

15.3 说明与可选优化

  • 为什么不做整卡点击跳转? 整卡可点会和卡片内其它交互(按钮、链接)冲突,用户也没法在新标签页打开。
    所以跳转放在两个明确的位置:底部「管理友链」入口 + 待审核磁贴(仅在有内容时)。
  • ab.ui.metric(...) 返回的元素带 .update(),拿到数据后就地刷新,不需要重建 DOM;
    pendingTile.update({ href: manageUrl }) 会把它变成可点击(手型 + 悬浮阴影 + 跳转)。
  • 缓存(上图已内置):ab.data.getCached(key, ttl, loader, opts) 一行搞定“读缓存 → 没有就拉 → 回写”,
    比早期示例里的 sessionStorage 方案好在:换浏览器 / 换设备 / 另一个管理员也命中同一份缓存(scope:'site'),
    而且是服务端存储(不怕清浏览器数据)。想强制刷新就 ab.data.clearCached(key, opts)(示例的「重新读取」按钮)。
  • 时间展示:ab.format.relative(updated_at) 把 Unix 秒直接变 3 分钟前;
    需要精确格式用 ab.format.date(updated_at, 'MM/DD HH:mm')。
  • 提示:ab.notify('…', 'success') 是右下角轻提示,比在卡片里堆文字更克制(示例用在「重新读取」后)。
  • 备用取数方式(管理页结构变动时):doc.querySelector('.lp-filter-tab[data-filter="2"] .lp-filter-badge') 直接读筛选徽标。
  • 只想看数量、不需要跳转时,把 ab.ui.footer(...) 那一行删掉即可。

16. 示例三:PicUp 上传卡片

目标插件:PicUp(插件目录 usr/plugins/PicUp,版本 v1.2.9)
效果:卡片里只显示两行最关键的策略 —— 当前方案 与 存储驱动
(不显示可选方案数量、驱动参数,也不跳转插件设置页),
中间是一整块 Material Design 3 拖放区:拖文件进卡片会高亮 + 呼吸光环,
松手直接打开与媒体库同款的上传窗口(拖进来的文件已自动排进待上传列表),
点一下拖放区、按 Enter 或点「打开上传窗口」按钮也会打开同一个窗口。

图标:cloud_upload | 卡片标题:PicUp 上传

HTML 内容(只有一小段样式:MD3 拖放区外观 + 拖拽动画;全部带 .ab-picup-* 前缀):

<style>
/* 仅作用于本卡片(统一 .ab-picup-* 前缀),不污染后台其它元素 */
.ab-picup-drop {
  position: relative; overflow: hidden; cursor: pointer;     /* ab.ui.empty 默认没有手型,这里补上 */
  border-radius: var(--md-radius-xl, 28px) !important;       /* MD3 大号容器圆角 */
  border: 1.5px dashed var(--md-outline-variant, #cac4d0) !important;
  background: var(--md-surface-container-low, #f7f2fa) !important;
  transition: border-color var(--md-transition-duration, .2s) var(--md-transition-easing, cubic-bezier(.2, 0, 0, 1)),
              background-color var(--md-transition-duration, .2s) var(--md-transition-easing, cubic-bezier(.2, 0, 0, 1)),
              box-shadow var(--md-transition-duration, .2s) var(--md-transition-easing, cubic-bezier(.2, 0, 0, 1)),
              transform var(--md-transition-duration, .2s) var(--md-transition-easing, cubic-bezier(.2, 0, 0, 1));
}
.ab-picup-drop .ab-ui-empty-icon {
  position: relative;                                        /* 呼吸光环的定位参照 */
  transition: transform .3s cubic-bezier(.2, 0, 0, 1), background-color .2s, color .2s;
}

/* 悬停:主色描边 + 一级阴影(MD3 elevation level 1) */
.ab-picup-drop:hover { border-color: var(--md-primary, #6750a4) !important; box-shadow: var(--md-elevation-1); }
.ab-picup-drop:hover .ab-ui-empty-icon { transform: translateY(-2px) scale(1.04); }

/* 文件拖到卡片上方:主色容器底 + 实线描边 + 整块轻微放大 */
.ab-picup-drop.is-dragover {
  border-style: solid !important;
  border-color: var(--md-primary, #6750a4) !important;
  background: var(--md-primary-container, #eaddff) !important;
  transform: scale(1.012);
  box-shadow: 0 0 0 4px var(--md-primary-container, #eaddff), var(--md-elevation-2);
}
.ab-picup-drop.is-dragover .ab-ui-empty-text { color: var(--md-on-primary-container, #21005d) !important; }
.ab-picup-drop.is-dragover .ab-ui-empty-hint { color: var(--md-on-primary-container, #21005d) !important; opacity: .78; }
.ab-picup-drop.is-dragover .ab-ui-empty-icon { animation: ab-picup-bob .9s cubic-bezier(.2, 0, 0, 1) infinite; }

/* 拖拽悬停时的呼吸光环(MD3 状态层的克制版:细描边 + 缓慢扩散) */
.ab-picup-ring { position: absolute; inset: -12px; border-radius: 50%; opacity: 0; pointer-events: none;
  border: 2px solid var(--md-primary, #6750a4); }
.ab-picup-drop.is-dragover .ab-picup-ring { animation: ab-picup-ripple 1.5s cubic-bezier(.2, 0, 0, 1) infinite; }
.ab-picup-drop.is-dragover .ab-picup-ring + .ab-picup-ring { animation-delay: .5s; }

/* 松手瞬间:图标 pop 一下,作为「已经收到文件」的反馈 */
.ab-picup-drop.is-dropped .ab-ui-empty-icon { animation: ab-picup-pop .45s cubic-bezier(.2, 0, 0, 1); }

@keyframes ab-picup-bob {
  0%, 100% { transform: translateY(0) scale(1.12); }
  50%      { transform: translateY(-5px) scale(1.12); }
}
@keyframes ab-picup-ripple {
  0%   { transform: scale(.72); opacity: .38; }
  70%  { opacity: 0; }
  100% { transform: scale(1.55); opacity: 0; }
}
@keyframes ab-picup-pop {
  0%   { transform: scale(1.12); }
  45%  { transform: scale(.9); }
  100% { transform: scale(1); }
}

/* 系统开了「减弱动态效果」时只留静态样式(插件设置里的「关闭动画」也会自动停掉这些) */
@media (prefers-reduced-motion: reduce) {
  .ab-picup-drop, .ab-picup-drop .ab-ui-empty-icon { transition: none !important; }
  .ab-picup-drop.is-dragover .ab-ui-empty-icon,
  .ab-picup-drop.is-dragover .ab-picup-ring,
  .ab-picup-drop.is-dropped .ab-ui-empty-icon { animation: none !important; }
}
</style>

JavaScript:

(function () {
  /* ================= 0. 环境 ================= */
  var CFG   = window.__PICUP_CFG__ || null;                 // PicUp 注入:{profiles, defaultProfile, mimeScope}
  var AB    = window.AdminBeautify || {};
  var ADMIN = location.pathname.replace(/[^\/]*$/, '');      // 形如 /admin/
  var SETS  = ADMIN + 'options-plugin.php?config=PicUp';     // PicUp 设置页(只用来读驱动显示名,卡片不跳过去)
  var MEDIA = ADMIN + 'manage-medias.php';                   // 媒体库
  var CACHE = 'picup-strategy';                              // 策略缓存键(ab.data,按用户隔离)
  var TTL   = 5 * 60 * 1000;

  var body = card.querySelector('.ab-custom-body');

  /* ================= 1. 骨架 ================= */
  var infoHost = ab.ui.el('div');   // 两行策略:当前方案 + 存储驱动
  var tipHost  = ab.ui.el('div');   // 提示条容器:按需创建,加载时不出现
  var tip      = null;

  var dropzone = ab.ui.empty({
    icon: 'cloud_upload',
    size: 'lg',                                              // MD3 大号容器:72px 圆形图标
    text: '拖放文件到卡片内上传',
    hint: '松手即打开上传窗口 · 点击此处也可以',
    id: 'ab-picup-drop',
    attrs: { role: 'button', tabindex: '0', 'aria-label': '打开上传窗口' },
    onClick: function () { openUploadDialog(); }             // 显式丢开事件对象,避免它被当成文件列表
  });
  dropzone.classList.add('ab-picup-drop');
  var iconBox = dropzone.querySelector('.ab-ui-empty-icon');
  if (iconBox) {                                             // 拖拽时的呼吸光环 ×2
    iconBox.appendChild(ab.ui.el('span', 'ab-picup-ring'));
    iconBox.appendChild(ab.ui.el('span', 'ab-picup-ring'));
  }
  dropzone.addEventListener('keydown', function (e) {        // 键盘可达:Enter / 空格
    if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); openUploadDialog(); }
  });

  ab.ui.mount(body, [
    infoHost,
    dropzone,
    ab.ui.actions([
      ab.ui.button({ text: '打开上传窗口', icon: 'drive_folder_upload', onClick: function () { openUploadDialog(); } }),
      ab.ui.button({ text: '刷新策略', icon: 'refresh', tone: 'info', onClick: refreshDetail }),
      ab.ui.button({ text: '媒体库', icon: 'perm_media', tone: 'info', href: MEDIA })
    ]),
    tipHost
  ]);
  ab.ui.footer(card, { text: 'PicUp 设置', icon: 'settings', href: SETS });

  /* ================= 2. 策略:只显示「当前方案 + 存储驱动」 ================= */
  // 故意不渲染方案数量、驱动参数:卡片是状态概览,细节回 PicUp 设置页看
  function renderInfo(d) {
    ab.ui.mount(infoHost, ab.ui.list([
      { icon: 'layers', label: '当前方案', value: d.profile || '—' },     // 纯展示,不跳转
      { icon: 'cloud',  label: '存储驱动', value: d.driverName || '—' }   // 只显示驱动名,不带参数
    ]));
  }

  // 读 PicUp 设置页,只取两样东西:当前方案名 + 驱动显示名
  function parseSettings(html) {
    var doc = new DOMParser().parseFromString(html, 'text/html');
    function field(sel) { var el = doc.querySelector(sel); return el ? String(el.value || '') : ''; }

    var key = field('input[name="defaultProfile"]') || (CFG && CFG.defaultProfile) || 'default';

    var profiles = {};
    try { profiles = JSON.parse(field('textarea[name="configJson"]') || '{}') || {}; } catch (e) { profiles = {}; }
    var prof = (profiles[key] && typeof profiles[key] === 'object') ? profiles[key] : {};

    // 驱动显示名:设置页里注入了 window.__PICUP__,里面有 driversMeta(名称 / 说明)
    var driverKey = prof.driver || '';
    var name = driverKey || '未配置';
    var m = html.match(/window\.__PICUP__=(\{[\s\S]*?\});<\/script>/);
    if (m) {
      try {
        var meta = JSON.parse(m[1]);
        var info = meta.driversMeta && meta.driversMeta[driverKey];
        if (info && info.name) name = info.name;
      } catch (e) {}
    }
    return { profile: key, driverName: name };
  }

  function loadDetail() {
    if (!CFG) {
      renderInfo({ profile: '—', driverName: '未检测到 PicUp' });
      fail('warn', 'warning_amber', '未检测到 PicUp(插件未启用或版本较低),上传窗口仍可使用');
      return;
    }
    renderInfo({ profile: CFG.defaultProfile || 'default', driverName: '—' });   // 先用注入的默认方案占位
    // 带 TTL 的缓存:5 分钟内重复进概要页不会再读一遍设置页(存在 abadmin 表里)
    ab.data.getCached(CACHE, TTL, function () {
      return ab.ui.getText(SETS).then(parseSettings);     // 只有缓存过期/不存在时才发请求
    }).then(renderInfo).catch(function () {
      // 设置页读不到(未启用登录态/无权限)→ 保留 __PICUP_CFG__ 的信息,不打扰用户
    });
  }

  function refreshDetail() {
    ab.data.clearCached(CACHE).then(loadDetail, loadDetail);
  }

  /* ================= 3. 提示条:只放错误 / 警告 ================= */
  // 约定:正常流程(打开了窗口、文件已排进列表)不打扰用户,这里只报错。
  // 按需创建 —— 没出问题就一条提示都不存在,卡片底部常年干净。
  function fail(tone, iconName, text) {
    if (!tip) {
      tip = ab.ui.notice({ tone: tone, icon: iconName, text: text });
      tipHost.appendChild(tip);
      return;
    }
    tip.update({ tone: tone, icon: iconName, text: text });
  }

  /* ================= 4. 打开「媒体库同款」上传窗口 ================= */
  var refreshBackup = null;   // 被临时屏蔽的 _refreshMediaGrid(幂等:重复打开只屏蔽一次)

  function shieldMediaGridRefresh() {
    if (refreshBackup || typeof AB._refreshMediaGrid !== 'function') return;
    refreshBackup = AB._refreshMediaGrid;
    AB._refreshMediaGrid = function () {};        // 仪表盘没有媒体卡片网格,跳过整页刷新请求

    function restore() {
      if (refreshBackup) { AB._refreshMediaGrid = refreshBackup; refreshBackup = null; }
    }
    /* 卡片被销毁(AJAX 切页)时也要恢复,否则会永久屏蔽媒体库的刷新 */
    ab.onCleanup(restore);

    var polls = 0;
    var timer = ab.interval(function () {         // ab.interval 在卡片销毁时会自动 clearInterval
      if (document.getElementById('ab-upload-dialog') && polls++ < 300) return;   // 窗口还开着
      clearInterval(timer);
      restore();
    }, 800);
  }

  function openUploadDialog(files) {
    if (typeof AB._openMediaUploadDialog !== 'function') {
      fail('warn', 'warning_amber', '当前版本的 AdminBeautify 没有上传窗口,请更新插件,或先去「媒体库」上传');
      return;
    }
    shieldMediaGridRefresh();
    AB._openMediaUploadDialog(null);
    if (files && files.length) seedDialog(files);   // 成功不提示,静默把拖入的文件排好
  }

  /* 把拖进来的文件直接排进刚打开的窗口:造一个 DataTransfer,再往窗口的拖放区派发 drop */
  function seedDialog(files) {
    var zone = document.getElementById('ab-upload-dropzone');
    if (zone && typeof DataTransfer === 'function' && typeof DragEvent === 'function') {
      try {
        var dt = new DataTransfer();
        for (var i = 0; i < files.length; i++) dt.items.add(files[i]);
        setTimeout(function () {
          try {
            zone.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }));
          } catch (e) {
            fail('warn', 'warning_amber', '拖入的文件没能自动排进上传窗口,请在窗口里手动选择文件');
          }
        }, 60);
        return;
      } catch (e) { /* 浏览器不给构造 DataTransfer/DragEvent → 走下面的降级提示 */ }
    }
    fail('warn', 'warning_amber', '拖入的文件没能自动排进上传窗口,请在窗口里手动选择文件');
  }

  /* ================= 5. 拖拽:卡片任意位置都能放,松手就开窗口 ================= */
  function hasFiles(e) {
    var t = e.dataTransfer && e.dataTransfer.types;
    return !!t && Array.prototype.indexOf.call(t, 'Files') !== -1;
  }

  var popTimer = 0;

  card.addEventListener('dragover', function (e) {
    if (!hasFiles(e)) return;
    e.preventDefault();                       // 必须阻止默认,否则浏览器会直接打开文件
    if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
    dropzone.classList.add('is-dragover');
  });
  card.addEventListener('dragleave', function (e) {
    if (card.contains(e.relatedTarget)) return;
    dropzone.classList.remove('is-dragover');
  });
  card.addEventListener('drop', function (e) {
    if (!hasFiles(e)) return;
    e.preventDefault();
    dropzone.classList.remove('is-dragover');
    // 松手反馈:图标 pop 一下再开窗口
    dropzone.classList.add('is-dropped');
    clearTimeout(popTimer);
    popTimer = setTimeout(function () { dropzone.classList.remove('is-dropped'); }, 500);
    // 文件列表必须在这里同步取出来(事件结束后 DataTransfer 就失效了)
    openUploadDialog(Array.prototype.slice.call(e.dataTransfer.files || []));
  });

  /* ================= 6. 启动 ================= */
  loadDetail();
})();

用法与说明

  1. 在 概要页卡片设置 → 自定义卡片 里点「添加卡片」,图标填 cloud_upload、标题填 PicUp 上传,
    HTML 内容粘贴上面那段 <style>,JavaScript 粘贴上面的脚本,保存后开启总开关。
  2. 三个入口,共用同一个上传窗口:

    • 把文件拖进卡片任意位置:拖拽时拖放区变成主色实线 + 呼吸光环,松手图标 pop 一下,
      然后直接打开上传窗口,并且拖进来的文件已经排进窗口的待上传列表,点「开始上传」即可。
    • 点击拖放区(或按 Tab 聚焦后按 Enter):打开同一个上传窗口,
      不带文件,自己在窗口里拖 / 选。
    • 「打开上传窗口」按钮:同上;「媒体库」直接去媒体库;底部「PicUp 设置」去插件设置页。
  3. 策略区只显示两行:当前方案 + 存储驱动(驱动显示名取自设置页注入的 driversMeta)。
    不显示方案数量、驱动参数、接管范围等细节,也不做任何跳转 —— 想改设置走底部入口。
    因为只取「方案名 + 驱动名」两个字段,卡片里结构性地不可能出现密钥或存储参数。
  4. 策略信息来自两处:window.__PICUP_CFG__(PicUp 在后台所有页面注入的
    {profiles, defaultProfile, mimeScope})+ 读取 PicUp 设置页解析方案 JSON,
    结果用 ab.data.getCached() 缓存 5 分钟(存在 abadmin 表,键名 picup-strategy);
    点「刷新策略」会先 ab.data.clearCached() 再重拉,绕过缓存。
  5. 提示条只用来报错:页面加载、打开窗口、文件排进列表都不显示任何提示
    (所以既没有「正在读取…」这种加载文案,也没有「已打开上传窗口」「已把 N 个文件放进上传窗口」这类成功提示);
    只有「没有上传窗口」「文件没能自动排进列表」「未检测到 PicUp」这些降级情况才出现一条。
  6. PicUp 未启用时不会报错:两行信息退化为 — / 未检测到 PicUp,上传窗口仍然可用。
兼容性说明:seedDialog 用浏览器的 DataTransfer / DragEvent 构造器来自动填充上传窗口,
并依赖 AdminBeautify._openMediaUploadDialog(就是媒体库「新增」按钮调的同一个函数)。
任一不可用时只打开窗口、不会报错,用户仍可在窗口里手动选择文件。

17. 用 AI 为自己的插件生成卡片(Prompt 模板)

复制下面整段,替换 <> 里的内容,交给任意 AI 助手,即可拿到可直接粘贴的卡片配置。

你是一名熟悉 Typecho 插件与 AdminBeautify 后台美化的前端工程师。
请为我的插件写一个「AdminBeautify 自定义卡片」的配置,要求如下。

【目标插件】
- 插件名:<插件名,如 Links Plus>
- 本地目录:<usr/plugins/XXX>(版本 <vX.Y.Z>)
- 数据来源:<数据表/字段说明,或后台页面地址,例如
  admin/extending.php?panel=XXX/manage.php,以及该页面里能标识状态的 DOM 结构/属性;
  若插件有自己的 action 接口也一并给出>
- 管理页地址(用于跳转):<admin/xxx.php 或 extending.php?panel=...>

【想展示的内容】
- 指标:<例如 已启用数量 / 待审核数量 / 今日新增…>
- 状态语义:<例如 state=1 启用、2 待审核、0 禁用>
- 需要跳转的地方:<例如 底部入口「管理友链」;有待审核时「待审核」磁贴可点>

【硬性约束】
1) 只能使用 AdminBeautify 内置组件库 ab.ui,**不要写任何 CSS**,也不要手写 HTML 结构;
   内容用 ab.ui.mount(card.querySelector('.ab-custom-body'), ...) 一次写入。
2) 需要持久化自己的数据(计数、上次同步时间、用户标记等)时,用 ab.data.save/get/list/remove,
   不要自己建表、不要写 SQL;键名只传 A-Za-z0-9_- ,不要带 card- 前缀。
   外部数据的缓存用 ab.data.getCached(key, ttl, loader, { scope:'site' })(全站共享),
   不要用 localStorage / sessionStorage。
3) 需要读站台数据库其它表时,只能用 ab.db.read(table, query)(只读、结构化参数),
   表名不带站点前缀,不要试图拼接 SQL;也不要读取 password / authCode 等敏感列。
4) 数据获取用 ab.ui.getText / getDoc / getJSON(同源、自带后台登录态);
   只有 AdminBeautify 自己的接口才用 ab.ajax。
5) 卡片底部入口必须用 ab.ui.footer(card, { text, href })(渲染 .ab-card-footer)。
6) 脚本用 IIFE 包裹,顶层只用 var(不要 let/const);必须可重复执行(幂等);
   请求前后用占位值 '—' 或 skeleton:true;失败时用 ab.ui.notice 提示(成功类反馈用 ab.notify)。
   定时器一律用 ab.interval / ab.timeout,挂在 window / document 上的监听用 ab.onCleanup 收尾——
   AJAX 无刷新切页时插件会自动执行清理,不要再写 card.__xxx 那种手写幂等。
7) 不要把整个卡片做成可点击。

【ab.ui 组件库 API 速查】
- ab.ui.el(tag, className, text) / ab.ui.icon(name, className)
- ab.ui.append(target, content) / ab.ui.mount(target, content)
- ab.ui.metric(item) / ab.ui.metrics(items, { cols })            // 指标磁贴(大数字)
- ab.ui.row(item)    / ab.ui.rows(items)                         // 图标胶囊 + 名称 + 右对齐数值 的行列表
- ab.ui.hero(item)                                               // 大号单一指标
- ab.ui.listItem(item) / ab.ui.list(items)                       // 图标 + 标题 + 副标题 + 右值 的清单
- ab.ui.badge(text, tone) / ab.ui.button({ text, icon, href, onClick, tone, block }) / ab.ui.actions([...])
- ab.ui.notice({ text, icon, tone })                             // 提示条
- ab.ui.empty({ icon, text, hint, href, size:'lg' })             // 空状态
- ab.ui.footer(card, { text, href, icon, onClick })              // 卡片吸底入口(= .ab-card-footer)
- ab.ui.dialog({ title, icon, text, body, actions, onClose })     // MD3 对话框(返回 {el, scrim, close})
- ab.ui.confirm({ title, text, okText, cancelText, tone }) → Promise<bool>   // 确认框
- ab.ui.switch({ label, checked, onChange })                      // 开关(.setValue / .getValue)
- ab.ui.tabs([{ id, label, icon }], { active, onChange })         // 分段标签(.setActive / .getActive)
- ab.ui.progress({ label, value, max, tone, hideValue })          // 进度条(.update / .setValue)
- ab.ui.field / ab.ui.input / ab.ui.select({ label, value, placeholder, options, hint,
    type:'text'|'textarea', onInput, onChange })                  // 轻量输入 / 下拉(.input / .getValue / .setValue)
- ab.notify(text, tone, ms)                                       // 右下角轻提示(info/success/warn/danger)
- ab.copy(text) → Promise<bool> / ab.copyText(text, tip)           // 剪贴板(http 环境也有兜底)
- ab.format.bytes/date/relative/duration/num(...)                  // 1.5 KB / 2026-09-20 15:04 / 3 分钟前
- ab.chart.line(el, { xData, yData, color, colorDark })            // 折线图(懒加载本地 SVG 图表库)
- ab.chart.polar(el, { data:[{name,value}] })                      // 极坐标柱状图
- ab.onCleanup(fn) / ab.interval(fn, ms) / ab.timeout(fn, ms)      // 卡片销毁时自动收尾(AJAX 切页会触发)
- ab.ui.num(n, compact)                                          // 数字格式化(compact 时 ≥1w 显示 x.xw)
- ab.ui.setValue(target, value) / ab.ui.set(target, patch)        // 更新指标数值 / 通用局部更新
- ab.ui.getText(url) / ab.ui.getDoc(url) / ab.ui.getJSON(url)     // 同源请求
- **ab.data(数据存储,默认按用户隔离;opts.scope='site' 时全站共享,服务端表 {prefix}abadmin)**:
  ab.data.save(key, value, opts?)   // 写入/覆盖(POST,value 可为任意 JSON 值)
  ab.data.get(key, fallback?, opts?) // 读出解码后的 JSON(不存在返回 null / fallback)
  ab.data.getRow(key, opts?)        // {exists, content, json, created_at, updated_at}
  ab.data.list(opts?)               // {items:[{key, created_at, updated_at}], count}
  ab.data.remove(key, opts?)        // 删除,返回 {deleted}
  ab.data.getCached(key, ttlMs, loader, opts?)   // 带 TTL 的缓存:命中不调 loader,未命中自动回写
  ab.data.clearCached(key, opts?)   // 删缓存(下次重拉)
  ab.data.siteSave / siteGet / siteGetRow / siteList / siteRemove   // 全站共享简写(= opts {scope:'site'})
  键规则:A-Za-z0-9_- ,1~64,**不要带 card- 前缀**(服务端自动拼接);单条 JSON ≤ 65535 字节
- **AdminBeautify 自己的只读接口(用 ab.request(名字, 参数) 调,自动带登录态与 token)**:
  stats                       → {posts:{total,thisWeek,lastWeek}, comments:{…}, cats:{…}}
  chart-data  {days}          → {frequency:[{date,count}], commentsByCat:[{name,count}]}
  list-media  {page,per,parent,search} → {total,page,per,items:[{cid,name,url,mime,size,created}]}
  get-media-urls {cids:'1,2,3'} → {'1':'https://…'};info → {version,posts,comments,categories}
  写类接口(upload-media / save-settings / do-update 等)卡片里不要调
- **ab.db(只读查询其它表,只能读不能写)**:
  ab.db.read(table, { columns, where:[{field, op, value}], orderBy:{field, dir}, limit, offset })
  → {table, columns, rows, count, limit, offset}
  op 白名单:= != <> > >= < <= LIKE NOT LIKE IN NOT IN;limit 默认 50 / 上限 200
  表名不带站点前缀;禁止访问 options / information_schema / mysql / sys 等表;
  password / authCode 等敏感列一律禁止(即使 columns:['*'] 也会被剔除)
- item 字段:{ id, icon, label, value, hint, tone, href, onClick, skeleton, attrs }
- tone:default | primary | success | warn | danger | info
- metric / row / hero / notice 返回的元素都带
  .update({ value, label, hint, icon, tone, href, onClick, skeleton })
- 其他可用 API:ab.esc(text) 转义、ab.request(do, params) / ab.ajax(do, params, opts) 调 AdminBeautify 自己的接口、
  ab.config(含 pluginVersion / siteName / user{uid,name,screenName,group} / pluginSettingsUrl / dashboardCardOrder 等)、
  ab.version(插件版本号)

【输出格式】
1) 图标:<一个 Material Icons Round 名称>
2) 卡片标题:<标题>
3) HTML 内容:留空(不需要)
4) JavaScript:完整代码(用 ```js 代码块),关键步骤写中文注释
5) 若不确定某个选择器/字段,请明确写「需要你确认 XXX」,不要编造。

参考文档(AdminBeautify 自定义卡片开发说明):
https://blog.lhl.one/artical/977.html
小技巧:把插件里相关的一段源码(表结构、管理页渲染状态的那几行 PHP)一起贴给 AI,
生成的取数逻辑会准确得多。

18. 常见问题 FAQ

Q:卡片不显示?

A:检查「自定义卡片」开关是否开启;保存设置后是否刷新了概要页;控制台是否有脚本报错。

Q:卡片显示但数字一直是 —?

A:说明 JS 没跑到赋值那一步。打开控制台看 ab.ui.getDoc(...) 的请求是否 404 / 302(未登录跳转),
以及你用的选择器是否与目标插件版本一致。

Q:ab.ui 是 undefined?

A:说明插件版本低于 v2.1.50。升级插件即可。

Q:ab.data / ab.db 是 undefined?

A:同样需要 v2.1.50+。另外它们只在自定义卡片脚本里注入(也在 AdminBeautify.data / AdminBeautify.db 上)。

Q:数据存在哪里?卸载插件会删掉吗?

A:存在 {站点前缀}abadmin 表(进设置页自动创建),按登录用户隔离。禁用/卸载插件不会删表,数据保留。

Q:ab.data.save() 会不会重复新增行?

A:不会。表上有 (uid, card_key) 唯一索引,同一用户同一键只会有一行:不存在则新增、存在则覆盖(返回值里的 created 区分两者)。

Q:能不能用 ab.db 写入?

A:不能,而且没有这个入口。只读接口只生成 SELECT,参数全部结构化白名单校验 + 参数绑定。

Q:为什么 ab.db.read('options') 被拒绝?

A:options 表含站点 secret,泄露可用于伪造认证/CSRF,属于强制禁止的表。

Q:ab.db.read('users') 读不到 password?

A:这是有意为之:password / passwd / authCode / salt 等列一律禁止读取(含变体名),
即使写 columns: ['*'] 也会在返回前被剔除。

Q:能不能自己写 CSS / HTML?

A:可以,但推荐用组件库;确需自定义时见 §9(主题变量会随深浅色自动切换)。

Q:会不会影响前台?

A:不会。自定义卡片只在后台概要页注入。

Q:能引入外部 JS 库吗?

A:可以,在 HTML 字段里加 <script src="https://…"></script>(同步脚本会阻塞,建议 defer 或动态 import())。

Q:卡片太高,把其它卡片挤变形了?

A:减少内容高度,或把次要信息折叠为两列 / 省略号显示;整行高度由最高卡片决定。

Q:如何直接跳到某个设置卡片?

A:设置页地址拼 &to=<卡片id>,例如 options-plugin.php?config=AdminBeautify&to=dashboardcards
会自动展开并高亮「概要页卡片设置」;to=<字段名> 则定位到具体字段(如 to=dashboardCustomCardsEnabled)。

Q:卡片想画折线图 / 分布图怎么办?

A:用 ab.chart.line(el, { xData, yData, color }) 和 ab.chart.polar(el, { data })(见 §6.4),
底层就是概要页那两张图用的本地 SVG 图表库,按需懒加载,不用自己引资源。

Q:数据怎么在所有管理员之间共享?

A:ab.data 默认按用户隔离;需要全站一份(共享缓存、汇总数据)就传 { scope: 'site' },
或用 ab.data.siteSave / siteGet / siteList / siteRemove(见 §8.5)。

Q:卡片能发提示 / 复制到剪贴板吗?

A:ab.notify(text, tone, ms) 弹右下角轻提示;ab.copy(text) 返回 Promise<bool>
(http 环境自动走 execCommand 兜底)、ab.copyText(text, tip?) 复制并顺带提示,见 §6.1 / §6.2。

Q:卡片里的定时器会不会泄漏?

A:只要用 ab.interval / ab.timeout 创建,或给手动创建的定时器 / window 监听调用
ab.onCleanup(fn) 就不会 —— AJAX 切页、卡片被替换前插件会统一执行清理函数(见 §6.5)。

Q:卡片脚本报错后一片空白,怎么排查?

A:现在卡片正文顶部会直接插入一条红色错误块(含错误信息),控制台同时打印
[AB] 自定义卡片脚本出错:custom:<id>。

Q:卡片想要输入框 / 开关 / 确认弹窗?

A:用 §7.11 ~ §7.15:ab.ui.dialog / ab.ui.confirm / ab.ui.switch / ab.ui.tabs /
ab.ui.progress / ab.ui.field(input / select 是同一个函数),都自带 MD3 样式与深浅色适配。
注意卡片里的输入不会写进插件设置,需要自己用 ab.data 或请求保存。

Q:粘上卡片一保存设置,直接跳到「Database Query Error」错误页?
A:这是卡片体积撞上了 Typecho 设置项的存储上限。插件设置写在 typecho_options.value(TEXT,65535 字节),
而 Typecho 1.3 写库时还会把整段设置 JSON 再转义一次(1 个中文 = 6 字节、1 个引号 = 2 字节),
实测 4 张官方卡片就约 87KB → MySQL 严格模式直接 Data too long for column 'value' → 错误页。

现在保存时会自动把卡片 HTML / JS 转存到数据表 {站点前缀}abadmin(键 card-cc-<id>-h / -j),
设置项里只留 id / icon / title 元数据(几百字节)—— 从根上消掉了这个上限,卡片再多也不会撞。
升级插件后重新保存一次设置即可自动迁移旧数据。

两个看起来无关的症状其实是同一个原因:MySQL 非严格模式(sql_mode 没开 STRICT_TRANS_TABLES)不会报错,
而是静默截断写入内容 → 设置里 JSON 少了一截 → 卡片报 missing } after function body。

Q:一张卡片最多能放多少代码?
A:单块(HTML、JS 各算一块)上限 65535 字节(数据表列类型 TEXT);超了保存会被拦住并提示,不会写坏设置。
代码已存在数据表里,所以:① 设置项体积不再随卡片数量增长;② 代码里的 emoji 不会再撞 utf8mb3 的 1366 错误(数据表是 utf8mb4)。
⚠️ 但 卡片标题仍然写在设置项里,utf8mb3 的老站点别在标题里放 emoji。

Q:设置页里卡片的 HTML / JS 框变空了(或在概要页看到「卡片代码缺失」)?
A:先升级到最新修补版,再打开一次设置页 —— 代码存在数据表 {前缀}abadmin(card-cc-<id>-h / -j),
正常情况会自动回填到编辑框里,不用重新粘贴。
如果升级后仍然是空的,说明数据表里确实没有代码行;先用 SQL 确认:
SELECT card_key, LENGTH(content) FROM {前缀}abadmin WHERE uid = 0 AND card_key LIKE 'card-cc-%';
一行都没有 = 需要重新粘贴(docs/cards/ 里 5 张官方卡片的代码都完整保留,卡片的 id / 图标 / 标题还在设置里)。

最新修补版修掉了三个会造成「看不到代码 / 代码消失」的坑:
① 编辑器只保留 id/icon/title/html/js,code:'db' 标记会被丢掉 —— 回填改成「只看本地有没有代码」,不再依赖标记;
② 提交上来是空代码时绝不删除数据表里的旧行(只有卡片从列表里删掉才清);想真清空一张卡片的代码,请删掉这张卡片;
③ 没有代码的卡片不再写 code:'db' 标记(否则概要页会报「卡片代码缺失」)。
另外:打开设置页时会顺手把「还在设置项里的内联代码」备份一份到数据表,之后即使某次保存出错也能从数据表恢复。

横幅通知API

AdminBeautify 提供了供第三方 Typecho 插件直接调用的 JavaScript 公开 API,无需自行实现通知样式。

AdminBeautify.showNotice(message, type?, duration?)

在页面顶部显示一条 MD3 Snackbar 风格的横幅通知。

参数类型默认值说明
messagestring必填通知文本内容
typestring'info'通知类型:'info' \ 'success' \ 'warn' \ 'error'
durationnumber5000自动消失时间(毫秒);传0 则只能点击关闭

调用前提: AdminBeautify 插件已启用,且页面已加载 AdminBeautify.min.v*.js。

示例:

// 1. 普通提示(默认 info 风格,5 秒后消失)
AdminBeautify.showNotice('数据已同步');

// 2. 成功提示
AdminBeautify.showNotice('设置保存成功', 'success');

// 3. 错误提示,显示 8 秒
AdminBeautify.showNotice('连接服务器失败,请稍后重试', 'error', 8000);

// 4. 持久通知(不自动消失,需用户点击关闭)
AdminBeautify.showNotice('后台任务正在运行…', 'info', 0);

在 PHP 插件中注入调用:

// 在插件的 renderFooter / action / panel 等方法中输出 inline script
echo '<script>
(function () {
    function doNotice() {
        if (window.AdminBeautify && typeof AdminBeautify.showNotice === "function") {
            AdminBeautify.showNotice("' . $message . '", "success");
        }
    }
    // 若 AB 已初始化则直接调用,否则等待 ab:pageload 事件
    if (document.readyState !== "loading" && window.AdminBeautify) {
        doNotice();
    } else {
        document.addEventListener("ab:pageload", doNotice, { once: true });
        document.addEventListener("DOMContentLoaded", doNotice, { once: true });
    }
})();
</script>';
注意: 此 API 需要 AdminBeautify ≥ v2.1.26。调用前请通过 typeof AdminBeautify !== "undefined" 判断可用性。

通知中心 API

侧边栏那个铃铛 + 通知面板就是通知中心(桌面浮层 / 手机端 MD3 底部抽屉)。往里写通知只有两种方式,
不用建表、不用碰数据库:PHP 侧调 pushNotice(),前端脚本走 notice-push。
面板由插件自动挂载(桌面展开挂在侧边栏头部、折叠挂在菜单下方、手机端挂在顶部三点左侧),
第三方插件只要把通知写进去即可。

PHP:AdminBeautify_Plugin::pushNotice()

AdminBeautify_Plugin::pushNotice($title, $body = '', $level = 'info', array $opt = array());
参数说明
$title必填,标题(≤120 字符;空标题直接返回 400)
$body正文(≤500 字符),列表里直接显示,可空
$levelinfo(默认)/ success / warn / error —— 决定图标与配色
$opt['detail']Markdown 详情(≤8000 字符),点开通知才显示;不写就用 body
$opt['src']来源标签(≤40 字符),显示在时间后面,例如 '备份'、'插件仓库'
$opt['link']可选链接,详情页里显示为一个按钮
$opt['act']可选按钮动作:ab-update = 详情页显示「立即更新」(此时 link 填 ZIP 地址);open-self = 同标签页打开。不填就是「打开链接」(新标签页)
$opt['dedup']去重键,默认按「级别 + 标题 + 正文」计算;同一件事文案会变就自己指定,如 'backup:failed'
$opt['uid'] / $opt['ts']指定接收者 / 时间戳(默认当前登录用户、当前时间)

返回值(写库失败不抛异常,一律返回 error,调用方不需要 try/catch):

array('id' => 12, 'created' => false, 'counted' => 3);   // 成功
array('error' => '通知标题不能为空', 'code' => 400);      // 失败

示例

// 命令跑完给个回执:列表一行摘要 + 点开才看的 Markdown 详情
AdminBeautify_Plugin::pushNotice('备份完成', '已生成 backup-2026-10-03.tar.gz(12.8 MB)', 'success',
    array(
        'src'    => '备份',
        'link'   => '/admin/backup.php',
        'detail' => "# 备份完成\n\n- `usr/uploads/` 共 128 个文件\n- 数据库导出 `typecho.sql`",
    ));

// 异常提醒:同一天同一异常只提醒一次(dedup 固定)
AdminBeautify_Plugin::pushNotice('SMTP 测试失败', '535 Authentication failed', 'error',
    array('dedup' => 'smtp:test-failed', 'src' => 'SMTP'));

行为约定(知道这几条就够,细节见仓库里的 docs/notice-api.md):

  • 每条通知有 未读 / 已读 / 已删除 三种状态,按用户隔离(各管理员的已读互不影响)
  • 相同内容一天只记 1 条:同一天重复触发只累加次数,跨天会重新提醒
  • 每个管理员最多留 200 条,「已删除」保留 30 天后自动清理 —— 都不用你操心
  • ⚠️ 未登录写不进通知(返回 error: 未登录):从登录页这类没有登录用户的场景提醒管理员,目前做不到

JS:notice-push

// AdminBeautify.request(动作名, 参数, 方法) —— 插件统一的前端请求封装(自动带 CSRF token)
AdminBeautify.request('notice-push', {
  title: '前端提醒', body: '来自主题脚本', level: 'warn', src: '主题'
}, 'POST').then(function (r) { console.log(r.created, r.counted); });

字段与 PHP 的 $opt 一一对应(title / body / level / detail / link / src / act / dedup),
返回值也一致。同一个 request() 还能读与改:notice-list(列表)、notice-detail(单条 + 详情正文)、
notice-state(标记已读 / 未读 / 删除)、notice-count(未读数)——参数见 docs/notice-api.md。

上面那节的一次性横幅(Snackbar)是另一个 API:AdminBeautify.showNotice()。两者用途不同:
横幅是「一瞬间的提示」,通知中心是「会留档、可翻查、能定向下发」的。

申请发布公告(AB Notice)

第三方插件 / 作者 / 合作商不用自己搭服务器:在官方分发平台提交公告,审核通过后,
装了相关插件的站点会在后台自动收到这条通知(按「AdminBeautify 版本 + 已装插件」定向,不打扰无关站点)。

  1. 打开 https://ab-admin-notice.lhl.one/ → 注册(邮箱 + 邮箱验证码)
  2. 登录后进「申请」,填写:

    • 标题(≤120)、正文(≤500,通知列表里直接显示)、详情 Markdown(≤8000,点开才显示)
    • 级别(info / success / warn / error)、来源标签(≤40,默认「第三方公告」)
    • 按钮动作 / 链接(可选,与上面的 act / link 同义)
    • 定向:适用 AdminBeautify 版本区间、生效起止时间、是否强制投递、
      以及只发给启用了指定插件的站点(不勾选 = 全体站点)
  3. 提交后等审核(结果会发到注册邮箱);通过后写入下发队列,各站点下次同步(约 10 分钟一次)就会出现在通知中心

对话框API

概述

AdminBeautify (AB) 在 interceptNativeDialogs() 中覆写了 window.alert、window.confirm、window.prompt,将浏览器原生阻塞式对话框替换为 Material Design 3 风格的异步弹窗。

同步→异步断裂问题

// 浏览器原生:confirm() 阻塞执行,等用户操作后返回 true/false
if (!confirm('确定删除?')) return;   // 正常
doDelete();

// AB 劫持后:confirm() 立即返回 false,然后才弹 MD3 对话框
if (!confirm('确定删除?')) return;   // 永远走 return
doDelete();
方法原生行为AB 劫持后
alert(msg)阻塞,返回undefined非阻塞,返回undefined
confirm(msg)阻塞,返回true / false立即返回 false
prompt(msg, def)阻塞,返回string / null立即返回 null

公开 API(v2.1.33+)

AB 提供了三个 Promise 化方法,直接调用内部 _open(),无需全局变量中转:

AdminBeautify.alert(message)

@param  {string} message  消息文本(支持 \n,首行作标题)
@return {Promise<void>}   用户点击"好的"后 resolve

AdminBeautify.confirm(message)

@param  {string} message  消息文本
@return {Promise<boolean>} 确定→true, 取消/ESC/点遮罩→false

AdminBeautify.prompt(message [, defaultVal])

@param  {string} message    消息文本
@param  {string} defaultVal 输入框默认值(可选)
@return {Promise<string|null>} 确定→输入值, 取消→null

使用示例

// confirm
AdminBeautify.confirm('确定删除?\n此操作不可撤销。').then(function(ok) {
    if (!ok) return;
    doDelete();
});

// prompt
AdminBeautify.prompt('输入新名称:', '默认值').then(function(name) {
    if (!name || !name.trim()) return;
    rename(name.trim());
});

// alert 链式
AdminBeautify.alert('保存成功').then(function() {
    location.reload();
});

原生引用

如需绕过 AB 弹窗,直接使用浏览器原生对话框:

AdminBeautify.nativeAlert(msg);
AdminBeautify.nativeConfirm(msg);   // 返回 true/false(同步阻塞)
AdminBeautify.nativePrompt(msg, def); // 返回 string/null(同步阻塞)

兼容旧版AB-Admin / 无AB-Admin环境

若需同时兼容新旧版本 AB 和无 AB 环境,可用三级降级封装:

function myDialog(type, msg, defVal) {
    var AB = window.AdminBeautify;
    // AB v2.1.33+ 公开 API
    if (AB && typeof AB[type] === 'function') {
        return AB[type](msg, defVal);
    }
    return new Promise(function(resolve) {
        if (!AB) {
            // 无 AB,原生同步
            if (type === 'confirm')    resolve(confirm(msg));
            else if (type === 'prompt') resolve(prompt(msg, defVal));
            else { alert(msg); resolve(); }
            return;
        }
        // 旧版 AB,全局回调
        if (type === 'confirm') {
            window._abPendingConfirm = function(r) { resolve(!!r); };
            window.confirm(msg);
        } else if (type === 'prompt') {
            window._abPendingPrompt = function(r) { resolve(r); };
            window.prompt(msg, defVal || '');
        } else {
            window.alert(msg);
            resolve();
        }
    });
}

内部机制

window.confirm(msg)
    │
    ├─ _parseMsg(msg)      → { title: 第一行, body: 其余行 }
    ├─ _open({ type:'confirm', title, message, onConfirm, onCancel })
    │       │
    │       ├─ 创建 #ab-dialog-overlay + #ab-dialog-box
    │       ├─ WAAPI 入场动画 (scale 0.85→1 / mobile: translateY)
    │       ├─ 绑定 确定/取消/ESC/Enter/点击遮罩
    │       └─ 用户操作 → 退场动画 → 调用 onConfirm(value) 或 onCancel()
    │
    └─ return false  ← 同步立即返回,不等待用户

异步回调通道

AB 暴露了两个全局变量作为回调桥梁:

// confirm 的异步结果
window._abPendingConfirm = function(result) { /* true 或 false */ };

// prompt 的异步结果
window._abPendingPrompt = function(result) { /* string 或 null */ };

AB 的 window.confirm 覆写内部:

window.confirm = function(message) {
    var p = _parseMsg(message);
    _open({
        type: 'confirm', title: p.title, message: p.body,
        onConfirm: function() {
            if (window._abPendingConfirm) {
                var fn = window._abPendingConfirm;
                window._abPendingConfirm = null;
                fn(true);
            }
        },
        onCancel: function() {
            if (window._abPendingConfirm) {
                var fn = window._abPendingConfirm;
                window._abPendingConfirm = null;
                fn(false);
            }
        }
    });
    return false;  // ← 问题根源
};

window.prompt 同理,内部调用 _abPendingPrompt(value) 或 _abPendingPrompt(null)。

_parseMsg 消息格式

function _parseMsg(message) {
    var parts = ('' + message).split('\n');
    return { title: parts[0], body: parts.slice(1).join('\n') };
}

第一个 \n 前 → 对话框标题;之后内容 → 对话框正文。

附录:_parseMsg 消息格式规范

"标题文字\n正文内容第一行\n正文内容第二行"
部分在 AB 对话框中对应 DOM
\n 之前标题(粗体 + 图标)#ab-dialog-title
\n 之后正文#ab-dialog-msg
无\n全部作为标题,无正文仅#ab-dialog-title

附录:AB 对话框 DOM 结构

#ab-dialog-overlay
└── #ab-dialog-box
    ├── #ab-dialog-host          "🌐 hostname"
    ├── #ab-dialog-title         图标 + 标题
    ├── #ab-dialog-msg           正文(可选)
    ├── #ab-dialog-input-wrap    输入框(仅 prompt)
    │   └── #ab-dialog-input
    └── #ab-dialog-actions
        ├── #ab-dialog-cancel    取消按钮(confirm/prompt)
        └── #ab-dialog-confirm   确定/好的 按钮

附录:AB 对话框键盘快捷键

按键行为
Enter等同点击「确定」按钮
Escapeconfirm/prompt → 等同「取消」;alert → 等同「好的」

相关链接

问题反馈
GitHub 仓库
公告分发平台 AB Notice

最后编辑于: 2026 年 10 月 16 日
返回文章列表 打赏
本页链接的二维码
打赏二维码
添加新评论

已有 6 条评论
  1. xh xh        
    都开源了还加密js吗
    1. LHL LHL        
      @xh后续可能变成收费插件,然后现在加密主要是现在基本日更
    2. LHL LHL        
      @xh也没怎么加密,就混淆+base64编码
    3. LHL LHL        
      @xh完全不加密了目前,从2.1.4x版本开始,虽然就是有点庞大,不是很好二开
  2. 蛋蛋之家 蛋蛋之家        
    我是1.2.1,测试大部分兼容,唯一的一点就是导航栏CSS有的问题,黑乎乎的,不过我另外开了SimpleAdmin,结合的挺好的
    1. LHL LHL        
      @蛋蛋之家1.2.1和1.3.0导航栏的布局不一致导致的兼容性问题