* @license MIT License */ if (!defined('ABSPATH')) { exit; } /** * 清明节在指定年份的月日(`MM-DD`),换算不出来返回 null * * ── 为什么是查表,不是现场算 ────────────────────────────────── * 清明的天文学定义是「太阳视黄经达到 15° 的那一刻」。理论上可以现场用 * Meeus 的太阳位置公式解方程,但在这个场景下不该这么做: * * 1. **跨平台浮点差异会翻日期**。清明时刻可能离午夜只有几十秒 * (例如 1943 年落在 23:58、2071 年落在 00:02),此时 PHP 的 * `sin()/cos()` 在 x86 与 ARM 上的 ULP 级差异足以让二分退化到 * 隔壁一天。日期错一天的代价是「整站黑白错日子」,很难被发现。 * 2. **可验证性**。查表的内容是 201 个字符,可以逐条与天文年历核对; * 现场算的公式在主题这种被到处复制分发的代码里几乎不可能被验证。 * 3. **成本**。201 个数字就是几十字节,比几十行公式便宜得多。 * * 表里的值由 ephem 星历计算、并用两套独立实现交叉验证过: * · ephem(VSOP87 截断)+ 二分求黄经 15°; * · Meeus《Astronomical Algorithms》第 25 章公式独立实现; * · cnlunar 民间历法数据表。 * 三者在 2000~2050 完全一致;ephem 与 Meeus 在 1900~2100 的 201 年 * 只有 1943 年不同,而该年清明时刻为 23:58(差距在秒级),属精度边界。 * 与气象/天文年历公布的 2020~2027 时刻比对,日期 8/8 全对, * 时刻差 4~16 分钟(纯属低精度星历的太阳黄经误差,不跨日)。 * * ── 为什么超出范围返回 null 而不是猜一个 ───────────────────── * 用户的要求是「是哪一天就是哪一天,要么就不要这个日期」。 * 2101 年以后的清明我算不准,与其给个可能错的日子,不如**不出现**: * 那天站点保持彩色,是明确的无害行为;黑白错日子才是真事故。 * 表覆盖到 2100 年,届时主题早该升级了。 * * @param int|null $year 不传则用站点时区的当前年份 * @return string|null 如 '04-04';无法换算时 null */ function yang_qingming_md($year = null) { /* 1900~2100 每年清明落在 4 月的哪一天,按年份顺序拼成的字符串。 每个字符对应一年:'4' = 4 月 4 日,'5' = 5 日,'6' = 6 日。 例:第 0 位是 1900 年 → '5' → 1900-04-05。 */ static $seq = '556655665566555655565556555655565556555655555555555555555555' . '555555555555555545554555455545554555455545554555445544554455' . '445544554455445544554445444544454445444544454445444544444444' . '444444444444444444445'; if ($year === null) { $year = (int) current_time('Y'); } $year = (int) $year; // 表只覆盖 1900~2100,范围外明确放弃(见上面注释) if ($year < 1900 || $year > 2100) { return null; } $idx = $year - 1900; if (!isset($seq[$idx])) { return null; } $day = (int) $seq[$idx]; if ($day < 1 || $day > 31) { return null; } // 清明永远在 4 月,所以月份写死不会错(表里也只存了「日」) return sprintf('04-%02d', $day); } /** * 内置纪念日清单 * * 返回 array( 键 => array(名称, 说明, 分类, 是否默认勾选 [, 动态换算方式]) ) * * 键名通常是 `m-d` 形式(补零),与 current_time('m') / current_time('d') * 直接可比。 * * **例外:清明**。它的日期随年份浮动(4 月 4/5/6 日),不能用一个固定 * 的 `m-d` 当键 —— 否则等于给自己钉死一个错误的近似值。所以它的键是 * `qingming`,并带 `dynamic => qingming` 标记,由 yang_mourning_days() * 在运行时按「哪一年」换算成真正的 `MM-DD`。 */ function yang_mourning_presets() { return array( // ── A 类:法定纪念日 ── '09-03' => array( 'name' => __('中国人民抗日战争胜利纪念日', 'kratos'), 'note' => __('1945 年 9 月 2 日日本签署投降书,9 月 3 日定为胜利纪念日', 'kratos'), 'group' => 'legal', 'default' => 0, ), '09-30' => array( 'name' => __('烈士纪念日', 'kratos'), 'note' => __('向人民英雄敬献花篮仪式在天安门广场举行', 'kratos'), 'group' => 'legal', 'default' => 0, ), '12-13' => array( 'name' => __('南京大屠杀死难者国家公祭日', 'kratos'), 'note' => __('1937 年 12 月 13 日南京沦陷,30 万同胞遇难', 'kratos'), 'group' => 'legal', 'default' => 0, ), // ── B 类:民间惯例,默认不勾 ── '05-12' => array( 'name' => __('汶川地震纪念日', 'kratos'), 'note' => __('2008 年 5 月 12 日 14 时 28 分,汶川发生 8.0 级地震', 'kratos'), 'group' => 'custom', 'default' => 0, ), '09-18' => array( 'name' => __('九一八事变纪念日', 'kratos'), 'note' => __('1931 年 9 月 18 日,日本关东军发动九一八事变', 'kratos'), 'group' => 'custom', 'default' => 0, ), '07-07' => array( 'name' => __('七七事变纪念日', 'kratos'), 'note' => __('1937 年 7 月 7 日,卢沟桥事变,全民族抗战爆发', 'kratos'), 'group' => 'custom', 'default' => 0, ), '12-12' => array( 'name' => __('西安事变纪念日', 'kratos'), 'note' => __('1936 年 12 月 12 日,张学良、杨虎城发动兵谏', 'kratos'), 'group' => 'custom', 'default' => 0, ), '08-15' => array( 'name' => __('日本宣布无条件投降纪念日', 'kratos'), 'note' => __('1945 年 8 月 15 日,日本天皇宣布接受波茨坦公告', 'kratos'), 'group' => 'custom', 'default' => 0, ), // ── D 类:节气,日期逐年换算 ── 'qingming' => array( 'name' => __('清明节', 'kratos'), 'note' => __('节气,日期每年浮动(4 月 4~6 日之间),已按天文历逐年换算,不是固定 4 月 4 日', 'kratos'), 'group' => 'custom', 'default' => 0, // 让后台清单里不要显示出 "qingming" 这个内部键 'key_label' => __('逐年', 'kratos'), // 标记:这个日期的 MM-DD 要按年份现算 'dynamic' => 'qingming', ), ); } /** * 把某个日期键解析成「指定年份的真实 MM-DD」 * * 静态日期(09-03 之类)原样返回;动态日期(清明)按年份换算。 * 换算不出来时返回 null —— 调用方必须把 null 当作「这天不存在」, * **不要**退化成某个固定值。 * * @param string $key presets 的键 * @param array $info 该项的 presets 定义 * @param int|null $year 年份,null 用站点时区当前年 * @return string|null */ function yang_mourning_resolve_key($key, $info, $year = null) { if (isset($info['dynamic']) && $info['dynamic'] === 'qingming') { return yang_qingming_md($year); } // 静态日期:必须是合法 MM-DD 才放行,防止脏数据 if (preg_match('/^\d{2}-\d{2}$/', $key)) { return $key; } return null; } /** * 读取用户勾选的纪念日,返回**该年份真实生效的 `MM-DD` 列表** * * 老站点升级上来时这个选项还不存在,此时返回 presets 里 default=1 的那些。 * 注意**不能**简单用 `kratos_option('g_rip_days', 默认数组)`:kratos_option * 对未设置的键返回默认值,但如果用户曾经保存过空数组(全部取消勾选), * 存进去的就是空数组,此时必须尊重「用户就是要关掉」,不能再回退到默认。 * 所以这里用「option 数组里有没有这个键」来判断,而不是判断值是否为空。 * * ── 返回值是 MM-DD,不是勾选的键 ────────────────────────────── * 因为清明的键是 `qingming`、真实日期是 `04-04`/`04-05`,两者不同。 * 统一解析成 MM-DD 之后,上游的 in_array($today, $days) 一行都不用改。 * * ── 兼容旧的 '04-04' 勾选 ───────────────────────────────────── * 3.2.17 及以前的 presets 里清明就是 `04-04`,勾过它的站点库里存着 * `04-04 => 1`。如果直接改键名,这些站点的勾选会被静默丢弃 —— * 用户明明选了清明,升级后反而没了。所以把 `04-04` 视为 `qingming`。 * * @param int|null $year 解析哪一年,null 用站点时区当前年 */ function yang_mourning_days($year = null) { $presets = yang_mourning_presets(); $opts = get_option('kratos'); $has = is_array($opts) && array_key_exists('g_rip_days', $opts); // 勾选状态:键 => bool $picked = array(); if (!$has) { // 从未保存过:用内置默认 foreach ($presets as $key => $info) { if (!empty($info['default'])) { $picked[$key] = true; } } } else { $saved = $opts['g_rip_days']; if (!is_array($saved)) { return array(); } foreach ($saved as $key => $v) { if (!$v) { continue; } // 旧键迁移:3.2.17 及以前的清明 if ($key === '04-04' && isset($presets['qingming'])) { $picked['qingming'] = true; continue; } // 只保留 presets 里存在的键(防止脏数据) if (isset($presets[$key])) { $picked[$key] = true; } } } // 解析成真实 MM-DD $out = array(); foreach ($picked as $key => $_) { $md = yang_mourning_resolve_key($key, $presets[$key], $year); if ($md !== null && !in_array($md, $out, true)) { $out[] = $md; } } return $out; } /** * 今天是否应当哀悼 */ function yang_is_mourning_today() { // 旧的手动开关:开了就一直黑白(保持向后兼容,不能让升级把黑白站点变回彩色) if (kratos_option('g_rip', false)) { return true; } if (!kratos_option('g_rip_auto', false)) { return false; } // 走站点时区,不是服务器时区 $md = current_time('m') . '-' . current_time('d'); $days = yang_mourning_days(); // 自定义附加日期(用户自己填的,如特定年份的哀悼日) foreach (yang_mourning_extra_days() as $one) { $days[] = $one; } return in_array($md, $days, true); } /** * 把「附加日期」输入框里的自由文本解析成月日列表 * * 支持两种写法,语义不同,不能混为一谈: * · `MM-DD` —— 每年循环(05-12 每年都会触发) * · `YYYY-MM-DD` —— 只在指定那年触发(2020-04-04 是一次性事件, * 明年 4 月 4 日不该再自动变黑白) * * 分隔符容错:空格、回车、英文逗号、中文逗号、顿号、分号都认, * 因为用户复制粘贴来源五花八门,要求他只准用半角空格不现实。 * * 非法格式**静默跳过**而不是报错:这是个自由文本框,中途保存到 * 一半(比如只打了 `10-`)不应该让整个后台保存失败。 */ function yang_mourning_extra_days() { $raw = kratos_option('g_rip_extra', ''); if (!is_string($raw) || trim($raw) === '') { return array(); } $out = array(); foreach (preg_split('/[\s,,、;;]+/u', $raw) as $one) { $one = trim($one); if ($one === '') { continue; } if (preg_match('/^(\d{1,2})-(\d{1,2})$/', $one, $m)) { // MM-DD:补零后与 current_time('m')-current_time('d') 可比 $out[] = sprintf('%02d-%02d', (int) $m[1], (int) $m[2]); } elseif (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $one, $m)) { if ((int) $m[1] === (int) current_time('Y')) { $out[] = sprintf('%02d-%02d', (int) $m[2], (int) $m[3]); } } } return $out; } /** * 附加日期的原始文本(后台选项说明里回显"当前填的是什么"用) * * 单独抽出来是因为后台 desc 里要展示它,而 desc 在 * kratos_options() 里拼装 —— 那里不该重复一遍取值逻辑。 */ function yang_mourning_extra_raw() { $raw = kratos_option('g_rip_extra', ''); return is_string($raw) ? trim($raw) : ''; } /** * 输出黑白样式 * * 几个细节: * 1. `html { filter: grayscale(1) }` 会给整页建一个合成层,fixed 定位的元素 * 在某些旧版 Chrome 上会错位。所以额外加 `background-attachment: fixed` * 的补丁样式不写;这里改用给 body 和 .k-header 分别处理更稳。 * —— 实际上实测最稳的是给 html 加 filter 并用 transform: translateZ(0) * 在 body 上兜住,避免 iOS Safari 的渲染错位。这里保留 html 方案, * 因为它在用户当前主题结构下工作正常(老功能的写法),只是补一条 * iOS 的兼容声明。 * 2. 不能只挂 is_home():详情页也应该黑白。老代码限定首页,那是 bug。 * 3. 只在真正哀悼的日子输出,避免给所有页面增加一条无用样式。 */ function yang_mourning_style() { if (is_admin() || !yang_is_mourning_today()) { return; } echo '\n"; } add_action('wp_head', 'yang_mourning_style', 1); /** * 后台显示今天的哀悼状态,便于确认配置是否生效 */ function yang_mourning_status_text() { if (kratos_option('g_rip', false)) { return __('当前为手动常开模式:站点一直黑白,与日期无关。', 'kratos'); } if (!kratos_option('g_rip_auto', false)) { return __('自动哀悼未开启。(上面的「手动常开」也没勾,所以现在整站是彩色的。)', 'kratos'); } $days = yang_mourning_days(); $extra = yang_mourning_extra_days(); foreach ($extra as $one) { if (!in_array($one, $days, true)) { $days[] = $one; } } if (empty($days)) { return __('自动哀悼已开启,但一个日期都没有 —— 清单没勾、附加日期也没填,因此不会生效。', 'kratos'); } sort($days); $today = current_time('m') . '-' . current_time('d'); $is = in_array($today, $days, true); /* 列表里给清明加个标注:它在库里是 `qingming` 这个键, 但真正生效的日期由当年换算得出,光看 `04-05` 用户不知道 那是清明还是他自己手填的。 */ $sorted = $days; $qmd = yang_qingming_md(); if ($qmd !== null) { foreach ($sorted as $i => $one) { if ($one === $qmd) { $sorted[$i] = sprintf(__('%s(清明)', 'kratos'), $one); break; } } } return sprintf( /* translators: 1: 今天的月日, 2: 是否处于哀悼, 3: 生效的日期列表 */ __('今天是 %1$s,%2$s。当前会变黑白的日期:%3$s', 'kratos'), $today, $is ? __('正在哀悼,站点已黑白', 'kratos') : __('不在哀悼日', 'kratos'), implode('、', $sorted) ); } /** * 哀悼期间在页面**最顶部正中**显示一条提示,链接到说明页 * * ── 为什么必须放在最顶部 ────────────────────────────────────── * 用户的原话是「别人都不知道咋回事,为啥变黑白」。只把入口藏在页脚, * 绝大多数人根本不会看到 —— 他们看到一片灰就关掉了。 * 所以提示条必须出现在**首屏第一眼就看到**的位置:整页最上方、水平居中。 * * ── 只在哀悼日出现 ──────────────────────────────────────────── * 平时打开站点的人不应该看到任何"哀悼说明"的入口 —— 他既没看到黑白, * 也不知道在对什么致哀,那个入口本身就是困惑源。所以页脚的常驻链接 * 已经移除,说明页的入口就只在这条提示里。 * * ── 为什么不做成弹窗 ────────────────────────────────────────── * 弹窗会挡住内容,纪念日当天每个访客每次访问都被弹一次,是骚扰。 * 一条可以关闭的细横条才是合适的强度,且关闭状态记在 sessionStorage * (用 session 而不是 localStorage:当天关闭后,第二天不应该还记着)。 * * ── 定位方式:占文档流第一行 + 同步下移导航栏 ───────────────── * 目标是「整页最上方、水平居中」。实现时连撞两次,都靠真实几何测出来: * * 第一版:`position:relative`,排进文档流。左右 padding 是 * `9px 46px 9px 18px`(左 18、右 46,为了给关闭按钮让位), * 内容整体偏左(实测文字中心 602、视口中心 640,偏 38px)—— * "最中间"根本没做到。同时它从 y=0 开始、高 40px,而导航栏 * `.k-nav` 是 `absolute; top:0`、z-index 1030,提示条 z-index 9990 * ⇒ **压在站点标题上方**(与 `.navbar-brand` 实测重叠 32px),把 Logo 遮住。 * * 第二版:改成 `position:fixed; top:0; left:0; right:0`,并给 body 加 * `padding-top`。居中问题解决了(中心精确等于视口中心),但**重叠仍在 31px**。 * 原因很隐蔽:`.k-nav` 是 `position:absolute`,它的包含块是初始包含块 * (视口),**body 的 padding 推不动它**。 * * 现在的做法(两件事配合): * 1. 提示条本身 `position:relative` 占文档流第一行、横跨整宽, * 内容交给内层 `.ymb-inner` 居中,左右 padding 等宽, * 关闭按钮 absolute 不占内部流 ⇒ 几何中心 = 视口中心。 * 它占位后,`.k-header` 会被自然推下去,正文不受影响。 * 2. 唯独 `.k-nav` 是 absolute,必须单独补一条 * `body.has-yang-mourning-bar .k-nav{top:var(--ymb-h)}` 把它挪到提示条下方。 * 没有提示条时该变量不存在、偏移为 0,行为与改动前完全一致。 * * 不用 fixed 的理由:fixed 会一直悬浮,滚动看文章时顶部永远压一条深色带, * 内容从它下面穿过去;而且它不占位,必须靠 padding 补偿,补偿量还算不准 * (窄屏换行后高度会变)。占文档流第一行没有这些问题。 */ function yang_mourning_notice_bar() { // 只在真正哀悼时显示,且不在说明页自身显示(那页自己会说明) if (is_admin() || !yang_is_mourning_today()) { return; } if (yang_mourning_page_is_current()) { return; } $url = yang_mourning_page_url(); $name = yang_mourning_today_name(); $text = $name !== '' ? sprintf(__('今天是%s,本站以黑白致哀。', 'kratos'), $name) : __('今天是纪念日,本站以黑白致哀。', 'kratos'); ?>
$info) { if (yang_mourning_resolve_key($key, $info) === $md) { return $info['name']; } } } // 命中的是「附加日期」里用户自己填的那一天 if (in_array($md, yang_mourning_extra_days(), true)) { return __('纪念日', 'kratos'); } return ''; }