* @license MIT License */ if (!defined('ABSPATH')) { exit; } /* ══════════════════════════════════════════════════════════════════ * UTF-8 工具:**全部不依赖 mbstring** * * ── 为什么不能用 mb_* ──────────────────────────────────────────── * mbstring 是 PHP 的**可选扩展**。线上这台机器装了,但主题是要发给 * 别人装的 —— 谁也不能保证对方的虚拟主机开了 mbstring。一旦没开, * `mb_substr()` 就是"调用未定义函数",PHP 7 里直接 Fatal error, * **整个站点白屏**,而报错指向头像模块,跟"没装扩展"看起来毫无关系。 * * 所以要用的三件事都自己实现: * · 按码点切开字符串(utf8_split) * · 取前 N 个字符(utf8_substr) * · 转大写(utf8_upper,只处理 Latin-1 + 常见希腊/西里尔) * * 转大写本来最麻烦(Unicode 有大写映射表),但这里只用在「首字母」 * 上,输入是 `\p{L}` 单个字符。真实场景里 99% 是 ASCII 或中文: * 中文没有大小写,原样返回即可;ASCII 走 strtoupper。其余语种按 * 原样返回 —— 首字母不大写不影响正确性,只是观感略差, * 远比"白屏"可接受。 * ══════════════════════════════════════════════════════════════════ */ /** * 单个 UTF-8 字符 → 码点(纯字节运算) * * @param string $ch 一个完整 UTF-8 字符 * @return int|null 非法序列返回 null */ function yang_avatar_cp($ch) { $n = strlen($ch); if ($n === 0) { return null; } $b0 = ord($ch[0]); if ($n === 1) { // 纯 ASCII if ($b0 < 0x80) { return $b0; } return null; } if ($n === 2) { $b1 = ord($ch[1]); if (($b0 & 0xE0) !== 0xC0 || ($b1 & 0xC0) !== 0x80) { return null; } return (($b0 & 0x1F) << 6) | ($b1 & 0x3F); } if ($n === 3) { $b1 = ord($ch[1]); $b2 = ord($ch[2]); if (($b0 & 0xF0) !== 0xE0 || ($b1 & 0xC0) !== 0x80 || ($b2 & 0xC0) !== 0x80) { return null; } return (($b0 & 0x0F) << 12) | (($b1 & 0x3F) << 6) | ($b2 & 0x3F); } if ($n === 4) { $b1 = ord($ch[1]); $b2 = ord($ch[2]); $b3 = ord($ch[3]); if (($b0 & 0xF8) !== 0xF0 || ($b1 & 0xC0) !== 0x80 || ($b2 & 0xC0) !== 0x80 || ($b3 & 0xC0) !== 0x80) { return null; } return (($b0 & 0x07) << 18) | (($b1 & 0x3F) << 12) | (($b2 & 0x3F) << 6) | ($b3 & 0x3F); } return null; } /** * UTF-8 字符串 → 字符数组(每个元素是一个完整字符) * * 遇到非法字节时**逐字节跳过并记一个 U+FFFD**,不整串放弃 —— * 邮箱理论上都是 ASCII,但注释作者名/昵称可能是乱码, * 那时应该画个替代符,而不是让整张头像渲染失败。 * * @param string $s * @return string[] */ function yang_avatar_utf8_split($s) { $out = array(); $len = strlen($s); $i = 0; while ($i < $len) { $b = ord($s[$i]); if ($b < 0x80) { $w = 1; } elseif (($b & 0xE0) === 0xC0) { $w = 2; } elseif (($b & 0xF0) === 0xE0) { $w = 3; } elseif (($b & 0xF8) === 0xF0) { $w = 4; } else { // 非法起始字节 $out[] = "\xEF\xBF\xBD"; $i++; continue; } if ($i + $w > $len) { // 结尾被截断 $out[] = "\xEF\xBF\xBD"; break; } $ch = substr($s, $i, $w); if (yang_avatar_cp($ch) === null) { $out[] = "\xEF\xBF\xBD"; $i++; continue; } $out[] = $ch; $i += $w; } return $out; } /** * 取前 $n 个字符(替代 mb_substr($s, 0, $n, 'UTF-8')) * * @param string $s * @param int $n * @return string */ function yang_avatar_utf8_sub($s, $n) { $n = (int) $n; if ($n <= 0) { return ''; } // ASCII 快路径:绝大多数种子(邮箱)都走这里 if (!preg_match('/[\x80-\xFF]/', $s)) { return strlen($s) <= $n ? $s : substr($s, 0, $n); } $chars = yang_avatar_utf8_split($s); if (count($chars) <= $n) { return $s; } return implode('', array_slice($chars, 0, $n)); } /** * 转大写(只保证 ASCII 与 Latin-1 补集,其余原样返回) * * @param string $s * @return string */ function yang_avatar_utf8_upper($s) { if ($s === '') { return ''; } if (!preg_match('/[\x80-\xFF]/', $s)) { return strtoupper($s); } // 带重音的拉丁字母:逐个码点处理,非 ASCII 的原样保留 $chars = yang_avatar_utf8_split($s); $out = ''; foreach ($chars as $ch) { $cp = yang_avatar_cp($ch); if ($cp === null || $cp > 0x7F) { $out .= $ch; } else { $out .= strtoupper($ch); } } return $out; } /** * UTF-8 字符串 → UTF-16 码元数组(JS charCodeAt 的语义) * * @param string $s * @return int[] */ function yang_avatar_utf16_units($s) { $out = array(); foreach (yang_avatar_utf8_split($s) as $ch) { $cp = yang_avatar_cp($ch); if ($cp === null) { $out[] = 0xFFFD; continue; } if ($cp <= 0xFFFF) { // 单个"代理区"码元直接照搬(含 U+D800..U+DFFF 这种非法单值) $out[] = $cp; } else { $cp -= 0x10000; $out[] = 0xD800 + ($cp >> 10); $out[] = 0xDC00 + ($cp & 0x3FF); } } return $out; } /** * 与 DiceBear 9.x 完全一致的 PRNG * * 对照 @dicebear/core/lib/utils/prng.js: * const MIN = -2147483648, MAX = 2147483647; * function xorshift(value) { * value ^= value << 13; value ^= value >> 17; value ^= value << 5; * return value; * } * function hashSeed(seed) { * let hash = 0; * for (let i = 0; i < seed.length; i++) { * hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0; * hash = xorshift(hash); * } * return hash; * } * * 三处必须小心的地方: * ① `<<` `>>` `| 0` 都是 32 位有符号运算,PHP 里要逐步 i32() 归一; * ② `>>` 是算术右移(保住符号位),PHP 的 >> 对负数行为一致, * 但前提是先把值收紧到 int32; * ③ charCodeAt 取的是 **UTF-16 码元**,不是 UTF-8 字节也不是码点。 * emoji 这类增补平面字符在 JS 里占两个码元(代理对), * 必须真的拆分,否则哈希完全不同。 */ class Yang_Avatar_Prng { const MIN = -2147483648; const MAX = 2147483647; /** @var int 当前状态 */ private $value; /** * 截断成 JS 的 int32(有符号) * * @param int $v * @return int */ private static function i32($v) { $v = $v & 0xFFFFFFFF; if ($v >= 0x80000000) { $v -= 0x100000000; } return $v; } /** * JS 的 `value << n`(32 位有符号左移) * * @param int $v * @param int $n * @return int */ private static function shl($v, $n) { return self::i32(self::i32($v) << $n); } /** * JS 的 `value >> n`(32 位有符号算术右移) * * @param int $v * @param int $n * @return int */ private static function shr($v, $n) { return self::i32(self::i32($v) >> $n); } /** * 与 JS 逐位一致的 xorshift * * @param int $value * @return int */ private static function xorshift($value) { $value = self::i32($value); $value = self::i32($value ^ self::shl($value, 13)); $value = self::i32($value ^ self::shr($value, 17)); $value = self::i32($value ^ self::shl($value, 5)); return $value; } /** * UTF-8 字符串 → UTF-16 码元数组 * * 不能直接用码点:JS 的 charCodeAt 按 UTF-16 码元取值, * U+1F389 这种在 JS 里是 0xD83C 0xDF89 两个码元。 * * 直接走字节,不用 mb_* —— 见文件头的「为什么不用 mbstring」。 * * @param string $s * @return int[] */ private static function utf16_code_units($s) { return yang_avatar_utf16_units($s); } /** * 单个 UTF-8 字符 → Unicode 码点 * * 不用 mb_ord():它是 PHP 7.2 才有的函数,而主题基线是 7.0。 * 实现见 yang_avatar_cp(),那里带静态缓存。 * * @param string $ch * @return int|null */ private static function code_point($ch) { static $cache = array(); if (array_key_exists($ch, $cache)) { return $cache[$ch]; } $v = yang_avatar_cp($ch); $cache[$ch] = $v; return $v; } /** * 与 JS 逐位一致的 hashSeed * * @param string $seed * @return int */ private static function hash_seed($seed) { $hash = 0; $units = self::utf16_code_units($seed); $n = count($units); for ($i = 0; $i < $n; $i++) { $hash = self::i32(self::shl($hash, 5) - $hash + $units[$i]); $hash = self::xorshift($hash); } return $hash; } /** * @param string $seed */ public function __construct($seed = '') { // JS: seed.toString().slice(0, 1024) —— 防止超长种子拖垮 CPU $seed = yang_avatar_utf8_sub((string) $seed, 1024); $h = self::hash_seed($seed); // hashSeed 可能返回 0,此时退化成 1(否则 xorshift 永远停在 0) $this->value = $h !== 0 ? $h : 1; } /** * @return int */ public function next() { $this->value = self::xorshift($this->value); return $this->value; } /** * 闭区间 [min, max] 取整 * * @param int $min * @param int $max * @return int */ public function integer($min, $max) { $v = $this->next(); $ratio = ($v - self::MIN) / (self::MAX - self::MIN); return (int) floor($ratio * ($max + 1 - $min) + $min); } /** * @param int $likelihood 百分比 * @return bool */ public function bool($likelihood = 50) { return $this->integer(1, 100) <= $likelihood; } /** * 从数组里取一个;空数组时**仍然消耗一次** next(),保证调用次数一致 * * @param array $arr * @param mixed $fallback * @return mixed */ public function pick($arr, $fallback = null) { if (!is_array($arr) || count($arr) === 0) { $this->next(); return $fallback; } return $arr[$this->integer(0, count($arr) - 1)]; } /** * 与 DiceBear 一致的 Fisher-Yates 洗牌 * * 关键:**只消耗本实例一次 next()**,其余随机数来自一个子 PRNG * (种子 = 那次 next() 结果的十进制字符串)。若这里多消耗或少消耗, * 后面 backgroundRotation 取到的值就歪了,整张图的背景色也会错。 * * @param array $arr * @return array */ public function shuffle($arr) { $child = new self((string) $this->next()); $n = count($arr); $work = array_values($arr); for ($i = $n - 1; $i > 0; $i--) { $j = $child->integer(0, $i); $tmp = $work[$i]; $work[$i] = $work[$j]; $work[$j] = $tmp; } return $work; } } /** * 收录的风格清单 * * 只收 CC0 1.0 与 MIT。CC BY 4.0 那些(adventurer / avataaars / bottts / * croodles / micah / toon-head 等)虽然更好看,但要求保留署名 —— * 主题是给用户装的,用户不会去维护署名,等于把合规义务转嫁给用户。 * * 'label' 用中文,因为后台是给中文站长看的。 * 'size' 是模板文件体积,用于后台提示「这套会占多少」。 * * @return array */ function yang_avatar_styles() { return array( 'lorelei' => array( 'label' => __('柔和卡通', 'kratos'), 'desc' => __('五官柔和,男女都有,头发变化多。最接近"真人头像"的一套。', 'kratos'), ), 'notionists' => array( 'label' => __('简笔线稿', 'kratos'), 'desc' => __('Notion 那种极简线稿。纯黑白,不挑主题配色。', 'kratos'), ), 'notionists-neutral' => array( 'label' => __('简笔线稿(带底色)', 'kratos'), 'desc' => __('跟上一套同一画风,但带一块背景色,更醒目一点。', 'kratos'), ), 'open-peeps' => array( 'label' => __('手绘速写', 'kratos'), 'desc' => __('手绘感强,表情夸张,很有个性。适合偏文艺的博客。', 'kratos'), ), 'lorelei-neutral' => array( 'label' => __('柔和卡通(带底色)', 'kratos'), 'desc' => __('同一套画风,带背景色块。', 'kratos'), ), 'shapes' => array( 'label' => __('几何色块', 'kratos'), 'desc' => __('随机几何图形叠加,不涉及人脸,永远不会"崩脸"。', 'kratos'), ), 'glass' => array( 'label' => __('毛玻璃', 'kratos'), 'desc' => __('模糊叠加的字母玻璃效果,设计感强。颜色比较鲜艳。', 'kratos'), ), 'rings' => array( 'label' => __('同心圆环', 'kratos'), 'desc' => __('极简同心圆,最不打扰视觉的一套。', 'kratos'), ), 'identicon' => array( 'label' => __('对称方块', 'kratos'), 'desc' => __('GitHub 那种 5x5 对称色块,程序员风格。', 'kratos'), ), 'thumbs' => array( 'label' => __('拇指', 'kratos'), 'desc' => __('只有"拇指"一个形象,靠表情与配色区分。', 'kratos'), ), 'pixel-art' => array( 'label' => __('像素风', 'kratos'), 'desc' => __('8-bit 像素人像。风格强烈,适合游戏/技术类博客。', 'kratos'), ), 'pixel-art-neutral' => array( 'label' => __('像素风(带底色)', 'kratos'), 'desc' => __('同一套像素画风,带背景色块。', 'kratos'), ), 'icons' => array( 'label' => __('图标', 'kratos'), 'desc' => __('抽象图标剪影,很干净。', 'kratos'), ), 'initials' => array( 'label' => __('首字母', 'kratos'), 'desc' => __('显示邮箱对应的首字母文字。若访客用中文邮箱会显示拼音首字母。', 'kratos'), ), 'avataaars' => array( 'label' => __('美式卡通', 'kratos'), 'desc' => __('美式插画风,表情与发型最丰富的一套。不画背景,头像直接压在页面上。', 'kratos'), ), 'avataaars-neutral' => array( 'label' => __('美式卡通(带底色)', 'kratos'), 'desc' => __('同一套画风,带一块背景色,更醒目一点。', 'kratos'), ), 'bottts' => array( 'label' => __('机器人', 'kratos'), 'desc' => __('机器脑袋,靠零件与配色区分,没有人脸所以不会"崩脸"。不画背景。', 'kratos'), ), 'bottts-neutral' => array( 'label' => __('机器人(带底色)', 'kratos'), 'desc' => __('同一套机器人画风,带背景色块。', 'kratos'), ), ); } /** * 当前是否启用了自动头像 * * @return bool */ function yang_avatar_active() { $on = kratos_option('a_auto_enable', '0'); return $on === '1' || $on === 1 || $on === true; } /** * 当前选中的风格名(已校验,非法值回落到默认风格) * * @return string */ function yang_avatar_style() { static $cached = null; if ($cached !== null) { return $cached; } $all = yang_avatar_styles(); $want = kratos_option('a_auto_style', 'lorelei'); if (is_string($want) && isset($all[$want])) { $cached = $want; } else { $cached = 'lorelei'; } return $cached; } /** * 加载某套风格的编译后模板 * * 只 require 用到的那一份,其余不解析 —— 18 套全量 1.2MB, * 每次请求都加载是不必要的开销。 * * @param string $style * @return array|null */ function yang_avatar_load_style($style) { static $loaded = array(); if (isset($loaded[$style])) { return $loaded[$style]; } $all = yang_avatar_styles(); if (!isset($all[$style])) { $loaded[$style] = null; return null; } // 风格名 → 文件名:- 换成 _ $file = str_replace('-', '_', $style); $path = get_template_directory() . '/inc/avatar-styles/' . $file . '.php'; if (!file_exists($path)) { $loaded[$style] = null; return null; } $data = require $path; $loaded[$style] = is_array($data) ? $data : null; return $loaded[$style]; } /** * 人类调色板 * * ── 为什么必须自带 ──────────────────────────────────────────────── * DiceBear 的默认调色板几乎全是单值:hairColor / eyesColor / mouthColor * 都只有 '000000',skinColor 只有 'ffffff'。实测不换调色板的话, * 30 个不同邮箱只会产生 2 种配色 —— 这才是"默认头像丑"的真正原因, * 光换风格解决不了。 * * 这里的键名对应各风格 schema 里的颜色字段;风格没有的键会被忽略。 * * @return array */ function yang_avatar_palette() { return array( // 背景色:低饱和,跟大多数主题都能搭 'backgroundColor' => array( '8ecae6', '219ebc', 'ffb703', 'fb8500', '90be6d', '43aa8b', 'f8961e', 'f9c74f', '4d908e', '577590', 'f94144', 'f3722c', 'a8dadc', '457b9d', ), // 肤色:从浅到深,覆盖常见人种 'skinColor' => array( 'f2d3b8', 'e8b98f', 'd9a066', 'c68642', '8d5524', 'ffdbac', 'f1c27d', ), // 发色 'hairColor' => array( '2c1b18', '4a312c', '6a4e42', 'a55728', 'b89778', 'd6b370', 'c93305', '1a1a1a', '5a3825', 'e6cea8', ), // 衣服色 'clothesColor' => array( '2f4858', '33658a', '86bbd8', 'f6ae2d', 'f26419', '758e4f', '5d2e46', '1b4079', '8f2d56', '3d315b', ), ); } /** * 把调色板映射到某套风格实际拥有的颜色字段 * * 不同风格用的是不同的键名(skinColor / baseColor、clothesColor / * shirtColor / bodyColor …)。这里只给风格**真实存在**的字段赋值, * 不存在的键不能塞进 options,否则会打乱 pick 的取值。 * * @param array $style 模板数据 * @return array 要叠加到 options 上的键值 */ function yang_avatar_style_palette($style) { $pal = yang_avatar_palette(); $props = isset($style['defaults']) && is_array($style['defaults']) ? $style['defaults'] : array(); // 字段名 → 调色板类别 $map = array( 'backgroundColor' => 'backgroundColor', 'skinColor' => 'skinColor', 'baseColor' => 'skinColor', 'hairColor' => 'hairColor', 'topColor' => 'hairColor', 'beardColor' => 'hairColor', 'facialHairColor' => 'hairColor', 'eyebrowsColor' => 'hairColor', 'clothesColor' => 'clothesColor', 'shirtColor' => 'clothesColor', 'bodyColor' => 'clothesColor', 'clothingColor' => 'clothesColor', ); $out = array(); foreach ($map as $field => $kind) { // 只处理风格真实声明过的字段 if (array_key_exists($field, $props) && isset($pal[$kind])) { $out[$field] = $pal[$kind]; } } return $out; } /** * 渲染一张头像 * * @param string $seed 种子(用邮箱即可;空则回落到站点地址) * @param string $style 风格名,空则用后台选中的 * @param int $size 像素边长 * @return string SVG 源码;失败时返回空串 */ function yang_avatar_svg($seed, $style = '', $size = 96) { if ($style === '') { $style = yang_avatar_style(); } $data = yang_avatar_load_style($style); if ($data === null) { return ''; } $seed = trim((string) $seed); if ($seed === '') { // 没有邮箱时不能返回空 —— 那就所有匿名访客共用一张脸, // 用站点地址兜底,至少是稳定的一张。 $seed = home_url(); } $size = (int) $size; if ($size <= 0) { $size = 96; } $palette = yang_avatar_style_palette($data); return yang_avatar_render($data, $seed, $size, $palette); } /** * 供后台预览用的 data URI * * @param string $seed * @param string $style * @param int $size * @return string */ function yang_avatar_data_uri($seed, $style = '', $size = 96) { $svg = yang_avatar_svg($seed, $style, $size); if ($svg === '') { return ''; } return 'data:image/svg+xml;base64,' . base64_encode($svg); } /** * 接 get_avatar 过滤器 * * ── 判据:只动「原本指向 Gravatar 默认图」的标签 ────────────────── * WP 的 get_avatar() 从不去查 Gravatar 到底有没有这张图 —— 它只是 * 拼一个 `https:///avatar/?s=48&d=mm&r=g` 交给浏览器, * 由 Gravatar 服务端在加载时决定返回真人头像还是默认图。 * * 也就是说:**服务端离线状态下,无法区分「这个邮箱有 Gravatar」和 * 「没有、会退回默认图」**。任何声称能精确判断的写法(比如去看 * 响应状态码)都要发一次外网请求,而 gravatar.com 在国内全部超时, * 这条路根本走不通。 * * 所以这里退一步,用「src 指向谁」做判据: * · src 是 data: → 说明别的插件/主题已经给过头像了,放行; * · src 主机属于 Gravatar 家族 → 替换成生成图; * · 其它(媒体库图片、外链头像)→ 放行。 * * 代价:如果站长开了「Gravatar 加速」镜像、并且访客确实有 Gravatar * 头像,这里也会替换掉。这是有意的取舍 —— 相比"偶尔盖掉一个真实 * 头像","多数访客是一张破图/灰头像"更伤。选项说明里写清了这一点。 * * ── 为什么不再重新拼整个 ──────────────────────────────────── * 早先的实现直接用 $args 里的 class / extra_attr 重新拼一个标签。 * 这有两个坑: * ① get_avatar() 输出了 `srcset`(2x/3x)。只改 src、留着 srcset, * 浏览器会**优先用 srcset**,我们生成的图根本不会被加载 —— * 现象是"代码明明换了,页面上还是老样子"。 * ② `decoding="async"`、`loading="lazy"`、`fetchpriority` 这类 * 较新 WP 版本才输出的属性,$args 里并不完整。 * 改成在原字符串上做定点替换:删掉 srcset/sizes,只换 src, * 其余属性一律原样保留。 * * @param string $avatar 已经拼好的 标签 * @param mixed $id_or_email * @param int $size * @param string $default * @param string $alt * @param array $args * @return string */ function yang_avatar_filter($avatar, $id_or_email, $size, $default, $alt, $args = array()) { if (!yang_avatar_active()) { return $avatar; } // 不是 就没有可换的地方(get_avatar 也可能是评论里的一段 HTML) if (!is_string($avatar) || stripos($avatar, ' 256) { $px = 256; } $svg = yang_avatar_svg($email, '', $px); if ($svg === '') { return $avatar; } $uri = 'data:image/svg+xml;base64,' . base64_encode($svg); // ① 删 srcset:不删的话浏览器优先用 srcset,替换等于没做 $out = preg_replace('/\s+srcset\s*=\s*(["\']).*?\1/isu', '', $avatar, 1); if (!is_string($out)) { $out = $avatar; } // ② 删 sizes:srcset 去了之后 sizes 已无意义,留着是脏属性 $out = preg_replace('/\s+sizes\s*=\s*(["\']).*?\1/isu', '', $out, 1); if (!is_string($out)) { $out = $avatar; } // ③ 只换 src $out = preg_replace( '/\ssrc\s*=\s*(["\'])[^"\']*\1/isu', ' src="' . esc_attr($uri) . '"', $out, 1 ); if (!is_string($out) || $out === '') { return $avatar; } return $out; } add_filter('get_avatar', 'yang_avatar_filter', 20, 6); /** * 判断一个头像 URL 是不是 Gravatar 家族的 * * 除了 gravatar.com 自身,还要认这些: * · gravatar.loli.net —— 本主题「Gravatar 加速」用的镜像 * · secure.gravatar.com / cn.gravatar.com / 0.gravatar.com 等子域 * · i0.wp.com / i1.wp.com / i2.wp.com —— Jetpack 的头像 CDN 代理 * * 一律按主机名后缀判断,不做整串 contains: * 否则 `https://evil.test/?x=gravatar.com` 这种也会被当成 Gravatar。 * * @param string $url * @return bool */ function yang_avatar_is_gravatar_url($url) { $host = parse_url($url, PHP_URL_HOST); if (!is_string($host) || $host === '') { return false; } $host = strtolower($host); $suffixes = array( '.gravatar.com', 'gravatar.com', '.loli.net', 'loli.net', '.wp.com', '.gravatar.com.cn', '.geekzu.org', '.233v4.com', '.nihao.eu.org', ); foreach ($suffixes as $sfx) { $len = strlen($sfx); if (strlen($host) >= $len && substr($host, -$len) === $sfx) { return true; } } return false; } /** * 从 get_avatar 的各种入参里取出邮箱 * * $id_or_email 可能是:用户 ID、WP_User、WP_Comment、WP_Post、邮箱字符串。 * * @param mixed $id_or_email * @return string */ function yang_avatar_email_of($id_or_email) { if (is_string($id_or_email)) { // 已经是邮箱 if (strpos($id_or_email, '@') !== false) { return trim($id_or_email); } return ''; } if (is_object($id_or_email)) { // WP_Comment / WP_User / WP_Post if (isset($id_or_email->comment_author_email)) { return trim((string) $id_or_email->comment_author_email); } if (isset($id_or_email->user_email)) { return trim((string) $id_or_email->user_email); } if (isset($id_or_email->post_author)) { $u = get_userdata((int) $id_or_email->post_author); if ($u && !empty($u->user_email)) { return trim((string) $u->user_email); } } if (isset($id_or_email->ID)) { $u = get_userdata((int) $id_or_email->ID); if ($u && !empty($u->user_email)) { return trim((string) $u->user_email); } } return ''; } if (is_int($id_or_email)) { $u = get_userdata($id_or_email); if ($u && !empty($u->user_email)) { return trim((string) $u->user_email); } } return ''; } /* ══════════════════════════════════════════════════════════════════ * 渲染器本体 * ══════════════════════════════════════════════════════════════════ */ /** * 渲染一张 SVG * * 必须严格复刻 @dicebear/core 里 createAvatar() 的**执行顺序**, * 因为 PRNG 是有状态的: * 1. style.create() 内部: * onPreCreate() → getComponents() → getColors() → onPostCreate() * 2. body = create() 返回的 body * 3. 然后 core 才依次做: * backgroundType = prng.pick(options.backgroundType, 'solid') * primary/secondary = getBackgroundColors(prng, options.backgroundColor, bgType) * backgroundRotation = prng.integer(min, max) * background 层 → clip mask * 顺序错一个,后面全歪。 * * @param array $style 模板数据 * @param string $seed * @param int $size * @param array $palette 叠加到 options 上的颜色 * @return string */ function yang_avatar_render($style, $seed, $size = 96, $palette = array()) { $d = $style['defaults']; foreach ($palette as $k => $v) { $d[$k] = $v; } $p = new Yang_Avatar_Prng($seed); /* ── 0. onPreCreate(会动态改 options,必须在一切之前) ── */ if (!empty($style['has_pre_create'])) { $d = yang_avatar_pre_create($style['name'], $p, $d); } /* ── 1. getComponents ── 两种语义并存: a) 普通 prng.pick(values) —— 只消耗 1 次 b) pickComponent(...) —— 额外取 rotation/offsetX/offsetY 三次整数 但**只有** glass / rings / shapes / thumbs 的 pickComponent 会取整数; 其余风格虽然也调 pickComponent,却是简化版(只 pick)。 若对它们多取三次整数,PRNG 序列会多走 3N 步、整张图全变 (实测 31 种里 27 种会挂)。编译期已用 pc_has_ints 标好。 */ $pcMap = array(); if (!empty($style['pc_meta']) && is_array($style['pc_meta'])) { foreach ($style['pc_meta'] as $m) { if ($m['group'] !== '') { $pcMap[$m['group']] = $m; } } } $useInts = !empty($style['pc_has_ints']); $picked = array(); $meta = array(); foreach ($style['pick_order'] as $g) { $vkey = (isset($pcMap[$g]) && $pcMap[$g]['values_opt'] !== '') ? $pcMap[$g]['values_opt'] : $g; $vals = (isset($d[$vkey]) && is_array($d[$vkey])) ? $d[$vkey] : array(); $picked[$g] = $p->pick($vals); if ($useInts && isset($pcMap[$g])) { $m = $pcMap[$g]; $meta[$g] = array( 'rotation' => yang_avatar_int_range($p, $m['rotation'], $d), 'offsetX' => yang_avatar_int_range($p, $m['offsetX'], $d), 'offsetY' => yang_avatar_int_range($p, $m['offsetY'], $d), 'width' => $m['width'], 'height' => $m['height'], ); } } $on = array(); foreach ($style['bool_order'] as $g) { $pk = $g . 'Probability'; $prob = isset($d[$pk]) ? $d[$pk] : 50; $on[$g] = $p->bool($prob); } /* ── 2. getColors ── */ $colors = array(); foreach ($style['color_order'] as $ck => $sk) { $vals = (isset($d[$sk]) && is_array($d[$sk])) ? $d[$sk] : array(); $colors[$ck] = yang_avatar_color($p->pick($vals, 'transparent')); } /* ── 3. onPostCreate(会改 colors / options.backgroundColor / 组件开关) ── 必须把 $picked 传进去:官方这里读的是 components.style.name 与 components.top.name。 */ if (!empty($style['has_post_create'])) { $res = yang_avatar_post_create($style['name'], $d, $colors, $on, $picked); $d = $res[0]; $colors = $res[1]; $on = $res[2]; } /* ── 4. 展开 body ── */ $subs = array(); foreach ($style['groups'] as $g => $bag) { if (in_array($g, $style['bool_order'], true) && empty($on[$g])) { $subs[$g] = null; } else { $subs[$g] = isset($picked[$g]) ? $picked[$g] : null; } } if (!empty($style['is_initials'])) { $body = yang_avatar_initials_body($d, $seed, $p); } else { $body = yang_avatar_expand($style, $subs, $colors, $meta); } /* ── 5. core 的背景(顺序不能提前!) ── */ $bgTypeArr = (isset($d['backgroundType']) && is_array($d['backgroundType'])) ? $d['backgroundType'] : $style['bg_type']; $bgType = $p->pick($bgTypeArr, 'solid'); $bgColors = (isset($d['backgroundColor']) && is_array($d['backgroundColor'])) ? $d['backgroundColor'] : $style['bg_colors']; $bg = yang_avatar_bg_colors($p, $bgColors, $bgType); $rotArr = (isset($d['backgroundRotation']) && is_array($d['backgroundRotation'])) ? $d['backgroundRotation'] : $style['bg_rotation']; $rotMin = count($rotArr) ? min($rotArr) : 0; $rotMax = count($rotArr) ? max($rotArr) : 0; $rotation = $p->integer($rotMin, $rotMax); /* ── 6. attributes(逐字保留,按编译期顺序) ── initials 完全没有 fill / shape-rendering; identicon / pixel-art / rings 是 crispEdges 而不是 auto。 */ $attrs = array(); foreach ($style['attr_pairs'] as $pair) { $attrs[$pair[0]] = $pair[1]; } if ($size > 0) { $attrs['width'] = (string) $size; $attrs['height'] = (string) $size; } /* ── 7. 背景层 ── */ if ($bg['primary'] !== 'transparent' && $bg['secondary'] !== 'transparent') { $body = yang_avatar_add_bg($body, $attrs, $bg['primary'], $bg['secondary'], $bgType, $rotation); } /* ── 8. clip(core schema 默认 true)⇒ 恒定包 mask ── */ if (!empty($style['clip_default'])) { $vb = explode(' ', $attrs['viewBox']); $body = '' . '' . '' . '' . $body . ''; } /* ── 9. 拼 SVG ── */ $attrStr = 'xmlns="http://www.w3.org/2000/svg"'; foreach ($attrs as $k => $v) { $attrStr .= ' ' . yang_avatar_esc($k) . '="' . yang_avatar_esc($v) . '"'; } return '' . yang_avatar_meta($style['meta']) . $body . ''; } /** * pickComponent 里的一次 integer 取值 * * 语义:options[$spec['opt']] 存在且非空 ⇒ 用它;否则用 $spec['fallback']。 * * @param Yang_Avatar_Prng $p * @param array $spec * @param array $d * @return int */ function yang_avatar_int_range($p, $spec, $d) { $arr = null; if (!empty($spec['opt']) && isset($d[$spec['opt']]) && is_array($d[$spec['opt']]) && count($d[$spec['opt']]) > 0) { $arr = $d[$spec['opt']]; } else { $arr = $spec['fallback']; } if (!is_array($arr) || count($arr) === 0) { $arr = array(0); } $nums = array(); foreach ($arr as $v) { $nums[] = (int) $v; } return $p->integer(min($nums), max($nums)); } /** * onPreCreate:按风格名精确复刻 * * 这些是**代码**(会动态改 options),不能当数据编译。 * 收录的 18 套里只有 shapes 有,且它会消耗 3 次 pick。 * * @param string $name * @param Yang_Avatar_Prng $p * @param array $d * @return array */ function yang_avatar_pre_create($name, $p, $d) { if ($name === 'shapes') { // 见 @dicebear/shapes/lib/hooks/onPreCreate.js: // 三种形状各取一个"还没用过的颜色",并把用过的颜色从背景里剔掉。 $used = array(); foreach (array('shape1Color', 'shape2Color', 'shape3Color') as $key) { $colors = isset($d[$key]) && is_array($d[$key]) ? $d[$key] : array(); $avail = array(); foreach ($colors as $c) { if (!in_array($c, $used, true)) { $avail[] = $c; } } if (count($avail) === 0) { $avail = $colors; } $c = $p->pick($avail, 'transparent'); $used[] = $c; $d[$key] = array($c); } $bg = isset($d['backgroundColor']) && is_array($d['backgroundColor']) ? $d['backgroundColor'] : array(); $avail = array(); foreach ($bg as $c) { if (!in_array($c, $used, true)) { $avail[] = $c; } } $d['backgroundColor'] = count($avail) > 0 ? $avail : $bg; } return $d; } /** * onPostCreate:按风格名精确复刻 * * @param string $name * @param array $d * @param array $colors * @param array $on * @param array $picked getComponents 已抽到的组件名(官方 onPostCreate 读的是 * components.style.name / components.top.name,必须传进来) * @return array array($d, $colors, $on) */ function yang_avatar_post_create($name, $d, $colors, $on, $picked = array()) { if ($name === 'avataaars') { /* 官方 onPostCreate 的三条,逐条复刻(顺序也要一样): ① style 组取到 'circle' ⇒ 背景整个清空; ② style 不是 'circle'、但背景**恰好只有 65c9ff 这一项** ⇒ 也清空。 65c9ff 是这款的默认背景色,意思是「用户没主动选过背景」, 留着会在脸后面糊一块天蓝,看着像没去底。 ⚠️ 注意这里判的是「只有一个、且等于 65c9ff」,不是「包含 65c9ff」: 若用户给了多个颜色,就说明是主动选的,要留着。 ③ 戴头巾(top = hijab)且没强制 100% 胡子 ⇒ 去掉胡子 (头巾配胡子现实中不存在)。 清空 backgroundColor 不是「不画背景」那么简单:下游 yang_avatar_bg_colors() 对空数组会走 shuffle + next() 消耗 PRNG, 与官方 getBackgroundColors() 的空数组分支步数一致 —— 两边都恰好消耗一次。 */ $styleName = isset($picked['style']) ? $picked['style'] : ''; if ($styleName === 'circle') { $d['backgroundColor'] = array(); } elseif (isset($d['backgroundColor']) && is_array($d['backgroundColor']) && count($d['backgroundColor']) === 1 && $d['backgroundColor'][0] === '65c9ff') { $d['backgroundColor'] = array(); } // ③ 头巾与胡子互斥 if (!empty($on['top'])) { $topName = isset($picked['top']) ? $picked['top'] : ''; $prob = isset($d['facialHairProbability']) ? $d['facialHairProbability'] : 10; if ((int) $prob !== 100 && $topName === 'hijab') { // 官方置 undefined ⇒ 展开时该组不出现;这里同步关掉 bool,避免取到残留值 $d['facialHair'] = array(); $on['facialHair'] = false; } } } elseif ($name === 'lorelei' || $name === 'lorelei-neutral') { // 有胡子且胡子与嘴同色时,把嘴改成白色,否则看不出嘴 if (!empty($on['beard']) && isset($colors['hair'], $colors['mouth']) && $colors['hair'] === $colors['mouth']) { $colors['mouth'] = '#ffffff'; } } elseif ($name === 'thumbs') { // ① 背景色里去掉跟主体同色的那一项 $shape = isset($colors['shape']) ? $colors['shape'] : ''; $bg = isset($d['backgroundColor']) && is_array($d['backgroundColor']) ? $d['backgroundColor'] : array(); $filtered = array(); foreach ($bg as $v) { if ($v !== str_replace('#', '', $shape)) { $filtered[] = $v; } } if (count($filtered) > 0) { $d['backgroundColor'] = $filtered; } // ② 眼睛/嘴按主体亮度取黑白对比色 $contrast = null; if (substr($shape, 0, 1) === '#') { $r = hexdec(substr($shape, 1, 2)); $g2 = hexdec(substr($shape, 3, 2)); $b = hexdec(substr($shape, 5, 6)); $yiq = ($r * 299 + $g2 * 587 + $b * 114) / 1000; $contrast = $yiq >= 200 ? '#000000' : '#ffffff'; } if ($contrast !== null) { foreach (array('eyes', 'mouth') as $ck) { $opt = (isset($d[$ck . 'Color']) && is_array($d[$ck . 'Color'])) ? $d[$ck . 'Color'] : array(); if (count($opt) === 2 && in_array('000000', $opt, true) && in_array('ffffff', $opt, true)) { $colors[$ck] = $contrast; } } } } return array($d, $colors, $on); } /** * initials 风格的 body 是代码拼出来的 ,单独复刻 * * @param array $d * @param string $seed * @param Yang_Avatar_Prng $p * @return string */ function yang_avatar_initials_body($d, $seed, $p) { // 顺序要紧:textColor 的 pick 在首字母计算**之前** $tc = (isset($d['textColor']) && is_array($d['textColor'])) ? $d['textColor'] : array(); $raw = $p->pick($tc); if ($raw === null) { $raw = 'ffffff'; } $textColor = yang_avatar_color($raw); $fontFamily = (isset($d['fontFamily']) && is_array($d['fontFamily'])) ? implode(', ', $d['fontFamily']) : 'Arial, sans-serif'; $fontSize = isset($d['fontSize']) ? $d['fontSize'] : 50; $fontWeight = isset($d['fontWeight']) ? $d['fontWeight'] : 400; $chars = isset($d['chars']) ? (int) $d['chars'] : 2; // JS 是 getInitials(prng.seed.trim()) —— 必须先 trim $initials = yang_avatar_utf8_sub(yang_avatar_initials(trim($seed)), $chars); return '' . yang_avatar_esc($initials) . ''; } /** * 复刻 @dicebear/initials 的 getInitials() * * 规则:先砍掉 @ 后面的部分,取首个与末个"词"的首字母。 * 中文没有空格时整串算一个词,取前 2 个字符。 * * @param string $seed * @param bool $discard_at * @return string */ function yang_avatar_initials($seed, $discard_at = true) { $input = $seed; if ($discard_at) { $input = preg_replace('/@.*/s', '', $input); } // 去掉各种撇号(与 DiceBear 的字符集一致) $input = preg_replace("/[`´‘’'ʼ]/u", '', $input); if (!preg_match_all('/(\p{L}[\p{L}\p{M}]*)/u', $input, $mm)) { // 一个字母都没匹配到 ⇒ 去掉 @ 限制再试一次 return $discard_at ? yang_avatar_initials($seed, false) : ''; } $matches = $mm[0]; if (count($matches) === 1) { if (preg_match('/^(?:\p{L}\p{M}*){1,2}/u', $matches[0], $s)) { return yang_avatar_utf8_upper($s[0]); } return ''; } $first = ''; $last = ''; if (preg_match('/^(?:\p{L}\p{M}*)/u', $matches[0], $s1)) { $first = $s1[0]; } if (preg_match('/^(?:\p{L}\p{M}*)/u', $matches[count($matches) - 1], $s2)) { $last = $s2[0]; } return yang_avatar_utf8_upper($first . $last); } /** * 复刻 core/utils/color.js 的 getBackgroundColors() * * 关键:**调用次数必须一致**。某个分支不 shuffle 时,也必须空 next() * 一次,否则后面 backgroundRotation 取到的值就歪了。 * * @param Yang_Avatar_Prng $p * @param array $list * @param string $bgType * @return array */ function yang_avatar_bg_colors($p, $list, $bgType) { $shuffled = $p->shuffle($list); if (count($shuffled) <= 1) { $shuffled = $list; $p->next(); } elseif (count($list) === 2 && $bgType === 'gradientLinear') { $shuffled = $list; $p->next(); } else { $shuffled = $p->shuffle($list); } if (count($shuffled) === 0) { $shuffled = array('transparent'); } $primary = $shuffled[0]; $secondary = isset($shuffled[1]) ? $shuffled[1] : $shuffled[0]; return array( 'primary' => yang_avatar_color($primary), 'secondary' => yang_avatar_color($secondary), ); } /** * @param string $c * @return string */ function yang_avatar_color($c) { return ($c === 'transparent') ? $c : '#' . $c; } /** * 复刻 core/utils/svg.js 的 addBackground() * * @param string $body * @param array $attrs * @param string $primary * @param string $secondary * @param string $type * @param int $rotation * @return string */ function yang_avatar_add_bg($body, $attrs, $primary, $secondary, $type, $rotation) { $vb = explode(' ', $attrs['viewBox']); $x = $vb[0]; $y = $vb[1]; $w = $vb[2]; $h = $vb[3]; if ($type === 'gradientLinear') { return '' . '' . '' . '' . '' . '' . '' . $body; } return '' . $body; } /** * 把 body 模板里的 @@k:group@@ / @@c:key@@ 展开 * * 循环 8 轮是因为存在「聚合组件」:rings 的 ring.container 里会 * 再调用 ringOne..ringFive,要展开两三层才干净。 * * @param array $style * @param array $subs 组名 → 选中的变体名 * @param array $colors * @param array $meta pickComponent 的旋转/偏移 * @return string */ function yang_avatar_expand($style, $subs, $colors, $meta) { $svg = $style['body_template']; for ($round = 0; $round < 8; $round++) { if (strpos($svg, '@@k:') === false) { break; } $changed = false; foreach ($subs as $g => $variant) { $token = '@@k:' . $g . '@@'; if (strpos($svg, $token) === false) { continue; } $rep = ''; if ($variant !== null && isset($style['groups'][$g][$variant])) { $rep = $style['groups'][$g][$variant]; } // pickComponent 的 value() 会包一层 transform(任一非零才包) if ($rep !== '' && isset($meta[$g])) { $m = $meta[$g]; if ($m['rotation'] != 0 || $m['offsetX'] != 0 || $m['offsetY'] != 0) { $rep = '' . $rep . ''; } } $svg = str_replace($token, $rep, $svg); $changed = true; } if (!$changed) { break; } } foreach ($colors as $k => $v) { $svg = str_replace('@@c:' . $k . '@@', $v, $svg); } return $svg; } /** * JS 里 150/2 输出 75、100/2 输出 50 —— 都是整数,统一去掉 .0 * * @param float $v * @return string */ function yang_avatar_num($v) { if (abs($v - round($v)) < 1e-9) { return (string) (int) round($v); } return (string) $v; } /** * XML 转义(顺序要紧:& 必须第一个换) * * @param string $s * @return string */ function yang_avatar_esc($s) { return str_replace( array('&', "'", '"', '<', '>'), array('&', ''', '"', '<', '>'), $s ); } /** * 版权元数据块(DiceBear 的 license.xml()) * * 这段必须保留:CC0 虽然不要求署名,但保留来源信息是良好实践, * 也方便以后追溯。体积约 500 字节,压缩后几乎为零。 * * @param array $m * @return string */ function yang_avatar_meta($m) { $rights = yang_avatar_rights($m); return '' . '' . '' . yang_avatar_esc($m['title']) . '' . '' . yang_avatar_esc($m['creator']) . '' . '' . yang_avatar_esc($m['source']) . '' . '' . yang_avatar_esc($m['license_url']) . '' . '' . yang_avatar_esc($rights) . '' . ''; } /** * 拼接版权声明文字(与 DiceBear 的 license.xml() 逐字一致) * * @param array $m * @return string */ function yang_avatar_rights($m) { $title = (isset($m['title']) && $m['title'] !== '') ? '„' . $m['title'] . '”' : 'Design'; $creator = '„' . ((isset($m['creator']) && $m['creator'] !== '') ? $m['creator'] : 'Unknown') . '”'; if (!empty($m['source'])) { $title .= ' (' . $m['source'] . ')'; } $r = ''; if ($m['license_name'] !== 'MIT' && $m['creator'] !== 'DiceBear' && !empty($m['title'])) { $r .= 'Remix of '; } $r .= $title . ' by ' . $creator; if (!empty($m['license_name'])) { $r .= ', licensed under „' . $m['license_name'] . '”'; if (!empty($m['license_url'])) { $r .= ' (' . $m['license_url'] . ')'; } } return $r; } /* ══════════════════════════════════════════════════════════════════ * 后台:风格选择器控件 * ══════════════════════════════════════════════════════════════════ */ /** * 渲染「头像风格」这个自定义选项类型 * * 挂 optionsframework_avatar_style 过滤器 —— 选项框架在 * class-options-interface.php 里对未知 type 会先查 * has_filter('optionsframework_' . $type),命中就交给这里。 * * ── 为什么不用框架自带 images 类型 ──────────────────────────── * images 类型要一组静态图片 URL,而这里的预览图是按邮箱实时生成的 * data URI。用自定义类型才能同时做到「点一下换预览」和「16 个种子 * 并排展示不同邮箱的效果」。 * * ── 为什么预览要 16 个种子 ──────────────────────────────────── * 用户选风格时最容易踩的坑是「看一张觉得好看,装上发现都长一样」。 * 单张预览看不出多样性,所以每个风格给 16 个不同邮箱的并排效果。 * * @param string $option_name 选项组名(这里是 'kratos') * @param array $value 选项定义 * @param mixed $val 当前值 * @return string */ function yang_avatar_option_html($option_name, $value, $val) { $styles = yang_avatar_styles(); $current = is_string($val) && isset($styles[$val]) ? $val : 'lorelei'; // 预览用的 16 个邮箱:集中定义,AJAX 与首屏渲染共用同一份 $seeds = yang_avatar_preview_seeds(); $name = $option_name . '[' . $value['id'] . ']'; $id = $value['id']; $o = array(); $o[] = '
'; foreach ($styles as $key => $meta) { $checked = ($key === $current); $rid = $id . '_' . $key; // 每个风格只生成 1 张 40px 预览(16 张全生成会让页面重 ~800KB)。 // 单选后由 JS 把下方大预览换成该风格的 16 张。 $thumb = yang_avatar_data_uri($seeds[0], $key, 80); $o[] = ''; } $o[] = '
'; /* 大预览区:16 个邮箱的并排效果。 初始内容由 PHP 直接渲染,不依赖 JS —— 万一 JS 没跑起来, 用户至少能看到当前选中风格的真实效果。 */ $o[] = '
'; $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');