这可能是史上颜值最高的 Typecho 后台美化插件
目录
AB Admin (原Admin Beautify)
安装方法
- 前往 GitHub Releases / AB Store Web 下载最新版本
- 解压后将
AdminBeautify文件夹上传至
/usr/plugins/- 后台进入
控制台 → 插件 → 启用 AdminBeautify
截图预览
| 首页 | 插件管理 | 个人设置 |
|---|---|---|
![]() | ![]() | ![]() |
| 顶部导航栏(原版) | 文章编辑页面 | 文章管理 |
|---|---|---|
![]() | ![]() | ![]() |
| 插件仓库 | 移动端导航栏 |
|---|---|
![]() | ![]() |
常见问题
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,内容留空。只要这个文件存在,人机验证立即全部跳过 —— 登录、注册、忘记密码三个入口一起放行,而且不需要清缓存、不需要动数据库、不需要进后台。
完整流程:
- 传空文件
usr/plugins/AdminBeautify/turnstile-off - 正常登录后台 → 插件 → AdminBeautify →「安全验证设置」→ 核对密钥
- 修好并保存 → 退出登录 → 试一次登录,确认验证能过
- 确认无误后,把这个文件删掉,验证功能恢复
⚠️ 这个文件在插件目录里,升级插件时会被自动更新清掉(更新程序会先清空插件目录再写入新文件)。所以如果你配置还没修好就点了升级,可能又被锁在外面 —— 升级后记得再放一次,或者干脆先把配置改对。
第 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-secret | Secret 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-error | Cloudflare 侧临时故障,稍后重试 |
几条少踩坑的配置建议
- 先关着配好,再开启。 两项密钥都留空 = 未启用,此时怎么折腾都不会拦住登录;确认密钥对了,再打开那三个开关。
- 粘贴后检查首尾空格。 从 Cloudflare 复制密钥很容易带上换行。插件虽然会自动
trim(),但别依赖它。 - 本地 / 测试站请用测试密钥。 Cloudflare 提供一对永远通过的测试密钥:站点密钥
1x00000000000000000000AA、密钥1x0000000000000000000000000000000AA。注意规则:用了测试站点密钥,就必须配测试密钥,混搭真密钥会直接失败。 - 一次只开一个入口。 先只勾「登录页」,跑通之后再考虑注册页与忘记密码页。
- 自己先用无痕窗口试一遍,确认验证能过再放给别人用。
完整操作顺序(TL;DR)
- 传一个空文件
usr/plugins/AdminBeautify/turnstile-off - 正常登录后台 → 插件 → AdminBeautify →「安全验证设置」→ 检查密钥的首尾空格、以及两个密钥是否同属一个部件
- 修好密钥并保存 → 退出登录 → 试一次登录,确认验证能过
- 确认无误后删掉
turnstile-off文件 - 回到设置页,确认提示区没有「⛔」标记,收工
插件样式适配 (主题色/亮暗适配)
本节面向 其他 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);
});
})();注意事项
- 必须判断页面:每个脚本必须先检测当前 URL 或 DOM,确定是目标页面才执行
- 支持 AJAX 导航:监听
ab:pageload事件以响应 AdminBeautify 的 AJAX 页面切换(推荐) - 保持幂等:修复函数可能被多次调用(首次加载 + 每次 AJAX 导航),需避免重复注入;离开目标页面时应清理已注入的样式
- 使用 IIFE:用
(function(){ ... })()包裹,避免全局变量污染 - 使用
'use strict':启用严格模式 - 兼容暗色模式:如有样式修复,注意处理
[data-theme="dark"] - 可用 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. 快速开始
- 进入 控制台 → 插件 → AdminBeautify → 设置。
- 找到 概要页卡片设置 卡片 → 底部的 自定义卡片 区域。
把 自定义卡片 开关切到
开启(允许执行自定义 HTML / JS)。- 默认是 关闭,关闭时不会渲染自定义卡片,也不会执行任何自定义 JS。
- 点击 添加卡片,填写:
| 字段 | 说明 |
|---|---|
| 图标 | Material Icons Round 字形名,如 link / insights / pending_actions |
| 卡片标题 | 显示在卡片标题栏 |
| HTML 内容 | 卡片正文的静态结构,用组件库时留空即可(JS 里动态生成) |
| JavaScript | 卡片渲染完成后执行的脚本(可留空) |
- 右下角 保存设置,回到概要页查看效果。
💡 一张自定义卡片都没配置时,概要页会出现一张引导卡(大加号 +「开始管理你的自定义卡片」),
点击即可直达本设置页(链接带&to=dashboardcards,会自动展开并高亮「概要页卡片设置」)。
2. 数据是怎么存的
自定义卡片保存在隐藏字段 dashboardCustomCards 中,是一个 JSON 数组:
[
{
"id": "c1m3k9x2ab",
"icon": "link",
"title": "友链概览",
"html": "",
"js": "(function(){ /* … */ })();"
}
]| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 自动生成(前缀 c),用于卡片 key custom:<id> 和排序,不要手工重复 |
icon | string | Material Icons Round 字形名,缺省 widgets |
title | string | 卡片标题,缺省 自定义卡片 |
html | string | 注入 .ab-custom-body 的静态 HTML(用组件库时可留空) |
js | string | 在卡片里执行的脚本源码 |
想用别人分享的配置?在 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.ui | object | 内置组件库,见 §7(本说明书的主角) |
ab.data | object | 数据存储:save / get / getRow / list / remove(scope:'site' 可存全站共享)+ getCached(),见 §8 |
ab.db | object | 只读查询:ab.db.read(table, query) 读数据库其它表(只能读,不能写),见 §8 |
ab.ajax | ab.ajax(do, params, opts) | 请求 AdminBeautify 自己的 后台接口(/action/admin-beautify?do=…,自动带 token);ab.data / ab.db 内部就是它的 Promise 版 |
ab.request | ab.request(do, params, method) | ab.ajax 的 Promise 版(出错时 reject,err.code 为业务码) |
ab.notify | ab.notify(text, tone?, ms?) | 轻提示(右下角 snackbar):tone = info/success/warn/danger,见 §6.1 |
ab.copy | ab.copy(text) → Promise<bool> | 复制到剪贴板(含 execCommand 兜底);ab.copyText(text, tip?) 复制并提示,见 §6.2 |
ab.format | object | 格式化:bytes / date / relative / duration / num,见 §6.3 |
ab.chart | object | 图表:load() / line(el, opts) / polar(el, opts)(懒加载本地 SVG 图表库),见 §6.4 |
ab.onCleanup | ab.onCleanup(fn) | 注册销毁回调(AJAX 切页时自动执行),见 §6.5 |
ab.interval / ab.timeout | ab.interval(fn, ms) | 定时器(卡片销毁时自动清理,不用再手写幂等) |
ab.cleanup | ab.cleanup() | 立即执行本卡片已注册的清理函数(一般不需要手动调) |
ab.esc | ab.esc(text) → string | HTML 转义;手写 HTML 拼接时使用 |
ab.config | object | 即 window.__AB_CONFIG__,见下表 |
ab.version | string | 插件版本号(同 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 / ajaxToken | AdminBeautify 后台接口地址与 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 | — | 提示文字(纯文本,已自动转义) |
tone | info | info / success / warn / danger(也接受 primary 与 warning/error 别名) |
ms | 2600 | 显示时长(毫秒) |
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-data | days(天数,0 = 全部) | {frequency:[{date,count}], commentsByCat:[{name,count}]} |
list-media | page、per(≤100)、parent(-1 全部 / 0 未归档 / N 属于文章 N)、search | {total,page,per,items:[{cid,name,url,mime,size,created}]} |
get-media-urls | cids(逗号分隔,如 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-badgeab.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> 并调用它 |
icon | arrow_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 | 自增主键 | |
uid | int | 所属用户(登录管理员的 uid,服务端写入,前端无法指定);0 表示全站共享(scope:'site' 的行) |
card_key | varchar(64) | 数据键,落库为 card-<自定义字符> |
content | TEXT | JSON 字符串(UTF-8,单条 ≤ 65535 字节) |
created_at | int | 创建时间(Unix 秒) |
updated_at | int | 更新时间(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 / offset | limit 默认 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. 调试技巧
- 打开后台概要页 → F12 控制台。
找不到卡片?依次检查:
- 「自定义卡片」开关是否为「开启」;
- 保存设置后是否刷新过概要页;
- 控制台是否有
[AB] 自定义卡片脚本出错:custom:<id>; window.__AB_CONFIG__.dashboardCustomCardsEnabled是否为'1',window.__AB_CONFIG__.dashboardCustomCards.length是否大于 0。
- 脚本报错看两处:控制台
[AB] 自定义卡片脚本出错:custom:<id>+
卡片正文顶部会插入一条红色错误块(带错误信息)。 控制台里可以直接玩组件库(
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')手动验证取数逻辑(以 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);- 想临时改样式:在控制台给元素加规则,满意后再写进 HTML 字段的
<style>。 看卡片有没有留下未清理的定时器:
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();
})();用法与说明
- 在 概要页卡片设置 → 自定义卡片 里点「添加卡片」,图标填
cloud_upload、标题填PicUp 上传,
HTML 内容粘贴上面那段<style>,JavaScript 粘贴上面的脚本,保存后开启总开关。 三个入口,共用同一个上传窗口:
- 把文件拖进卡片任意位置:拖拽时拖放区变成主色实线 + 呼吸光环,松手图标 pop 一下,
然后直接打开上传窗口,并且拖进来的文件已经排进窗口的待上传列表,点「开始上传」即可。 - 点击拖放区(或按 Tab 聚焦后按 Enter):打开同一个上传窗口,
不带文件,自己在窗口里拖 / 选。 - 「打开上传窗口」按钮:同上;「媒体库」直接去媒体库;底部「PicUp 设置」去插件设置页。
- 把文件拖进卡片任意位置:拖拽时拖放区变成主色实线 + 呼吸光环,松手图标 pop 一下,
- 策略区只显示两行:
当前方案+存储驱动(驱动显示名取自设置页注入的driversMeta)。
不显示方案数量、驱动参数、接管范围等细节,也不做任何跳转 —— 想改设置走底部入口。
因为只取「方案名 + 驱动名」两个字段,卡片里结构性地不可能出现密钥或存储参数。 - 策略信息来自两处:
window.__PICUP_CFG__(PicUp 在后台所有页面注入的{profiles, defaultProfile, mimeScope})+ 读取 PicUp 设置页解析方案 JSON,
结果用ab.data.getCached()缓存 5 分钟(存在abadmin表,键名picup-strategy);
点「刷新策略」会先ab.data.clearCached()再重拉,绕过缓存。 - 提示条只用来报错:页面加载、打开窗口、文件排进列表都不显示任何提示
(所以既没有「正在读取…」这种加载文案,也没有「已打开上传窗口」「已把 N 个文件放进上传窗口」这类成功提示);
只有「没有上传窗口」「文件没能自动排进列表」「未检测到 PicUp」这些降级情况才出现一条。 - 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 风格的横幅通知。
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
message | string | 必填 | 通知文本内容 |
type | string | 'info' | 通知类型:'info' \ 'success' \ 'warn' \ 'error' |
duration | number | 5000 | 自动消失时间(毫秒);传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 字符),列表里直接显示,可空 |
$level | info(默认)/ 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 版本 + 已装插件」定向,不打扰无关站点)。
- 打开 https://ab-admin-notice.lhl.one/ → 注册(邮箱 + 邮箱验证码)
登录后进「申请」,填写:
- 标题(≤120)、正文(≤500,通知列表里直接显示)、详情 Markdown(≤8000,点开才显示)
- 级别(info / success / warn / error)、来源标签(≤40,默认「第三方公告」)
- 按钮动作 / 链接(可选,与上面的
act/link同义) - 定向:适用 AdminBeautify 版本区间、生效起止时间、是否强制投递、
以及只发给启用了指定插件的站点(不勾选 = 全体站点)
- 提交后等审核(结果会发到注册邮箱);通过后写入下发队列,各站点下次同步(约 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>} 用户点击"好的"后 resolveAdminBeautify.confirm(message)
@param {string} message 消息文本
@return {Promise<boolean>} 确定→true, 取消/ESC/点遮罩→falseAdminBeautify.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 | 等同点击「确定」按钮 |
Escape | confirm/prompt → 等同「取消」;alert → 等同「好的」 |
相关链接
本文章采用 CC BY-NC-SA 4.0 协议授权,转载请注明来源。
文章名称:Typecho 后台增强插件 - AB Admin
文章链接:https://blog.lhl.one/artical/977.html








