Files

1816 lines
77 KiB
PHP
Raw Permalink Normal View History

<?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;
}