Files
wordpress-kratos-yang/inc/theme-stat.php
T
laoyang 3b8244d7d3 修 4.1.0 后台文案里残留的 Markdown 星号(用户会看到字面 ** ) #33
线上真实 WP 环境预演 render_shell() 时发现两处文案把 Markdown 的
`**加粗**` 原样拼进了 HTML,浏览器会直接显示两个星号:

- inc/theme-stat.php:1128  render_shell 的授权步骤
  `用**你自己的百度账号**登录` → 用<b>…</b>
- inc/theme-options.php:837  stat_track_enable 的 desc
  `代码本身**不会被删除**` → <b>…</b>

同一批里 readme 也中过一处(`这十三项**都能单独关掉**`),
已在 _readme410.py 里修掉并重新生成 readme。

补的判据(都做了注入反证,不是恒真):
- _mkzip410.py 第 11b 段:剥注释后扫 PHP 字符串里的 `**…**`,
  83 通过 / 0 失败
- _mkzip410_falsify.py 第 16 组:把 <b> 还原成 ** 必须变红,16/16 抓到
- _readme410.py 第 8 段:本版节里不许有 Markdown 星号,
  注入后 38/1、恢复后 39/0

顺带修两个判据脚本自身的缺陷:
- _stat410.php 自己跑(文档写的 `php _stat410.php`)会因
  $GLOBALS['__theme_dir'] 未定义而 Fatal error ⇒ 加回落路径 +
  找不到文件时 exit 2 明说"没跑任何判据,这不是通过"
- _publish410.sh 的 LOCAL_RD 默认值指向上一版遗留的
  /d/tmp/readme410.html ⇒ 首跑把旧 readme 传上去、出现 1 条失败,
  重跑手工设环境变量又"好了"⇒ 默认值改成 _readme410.py 的真实
  输出路径,且默认路径不存在时直接停

线上已重发:md5 5cf4e1b3242a2e092233fd573d0196d4
主题目录仍是 4.0.0(未被覆盖),等用户后台点升级。
2026-10-28 17:18:54 +08:00

