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'; } /** * 读凭据。返回统一结构的数组,缺的键是空串。 * * 文件内容是 ` '', '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, ' $_) { 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 = "—'; } 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 '—'; } $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 = '
'; $html .= '
' . esc_html($label) . '
'; $html .= '
' . yang_stat_fmt($value, $suffix) . '
'; /* 只有两个值都拿到了才显示环比。缺一个就不显示, 否则会出现"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 .= '
' . esc_html($sign . number_format($pct, 1) . '%') . ' 较昨日
'; } $html .= '
'; 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 = '
'; $h .= '
还没有接通百度统计
'; $h .= '

还缺:' . esc_html(implode('、', $miss)) . '

'; $h .= '
    '; $h .= '
  1. 打开 百度统计 → 管理 → 数据导出服务,开通后拿到 API Key 与 Secret Key,填在本页上方。
  2. '; $h .= '
  3. 把下面这个地址粘到浏览器,用**你自己的百度账号**登录并同意授权:
  4. '; $h .= '
'; /* 授权链接要把 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 .= '

点此授权

'; } else { $h .= '

先填 API Key,这里会出现一键授权链接。

'; } $h .= '

授权后会跳到一个显示 code 的页面,复制它填回本页的「授权码」框,保存即可 —— 主题会用它换 refresh_token,之后自动续期。

'; $h .= '

站点 ID 在百度统计的「管理 → 网站列表」里,点进站点后地址栏的 siteId= 后面那串数字。

'; $h .= '
'; return $h; } /* ── 已配置:取数 ─────────────────────────────────────────── */ $data = yang_stat_fetch($force); $enabled = yang_stat_enabled_metrics(); $h = '
'; /* 失败:显示原因 + 已有缓存时间,绝不白屏 */ if (empty($data['ok'])) { $msg = isset($data['error']) ? $data['error'] : '未知错误'; $h .= '
'; $h .= '
统计暂不可用
'; $h .= '

' . esc_html($msg) . '

'; $h .= '

设置页其它部分不受影响,可以照常修改并保存。' . '若是 token 失效,重新走一次上面的授权流程即可。

'; $h .= '
'; $h .= '
'; return $h; } /* 元信息(缓存状态 / 取数时间) */ $when = !empty($data['fetched']) ? date_i18n('Y-m-d H:i', $data['fetched'] + (int) (get_option('gmt_offset') * 3600)) : ''; $h .= '
'; $h .= '数据时间:' . esc_html($when); if (!empty($data['cached'])) { $h .= ' 缓存'; } $h .= ' · 每 30 分钟自动更新一次'; $h .= '
'; /* ── 今日 / 昨日 数字卡片 ─────────────────────────────────── */ $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 .= '
' . implode('', $cards) . '
'; } /* ── 质量指标 ─────────────────────────────────────────────── */ $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']) : '—'; $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 .= '
'; foreach ($q as $item) { $h .= '
'; $h .= '' . esc_html($item[0]) . ''; /* 时长已经渲染成 HTML 片段了,不能再 esc_html;其余走 fmt。 */ if (is_string($item[1]) && strpos($item[1], '<') === 0) { $h .= '' . $item[1] . ''; } else { $h .= '' . yang_stat_fmt($item[1], $item[2]) . ''; } $h .= '
'; } $h .= '
'; } /* ── 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 .= '
'; $h .= '
最近 7 天浏览趋势
'; $h .= '
'; 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 .= '
'; $h .= '
'; $h .= '
' . esc_html(preg_replace('#^\d{4}/#', '', $label)) . '
'; $h .= '
'; } $h .= '
'; $h .= '
'; } /* ── 来源 / 页面 / 地域 三张清单 ───────────────────────────── */ $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 .= '
'; foreach ($lists as $one) { $h .= '
'; $h .= '
' . esc_html($one[0]) . '
'; $h .= '
    '; $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 .= '
  1. '; $h .= '' . esc_html($row['dim']) . ''; $h .= '' . yang_stat_fmt($pv) . ''; $h .= '
  2. '; $shown++; } if ($shown === 0) { $h .= '
  3. 暂无数据
  4. '; } $h .= '
'; $h .= '
'; } $h .= '
'; } /* ── 设备分布 ─────────────────────────────────────────────── */ 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 .= '
'; $h .= '
设备分布(近 7 天)
'; if ($total > 0) { $pcPct = round($pcPv / $total * 100, 1); $mbPct = round(100 - $pcPct, 1); $h .= '
'; $h .= ''; $h .= '
'; $h .= '
'; $h .= '电脑 ' . esc_html($pcPv) . ' 次(' . esc_html($pcPct) . '%)'; $h .= '移动 ' . esc_html($mbPv) . ' 次(' . esc_html($mbPct) . '%)'; $h .= '
'; } else { $h .= '

近 7 天没有可用的设备数据。

'; } $h .= '
'; } /* ── 底部:刷新按钮(走 AJAX,不整页刷新)────────────────── */ $h .= '
'; $h .= ''; $h .= ''; $h .= '
'; $h .= ''; 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 = '
'; if (!$configured) { /* 未配置时不需要打接口,直接同步给指引就行 —— 没有网络请求, 不存在卡住风险。 */ $h .= yang_stat_render_panel(); $h .= '
'; return $h; } /* 已配置:先给占位,JS 随后替换。 */ $h .= '
' . ' 正在读取百度统计数据…
'; $h .= '
'; /* 底部操作区放在壳里,这样刷新按钮在加载期间也可见。 */ $h .= '
'; $h .= ''; $h .= ''; $h .= '
'; $h .= ''; 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 画,避免多一个 又要处理缩进 */ .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 = '
统计暂不可用

' + String(msg).replace(/[<>&]/g, function(c){ return {'<':'<','>':'>','&':'&'}[c]; }) + '

设置页其它部分不受影响。

'; } }) .catch(function(err){ if (loading) { loading.style.display = 'none'; } body.innerHTML = '
统计暂不可用

' + '请求没走通(' + String(err && err.message ? err.message : err) + '),可能是网络或权限问题。

' + '

设置页其它部分不受影响,可以照常保存。

'; }) .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; }