diff --git a/footer.php b/footer.php
index 2f18c90..5eb7abb 100644
--- a/footer.php
+++ b/footer.php
@@ -55,7 +55,13 @@
if (kratos_option('s_gov')) {
echo '
' . kratos_option('s_gov') . '
';
}
- if (kratos_option('seo_statistical')) {echo '' . kratos_option('seo_statistical') . '
';}
+ /* 统计脚本:内容由用户在「收录与统计 → 统计代码」里填,
+ 开关由「埋点开关」控制(见 inc/theme-stat.php)。
+ 开关与代码分离,是为了让"临时停掉统计"不必删代码 ——
+ 删了之后想恢复就得重新去百度后台找那串 hash。 */
+ if (yang_stat_tracking_enabled() && kratos_option('seo_statistical')) {
+ echo '' . kratos_option('seo_statistical') . '
';
+ }
/* 国旗:勾了「是否添加国旗」才显示。以前 src 直接取选项,
用户没上传地址就得到
,页面上是一个破图图标。
逻辑抽到 yang_flag_block() 里,测试能直接测同一个函数,
diff --git a/functions.php b/functions.php
index 2b8c3c4..7b0f50e 100644
--- a/functions.php
+++ b/functions.php
@@ -99,4 +99,14 @@ require get_template_directory() . '/inc/theme-mail.php';
require get_template_directory() . '/inc/theme-smtp.php';
// 写作增强:发布时自动生成别名 / 标签 / 分类 / 摘要(四引擎各自独立)
-require get_template_directory() . '/inc/theme-writing.php';
\ No newline at end of file
+require get_template_directory() . '/inc/theme-writing.php';
+
+// 访问统计面板:百度统计 API 的数据直出到设置页 + 前台埋点开关。
+// 加载时序注意:本文件(functions.php)在 wp-settings 阶段就执行,
+// 而 inc/theme-options.php 要等到 init:20 才被 autoload.php require
+// (见 inc/options-framework/autoload.php:40/59)。所以本模块**先于**
+// theme-options.php 加载 —— 它定义的面板函数到构建选项数组时已经就绪,
+// 那边可以直接调用,不需要 function_exists 兜底。
+// 但也因为先加载:本文件里不得调用 kratos_option(),
+// 那个函数由 autoload.php 在更晚才定义(目前只在函数体内调用,安全)。
+require get_template_directory() . '/inc/theme-stat.php';
diff --git a/inc/theme-options.php b/inc/theme-options.php
index 8a4cb3f..427c3a6 100644
--- a/inc/theme-options.php
+++ b/inc/theme-options.php
@@ -825,6 +825,110 @@ function kratos_options()
'type' => 'textarea',
);
+ /* ══════════════════════════════════════════════════════════
+ * 访问统计面板(百度统计 API)
+ *
+ * 这一整段都是「附加功能」:不填任何东西时,本页其余部分
+ * 完全不受影响。面板本身由 inc/theme-stat.php 负责渲染,
+ * 这里只负责把控件摆出来。
+ * ══════════════════════════════════════════════════════════ */
+ $options[] = array(
+ 'name' => __('启用统计埋点', 'kratos'),
+ 'desc' => __('关掉后前台不再输出上面那段「统计代码」,但代码本身**不会被删除** —— '
+ . '想恢复时勾回来即可,不用再去百度后台找那串 hash。', 'kratos'),
+ 'id' => 'stat_track_enable',
+ 'std' => '1',
+ 'type' => 'checkbox',
+ );
+
+ $options[] = array(
+ 'name' => __('访问统计面板', 'kratos'),
+ 'desc' => __('下面的面板会读百度统计的接口,把数据直接显示在这里。'
+ . '需要先在百度统计开通「数据导出服务」拿到 API Key。', 'kratos'),
+ 'type' => 'info',
+ );
+
+ $options[] = array(
+ 'name' => __('API Key', 'kratos'),
+ 'desc' => __('百度统计 → 管理 → 数据导出服务'
+ . ' 开通后可见,形如 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx。
'
+ . '留空表示不修改已保存的值。', 'kratos'),
+ 'id' => 'stat_api_key',
+ 'type' => 'password',
+ 'std' => '',
+ );
+
+ $options[] = array(
+ 'name' => __('Secret Key', 'kratos'),
+ 'desc' => __('与 API Key 在同一页面。'
+ . '留空表示不修改。这一项只存在服务器的独立文件里(权限 600),'
+ . '不会写进设置表,也不会在表单里回显。', 'kratos'),
+ 'id' => 'stat_api_secret',
+ 'type' => 'password',
+ 'std' => '',
+ );
+
+ $options[] = array(
+ 'name' => __('站点 ID', 'kratos'),
+ 'desc' => __('百度统计「管理 → 网站列表」点进站点,地址栏里 siteId= 后面那串数字。'
+ . '直接粘整条地址也行,会自动抠出数字。', 'kratos'),
+ 'id' => 'stat_site_id',
+ 'type' => 'text',
+ );
+
+ $options[] = array(
+ 'name' => __('授权码', 'kratos'),
+ 'desc' => __('在面板里点「授权」拿到 code 之后,粘到这里并保存 —— '
+ . '主题会立刻用它换 refresh_token,之后自动续期,不用再管。
'
+ . '授权码只能用一次,保存后这一格会自动清空。', 'kratos'),
+ 'id' => 'stat_auth_code',
+ 'type' => 'text',
+ 'std' => '',
+ );
+
+ $options[] = array(
+ 'name' => __('refresh_token(高级)', 'kratos'),
+ 'desc' => __('一般不需要手动填 —— 走上面的「授权码」流程会自动得到。'
+ . '只有在别处已经拿过 token、要迁移过来时才用。
'
+ . '留空表示不修改。', 'kratos'),
+ 'id' => 'stat_refresh',
+ 'type' => 'password',
+ 'std' => '',
+ );
+
+ /* 指标多选:全做,但让用户决定显示哪些。
+ 理由见 inc/theme-stat.php 的 yang_stat_metric_defs() ——
+ 每个指标对应一次 API 调用,而配额是按次计的。 */
+ $yang_stat_metric_opts = array();
+ if (function_exists('yang_stat_metric_defs')) {
+ foreach (yang_stat_metric_defs() as $k => $def) {
+ $yang_stat_metric_opts[$k] = array('name' => $def['label']);
+ }
+ }
+ $yang_stat_metric_all = array();
+ foreach ($yang_stat_metric_opts as $k => $_) {
+ $yang_stat_metric_all[$k] = '1';
+ }
+
+ $options[] = array(
+ 'name' => __('面板显示哪些指标', 'kratos'),
+ 'desc' => __('全部取消勾选 = 面板只显示"已接通"不显示数字。'
+ . '勾得越多,每次刷新后台时调用的接口越多(配额按次计)。'
+ . '默认全选。', 'kratos'),
+ 'id' => 'stat_metrics',
+ 'std' => $yang_stat_metric_all,
+ 'type' => 'multicheck',
+ 'options' => $yang_stat_metric_opts,
+ );
+
+ $options[] = array(
+ 'name' => __('数据概览', 'kratos'),
+ 'desc' => function_exists('yang_stat_render_shell')
+ ? yang_stat_render_shell()
+ : '统计模块未加载',
+ 'type' => 'info',
+ );
+
$options[] = array(
'name' => __('robots.txt 内容', 'kratos'),
'desc' => __('- 需要 ', 'kratos') . $seoreading . __(' 是开启的状态,下面的配置才会生效', 'kratos'),
diff --git a/inc/theme-stat.php b/inc/theme-stat.php
new file mode 100644
index 0000000..7d46cf9
--- /dev/null
+++ b/inc/theme-stat.php
@@ -0,0 +1,1815 @@
+ 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 .= '- 打开 百度统计 → 管理 → 数据导出服务,开通后拿到 API Key 与 Secret Key,填在本页上方。
';
+ $h .= '- 把下面这个地址粘到浏览器,用**你自己的百度账号**登录并同意授权:
';
+ $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 .= '- ';
+ $h .= '' . esc_html($row['dim']) . '';
+ $h .= '' . yang_stat_fmt($pv) . '';
+ $h .= '
';
+ $shown++;
+ }
+ if ($shown === 0) {
+ $h .= '- 暂无数据
';
+ }
+ $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;
+}
diff --git a/style.css b/style.css
index 251b03d..939b5ac 100644
--- a/style.css
+++ b/style.css
@@ -1,7 +1,7 @@
/*
Theme Name: Kratos_Yang
Text Domain: kratos_Yang
-Version: 4.0.0
+Version: 4.1.0
Requires PHP: 7.0
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好