1816 lines
77 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 访问统计面板:接入百度统计 Tongji API,把数据直出到主题设置页。
*
* ══════════════════════════════════════════════════════════════════
* 这个文件只做三件事
* ══════════════════════════════════════════════════════════════════
* 1. OAuth 凭据的独立存储(**不进 options 表**)
* 2. 调百度统计 getData 接口取数,结果进 transient 缓存
* 3. 把数字画成后台面板 HTML
*
* 前台的埋点输出不在这里 —— 那是 footer.php 里 `seo_statistical`
* 那个文本框的事,本文件只在它前面加一道开关判断。
*
* ══════════════════════════════════════════════════════════════════
* 三个硬性约束(都是踩过坑才写下来的)
* ══════════════════════════════════════════════════════════════════
*
* ── ① 后台绝不能被 API 拖死 ─────────────────────────────────────
* 百度统计的接口是同步 HTTP,一旦对方超时默认要等 30 秒以上。
* 设置页会因为一个"附加面板"而卡住,用户会以为主题坏了。
* 做法:timeout 压到 5 秒 + `blocking => false`,并且**任何**
* 失败路径都返回数组(含 error 字段),渲染层只负责显示,
* 不负责判断成不成功。整条链路没有一个 `wp_die()` / 异常外抛。
*
* ── ② "--" 不是 0 ───────────────────────────────────────────────
* 百度统计在没有数据的格子上返回字符串 `"--"`。
* PHP 里 `(int)"--" === 0`,`"--" + 0 === 0`(PHP 8 下会 warning
* 但结果仍是 0)。如果直接强转,面板会把"没有数据"显示成"0 访问",
* 这是两件完全不同的事。所有数值都必须过 yang_stat_num()。
*
* ── ③ 凭据不能进 options 表 ─────────────────────────────────────
* `kratos` 这个 option 会被导出插件打包、被备份脚本复制、被其他
* 代码 `get_option('kratos')` 读走。API Secret 放进去等于公开。
* 存独立文件,权限 0600,且放在 `wp-content/uploads/` 之外
* (uploads 目录线上是 403,但那是"不能直接访问",不是"不能读")。
*
* @package kratos-yang
*/
/* 防止直接访问。WP 正常加载时 ABSPATH 一定存在。 */
if (!defined('ABSPATH')) {
exit;
}
/* ══════════════════════════════════════════════════════════════════
* 一、常量与凭据文件
* ══════════════════════════════════════════════════════════════════ */
/**
* 凭据文件名。
*
* 用 `.` 开头让它默认被大部分静态服务器忽略,再加随机后缀防猜。
* 真正拦住外部访问的是「不在 web 根下的可直读目录」+ 0600 权限,
* 文件名只是顺手的一层。
*/
function yang_stat_cred_file()
{
/**
* 存放位置的选择。
*
* `wp-content/uploads/` 在线上是 403(.user.ini 带的规则),
* 但那是 HTTP 层的规则 —— PHP 自己读写不受影响,而
* 换到 `wp-content/` 根下反而更容易被别的插件扫到。
*
* 用 uploads 的好处:WP 保证它存在且可写,多站点各自独立,
* 且不会因为换主题而被删(换主题会删掉整个主题目录!)。
* 这一条最关键:**凭据绝不能存在主题目录里**,
* 否则用户一升级主题,token 就没了,还得重新授权一次。
*/
$dir = wp_upload_dir(null, false);
$base = isset($dir['basedir']) ? $dir['basedir'] : WP_CONTENT_DIR . '/uploads';
return trailingslashit($base) . '.yang-stat-credentials.php';
}
/**
* 读凭据。返回统一结构的数组,缺的键是空串。
*
* 文件内容是 `<?php return array(...);`,用 include 而不是 json_decode:
* 即使文件被谁通过某种途径当静态文件吐出去了,PHP 标签也会先被
* 执行掉(输出为空),而不是把 token 明文吐给浏览器。
*
* @return array{client_id:string, client_secret:string, refresh_token:string, access_token:string, expires_at:int, site_id:string}
*/
function yang_stat_credentials()
{
/* 🔴 缓存必须带版本号,不能被 static 卡死。
第一版这里只写了 `static $cache`,结果 yang_stat_save_credentials()
保存成功后再读,拿到的还是保存前的旧值 —— 因为 static 变量
在同一个请求内永远不会重新执行函数体,$GLOBALS 计数器根本
影响不到它。表现是「改了凭据、面板还是旧 token」,而且
只有在同一个请求里连续读写的路径(保存后立即预览)才暴露。 */
static $cache = null;
static $cacheVer = -1;
$ver = isset($GLOBALS['__yang_stat_cred_ver']) ? (int) $GLOBALS['__yang_stat_cred_ver'] : 0;
if ($cache !== null && $cacheVer === $ver) {
return $cache;
}
$cacheVer = $ver;
$empty = array(
'client_id' => '',
'client_secret' => '',
'refresh_token' => '',
'access_token' => '',
'expires_at' => 0,
'site_id' => '',
);
$file = yang_stat_cred_file();
if (!is_readable($file)) {
$cache = $empty;
return $cache;
}
/* include 一个坏文件会 fatal。先做语法层防护:读前 32 字节确认是
我们自己写的格式,不是被别的东西覆盖成了一堆二进制。 */
$head = @file_get_contents($file, false, null, 0, 32);
if (!is_string($head) || strpos($head, '<?php') !== 0) {
$cache = $empty;
return $cache;
}
$data = @include $file;
if (!is_array($data)) {
$cache = $empty;
return $cache;
}
$out = $empty;
foreach ($empty as $k => $_) {
if (isset($data[$k])) {
$out[$k] = 'expires_at' === $k ? (int) $data[$k] : (string) $data[$k];
}
}
$cache = $out;
return $cache;
}
/**
* 写凭据。整份覆盖,权限压到 0600。
*
* @param array $data 至少含 client_id / client_secret / refresh_token 三者之一
* @return bool 写入成功且权限正确才算 true
*/
function yang_stat_save_credentials($data)
{
$cur = yang_stat_credentials();
$next = array_merge($cur, is_array($data) ? $data : array());
$file = yang_stat_cred_file();
/* 目录可能还不存在(全新站点 uploads 还没建)。 */
$dir = dirname($file);
if (!is_dir($dir)) {
wp_mkdir_p($dir);
}
$body = "<?php\n"
. "/* 百度统计 API 凭据。不要提交到版本库,不要分享。 */\n"
. "return " . var_export($next, true) . ";\n";
/* 🔴 先 chmod 再写内容,不能反过来:
如果先写内容再 chmod,中间那一瞬间文件是 0644,
同机其他用户能读走 token。虽然窗口极短,但没有理由留这个缝。 */
$fp = @fopen($file, 'c');
if (!$fp) {
return false;
}
@chmod($file, 0600);
$ok = @fwrite($fp, $body);
@fclose($fp);
if ($ok === false || strlen($body) !== (int) $ok) {
yang_stat_credentials_flush();
return false;
}
/* 反证:写完必须真的可读且权限是 0600。
有的主机(容器)chmod 会静默失败,不验就等于没做。
🔴 无论权限检查过不过,缓存都必须立刻失效 ——
文件内容**已经写进去了**,如果这里因为权限不对而 return false
却不 flush,同一次请求后续读到的还是旧的凭据,
表现是"保存失败但内容其实变了",排查时极其误导。 */
clearstatcache(true, $file);
$perm = @fileperms($file);
yang_stat_credentials_flush();
/* Windows 没有 POSIX 权限位,chmod 只影响只读标志,
fileperms 会返回 0666 & ~umask 之类的东西。
在那种平台上强判 0600 会永远失败,而这不是代码缺陷。
所以只在支持 POSIX 权限的平台上校验。 */
if (DIRECTORY_SEPARATOR === '\\') {
return true;
}
if ($perm === false || ($perm & 0777) !== 0600) {
return false;
}
return true;
}
/**
* 清掉 yang_stat_credentials() 的静态缓存。
*
* 单独拆出来是因为 static 变量没法从外部置空,
* 而保存后必须让下一次读拿到新值。
*/
function yang_stat_credentials_flush()
{
/* 利用一个 ref 参数把 static 清掉的做法不适用(函数已返回)。
用 wp_cache 式的思路:靠一个全局计数器让 static 失效。 */
$GLOBALS['__yang_stat_cred_ver'] = isset($GLOBALS['__yang_stat_cred_ver'])
? $GLOBALS['__yang_stat_cred_ver'] + 1
: 1;
}
/**
* 清凭据(删除文件)。
*
* @return bool
*/
function yang_stat_clear_credentials()
{
$file = yang_stat_cred_file();
yang_stat_credentials_flush();
if (!file_exists($file)) {
return true;
}
return (bool) @unlink($file);
}
/* ══════════════════════════════════════════════════════════════════
* 二、数值清洗
* ══════════════════════════════════════════════════════════════════ */
/**
* 把百度统计返回的原始格子转成可计算的数字。
*
* ── 为什么必须单独一个函数 ────────────────────────────────────
* 百度统计对"没有数据"返回字符串 `"--"`。PHP 的 `(int)"--"` 是 0,
* 于是"没数据"和"真的 0 次访问"在面板上长得一模一样。
* 这个函数把 `"--"` / `""` / `null` 统一转成 `null`(表示无数据),
* 只有真正能解析成数字的才返回数值。
*
* @param mixed $v
* @return float|int|null null 表示「无数据」,不是 0
*/
function yang_stat_num($v)
{
if ($v === null) {
return null;
}
if (is_int($v) || is_float($v)) {
/* 🔴 浮点整数值要归一成 int。
*
* 第一版这里直接 `return $v;`,于是 1234.0 原样返回,
* 面板上显示成 "1,234.0" —— 因为 yang_stat_fmt() 里判
* `(float)(int)$n !== $n` 时是 false(1234.0 确实等于 1234),
* 走的是整数分支,可 number_format((int)$n) 又是对的……
* 真正的表现是 is_int() 判 false,下游任何按类型分支的地方
* 都会把它当"带小数的指标"处理。
* 指标里 PV/UV/IP 全是整数,没有理由留着 .0。 */
if (is_float($v) && (float) (int) $v === $v) {
return (int) $v;
}
return $v;
}
if (!is_string($v)) {
return null;
}
$s = trim($v);
if ($s === '' || $s === '--' || $s === '-' || $s === '—') {
return null;
}
/* 允许 "123" / "12.5" / "1,234"。
百度统计偶尔会带千分位,不 strip 的话 "1,234" 会被当成 1。 */
$s = str_replace(array(',', ' '), '', $s);
if (!is_numeric($s)) {
return null;
}
/* 整数就回 int,避免面板上出现 "1234.0" 这种难看的东西。 */
$f = (float) $s;
if ((float) (int) $f === $f) {
return (int) $f;
}
return $f;
}
/**
* 数字 → 面板显示文本。
*
* @param mixed $v 原始值
* @param string $suffix 后缀(如 '%')
* @return string 无数据返回 '—'(em dash),与 0 明确区分
*/
function yang_stat_fmt($v, $suffix = '')
{
$n = yang_stat_num($v);
if ($n === null) {
return '<span class="yang-stat-na">—</span>';
}
if (is_float($n) && (float) (int) $n !== $n) {
$s = number_format($n, 1, '.', ',');
} else {
$s = number_format((int) $n);
}
return esc_html($s . $suffix);
}
/**
* 秒数 → 「1 分 23 秒」这类人话。
*
* @param mixed $v
* @return string
*/
function yang_stat_duration($v)
{
$n = yang_stat_num($v);
if ($n === null) {
return '<span class="yang-stat-na">—</span>';
}
$n = (int) round($n);
if ($n < 60) {
return esc_html($n . ' 秒');
}
$m = (int) floor($n / 60);
$s = $n % 60;
if ($m < 60) {
return esc_html($s > 0 ? $m . ' 分 ' . $s . ' 秒' : $m . ' 分');
}
$h = (int) floor($m / 60);
return esc_html($h . ' 小时 ' . ($m % 60) . ' 分');
}
/* ══════════════════════════════════════════════════════════════════
* 三、HTTP 客户端
* ══════════════════════════════════════════════════════════════════ */
/**
* 发一个 GET 并解析 JSON。永不抛异常,失败一律返回 WP_Error。
*
* ── 为什么不用 wp_remote_get 的默认超时 ─────────────────────────
* 默认 5 秒其实已经够,但 `wp_remote_get` 在某些主机上会因为
* cURL 的 `CURLOPT_CONNECTTIMEOUT` 未单独设置而卡在 DNS 上。
* 这里显式给 timeout 与一个更短的连接超时。
*
* @param string $url
* @return array|WP_Error
*/
function yang_stat_http_get($url)
{
$resp = wp_remote_get($url, array(
'timeout' => 5,
'redirection' => 2,
'sslverify' => true,
'headers' => array(
/* 不伪造浏览器 UA:百度统计的 API 网关对 UA 不敏感,
伪造反而可能触发风控。 */
'Accept' => 'application/json',
),
));
if (is_wp_error($resp)) {
return new WP_Error('yang_stat_http', $resp->get_error_message());
}
$code = (int) wp_remote_retrieve_response_code($resp);
$body = (string) wp_remote_retrieve_body($resp);
$json = json_decode($body, true);
/* 🔴 非 200 时先把 body 解析一遍再决定报什么。
*
* 实测(2026-10-28,真实请求百度):
* 假 client_id → HTTP **401** + {"error":"invalid_client",
* "error_description":"unknown client id"}
* 第一版直接 `return 接口返回 HTTP 401:{...原样 body...}`,
* 用户看到的是「接口返回 HTTP 401:{"error":"invalid_client",...}」——
* 能用,但把最关键的那句 "unknown client id" 埋在一坨 JSON 里。
*
* 正确做法:能解析出 error_description / error_msg 就只报那一句,
* 让用户一眼知道该去改什么。
*/
if ($code !== 200) {
$msg = '';
if (is_array($json)) {
foreach (array('error_description', 'error_msg', 'error', 'msg') as $k) {
if (!empty($json[$k]) && is_string($json[$k])) { $msg = $json[$k]; break; }
}
}
if ($msg === '') {
$msg = mb_substr(trim($body), 0, 200, 'UTF-8');
}
return new WP_Error('yang_stat_http', sprintf('接口返回 HTTP %d:%s', $code, $msg));
}
if (!is_array($json)) {
return new WP_Error('yang_stat_json', '接口返回的不是合法 JSON:' . mb_substr(trim($body), 0, 200, 'UTF-8'));
}
/* OAuth 错误(HTTP 200 但体内带 error)。 */
if (isset($json['error'])) {
$msg = isset($json['error_description']) ? $json['error_description'] : $json['error'];
return new WP_Error('yang_stat_api', (string) $msg);
}
/* 🔴 报告接口的错误格式(实测,与 OAuth 完全不同):
* 假 token → HTTP **200** + {"error_code":110,
* "error_msg":"Access token invalid or no longer valid"}
* 注意:是 error_code/error_msg,**不是** error,也**不是** header.status。
* 第一版只认 error 和 header.status,于是这种响应会被当成
* "正常返回但找不到 result",最终报「接口返回里找不到 result 节点」——
* 一句完全误导的话,用户会去查 site_id,真正的问题却是 token 过期。
*
* error_code 常见值:110 = token 失效;111 = token 过期;
* 其他非 0 值一律按错误处理,宁严不宽。 */
if (isset($json['error_code']) && (string) $json['error_code'] !== '0'
&& (string) $json['error_code'] !== '') {
$m = isset($json['error_msg']) ? (string) $json['error_msg'] : '未知错误';
$hint = '';
if (in_array((string) $json['error_code'], array('110', '111'), true)) {
$hint = '(token 已失效,请重新走一次授权)';
}
return new WP_Error('yang_stat_apicode',
'百度统计接口报错(code ' . $json['error_code'] . '):' . $m . $hint);
}
return $json;
}
/**
* 用 refresh_token 换新的 access_token。
*
* ── 关键点:百度每次刷新都会返回**新的** refresh_token ─────────
* 官方文档写「REFRESH_TOKEN 有效期十年」,但实际行为是每次
* refresh 都轮换出一个新的 refresh_token,旧的随即作废。
* 如果只更新 access_token 而丢掉新的 refresh_token,
* 这一轮还能用,**下一轮就永久失效**,用户必须重新走一遍授权。
*
* 所以这里刷新成功后必须把 refresh_token 一起写回文件。
*
* @return string|WP_Error access_token
*/
function yang_stat_refresh_token()
{
$cred = yang_stat_credentials();
if ($cred['client_id'] === '' || $cred['client_secret'] === '' || $cred['refresh_token'] === '') {
return new WP_Error('yang_stat_nocred', '还没有填写 API Key / Secret Key / refresh_token');
}
$url = add_query_arg(array(
'grant_type' => 'refresh_token',
'refresh_token' => $cred['refresh_token'],
'client_id' => $cred['client_id'],
'client_secret' => $cred['client_secret'],
), 'https://openapi.baidu.com/oauth/2.0/token');
$json = yang_stat_http_get($url);
if (is_wp_error($json)) {
return $json;
}
$token = isset($json['access_token']) ? (string) $json['access_token'] : '';
if ($token === '') {
return new WP_Error('yang_stat_notoken', '刷新接口没有返回 access_token');
}
$expires = isset($json['expires_in']) ? (int) $json['expires_in'] : 2592000;
/* 提前 1 天过期,避免"刚好在过期那一刻调用"的边界问题。
30 天 - 1 天 = 29 天,缓存 30 分钟后最多多刷几次,无所谓。 */
$patch = array(
'access_token' => $token,
'expires_at' => time() + max(3600, $expires - 86400),
);
/* 🔴 必须带上新 refresh_token(若返回了)。 */
if (isset($json['refresh_token']) && (string) $json['refresh_token'] !== '') {
$patch['refresh_token'] = (string) $json['refresh_token'];
}
/* 写盘失败不能让整条链路崩:本次内存里已经有 token 了,
照样能取数据;只是下次要重新刷。所以失败只记录不返回错误。 */
yang_stat_save_credentials($patch);
return $token;
}
/**
* 取一个可用的 access_token(本地有效就直接用,否则刷新)。
*
* @param bool $force 强制刷新
* @return string|WP_Error
*/
function yang_stat_access_token($force = false)
{
$cred = yang_stat_credentials();
if (!$force && $cred['access_token'] !== '' && $cred['expires_at'] > time()) {
return $cred['access_token'];
}
return yang_stat_refresh_token();
}
/* ══════════════════════════════════════════════════════════════════
* 四、报告取数
* ══════════════════════════════════════════════════════════════════ */
/**
* 调一次 getData。
*
* ── 返回结构(这一层最容易写错,先把形状钉死)──────────────────
* 百度账号版(GET)的响应**可能**有两种层级:
* A. 根就是 result:{ "items": [...], "fields": [...], ... }
* B. 包在 body.data[0].result 里(与商业账号一致)
* 文档示例给的是 B,但社区实现里两种都出现过。
* 所以这里统一用 yang_stat_dig_result() 往下钻,两种都吃得下。
*
* @param string $method 报告 method,如 trend/time/a
* @param string $metrics 逗号分隔的指标
* @param array $extra 额外参数(gran / order / max_results 等)
* @return array|WP_Error 已展开成 array 的 result 节点
*/
function yang_stat_get_data($method, $metrics, $extra = array())
{
$cred = yang_stat_credentials();
$site = $cred['site_id'];
/* site_id 留空时退回主题设置里的值(用户可能只在设置页填了)。 */
if ($site === '' && function_exists('kratos_option')) {
$site = (string) kratos_option('stat_site_id', '');
}
if ($site === '') {
return new WP_Error('yang_stat_nosite', '还没有填写站点 ID(site_id)');
}
$token = yang_stat_access_token();
if (is_wp_error($token)) {
return $token;
}
$args = array_merge(array(
'access_token' => $token,
'site_id' => $site,
'method' => $method,
'start_date' => gmdate('Ymd', current_time('timestamp') - 86400),
'end_date' => gmdate('Ymd', current_time('timestamp')),
'metrics' => $metrics,
'max_results' => 0,
), $extra);
$url = add_query_arg($args, 'https://openapi.baidu.com/rest/2.0/tongji/report/getData');
$json = yang_stat_http_get($url);
if (is_wp_error($json)) {
return $json;
}
/* 🔴 顺序不能反:先判 header.status,再去钻 result。
*
* 第一版把钻 result 写在前面,结果「配额用尽」这种故障下
* header.status=1、body 里没有 result,于是用户看到的是
* 「接口返回里找不到 result 节点」—— 一句完全指向错误方向的话,
* 用户会去怀疑 ak/sk 填错了、站点 ID 错了,实际只是今天额度没了。
*
* 百度统计对「配额用尽 / 无权限 / site_id 不存在」全都是
* HTTP 200 + header.status 非 0,所以这个判断必须排在最前面。 */
if (isset($json['header']['status']) && (int) $json['header']['status'] !== 0) {
$desc = isset($json['header']['desc']) ? $json['header']['desc'] : '未知错误';
$fail = '';
if (!empty($json['header']['failures']) && is_array($json['header']['failures'])) {
$first = reset($json['header']['failures']);
if (is_array($first) && isset($first['message'])) {
$fail = ':' . $first['message'];
}
}
return new WP_Error('yang_stat_apistatus', (string) $desc . $fail);
}
$result = yang_stat_dig_result($json);
if ($result === null) {
return new WP_Error('yang_stat_shape', '接口返回里找不到 result 节点');
}
return $result;
}
/**
* 从任意层级的响应里钻出 result 节点。
*
* 兼容:
* - 根就是 result(有 items + fields)
* - body.data[0].result
* - data[0].result
*
* @param array $json
* @return array|null
*/
function yang_stat_dig_result($json)
{
if (!is_array($json)) {
return null;
}
if (isset($json['items']) && isset($json['fields'])) {
return $json;
}
$candidates = array();
if (isset($json['body']['data']) && is_array($json['body']['data'])) {
$candidates[] = $json['body']['data'];
}
if (isset($json['data']) && is_array($json['data'])) {
$candidates[] = $json['data'];
}
foreach ($candidates as $list) {
foreach ($list as $node) {
if (!is_array($node)) {
continue;
}
if (isset($node['result']) && is_array($node['result'])) {
$r = $node['result'];
if (isset($r['items']) && isset($r['fields'])) {
return $r;
}
}
if (isset($node['items']) && isset($node['fields'])) {
return $node;
}
}
}
return null;
}
/**
* 把 result 节点解成「维度 => 指标数组」的普通数组。
*
* ── items 的形状(官方文档 + 实测都一致)───────────────────────
* items[0] = 维度数组。趋势报告里每项是 **字符串数组** ["2019/01/04"];
* 地域报告里每项是 **对象** {"name":"广东","area":"province,4"}。
* items[1] = 指标数组。每项是与 fields(去掉第 0 个维度名后)等长的数值数组。
* items[2] / items[3] = 对比期数据 / 变化率,通常为空。
*
* fields[0] 恒为维度名(simple_date_title / visit_district_title 等),
* **指标从 fields[1] 开始** —— 这个偏移量写错,所有数字会整体左移一格。
*
* @param array $result
* @return array 每项形如 array('dim' => string, 'metrics' => array('pv_count' => 12, ...))
*/
function yang_stat_parse_rows($result)
{
$rows = array();
if (!is_array($result) || empty($result['items']) || empty($result['fields'])) {
return $rows;
}
$fields = $result['fields'];
$dims = isset($result['items'][0]) && is_array($result['items'][0]) ? $result['items'][0] : array();
$vals = isset($result['items'][1]) && is_array($result['items'][1]) ? $result['items'][1] : array();
/* 指标名 = fields 去掉第 0 个(维度名)。 */
$metricNames = array_slice($fields, 1);
$n = max(count($dims), count($vals));
for ($i = 0; $i < $n; $i++) {
$dim = '';
if (isset($dims[$i])) {
$d = $dims[$i];
if (is_array($d)) {
/* 对象形态:优先 name,其次任意字符串值。 */
if (isset($d['name'])) {
$dim = (string) $d['name'];
} else {
foreach ($d as $dv) {
if (is_string($dv) && $dv !== '') {
$dim = $dv;
break;
}
}
}
} elseif (is_string($d)) {
$dim = $d;
}
}
$metrics = array();
if (isset($vals[$i]) && is_array($vals[$i])) {
foreach ($metricNames as $k => $name) {
$metrics[$name] = isset($vals[$i][$k]) ? $vals[$i][$k] : null;
}
}
$rows[] = array('dim' => $dim, 'metrics' => $metrics);
}
return $rows;
}
/**
* 从 result.sum 里取合计值。
*
* sum 的形状:`[[88498794, 35978529, 25665169], []]`
* —— 第一个子数组按指标顺序排列,第二个是变化率。
*
* @param array $result
* @param array $metricNames 需要的指标名(按顺序)
* @return array 指标名 => 原始值
*/
function yang_stat_parse_sum($result, $metricNames)
{
$out = array();
foreach ($metricNames as $m) {
$out[$m] = null;
}
if (!is_array($result) || !isset($result['sum'][0]) || !is_array($result['sum'][0])) {
return $out;
}
$arr = $result['sum'][0];
foreach ($metricNames as $i => $m) {
if (array_key_exists($i, $arr)) {
$out[$m] = $arr[$i];
}
}
return $out;
}
/* ══════════════════════════════════════════════════════════════════
* 五、指标定义(用户可选哪些显示)
* ══════════════════════════════════════════════════════════════════ */
/**
* 全部可用指标。key 是设置里的存储键,value 是显示配置。
*
* ── 为什么指标要用户自己选 ────────────────────────────────────
* 每次开面板都要打 4~5 个 getData 请求,而百度统计的配额是
* **按次**计的(5 万/周/账号)。全部指标都拉一遍,一天开 10 次
* 后台就是几百次调用。让用户关掉不看的,是省配额最直接的办法。
*
* `group` 用来把请求合并:同一 group 的指标一次请求就能拿回来,
* 所以选得多不一定调用多。
*
* @return array
*/
function yang_stat_metric_defs()
{
return array(
/* ── 今日快照:一次 trend 请求拿全部 ──────────────────── */
'pv' => array('label' => '浏览量(PV)', 'group' => 'today'),
'uv' => array('label' => '访客数(UV)', 'group' => 'today'),
'ip' => array('label' => 'IP 数', 'group' => 'today'),
'new_visitor' => array('label' => '新访客数', 'group' => 'today'),
'visit' => array('label' => '访问次数', 'group' => 'today'),
/* ── 质量指标:同上,一次 trend 请求 ──────────────────── */
'bounce' => array('label' => '跳出率', 'group' => 'quality'),
'avg_time' => array('label' => '平均访问时长', 'group' => 'quality'),
'avg_pages' => array('label' => '平均访问页数', 'group' => 'quality'),
/* ── 趋势:单独一次请求(7 天按天粒度)──────────────────── */
'trend' => array('label' => '最近 7 天趋势', 'group' => 'trend'),
/* ── 来源 / 页面 / 设备 / 地域:各自一次请求 ────────────── */
'source' => array('label' => '流量来源', 'group' => 'source'),
'top_page' => array('label' => '热门页面', 'group' => 'toppage'),
'device' => array('label' => '设备分布', 'group' => 'device'),
'region' => array('label' => '地域分布', 'group' => 'region'),
);
}
/**
* 用户勾选了哪些指标。
*
* 默认全选(首次使用时不至于看到空面板)。
* 注意 multicheck 的存储是 `array('pv' => '1', ...)` 形式,
* 值可能是字符串 '1',也可能是 true。
*
* @return array 形如 array('pv', 'uv', ...)
*/
function yang_stat_enabled_metrics()
{
$defs = yang_stat_metric_defs();
$all = array_keys($defs);
$saved = function_exists('kratos_option') ? kratos_option('stat_metrics', null) : null;
/* 🔴 没保存过 ≠ 全不选。
如果这里返回空数组,用户第一次打开设置页会看到一个空面板,
以为功能坏了。用 null 判断「从未配置」并回退到全选;
只有用户**主动全部取消**(存成空数组)才真的是空。 */
if ($saved === null || $saved === false) {
return $all;
}
if (!is_array($saved)) {
return $all;
}
$out = array();
foreach ($all as $k) {
if (!empty($saved[$k])) {
$out[] = $k;
}
}
return $out;
}
/* ══════════════════════════════════════════════════════════════════
* 六、取数主流程(带缓存)
* ══════════════════════════════════════════════════════════════════ */
/**
* 缓存键。含日期,跨天自动失效。
*
* @return string
*/
function yang_stat_cache_key()
{
$cred = yang_stat_credentials();
$site = $cred['site_id'] !== '' ? $cred['site_id']
: (function_exists('kratos_option') ? (string) kratos_option('stat_site_id', '') : '');
/* 把已启用指标的指纹也放进键里:用户刚勾了新指标,
不该继续吃旧缓存里的残缺数据。 */
$sig = md5(implode(',', yang_stat_enabled_metrics()));
return 'yang_stat_' . md5($site . '|' . current_time('Ymd') . '|' . $sig);
}
/**
* 取全部数据。结果会被 transient 缓存 30 分钟。
*
* 返回结构:
* array(
* 'ok' => bool,
* 'error' => string, // ok=false 时的原因
* 'today' => array(...), // 今日快照
* 'yesterday' => array(...),
* 'trend' => array(...),
* 'source' => array(...),
* 'top_page'=> array(...),
* 'device' => array(...),
* 'region' => array(...),
* 'fetched' => int, // 取数时间戳
* 'cached' => bool, // 是否来自缓存
* )
*
* @param bool $force 跳缓存
* @return array
*/
function yang_stat_fetch($force = false)
{
$key = yang_stat_cache_key();
if (!$force) {
$hit = get_transient($key);
if (is_array($hit) && isset($hit['ok'])) {
$hit['cached'] = true;
return $hit;
}
}
$out = yang_stat_fetch_remote();
$out['cached'] = false;
/* 🔴 失败结果也要缓存,但只能短缓存。
否则 API 一挂,用户每刷新一次后台就打一次 5 秒超时的请求 ——
设置页会变成"每点一下就卡 5 秒",比显示"暂不可用"糟得多。
5 分钟足够挡掉连续刷新,又不至于让用户等太久才看到恢复。 */
$ttl = $out['ok'] ? 1800 : 300;
set_transient($key, $out, $ttl);
return $out;
}
/**
* 真正打接口的那一层。任何错误都收敛成 ok=false,不抛不 die。
*
* @return array
*/
function yang_stat_fetch_remote()
{
$fail = function ($msg) {
return array('ok' => false, 'error' => $msg, 'fetched' => time());
};
$cred = yang_stat_credentials();
$site = $cred['site_id'] !== '' ? $cred['site_id']
: (function_exists('kratos_option') ? (string) kratos_option('stat_site_id', '') : '');
if ($cred['client_id'] === '' || $cred['client_secret'] === '' || $cred['refresh_token'] === '') {
return $fail('还没有填写 API 凭据(API Key / Secret Key / refresh_token)');
}
if ($site === '') {
return $fail('还没有填写站点 ID(site_id)');
}
/* token 先拿到手。拿不到就整条链路停在这儿,
后面的请求一个都不发 —— 否则会连续 5 次超时。 */
$token = yang_stat_access_token();
if (is_wp_error($token)) {
return $fail($token->get_error_message());
}
$enabled = yang_stat_enabled_metrics();
$want = function ($k) use ($enabled) {
return in_array($k, $enabled, true);
};
$out = array('ok' => true, 'error' => '', 'fetched' => time());
/* ── ① 今日 + 昨日快照,一次 trend 请求覆盖两天 ───────────────
单独为"今日"和"昨日"各发一次请求是浪费:trend 支持区间,
一次拿两天即可。 */
$todayStr = current_time('Ymd');
$yestStr = gmdate('Ymd', current_time('timestamp') - 86400);
$snapMetrics = array();
if ($want('pv')) { $snapMetrics[] = 'pv_count'; }
if ($want('uv')) { $snapMetrics[] = 'visitor_count'; }
if ($want('ip')) { $snapMetrics[] = 'ip_count'; }
if ($want('new_visitor')) { $snapMetrics[] = 'new_visitor_count'; }
if ($want('visit')) { $snapMetrics[] = 'visit_count'; }
if ($snapMetrics) {
$r = yang_stat_get_data('trend/time/a', implode(',', $snapMetrics), array(
'start_date' => $yestStr,
'end_date' => $todayStr,
'gran' => 'day',
));
if (is_wp_error($r)) {
return $fail($r->get_error_message());
}
$rows = yang_stat_parse_rows($r);
/* 按维度(日期)分桶。日期格式是 2026/10/28。 */
$byDate = array();
foreach ($rows as $row) {
$d = str_replace('/', '', $row['dim']);
$byDate[$d] = $row['metrics'];
}
$out['today'] = isset($byDate[$todayStr]) ? $byDate[$todayStr] : array();
$out['yesterday'] = isset($byDate[$yestStr]) ? $byDate[$yestStr] : array();
}
/* ── ② 质量指标(跳出率 / 时长 / 页数)────────────────────── */
$qMetrics = array();
if ($want('bounce')) { $qMetrics[] = 'bounce_ratio'; }
if ($want('avg_time')) { $qMetrics[] = 'avg_visit_time'; }
if ($want('avg_pages')) { $qMetrics[] = 'avg_visit_pages'; }
if ($qMetrics) {
$r = yang_stat_get_data('trend/time/a', implode(',', $qMetrics), array(
'start_date' => $yestStr,
'end_date' => $todayStr,
'gran' => 'day',
));
if (!is_wp_error($r)) {
$rows = yang_stat_parse_rows($r);
foreach ($rows as $row) {
$d = str_replace('/', '', $row['dim']);
if ($d === $todayStr) {
$out['today'] = array_merge(
isset($out['today']) ? $out['today'] : array(),
$row['metrics']
);
} elseif ($d === $yestStr) {
$out['yesterday'] = array_merge(
isset($out['yesterday']) ? $out['yesterday'] : array(),
$row['metrics']
);
}
}
}
/* 质量指标失败不整体报错 —— 主数字已经拿到了,
少几个次要指标比整块面板消失好。 */
}
/* ── ③ 7 天趋势 ─────────────────────────────────────────── */
if ($want('trend')) {
$r = yang_stat_get_data('trend/time/a', 'pv_count,visitor_count', array(
'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400),
'end_date' => $todayStr,
'gran' => 'day',
));
if (!is_wp_error($r)) {
$out['trend'] = yang_stat_parse_rows($r);
}
}
/* ── ④ 流量来源 ─────────────────────────────────────────── */
if ($want('source')) {
$r = yang_stat_get_data('source/all/a', 'pv_count,visitor_count', array(
'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400),
'end_date' => $todayStr,
'max_results' => 8,
));
if (!is_wp_error($r)) {
$out['source'] = yang_stat_parse_rows($r);
}
}
/* ── ⑤ 热门页面 ─────────────────────────────────────────── */
if ($want('top_page')) {
$r = yang_stat_get_data('visit/toppage/a', 'pv_count,visitor_count', array(
'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400),
'end_date' => $todayStr,
'max_results' => 8,
'order' => 'pv_count,desc',
));
if (!is_wp_error($r)) {
$out['top_page'] = yang_stat_parse_rows($r);
}
}
/* ── ⑥ 设备分布 ─────────────────────────────────────────────
🔴 这里不能用 visit/district/a —— 那是**地域**报告,
拿它当设备报告用,面板上会出现"广东省 3 次"这种驴唇不对马嘴的内容。
设备没有独立的分布报告,只能对 trend 打两次、各带一个
clientDevice 过滤,再把 sum 加起来。
两次请求换一份准确数据,比一次请求拿一份假数据值得。 */
if ($want('device')) {
$dev = array('pc' => null, 'mobile' => null);
$devFail = false;
foreach (array('pc', 'mobile') as $d) {
$r = yang_stat_get_data('trend/time/a', 'pv_count,visitor_count', array(
'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400),
'end_date' => $todayStr,
'gran' => 'day',
'clientDevice' => $d,
));
if (is_wp_error($r)) {
$devFail = true;
break;
}
$sum = yang_stat_parse_sum($r, array('pv_count', 'visitor_count'));
$dev[$d] = $sum;
}
if (!$devFail) {
$out['device'] = $dev;
}
}
/* ── ⑦ 地域分布 ─────────────────────────────────────────── */
if ($want('region')) {
$r = yang_stat_get_data('visit/district/a', 'pv_count,visitor_count', array(
'start_date' => gmdate('Ymd', current_time('timestamp') - 6 * 86400),
'end_date' => $todayStr,
'max_results' => 8,
'order' => 'pv_count,desc',
));
if (!is_wp_error($r)) {
$out['region'] = yang_stat_parse_rows($r);
}
}
return $out;
}
/* ══════════════════════════════════════════════════════════════════
* 七、面板渲染
* ══════════════════════════════════════════════════════════════════ */
/**
* 单个数字卡片。
*
* @param string $label
* @param mixed $value 已经过 yang_stat_num 的原始值(可为 null)
* @param string $cmp 对比值(昨日),null 表示不显示对比
* @param string $suffix
* @return string
*/
function yang_stat_card($label, $value, $cmp = null, $suffix = '')
{
$n = yang_stat_num($value);
$html = '<div class="yang-stat-card">';
$html .= '<div class="yang-stat-card-label">' . esc_html($label) . '</div>';
$html .= '<div class="yang-stat-card-value">' . yang_stat_fmt($value, $suffix) . '</div>';
/* 只有两个值都拿到了才显示环比。缺一个就不显示,
否则会出现"0% 增长"这种看着像真数据的假信息。 */
$c = yang_stat_num($cmp);
if ($n !== null && $c !== null && $c > 0) {
$pct = ($n - $c) / $c * 100;
$cls = $pct >= 0 ? 'up' : 'down';
$sign = $pct >= 0 ? '+' : '';
$html .= '<div class="yang-stat-card-diff ' . $cls . '">'
. esc_html($sign . number_format($pct, 1) . '%') . ' 较昨日</div>';
}
$html .= '</div>';
return $html;
}
/**
* 渲染整个面板。返回 HTML 字符串(供 info 块的 desc 用)。
*
* ── 为什么要返回字符串而不是直接 echo ──────────────────────────
* info 块的 desc 是拼进 `$output` 再 echo 的,所以这里必须返回字符串。
* 顺带好处:可以整体缓存、单测时直接比字符串。
*
* @param bool $force 跳缓存(「刷新数据」按钮传 true)
* @return string
*/
function yang_stat_render_panel($force = false)
{
$cred = yang_stat_credentials();
$site = $cred['site_id'] !== '' ? $cred['site_id']
: (function_exists('kratos_option') ? (string) kratos_option('stat_site_id', '') : '');
$hasCred = ($cred['client_id'] !== '' && $cred['client_secret'] !== '' && $cred['refresh_token'] !== '');
$hasSite = ($site !== '');
/* ── 未配置:给出明确的操作指引,不是一句"请先配置" ───────── */
if (!$hasCred || !$hasSite) {
$miss = array();
if (!$hasCred) { $miss[] = 'API 凭据(API Key / Secret Key / refresh_token)'; }
if (!$hasSite) { $miss[] = '站点 ID(site_id)'; }
$h = '<div class="yang-stat-panel yang-stat-empty">';
$h .= '<div class="yang-stat-empty-title">还没有接通百度统计</div>';
$h .= '<p>还缺:<b>' . esc_html(implode('、', $miss)) . '</b></p>';
$h .= '<ol class="yang-stat-steps">';
$h .= '<li>打开 <a href="https://tongji.baidu.com/sc-web/home/dataapi" target="_blank" rel="noopener">百度统计 → 管理 → 数据导出服务</a>,开通后拿到 <b>API Key</b> 与 <b>Secret Key</b>,填在本页上方。</li>';
$h .= '<li>把下面这个地址粘到浏览器,用<b>你自己的百度账号</b>登录并同意授权:</li>';
$h .= '</ol>';
/* 授权链接要把 client_id 填进去,用户直接点就行。
没填 Key 时给占位提示。 */
if ($cred['client_id'] !== '') {
$authUrl = add_query_arg(array(
'response_type' => 'code',
'client_id' => $cred['client_id'],
'redirect_uri' => 'oob',
'scope' => 'basic',
'display' => 'popup',
), 'https://openapi.baidu.com/oauth/2.0/authorize');
$h .= '<p class="yang-stat-authurl"><a href="' . esc_url($authUrl) . '" target="_blank" rel="noopener">点此授权</a></p>';
} else {
$h .= '<p class="yang-stat-authurl"><em>先填 API Key,这里会出现一键授权链接。</em></p>';
}
$h .= '<p>授权后会跳到一个显示 <b>code</b> 的页面,复制它填回本页的「授权码」框,保存即可 —— 主题会用它换 refresh_token,之后自动续期。</p>';
$h .= '<p class="yang-stat-tip">站点 ID 在百度统计的「管理 → 网站列表」里,点进站点后地址栏的 <code>siteId=</code> 后面那串数字。</p>';
$h .= '</div>';
return $h;
}
/* ── 已配置:取数 ─────────────────────────────────────────── */
$data = yang_stat_fetch($force);
$enabled = yang_stat_enabled_metrics();
$h = '<div class="yang-stat-panel">';
/* 失败:显示原因 + 已有缓存时间,绝不白屏 */
if (empty($data['ok'])) {
$msg = isset($data['error']) ? $data['error'] : '未知错误';
$h .= '<div class="yang-stat-error">';
$h .= '<div class="yang-stat-error-title">统计暂不可用</div>';
$h .= '<p>' . esc_html($msg) . '</p>';
$h .= '<p class="yang-stat-tip">设置页其它部分不受影响,可以照常修改并保存。'
. '若是 token 失效,重新走一次上面的授权流程即可。</p>';
$h .= '</div>';
$h .= '</div>';
return $h;
}
/* 元信息(缓存状态 / 取数时间) */
$when = !empty($data['fetched']) ? date_i18n('Y-m-d H:i', $data['fetched'] + (int) (get_option('gmt_offset') * 3600)) : '';
$h .= '<div class="yang-stat-meta">';
$h .= '数据时间:' . esc_html($when);
if (!empty($data['cached'])) {
$h .= ' <span class="yang-stat-badge">缓存</span>';
}
$h .= ' · 每 30 分钟自动更新一次';
$h .= '</div>';
/* ── 今日 / 昨日 数字卡片 ─────────────────────────────────── */
$today = isset($data['today']) && is_array($data['today']) ? $data['today'] : array();
$yest = isset($data['yesterday']) && is_array($data['yesterday']) ? $data['yesterday'] : array();
$cards = array();
if (in_array('pv', $enabled, true)) {
$cards[] = yang_stat_card('今日浏览量', isset($today['pv_count']) ? $today['pv_count'] : null,
isset($yest['pv_count']) ? $yest['pv_count'] : null);
}
if (in_array('uv', $enabled, true)) {
$cards[] = yang_stat_card('今日访客数', isset($today['visitor_count']) ? $today['visitor_count'] : null,
isset($yest['visitor_count']) ? $yest['visitor_count'] : null);
}
if (in_array('ip', $enabled, true)) {
$cards[] = yang_stat_card('今日 IP 数', isset($today['ip_count']) ? $today['ip_count'] : null,
isset($yest['ip_count']) ? $yest['ip_count'] : null);
}
if (in_array('new_visitor', $enabled, true)) {
$cards[] = yang_stat_card('今日新访客', isset($today['new_visitor_count']) ? $today['new_visitor_count'] : null,
isset($yest['new_visitor_count']) ? $yest['new_visitor_count'] : null);
}
if (in_array('visit', $enabled, true)) {
$cards[] = yang_stat_card('今日访问次数', isset($today['visit_count']) ? $today['visit_count'] : null,
isset($yest['visit_count']) ? $yest['visit_count'] : null);
}
if ($cards) {
$h .= '<div class="yang-stat-cards">' . implode('', $cards) . '</div>';
}
/* ── 质量指标 ─────────────────────────────────────────────── */
$q = array();
if (in_array('bounce', $enabled, true)) {
$q[] = array('跳出率', isset($today['bounce_ratio']) ? $today['bounce_ratio'] : null, '%');
}
if (in_array('avg_time', $enabled, true)) {
$v = isset($today['avg_visit_time']) ? yang_stat_duration($today['avg_visit_time']) : '<span class="yang-stat-na">—</span>';
$q[] = array('平均访问时长', $v, '');
}
if (in_array('avg_pages', $enabled, true)) {
$q[] = array('平均访问页数', isset($today['avg_visit_pages']) ? $today['avg_visit_pages'] : null, '');
}
if ($q) {
$h .= '<div class="yang-stat-quality">';
foreach ($q as $item) {
$h .= '<div class="yang-stat-quality-item">';
$h .= '<span class="yang-stat-quality-label">' . esc_html($item[0]) . '</span>';
/* 时长已经渲染成 HTML 片段了,不能再 esc_html;其余走 fmt。 */
if (is_string($item[1]) && strpos($item[1], '<') === 0) {
$h .= '<span class="yang-stat-quality-value">' . $item[1] . '</span>';
} else {
$h .= '<span class="yang-stat-quality-value">' . yang_stat_fmt($item[1], $item[2]) . '</span>';
}
$h .= '</div>';
}
$h .= '</div>';
}
/* ── 7 天趋势(纯 CSS 柱状条,不引图表库)────────────────────
引 chart.js 要多一个后台请求、几十 KB 脚本,而这里只是
七个数字的相对高度 —— 用 div 的 height 百分比就够。 */
if (in_array('trend', $enabled, true) && !empty($data['trend'])) {
$rows = $data['trend'];
$max = 0;
foreach ($rows as $r) {
$v = yang_stat_num(isset($r['metrics']['pv_count']) ? $r['metrics']['pv_count'] : null);
if ($v !== null && $v > $max) { $max = $v; }
}
$h .= '<div class="yang-stat-block">';
$h .= '<div class="yang-stat-block-title">最近 7 天浏览趋势</div>';
$h .= '<div class="yang-stat-chart">';
foreach ($rows as $r) {
$pv = yang_stat_num(isset($r['metrics']['pv_count']) ? $r['metrics']['pv_count'] : null);
$uv = yang_stat_num(isset($r['metrics']['visitor_count']) ? $r['metrics']['visitor_count'] : null);
/* 高度按最大值归一。max 为 0(全无数据)时给 0,不出 NaN。 */
$hp = ($max > 0 && $pv !== null) ? round($pv / $max * 100) : 0;
$label = $r['dim'] !== '' ? $r['dim'] : '';
$h .= '<div class="yang-stat-bar" title="' . esc_attr($label . ':PV ' . ($pv === null ? '—' : $pv) . ' / UV ' . ($uv === null ? '—' : $uv)) . '">';
$h .= '<div class="yang-stat-bar-inner" style="height:' . (int) $hp . '%"></div>';
$h .= '<div class="yang-stat-bar-label">' . esc_html(preg_replace('#^\d{4}/#', '', $label)) . '</div>';
$h .= '</div>';
}
$h .= '</div>';
$h .= '</div>';
}
/* ── 来源 / 页面 / 地域 三张清单 ───────────────────────────── */
$lists = array();
if (in_array('source', $enabled, true) && !empty($data['source'])) {
$lists[] = array('流量来源', $data['source']);
}
if (in_array('top_page', $enabled, true) && !empty($data['top_page'])) {
$lists[] = array('热门页面', $data['top_page']);
}
if (in_array('region', $enabled, true) && !empty($data['region'])) {
$lists[] = array('地域分布', $data['region']);
}
if ($lists) {
$h .= '<div class="yang-stat-lists">';
foreach ($lists as $one) {
$h .= '<div class="yang-stat-list">';
$h .= '<div class="yang-stat-block-title">' . esc_html($one[0]) . '</div>';
$h .= '<ol class="yang-stat-rank">';
$shown = 0;
foreach ($one[1] as $row) {
if ($shown >= 8) { break; }
$pv = yang_stat_num(isset($row['metrics']['pv_count']) ? $row['metrics']['pv_count'] : null);
if ($pv === null) { continue; }
$h .= '<li>';
$h .= '<span class="yang-stat-rank-name">' . esc_html($row['dim']) . '</span>';
$h .= '<span class="yang-stat-rank-val">' . yang_stat_fmt($pv) . '</span>';
$h .= '</li>';
$shown++;
}
if ($shown === 0) {
$h .= '<li class="yang-stat-rank-empty">暂无数据</li>';
}
$h .= '</ol>';
$h .= '</div>';
}
$h .= '</div>';
}
/* ── 设备分布 ─────────────────────────────────────────────── */
if (in_array('device', $enabled, true) && !empty($data['device'])) {
$dev = $data['device'];
$pcPv = isset($dev['pc']['pv_count']) ? yang_stat_num($dev['pc']['pv_count']) : null;
$mbPv = isset($dev['mobile']['pv_count']) ? yang_stat_num($dev['mobile']['pv_count']) : null;
$pcPv = $pcPv === null ? 0 : $pcPv;
$mbPv = $mbPv === null ? 0 : $mbPv;
$total = $pcPv + $mbPv;
$h .= '<div class="yang-stat-block">';
$h .= '<div class="yang-stat-block-title">设备分布(近 7 天)</div>';
if ($total > 0) {
$pcPct = round($pcPv / $total * 100, 1);
$mbPct = round(100 - $pcPct, 1);
$h .= '<div class="yang-stat-device-bar">';
$h .= '<span class="yang-stat-device-pc" style="width:' . $pcPct . '%"></span>';
$h .= '</div>';
$h .= '<div class="yang-stat-device-legend">';
$h .= '<span>电脑 ' . esc_html($pcPv) . ' 次(' . esc_html($pcPct) . '%)</span>';
$h .= '<span>移动 ' . esc_html($mbPv) . ' 次(' . esc_html($mbPct) . '%)</span>';
$h .= '</div>';
} else {
$h .= '<p class="yang-stat-tip">近 7 天没有可用的设备数据。</p>';
}
$h .= '</div>';
}
/* ── 底部:刷新按钮(走 AJAX,不整页刷新)────────────────── */
$h .= '<div class="yang-stat-actions">';
$h .= '<button type="button" class="button button-secondary" id="yang-stat-refresh">立即刷新数据</button>';
$h .= '<span class="yang-stat-refresh-note" id="yang-stat-refresh-note"></span>';
$h .= '</div>';
$h .= '</div>';
return $h;
}
/* ══════════════════════════════════════════════════════════════════
* 八、面板外壳(AJAX 懒加载)
* ══════════════════════════════════════════════════════════════════ */
/**
* 输出面板外壳。真正的数据由 JS 异步填进来。
*
* ── 为什么不能同步渲染(这是本功能最关键的一个决定)────────────
* 设置页会把所有 info 块一次性渲染完。如果面板在这里同步调接口,
* 一次要发 5~8 个 HTTP 请求:
* · 正常情况每个 200~400 ms ⇒ 设置页多等约 2 秒(能忍)
* · 百度统计挂了 / 被墙 / 超时 ⇒ 每个 5 秒 × 8 = **40 秒白屏**
* 而「API 失败绝不能让设置页白屏或卡住」是写在工单验收标准里的硬约束。
*
* 同步渲染无论如何都满足不了它,所以面板必须是"先出壳、后取数"。
* 好在后台永远有 JS,这个降级代价可以忽略。
*
* @return string
*/
function yang_stat_render_shell()
{
$cred = yang_stat_credentials();
$site = $cred['site_id'] !== '' ? $cred['site_id']
: (function_exists('kratos_option') ? (string) kratos_option('stat_site_id', '') : '');
$configured = ($cred['client_id'] !== '' && $cred['client_secret'] !== '' && $cred['refresh_token'] !== '' && $site !== '');
$h = '<div class="yang-stat-panel" id="yang-stat-panel"'
. ' data-configured="' . ($configured ? '1' : '0') . '">';
if (!$configured) {
/* 未配置时不需要打接口,直接同步给指引就行 —— 没有网络请求,
不存在卡住风险。 */
$h .= yang_stat_render_panel();
$h .= '</div>';
return $h;
}
/* 已配置:先给占位,JS 随后替换。 */
$h .= '<div class="yang-stat-loading" id="yang-stat-loading">'
. '<span class="spinner is-active"></span> 正在读取百度统计数据…</div>';
$h .= '<div id="yang-stat-body"></div>';
/* 底部操作区放在壳里,这样刷新按钮在加载期间也可见。 */
$h .= '<div class="yang-stat-actions">';
$h .= '<button type="button" class="button button-secondary" id="yang-stat-refresh">刷新数据</button>';
$h .= '<span class="yang-stat-refresh-note" id="yang-stat-refresh-note"></span>';
$h .= '</div>';
$h .= '</div>';
return $h;
}
/**
* AJAX:返回面板 HTML。
*
* 用 `wp_send_json_success(array('html' => ...))` 而不是直接输出 HTML,
* 这样 JS 能区分「成功」与「拿到的是一段错误页」。
*/
function yang_stat_ajax_panel()
{
/* 两道门,跟 theme-avatar.php 里保持一致的做法。 */
check_ajax_referer('yang_stat_panel', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => '权限不足'), 403);
}
$force = !empty($_POST['force']) && '1' === $_POST['force'];
$html = yang_stat_render_panel($force);
wp_send_json_success(array('html' => $html));
}
add_action('wp_ajax_yang_stat_panel', 'yang_stat_ajax_panel');
/* ══════════════════════════════════════════════════════════════════
* 九、授权码换 refresh_token
* ══════════════════════════════════════════════════════════════════ */
/**
* 用 authorization code 换 access_token + refresh_token,并存盘。
*
* @param string $code
* @return true|WP_Error
*/
function yang_stat_exchange_code($code)
{
$cred = yang_stat_credentials();
if ($cred['client_id'] === '' || $cred['client_secret'] === '') {
return new WP_Error('yang_stat_nocred', '要先填 API Key 与 Secret Key');
}
$code = trim($code);
if ($code === '') {
return new WP_Error('yang_stat_nocode', '授权码为空');
}
$url = add_query_arg(array(
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => $cred['client_id'],
'client_secret' => $cred['client_secret'],
'redirect_uri' => 'oob',
), 'https://openapi.baidu.com/oauth/2.0/token');
$json = yang_stat_http_get($url);
if (is_wp_error($json)) {
return $json;
}
$token = isset($json['access_token']) ? (string) $json['access_token'] : '';
$refresh = isset($json['refresh_token']) ? (string) $json['refresh_token'] : '';
if ($token === '' || $refresh === '') {
return new WP_Error('yang_stat_notoken', '换取失败:响应里没有 access_token / refresh_token');
}
$expires = isset($json['expires_in']) ? (int) $json['expires_in'] : 2592000;
$ok = yang_stat_save_credentials(array(
'access_token' => $token,
'refresh_token' => $refresh,
'expires_at' => time() + max(3600, $expires - 86400),
));
if (!$ok) {
return new WP_Error('yang_stat_write', '凭据写盘失败(检查 wp-content/uploads 是否可写)');
}
/* 授权成功后清掉旧缓存,避免面板还显示上一次失败的结果。 */
yang_stat_flush_cache();
return true;
}
/**
* 清掉所有本功能的 transient。
*/
function yang_stat_flush_cache()
{
global $wpdb;
/* transient 的键名带 md5,没法逐个 delete,只能按 LIKE 扫。
数量极少(一天最多几十条),一次 DELETE 足够。 */
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '\\_transient\\_yang\\_stat\\_%'
OR option_name LIKE '\\_transient\\_timeout\\_yang\\_stat\\_%'"
);
}
/* ══════════════════════════════════════════════════════════════════
* 十、凭据字段的拦截保存
* ══════════════════════════════════════════════════════════════════ */
/**
* 需要存进独立文件的字段。key = 设置字段 id,value = 凭据文件里的键。
*
* @return array
*/
function yang_stat_secret_fields()
{
return array(
'stat_api_key' => 'client_id',
'stat_api_secret' => 'client_secret',
'stat_refresh' => 'refresh_token',
);
}
/**
* 拦截 password 字段提交:存进凭据文件,options 表里只留空串。
*
* ── 为什么复用 of_sanitize_password ─────────────────────────────
* 跟 theme-writing.php 里的做法一致:这是唯一能同时拿到「字段 id」
* 和「新值」又不必改框架的位置。而且 password 类型本来就不会
* 回显真实值给"让我偷懒把明文存 options"留借口 —— 这里更进一步,
* 连 options 里都不放。
*
* ── 空值 = 不修改 ───────────────────────────────────────────────
* 表单永远回显空串,所以"空"必须解释成"别动已保存的"。
* 若当成清空,用户每次保存别的设置都会把凭据抹掉。
*
* @param mixed $value
* @param array $option
* @return string 恒为空串
*/
function yang_stat_capture_secret($value, $option = array())
{
if (!is_array($option) || empty($option['id'])) {
return $value;
}
$map = yang_stat_secret_fields();
$id = $option['id'];
if (!isset($map[$id])) {
return $value;
}
$new = is_string($value) ? trim($value) : '';
if ($new === '') {
return '';
}
yang_stat_save_credentials(array($map[$id] => $new));
return '';
}
add_filter('of_sanitize_password', 'yang_stat_capture_secret', 10, 2);
/**
* 拦截「授权码」字段:不是存起来,而是立刻换 token。
*
* ── 为什么在 sanitize 里做网络请求 ──────────────────────────────
* 这是唯一能在"用户点保存"那一刻拿到值的地方。代价是保存会多花
* 最多 5 秒(超时上限),但**只有当用户真的粘了 code 时**才发生 ——
* 日常改别的设置走 `$new === ''` 分支,零开销。
*
* 结果写进一个 transient,让下次渲染面板时把成败说清楚。
*
* @param mixed $value
* @param array $option
* @return string 恒为空串(code 是一次性的,不该存)
*/
function yang_stat_capture_code($value, $option = array())
{
if (!is_array($option) || empty($option['id']) || $option['id'] !== 'stat_auth_code') {
return $value;
}
$code = is_string($value) ? trim($value) : '';
if ($code === '') {
return '';
}
$r = yang_stat_exchange_code($code);
if (is_wp_error($r)) {
set_transient('yang_stat_code_result', array('ok' => false, 'msg' => $r->get_error_message()), 300);
} else {
set_transient('yang_stat_code_result', array('ok' => true, 'msg' => '授权成功,已拿到 refresh_token'), 300);
}
return '';
}
add_filter('of_sanitize_text', 'yang_stat_capture_code', 10, 2);
/**
* 站点 ID 也要进凭据文件(它跟 token 是一套东西,
* 放 options 表里会让"换了站点但 token 是旧的"这种错配难以排查)。
*
* @param mixed $value
* @param array $option
* @return string
*/
function yang_stat_capture_site($value, $option = array())
{
if (!is_array($option) || empty($option['id']) || $option['id'] !== 'stat_site_id') {
return $value;
}
$v = is_string($value) ? trim($value) : '';
/* 站 ID 是纯数字。非数字直接丢弃,避免把整串 URL 存进去。 */
if ($v !== '' && !preg_match('/^\d+$/', $v)) {
/* 用户可能粘了整个地址栏。尝试从中抠 siteId。 */
if (preg_match('/siteId=(\d+)/i', $v, $m)) {
$v = $m[1];
} else {
$v = '';
}
}
if ($v !== '') {
yang_stat_save_credentials(array('site_id' => $v));
}
return $v;
}
add_filter('of_sanitize_text', 'yang_stat_capture_site', 10, 2);
/* ══════════════════════════════════════════════════════════════════
* 十一、后台资源(样式 + 懒加载脚本)
* ══════════════════════════════════════════════════════════════════ */
/**
* 挂后台样式与脚本。
*
* 🔴 判断依据必须是 `$_GET['page']`,不能用 `$hook`。
* 理由与 theme-avatar.php 里那段注释一致:框架把真实 hook 后缀
* 存在私有的 `$this->options_screen` 里,外面拿不到。靠 `$hook`
* 模糊匹配一旦失手,CSS/JS **静默不加载**,表现是"面板一片空白
* 且没有刷新按钮",连带报错都没有。
*
* @param string $hook
*/
function yang_stat_admin_assets($hook)
{
if (!isset($_GET['page']) || $_GET['page'] !== 'kratos_options') {
return;
}
$css = '
/* ── 统计面板 ─────────────────────────────────────────────────── */
.yang-stat-panel{margin:2px 0 6px;padding:0}
.yang-stat-loading{display:flex;align-items:center;gap:8px;padding:22px 0;color:#646970;font-size:13px}
.yang-stat-loading .spinner{margin:0;float:none}
.yang-stat-empty{padding:18px 20px;border:1px solid #dcdcde;border-radius:8px;background:#fff}
.yang-stat-empty-title{font-size:14px;font-weight:600;color:#1d2327;margin-bottom:10px}
.yang-stat-steps{margin:8px 0 12px 20px;padding:0;list-style:decimal}
.yang-stat-steps li{margin:6px 0;font-size:13px;line-height:1.65;color:#3c434a}
.yang-stat-authurl{margin:6px 0 12px}
.yang-stat-tip{font-size:12px;color:#646970;line-height:1.6;margin:8px 0 0}
.yang-stat-meta{font-size:12px;color:#646970;margin:0 0 12px;padding-bottom:10px;border-bottom:1px solid #f0f0f1}
.yang-stat-badge{display:inline-block;padding:0 6px;border-radius:3px;background:#f0f0f1;color:#646970;font-size:11px;line-height:18px}
.yang-stat-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:0 0 14px}
.yang-stat-card{padding:12px 14px;border:1px solid #dcdcde;border-radius:8px;background:#fff}
.yang-stat-card-label{font-size:12px;color:#646970;margin-bottom:6px}
.yang-stat-card-value{font-size:24px;line-height:1.2;font-weight:600;color:#1d2327;font-variant-numeric:tabular-nums}
.yang-stat-card-diff{margin-top:5px;font-size:11px;color:#646970}
.yang-stat-card-diff.up{color:#d63638}
.yang-stat-card-diff.down{color:#00a32a}
.yang-stat-quality{display:flex;flex-wrap:wrap;gap:22px;padding:12px 14px;margin:0 0 14px;border:1px solid #dcdcde;border-radius:8px;background:#fafafa}
.yang-stat-quality-item{display:flex;flex-direction:column;gap:4px}
.yang-stat-quality-label{font-size:12px;color:#646970}
.yang-stat-quality-value{font-size:16px;font-weight:600;color:#1d2327;font-variant-numeric:tabular-nums}
.yang-stat-na{color:#a7aaad;font-weight:400}
.yang-stat-block{margin:0 0 14px}
.yang-stat-block-title{font-size:13px;font-weight:600;color:#1d2327;margin:0 0 10px}
/* 7 天趋势:纯 CSS 柱条,柱底对齐 */
.yang-stat-chart{display:flex;align-items:flex-end;gap:8px;height:110px;padding:0 2px;border-bottom:1px solid #dcdcde}
.yang-stat-bar{flex:1 1 0;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;height:100%;min-width:0}
.yang-stat-bar-inner{width:100%;max-width:46px;background:#2271b1;border-radius:3px 3px 0 0;min-height:2px;transition:height .2s}
.yang-stat-bar-label{font-size:10px;color:#646970;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.yang-stat-lists{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:18px;margin:0 0 14px}
.yang-stat-list{min-width:0}
.yang-stat-rank{margin:0;padding:0;list-style:none;counter-reset:yangrank}
.yang-stat-rank li{display:flex;justify-content:space-between;gap:10px;padding:6px 0;border-bottom:1px dashed #f0f0f1;font-size:12px;counter-increment:yangrank}
.yang-stat-rank li:last-child{border-bottom:0}
.yang-stat-rank-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#3c434a}
/* 序号用 ::before 画,避免多一个 <span> 又要处理缩进 */
.yang-stat-rank-name::before{content:counter(yangrank) ". ";color:#a7aaad}
.yang-stat-rank-val{flex:0 0 auto;font-weight:600;color:#1d2327;font-variant-numeric:tabular-nums}
.yang-stat-rank-empty{color:#a7aaad;font-size:12px;padding:6px 0;display:block}
.yang-stat-rank-empty::before{content:none}
.yang-stat-device-bar{display:flex;height:14px;border-radius:7px;overflow:hidden;background:#f0f0f1}
.yang-stat-device-pc{display:block;background:#2271b1;height:100%}
.yang-stat-device-legend{display:flex;gap:18px;margin-top:7px;font-size:12px;color:#646970}
.yang-stat-error{padding:14px 16px;border:1px solid #f0c0c0;border-left:3px solid #d63638;border-radius:6px;background:#fcf0f0}
.yang-stat-error-title{font-size:13px;font-weight:600;color:#1d2327;margin-bottom:6px}
.yang-stat-error p{margin:4px 0;font-size:12px;line-height:1.65;color:#3c434a;word-break:break-word}
.yang-stat-actions{display:flex;align-items:center;gap:10px;margin-top:12px}
.yang-stat-refresh-note{font-size:12px;color:#646970}
';
wp_register_style('yang-stat-admin', false, array(), null);
wp_enqueue_style('yang-stat-admin');
wp_add_inline_style('yang-stat-admin', $css);
$js = <<<'JS'
(function(){
var panel = document.getElementById('yang-stat-panel');
if (!panel) return;
var body = document.getElementById('yang-stat-body');
var loading = document.getElementById('yang-stat-loading');
var btn = document.getElementById('yang-stat-refresh');
var note = document.getElementById('yang-stat-refresh-note');
if (!body) return; // 未配置:同步渲染,无需拉取
var CFG = window.YANG_STAT_CFG || {};
function setNote(t){ if (note) { note.textContent = t || ''; } }
function load(force){
if (btn) { btn.disabled = true; }
if (loading) { loading.style.display = 'flex'; }
body.innerHTML = '';
setNote('');
var fd = new FormData();
fd.append('action', 'yang_stat_panel');
fd.append('nonce', CFG.nonce || '');
if (force) { fd.append('force', '1'); }
fetch(CFG.ajaxUrl || '', { method: 'POST', credentials: 'same-origin', body: fd })
.then(function(r){
/* 即使 HTTP 不是 200 也要往下读 —— 后台可能返回 WP 的错误页,
直接丢会把"没有权限"显示成"网络错误",用户无从判断。 */
return r.json().catch(function(){ return null; });
})
.then(function(j){
if (loading) { loading.style.display = 'none'; }
if (j && j.success && j.data && typeof j.data.html === 'string') {
body.innerHTML = j.data.html;
setNote(force ? '已刷新' : '');
} else {
var msg = (j && j.data && j.data.message) ? j.data.message : '取数失败';
body.innerHTML = '<div class="yang-stat-error"><div class="yang-stat-error-title">统计暂不可用</div><p>'
+ String(msg).replace(/[<>&]/g, function(c){
return {'<':'&lt;','>':'&gt;','&':'&amp;'}[c];
}) + '</p><p class="yang-stat-tip">设置页其它部分不受影响。</p></div>';
}
})
.catch(function(err){
if (loading) { loading.style.display = 'none'; }
body.innerHTML = '<div class="yang-stat-error"><div class="yang-stat-error-title">统计暂不可用</div><p>'
+ '请求没走通(' + String(err && err.message ? err.message : err) + '),可能是网络或权限问题。</p>'
+ '<p class="yang-stat-tip">设置页其它部分不受影响,可以照常保存。</p></div>';
})
.then(function(){ if (btn) { btn.disabled = false; } });
}
/* 刷新按钮用事件委托:AJAX 换进来的新 HTML 里没有这个按钮,
绑在 document 上才不会被替换掉。 */
document.addEventListener('click', function(e){
var t = e.target;
if (t && t.id === 'yang-stat-refresh') {
e.preventDefault();
load(true);
}
});
load(false);
})();
JS;
/* 与 theme-avatar.php 保持一致的注册方式:src 传空串,
配置用 wp_localize_script 挂 —— 它会把 JSON 放在脚本之前,
且能自动转义引号,比自己拼字符串再 wp_add_inline_script 稳。 */
wp_register_script('yang-stat-admin', '', array(), null, true);
wp_enqueue_script('yang-stat-admin');
wp_localize_script('yang-stat-admin', 'YANG_STAT_CFG', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('yang_stat_panel'),
));
wp_add_inline_script('yang-stat-admin', $js);
}
add_action('admin_enqueue_scripts', 'yang_stat_admin_assets');
/* ══════════════════════════════════════════════════════════════════
* 十二、前台埋点开关
* ══════════════════════════════════════════════════════════════════ */
/**
* 埋点是否启用。
*
* ── 开关与代码分离的意义 ────────────────────────────────────────
* footer.php 里那段 `seo_statistical` 是用户自己粘的统计脚本。
* 以前想停掉只能把代码删了、想恢复再粘回来 —— 代码一旦丢了
* (换主题、误清空)就得重新去百度后台找。开关把它变成一次点击。
*
* 默认值取 `1`(开):升级上来的老用户本来就一直在跑这段代码,
* 默认关掉等于"升级后统计突然断了",属于静默破坏。
*
* @return bool
*/
function yang_stat_tracking_enabled()
{
if (!function_exists('kratos_option')) {
return true;
}
/* 🔴 三种状态必须分清,不能折叠成两种:
* null = 从未配置过 ⇒ 开(老用户一直在跑,默认关掉等于静默断统计)
* false = 用户主动取消 ⇒ **关**
* '1' = 用户主动勾选 ⇒ 开
*
* 第一版这里写的是 `if ($v === null || $v === false) return true;`,
* 把 false 也归进"开",导致**取消勾选完全无效** ——
* 埋点永远输出,用户以为关了其实没关,而且前台毫无异常可供发现。
* 框架的 of_sanitize_checkbox() 对未勾选明确写 `false`
* (见 class-options-sanitization.php:88-95),所以 false 是
* "用户明确说不要",必须尊重。 */
$v = kratos_option('stat_track_enable', null);
if ($v === null || $v === '') {
return true;
}
return (bool) $v;
}