';
+ $o[] = '
'
+ . esc_html__('效果预览(16 个不同邮箱)', 'kratos') . '
';
+ $o[] = '
';
+ foreach ($seeds as $seed) {
+ $uri = yang_avatar_data_uri($seed, $current, 96);
+ if ($uri === '') {
+ continue;
+ }
+ $o[] = '
'
+ . '
'
+ . '' . esc_html(preg_replace('/@.*/', '', $seed)) . ''
+ . '';
+ }
+ $o[] = '
';
+ $o[] = '
'
+ . esc_html__('同一个邮箱永远得到同一张脸;换邮箱就换一张。', 'kratos')
+ . '
';
+ $o[] = '
';
+
+ return implode("\n", $o);
+}
+add_filter('optionsframework_avatar_style', 'yang_avatar_option_html', 10, 3);
+
+/**
+ * 保存校验:只接受收录清单里真实存在的风格名
+ *
+ * 框架自带的 of_sanitize_enum 要读 $option['options'],而这里没设
+ * 那个键(风格清单是运行时算的),所以单独写一个。
+ *
+ * 非法值一律回落到默认风格,**不是**存空 —— 存空会导致下次
+ * kratos_option() 拿到 ''、yang_avatar_style() 又要兜一次底,
+ * 多一层不必要的间接。
+ *
+ * @param mixed $input
+ * @return string
+ */
+function yang_avatar_sanitize_style($input)
+{
+ $styles = yang_avatar_styles();
+ if (is_string($input) && isset($styles[$input])) {
+ return $input;
+ }
+ return 'lorelei';
+}
+add_filter('of_sanitize_avatar_style', 'yang_avatar_sanitize_style');
+
+/**
+ * 后台:给 打上开关状态 class
+ *
+ * ── 为什么必须在服务端打 ──────────────────────────────────────────
+ * 风格列表的显隐靠 `body.yang-avatar-off` 控制。如果这个 class 只在
+ * 底部 JS 里加,浏览器解析 HTML 时是「先显示全部内容」,等脚本跑到
+ * 才藏起来 —— 每次进设置页、每次保存后重载,都会看到风格列表闪一下
+ * 再消失。放在 PHP 里输出,首帧就是对的。
+ *
+ * JS 里仍保留同一段 class 切换逻辑,负责「勾选/取消时立刻变化」。
+ *
+ * @param string $classes
+ * @return string
+ */
+function yang_avatar_body_class($classes)
+{
+ if (yang_avatar_active()) {
+ return $classes;
+ }
+ return trim($classes . ' yang-avatar-off');
+}
+add_filter('admin_body_class', 'yang_avatar_body_class');
+
+/**
+ * 后台预览用的 16 个邮箱种子
+ *
+ * 覆盖常见的中文博客访客形态:QQ / 163 / 126 / gmail / 企业邮,
+ * 以及几个极短、极长的边界值(`a@b.co`、`admin@example.org`)。
+ * 短种子的哈希分布和长的不一样,混着放才能看出这套风格在极端
+ * 输入下会不会崩。
+ *
+ * @return array
+ */
+function yang_avatar_preview_seeds()
+{
+ return array(
+ 'yangyq@example.com', 'zhang.wei@qq.com', 'li.na@163.com', 'wang@126.com',
+ 'chen.jing@gmail.com', 'liu.yang@outlook.com', 'zhao.min@foxmail.com', 'sun.lei@sina.com',
+ 'admin@example.org', 'test@test.com', 'hello@world.net', 'a@b.co',
+ 'zhou.jie@139.com', 'xu@sohu.com', 'ma.chao@yeah.net', 'huang@vip.qq.com',
+ );
+}
+
+/**
+ * AJAX:换风格时重绘 16 格大预览
+ *
+ * ── 为什么走 AJAX 而不是前端算 ────────────────────────────────────
+ * 渲染器是 PHP 的(跟前台头像共用同一份代码)。前端要「即时换预览」
+ * 只能要么重跑一遍 JS 版渲染器(等于维护两份实现,且极易不一致),
+ * 要么就请求后端。后者显然更稳。
+ *
+ * 一次请求只生成一套风格的 16 张(约 20~40 ms、几十 KB),
+ * 只在用户点选时触发,不会拖慢设置页首屏。
+ */
+function yang_avatar_ajax_preview()
+{
+ // 两道门:nonce + 权限。少一道都可能被低权限账号拿来刷 CPU
+ check_ajax_referer('yang_avatar_preview', 'nonce');
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error(array('message' => __('权限不足', 'kratos')), 403);
+ }
+
+ $style = isset($_POST['style']) ? sanitize_text_field(wp_unslash($_POST['style'])) : '';
+ $styles = yang_avatar_styles();
+ // 白名单校验,别把用户传的值直接拼进文件路径
+ if (!isset($styles[$style])) {
+ wp_send_json_error(array('message' => __('未知的风格', 'kratos')), 400);
+ }
+
+ $cells = array();
+ foreach (yang_avatar_preview_seeds() as $seed) {
+ $uri = yang_avatar_data_uri($seed, $style, 96);
+ if ($uri === '') {
+ continue;
+ }
+ $cells[] = array(
+ 'uri' => $uri,
+ 'name' => preg_replace('/@.*/', '', $seed),
+ );
+ }
+
+ if (empty($cells)) {
+ wp_send_json_error(array('message' => __('这套风格的模板文件缺失', 'kratos')), 500);
+ }
+
+ wp_send_json_success(array('style' => $style, 'cells' => $cells));
+}
+add_action('wp_ajax_yang_avatar_preview', 'yang_avatar_ajax_preview');
+
+/**
+ * 后台样式与脚本
+ *
+ * ── 为什么用 $_GET['page'] 判断而不是 $hook ──────────────────────
+ * 框架把真实的 hook 后缀存在 $this->options_screen 里(私有,
+ * 外面拿不到),对外的 add_menu_page 用的是 slug `kratos_options`。
+ * 想靠 $hook 猜就得写 `strpos($hook, 'options')` 这种模糊匹配——
+ * 一旦 WP 改了 hook 命名规则,匹配不上时 CSS/JS 会**静默不加载**,
+ * 表现就是「开关勾了没反应」,而且报错都没有。
+ *
+ * 直接比 page 参数:菜单 slug 是我们自己注册的常量,永远对得上。
+ *
+ * @param string $hook
+ */
+function yang_avatar_admin_assets($hook)
+{
+ if (!isset($_GET['page']) || $_GET['page'] !== 'kratos_options') {
+ return;
+ }
+
+ $css = '
+.of-avatar-styles{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px;margin:0 0 16px;padding:0}
+.of-avatar-style{display:flex;gap:11px;align-items:flex-start;padding:11px 12px;border:1px solid #dcdcde;border-radius:8px;background:#fff;cursor:pointer;transition:border-color .12s,box-shadow .12s}
+.of-avatar-style:hover{border-color:#8c8f94}
+.of-avatar-style.is-selected{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1}
+.of-avatar-style-radio{position:absolute;opacity:0;pointer-events:none}
+.of-avatar-style-thumb{flex:0 0 56px;line-height:0}
+.of-avatar-style-thumb img{border-radius:8px;display:block;background:#f6f7f7}
+.of-avatar-style-text{display:flex;flex-direction:column;gap:3px;min-width:0}
+.of-avatar-style-label{font-weight:600;font-size:13px;color:#1d2327}
+.of-avatar-style-desc{font-size:12px;line-height:1.5;color:#646970}
+.of-avatar-preview{margin-top:4px;padding:14px;border:1px solid #dcdcde;border-radius:8px;background:#f6f7f7}
+.of-avatar-preview-title{margin:0 0 10px;font-size:13px;font-weight:600;color:#1d2327}
+.of-avatar-preview-grid{display:flex;flex-wrap:wrap;gap:10px}
+.of-avatar-preview-cell{margin:0;text-align:center;width:64px}
+.of-avatar-preview-cell img{display:block;border-radius:8px;background:#fff}
+.of-avatar-preview-cell figcaption{font-size:10px;color:#646970;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.of-avatar-preview-note{margin:10px 0 0;font-size:12px;color:#646970}
+/* 开关没打开时把风格那一行藏起来 —— 用 body class 而不是 JS 内联样式,
+ 这样保存后页面重载也不会闪一下 */
+body.yang-avatar-off #section-a_auto_style{display:none}
+';
+ wp_register_style('yang-avatar-admin', false, array(), null);
+ wp_enqueue_style('yang-avatar-admin');
+ wp_add_inline_style('yang-avatar-admin', $css);
+
+ $js = <<<'JS'
+(function(){
+ var box = document.getElementById('a_auto_enable');
+ var section = document.getElementById('section-a_auto_style');
+ var prev = document.querySelector('.of-avatar-preview');
+ var grid = prev ? prev.querySelector('.of-avatar-preview-grid') : null;
+
+ // ── 开关控制风格列表显隐 ────────────────────────────────────
+ // 首帧的显隐由 PHP 打在 body class 上(不闪),这里只管"点了之后"。
+ function syncToggle(){
+ if (!box) return;
+ if (box.checked) {
+ document.body.classList.remove('yang-avatar-off');
+ } else {
+ document.body.classList.add('yang-avatar-off');
+ }
+ }
+ if (box) { box.addEventListener('change', syncToggle); syncToggle(); }
+
+ // ── 点选风格:切换高亮 + 拉取该风格的 16 格预览 ──────────────
+ var CFG = window.YANG_AVATAR_CFG || {};
+ var inflight = 0; // 请求序号,用来丢弃过期响应
+ var cache = {}; // 已取过的风格,回点不再打后端
+ var cur = prev ? prev.getAttribute('data-preview') : '';
+
+ // 首屏那份 PHP 渲染的格子直接进缓存。
+ // 不这么做的话,用户点了一圈回到默认风格,会白白再请求一次
+ // ——而屏幕上已经有一份一模一样的内容。
+ function seedCache(){
+ if (!prev || !grid || !cur || cache[cur]) return;
+ var cells = [];
+ Array.prototype.forEach.call(
+ grid.querySelectorAll('.of-avatar-preview-cell'),
+ function(fig){
+ var im = fig.querySelector('img');
+ var cp = fig.querySelector('figcaption');
+ if (!im) return;
+ cells.push({uri: im.getAttribute('src'), name: cp ? cp.textContent : ''});
+ }
+ );
+ if (cells.length) { cache[cur] = cells; }
+ }
+ seedCache();
+
+ function paint(cells){
+ if (!grid) return;
+ while (grid.firstChild) { grid.removeChild(grid.firstChild); }
+ cells.forEach(function(c){
+ var fig = document.createElement('figure');
+ fig.className = 'of-avatar-preview-cell';
+ var img = document.createElement('img');
+ img.src = c.uri;
+ img.alt = '';
+ img.width = 64;
+ img.height = 64;
+ var cap = document.createElement('figcaption');
+ cap.textContent = c.name;
+ fig.appendChild(img);
+ fig.appendChild(cap);
+ grid.appendChild(fig);
+ });
+ }
+
+ function load(style){
+ if (!grid || !CFG.ajax || !style) return;
+ if (cache[style]) { paint(cache[style]); return; }
+
+ // 连点时只认最后一次:先自增序号,回来时对不上就丢弃
+ var seq = ++inflight;
+ if (prev) { prev.classList.add('is-loading'); }
+
+ var body = new URLSearchParams();
+ body.append('action', 'yang_avatar_preview');
+ body.append('nonce', CFG.nonce);
+ body.append('style', style);
+
+ fetch(CFG.ajax, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
+ body: body.toString()
+ }).then(function(r){ return r.json(); }).then(function(res){
+ if (!res || !res.success || !res.data || !res.data.cells) return;
+ // 先入缓存:即使这次响应已过期(用户又点了别的),
+ // 下次点回来也能直接用,不必再打一次后端
+ cache[style] = res.data.cells;
+ if (seq !== inflight) return; // 过期响应,不重绘
+ if (prev) { prev.classList.remove('is-loading'); }
+ paint(res.data.cells);
+ }).catch(function(){
+ if (seq !== inflight) return;
+ if (prev) { prev.classList.remove('is-loading'); }
+ // 失败就保持原样 —— 首屏已经有 PHP 渲染的一份,不必报错打扰用户
+ });
+ }
+
+ function select(style){
+ document.querySelectorAll('.of-avatar-style').forEach(function(x){
+ x.classList.toggle('is-selected', x.getAttribute('data-style') === style);
+ });
+ if (prev) { prev.setAttribute('data-preview', style); }
+ cur = style;
+ load(style);
+ }
+
+ // ⚠️ 必须监听 radio 的 change,**不能**监听 label 的 click。
+ // label 的默认行为是"把点击转发给关联的 input",而在 input 内部
+ // 触发的那次 click 会继续冒泡回 label ⇒ label 的处理器被调用两次
+ // ⇒ 同一风格发两次请求(实测 hits 2→4)。
+ // change 只在选中状态真正变化时触发一次,天然去重;
+ // 点已选中的那张也不会触发(值没变),正好省掉一次无意义的重绘。
+ document.querySelectorAll('.of-avatar-style-radio').forEach(function(r){
+ r.addEventListener('change', function(){
+ if (r.checked) { select(r.value); }
+ });
+ });
+
+ // 键盘可达性:radio 被视觉隐藏,但焦点仍在它上面。
+ // 方向键切换时浏览器自己会触发 change,上面已覆盖。
+ window.yangAvatarSelect = select;
+})();
+JS;
+ wp_register_script('yang-avatar-admin', '', array(), null, true);
+ wp_enqueue_script('yang-avatar-admin');
+ wp_add_inline_script('yang-avatar-admin', $js);
+
+ // 把 ajax 地址与 nonce 交给上面的脚本(不能内联进 heredoc,会破坏 <<<'JS')
+ wp_localize_script('yang-avatar-admin', 'YANG_AVATAR_CFG', array(
+ 'ajax' => admin_url('admin-ajax.php'),
+ 'nonce' => wp_create_nonce('yang_avatar_preview'),
+ ));
+}
+add_action('admin_enqueue_scripts', 'yang_avatar_admin_assets');
\ No newline at end of file
diff --git a/inc/theme-options.php b/inc/theme-options.php
index 8ae932c..a302d95 100644
--- a/inc/theme-options.php
+++ b/inc/theme-options.php
@@ -229,12 +229,44 @@ function kratos_options()
$options[] = array(
'name' => __('Gravatar 加速', 'kratos'),
- 'desc' => __('把评论头像的请求转发到国内可访问的镜像,改善加载速度', 'kratos'),
+ 'desc' => __('把评论头像的请求转发到国内可访问的镜像,改善加载速度。若开启了下面的「自动头像」,未上传头像的访客不再需要这张图。', 'kratos'),
'std' => '0',
'id' => 'g_gravatar',
'type' => 'checkbox',
);
+ /* ── 自动头像 ────────────────────────────────────────────────
+ *
+ * 总开关与风格选择拆成两个字段,但**风格列表只在开关打开后才显示**。
+ * 这是用户明确要求的形式:先勾选,再出现列表。
+ *
+ * 为什么不是一个字段直接存风格名(留空=关闭):
+ * 用户「关闭功能」和「还没选过风格」在库里会变成同一种状态(都空),
+ * 以后想恢复默认值时就分不清该填什么。两个字段各管一件事更清楚。
+ *
+ * ⚠️ std 只在后台预填和「恢复默认」时起作用,运行时 kratos_option()
+ * 完全不看它。所以运行时默认值必须在这里同步给一份:
+ * 开关默认关('0'),风格默认 lorelei(见 yang_avatar_style())。
+ */
+ $options[] = array(
+ 'name' => __('自动头像', 'kratos'),
+ 'desc' => __('访客没注册、也没上传头像时,按他的邮箱生成一张头像。