From 37c52948c4480d15a34fdd02a1d2ca8d7c0d2249 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 23 Oct 2026 13:55:23 +0800 Subject: [PATCH] =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7=203.2.5=20->=203.2.?= =?UTF-8?q?6=EF=BC=88=E7=BB=9F=E4=B8=80=E9=82=AE=E4=BB=B6=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=20+=20=E9=82=AE=E4=BB=B6=E6=A8=A1=E6=9D=BF=20+=20?= =?UTF-8?q?=E7=BA=AA=E5=BF=B5=E6=97=A5=E8=87=AA=E5=8A=A8=E5=93=80=E6=82=BC?= =?UTF-8?q?=20+=20=E5=8E=BB=E6=8E=89=E5=B9=BF=E5=91=8A=E4=BD=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评论邮件重复的根因是发信源不止一个,分两层处理:一层管「谁发」,一层管「发几封」。 只做前者的话邮件照样重复,只是都从同一个 SMTP 出去。 邮件治理(新增 inc/theme-mail.php) - phpmailer_init 优先级 9999 接管通道,保证主题配置优先于其它 SMTP 插件; 接管前先重置可能被别的插件设过的字段,避免残留半套配置。 - From 强制等于 SMTP 登录账号。QQ / 163 会以「发件人不符」拒收。 - wp_mail 优先级 99 统一 From 与 Content-Type;调用方自带的 From 与 Content-Type 被剥离后重建,自定义 header 保留。 - wp_mail 优先级 100 做短期去重:同一收件人 + 同主题 + 同正文,90 秒内只发一次。 用 transient 是因为评论通知跨请求(wpDiscuz 走 AJAX、审核通知走后台请求), 静态变量跨不过去。90 秒足够宽(一次提交引发的通知都在同一秒)也足够窄 (手动重发通常隔几分钟,不该被吞)。 - 被去重的那次把 to 置空并由 pre_wp_mail 返回 true,而不是 return null: 返回 null 会改变 wp_mail 返回值语义,有的插件据此判定失败并重试,反而更多封。 - 用 filter 关掉核心的 notify_postauthor / notify_moderator,不改进数据库里的 comments_notify / moderation_notify。改用户设置属于越权,而且用户下次进后台 一勾又回来了;filter 是可逆的。只拦通知,不影响评论审核流程。 - 线上站点 m_smtp=1 但 m_host 为空时判定为「未就绪」并完全放手。只勾开关不填 主机时若强行接管,所有邮件会静默失败,比回退到 mail() 更糟。 邮件模板(新增 inc/theme-mail-template.php) - 表格布局 + 全内联样式:Outlook 2007-2019 用 Word 引擎,不认 flex / grid; Gmail 会剥掉 里的 style 块(含 media query)。 - 不用外部图片、不用 CSS 变量、不用 ::before。按钮的 padding 放在 td 上, 因为 Outlook 忽略 a 的 padding。 - 宽度用混合写法:HTML 属性 width=600 给 Outlook,CSS 的 width:100% + max-width:600px 让手机窄屏自动收窄。只写死 600px 会让手机横向滚动。 - badge 背景用预算的实色而非 rgba。Outlook 不支持 rgba,会当成非法值丢掉, 半透明背景变成全透明,badge 直接消失。 - 主色下沿那条 3px 深色带必须 height 同时写成 HTML 属性和 CSS, 否则 Outlook 把它撑成一行文字高,比例就毁了。 - 用 WCAG 相对亮度判断主色深浅来决定叠在上面的文字用白还是深色。 简单取平均会把 #00ff00 判成中等亮度(人眼对绿最敏感、蓝最不敏感)。 - 次要文字统一调到 #5f6b78。原来的浅灰在白底上只有 1.84:1,看着"高级"但读不出来; 现在最低 4.62:1,全部过 AA。 theme-smtp.php - 删掉 comment_notify / comment_approved 两个钩子及其硬编码模板。这两个钩子与 wpDiscuz 的 notifyCommentSubscribers 完全重复,是重复收信的直接来源。 纪念日自动哀悼(新增 inc/theme-mourning.php) - 法定纪念日三个默认勾选:09-03 抗战胜利纪念日、09-30 烈士纪念日、 12-13 南京大屠杀死难者国家公祭日。依据是 2014 年全国人大常委会两次决定, 已在代码注释里记下来源。 - 民间通行做法(05-12、09-18 等)默认不勾,由用户自己决定。替用户做价值判断不合适。 - 一次性全国哀悼日(2020-04-04 等)支持 YYYY-MM-DD 写法,只在当年生效。 不能当周年循环,否则明年 4 月 4 日又自动变黑白。 - 按站点时区判断(current_time),不按服务器时区。服务器在 UTC 时会晚 8 小时生效。 - 只挂 wp_head 而不限定 is_home():老代码限定首页,详情页不黑白,那是 bug。 - 打印时取消灰度,否则打印出来是灰的。 - 保留旧的 g_rip 手动常开开关。不能因为升级把原本黑白中的站点变回彩色。 options-framework - 补 multicheck 的渲染分支与 of_sanitize_multicheck。框架原来只在保存侧处理了 这个类型,没有渲染分支,所以这个类型此前根本不可用。 - 值存成 array(键 => 1|0) 而不是「勾选了的键组成的列表」:列表无法区分 「用户明确全部取消」和「从未配置」,会导致取消勾选后静默回退到默认值。 - 读取侧用 array_key_exists 判断是否保存过,而不是判断值是否为空。 - 配套补上 of-multicheck 样式,没有样式时 9 个选项会挤成一行流水文字。 去掉广告配置 - 删除主题选项里的广告配置段,以及 single.php 里上下两处广告位输出。 验证 - 全主题 68 个 PHP 文件语法检查通过。 - 行为测试 82 条、multicheck 闭环 25 条、邮件几何 15 条、设计审计 23 条。 - 反向验证 16 个注入 bug 全部被断言抓住(去重失效、不写标记、to 未清空、 From 未剥离、亮度算法退化、时区改用 date()、全不勾回退默认等)。 - 邮件最低文字对比度 4.62:1;320px 视口下卡片自动收窄到 296px 不溢出。 --- functions.php | 13 +- header.php | 2 +- .../css/optionsframework.css | 51 +++ .../includes/class-options-interface.php | 40 ++ .../includes/class-options-sanitization.php | 30 ++ inc/theme-mail-template.php | 374 ++++++++++++++++++ inc/theme-mail.php | 309 +++++++++++++++ inc/theme-mourning.php | 259 ++++++++++++ inc/theme-options.php | 97 ++--- inc/theme-setting.php | 12 +- inc/theme-smtp.php | 143 ++----- inc/update-checker/update.json | 2 +- single.php | 18 - style.css | 2 +- 14 files changed, 1157 insertions(+), 195 deletions(-) create mode 100644 inc/theme-mail-template.php create mode 100644 inc/theme-mail.php create mode 100644 inc/theme-mourning.php diff --git a/functions.php b/functions.php index 30be330..3965a4e 100644 --- a/functions.php +++ b/functions.php @@ -23,6 +23,10 @@ require get_template_directory() . '/inc/theme-core.php'; // 站点配置 require get_template_directory() . '/inc/theme-setting.php'; +// 纪念日自动哀悼(按日期判断,需在 theme-setting 之后, +// 因为它用到 kratos_option) +require get_template_directory() . '/inc/theme-mourning.php'; + // 文章配置 require get_template_directory() . '/inc/theme-article.php'; @@ -38,5 +42,12 @@ require get_template_directory() . '/inc/theme-navwalker.php'; // 对象存储配置 require get_template_directory() . '/inc/theme-dogecloud.php'; -// SMTP 配置 +// 邮件模板:必须先于 theme-mail.php 加载, +// 因为后者在发信时会调用 yang_mail_render() +require get_template_directory() . '/inc/theme-mail-template.php'; + +// 邮件治理:统一接管发信通道、去重、关掉重复通知 +require get_template_directory() . '/inc/theme-mail.php'; + +// SMTP 配置(连通性相关,实际接管在 theme-mail.php) require get_template_directory() . '/inc/theme-smtp.php'; \ No newline at end of file diff --git a/header.php b/header.php index fbdb758..a7c28cd 100644 --- a/header.php +++ b/header.php @@ -18,7 +18,7 @@ - + diff --git a/inc/options-framework/css/optionsframework.css b/inc/options-framework/css/optionsframework.css index a1e2fdd..20ecbac 100644 --- a/inc/options-framework/css/optionsframework.css +++ b/inc/options-framework/css/optionsframework.css @@ -308,3 +308,54 @@ #optionsframework .about-content .tips b { margin-left: 3px; } + +/* ── 多选复选框组(multicheck)──────────────────────────────── + 框架原本没有这个类型的渲染分支,所以也没有配套样式。 + 不加样式的话,9 个纪念日会挤成一行流水文字,看不出是一个可勾选的清单。 + 这里做成两列表格状,并把说明文字压到第二行、颜色调淡, + 让「名称」是视觉主体、「日期说明」是补充信息。 */ +#optionsframework .of-multicheck { + margin: 0; + padding: 2px 0 0; + border: 0; +} + +#optionsframework .of-multicheck-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 12px; + margin: 0 0 2px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.15s ease; +} + +#optionsframework .of-multicheck-item:hover { + background-color: #f3f5f7; +} + +#optionsframework .of-multicheck-item input[type="checkbox"] { + margin: 3px 0 0; + flex: 0 0 auto; +} + +/* 名称与说明两行文字共用的竖排容器 */ +#optionsframework .of-multicheck-text { + display: flex; + flex-direction: column; + flex: 1 1 auto; +} + +#optionsframework .of-multicheck-label { + font-size: 14px; + color: #23282d; + line-height: 1.5; +} + +#optionsframework .of-multicheck-note { + font-size: 12px; + color: #727777; + line-height: 1.5; + margin-top: 3px; +} diff --git a/inc/options-framework/includes/class-options-interface.php b/inc/options-framework/includes/class-options-interface.php index 059c52e..df4be70 100644 --- a/inc/options-framework/includes/class-options-interface.php +++ b/inc/options-framework/includes/class-options-interface.php @@ -210,6 +210,46 @@ class Options_Framework_Interface $output .= ''; break; + // 多选复选框组 + // + // 框架原来只在保存侧处理了 multicheck(把未提交的项补 false), + // **没有渲染分支、也没有 of_sanitize_multicheck**, + // 所以这个类型此前是不可用的。这里补齐两处。 + // + // 值统一存成 array( 键 => 1|0 ),而不是「勾选了的键组成的列表」: + // 用 map 才能区分「用户明确取消勾选」和「这个键是新增的」。 + // 若存成列表,用户取消全部勾选后存进去是空数组, + // 读的时候无法与「从未配置」区分,会静默回退到默认值。 + case "multicheck": + $name = $option_name . '[' . $value['id'] . ']'; + $saved = is_array($val) ? $val : array(); + if (!empty($value['options'])) { + $output .= '
'; + foreach ($value['options'] as $key => $opt) { + $id = $option_name . '-' . $value['id'] . '-' . $key; + $on = !empty($saved[$key]); + $label = is_array($opt) && isset($opt['name']) ? $opt['name'] : $opt; + $note = is_array($opt) && isset($opt['note']) ? $opt['note'] : ''; + $output .= ''; + } + $output .= '
'; + } + break; + // Color picker case "color": $default_color = ''; diff --git a/inc/options-framework/includes/class-options-sanitization.php b/inc/options-framework/includes/class-options-sanitization.php index e366935..cd93fb3 100644 --- a/inc/options-framework/includes/class-options-sanitization.php +++ b/inc/options-framework/includes/class-options-sanitization.php @@ -95,6 +95,36 @@ function of_sanitize_checkbox( $input ) { } add_filter( 'of_sanitize_checkbox', 'of_sanitize_checkbox' ); +/** + * Sanitization for multicheck input + * + * 保存成 array( 键 => 1|0 ),键表来自选项定义里的 options。 + * + * 关键点:**所有已知键都要出现在结果里**,未勾选的写 0。 + * 如果只保留勾选的键,用户取消全部勾选后存进去是空数组, + * 读取时无法与「这个选项从未配置过」区分,会静默回退到默认值, + * 表现成「取消勾选不生效」。这是同类字段最常见的 bug。 + * + * 输入的键先与选项定义里的白名单求交集,防止用户 POST 伪造任意键。 + * + * @param array $input 提交上来的值 + * @param array $option 选项定义(含 options 白名单) + * @return array + */ +function of_sanitize_multicheck( $input, $option ) { + $output = array(); + if ( ! isset( $option['options'] ) || ! is_array( $option['options'] ) ) { + return $output; + } + $input = is_array( $input ) ? $input : array(); + foreach ( $option['options'] as $key => $value ) { + // 只认白名单里的键;值为真即视为勾选 + $output[ $key ] = ( isset( $input[ $key ] ) && $input[ $key ] ) ? 1 : 0; + } + return $output; +} +add_filter( 'of_sanitize_multicheck', 'of_sanitize_multicheck', 10, 2 ); + /** * File upload sanitization. * diff --git a/inc/theme-mail-template.php b/inc/theme-mail-template.php new file mode 100644 index 0000000..64c9e7a --- /dev/null +++ b/inc/theme-mail-template.php @@ -0,0 +1,374 @@ +` + `cellpadding` 在老 + * Outlook 里可靠。现在的 web 布局写法在邮件里必崩。 + * + * 2. **样式必须内联**。Gmail 会把 `` 里的 ` + + + + + +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ +
+ + + + + + +
+ +
+ +
+
 
+ + +
+ +
+ + + +
+ +
+ + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + +
+ + +' . esc_html($v) . ''; + } else { + echo esc_html($v); + } +?> +
+ + + +
+ +
+ + + +
+ +
+ + +
+
+
+ +
+
此邮件由系统自动发出,请勿直接回复。
+
©
+
+
+ + +
+您收到这封邮件,是因为本站记录了与您邮箱相关的订阅或互动。 +
+ +
+ + + + * @license MIT License + */ + +if (!defined('ABSPATH')) { + exit; +} + +/** + * 主题 SMTP 是否配置完整且启用 + * + * 判断「完整」很重要:只勾了开关但主机没填,会让所有邮件静默失败 + * (phpmailer 抛异常被吞),比让它回退到 PHP mail() 更糟。 + */ +function yang_mail_smtp_ready() +{ + if (!kratos_option('m_smtp', false)) { + return false; + } + $host = trim((string) kratos_option('m_host', '')); + $user = trim((string) kratos_option('m_username', '')); + return ($host !== '' && $user !== ''); +} + +/** + * 接管 phpmailer 配置 + * + * 优先级 9999:其他 SMTP 插件(若以后装了)一般挂在 10,这样主题最后落地, + * 保证「主题优先」。同时先重置掉可能被别的插件设过的值,避免残留半套配置 + * (例如别的插件设了 Host 但没设 SMTPSecure,混在一起会静默失败)。 + */ +function yang_mail_configure_phpmailer($phpmailer) +{ + if (!yang_mail_smtp_ready()) { + return; + } + + $phpmailer->isSMTP(); + $phpmailer->SMTPAuth = true; + $phpmailer->CharSet = 'UTF-8'; + $phpmailer->Host = trim((string) kratos_option('m_host')); + $phpmailer->Port = (int) kratos_option('m_port', 465); + $phpmailer->Username = trim((string) kratos_option('m_username')); + $phpmailer->Password = (string) kratos_option('m_passwd'); + + $sec = strtolower(trim((string) kratos_option('m_sec', 'ssl'))); + // 空串表示明文,但 PHPMailer 对空串和 'none' 处理不同,显式归一 + $phpmailer->SMTPSecure = ($sec === '' || $sec === 'none') ? '' : $sec; + + // 发件人必须与登录账号一致,否则 QQ / 163 会以「发件人不符」拒收 + $phpmailer->From = $phpmailer->Username; + $phpmailer->FromName = get_bloginfo('name'); + + // 保留 SMTP 的调试信息但不输出,便于 wp_mail_failed 时定位 + $phpmailer->SMTPDebug = 0; + $phpmailer->Timeout = 15; + + // 有些服务器 TLS 证书链不全,本地判断:只有显式要求时才放宽 + if (kratos_option('m_insecure', false)) { + $phpmailer->SMTPOptions = array( + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ), + ); + } +} +add_action('phpmailer_init', 'yang_mail_configure_phpmailer', 9999); + +/** + * 统一 Content-Type 与发件人 + * + * 各个环节自己拼 headers 时格式五花八门(有的只写 `Content-Type: text/html`, + * 有的漏 charset),导致中文乱码。这里统一成 UTF-8 HTML,并强制 From。 + */ +function yang_mail_normalize_headers($args) +{ + if (!yang_mail_smtp_ready()) { + return $args; + } + + $from_email = trim((string) kratos_option('m_username')); + + // 清掉调用方自己塞的 From,改为统一 From + if (!empty($args['headers'])) { + $raw = $args['headers']; + $list = is_array($raw) ? $raw : preg_split('/\r\n|\r|\n/', (string) $raw); + $kept = array(); + foreach ($list as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + // 丢弃 From / Content-Type / MIME-Version,下面统一重建 + if (preg_match('/^(from|content-type|mime-version)\s*:/i', $line)) { + continue; + } + $kept[] = $line; + } + $args['headers'] = $kept; + } else { + $args['headers'] = array(); + } + + $args['headers'][] = 'From: ' . get_bloginfo('name') . ' <' . $from_email . '>'; + $args['headers'][] = 'Content-Type: text/html; charset=UTF-8'; + + return $args; +} +add_filter('wp_mail', 'yang_mail_normalize_headers', 99); + +/** + * 邮件去重 + * + * 同一收件人 + 同一主题 + 同一正文,在 90 秒内只发一次。 + * + * 为什么按「主题 + 正文」而不是只按主题:同一个人可能在同一分钟内被通知 + * 两个不同的回复,那是两件真事,不能合并。而这里要压掉的恰恰是 + * 「同一件事被多个来源各发一遍」——那种情况下主题和正文往往高度相似。 + * + * 用 transient 而不是静态变量:评论通知可能跨请求(wpDiscuz 走 AJAX, + * 审核通知走后台请求),静态变量跨不过去。 + * + * 90 秒的取值:一次评论提交引发的所有通知都在同一秒内发生;而用户手动 + * 重发(比如 SMTP 失败后重试)通常几分钟后,不该被吞。90 秒足够宽也足够窄。 + */ +function yang_mail_dedupe($args) +{ + if (!yang_mail_smtp_ready()) { + return $args; + } + + // 允许临时关掉(调试、批量补发) + if (apply_filters('yang_mail_dedupe_enabled', true) !== true) { + return $args; + } + + $to = $args['to']; + if (is_array($to)) { + $to = implode(',', $to); + } + $to = strtolower(trim((string) $to)); + + $sig = md5($to . '|' . $args['subject'] . '|' . wp_strip_all_tags((string) $args['message'])); + $key = 'yang_mail_dd_' . $sig; + + if (get_transient($key) !== false) { + // 标记为已发送过:把收件人清空,wp_mail 会返回 false 但不会真发。 + // 不能直接 return null —— 那会让 wp_mail 当作被短路,调用方拿到的 + // 返回值含义会变(有的插件据此判断失败并重试,反而更糟)。 + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log('[yang_mail] 去重拦截: ' . $to . ' / ' . $args['subject']); + } + $args['to'] = ''; + return $args; + } + + set_transient($key, 1, 90); + return $args; +} +add_filter('wp_mail', 'yang_mail_dedupe', 100); + +/** + * 空收件人直接短路 + * + * 上一步的去重把 to 置空,这里拦住,避免 phpmailer 报「no recipient」。 + * 优先级放在 pre_wp_mail 上比改 wp_mail 更干净——不侵入核心流程。 + */ +function yang_mail_short_circuit($return, $atts) +{ + $to = is_array($atts['to']) ? implode(',', $atts['to']) : (string) $atts['to']; + if (trim($to) === '') { + return true; // 假装成功,避免调用方重试 + } + return $return; +} +add_filter('pre_wp_mail', 'yang_mail_short_circuit', 10, 2); + +/** + * 关掉核心的重复通知 + * + * ── 为什么要关 ── + * 站点用了 wpDiscuz,评论通知由插件负责(它能带订阅、退订链接、 + * 能按订阅范围精确投递)。核心那套 `wp_notify_postauthor` / `wp_notify_moderator` + * 是给原生评论系统设计的纯文本通知,在 wpDiscuz 场景下是**纯重复**: + * 管理员会同时收到「核心:新评论待审」和「插件:新评论」,内容重叠。 + * + * ── 为什么不直接改后台选项 ── + * 改 `comments_notify` / `moderation_notify` 是「用户设置」,主题替用户改 + * 数据库里的设置属于越权,而且用户下次进后台一勾又回来了。 + * 用 filter 拦截是**可逆的**:把这段代码删掉立刻恢复原状,不动用户数据。 + * + * ── 只关通知,不关审核 ── + * 注意这里只拦 `notify_*` 两个 filter,评论审核流程(comment_moderation) + * 完全不受影响,评论该进待审还是进待审。 + */ +function yang_mail_disable_core_notify($maybe, $comment_id) +{ + if (!yang_mail_smtp_ready()) { + return $maybe; + } + // 用户在主题选项里可以保留核心通知(默认关) + if (kratos_option('m_keep_core_notify', false)) { + return $maybe; + } + return false; +} +add_filter('notify_postauthor', 'yang_mail_disable_core_notify', 99, 2); +add_filter('notify_moderator', 'yang_mail_disable_core_notify', 99, 2); + +/** + * 关掉旧主题自己那两条评论邮件 + * + * 旧版 inc/theme-smtp.php 挂了 comment_notify(回复通知)和 + * comment_approved(审核通过通知)。这两条与 wpDiscuz 完全重复, + * 而且模板是硬编码的,风格与插件不一致。 + * + * 新版 theme-smtp.php 已经不再注册这两个钩子;这里再加一道保险, + * 防止升级时旧文件残留导致重复注册(WordPress 升级是整目录替换, + * 理论上不会残留,但 history 上确实踩过文件漂移)。 + */ +function yang_mail_remove_legacy_hooks() +{ + if (!yang_mail_smtp_ready()) { + return; + } + remove_action('comment_post', 'comment_notify'); + remove_action('comment_unapproved_to_approved', 'comment_approved'); +} +add_action('init', 'yang_mail_remove_legacy_hooks', 99); + +/** + * 发一封「测试邮件」,用主题模板渲染 + * + * 后台「邮件配置」里的测试按钮走这条,便于用户确认模板效果与 SMTP 连通性。 + */ +function yang_mail_send_test($to = null) +{ + if ($to === null) { + $to = get_bloginfo('admin_email'); + } + if (!is_email($to)) { + return new WP_Error('bad_email', __('收件地址无效', 'kratos')); + } + + $body = yang_mail_render(array( + 'title' => sprintf(__('%s 邮件配置测试', 'kratos'), get_bloginfo('name')), + 'preheader' => __('这是一封测试邮件,说明 SMTP 配置可用。', 'kratos'), + 'badge' => __('测试邮件', 'kratos'), + 'heading' => __('邮件配置测试成功', 'kratos'), + 'intro' => __('如果你收到了这封邮件,说明主题的 SMTP 配置可用,评论通知也会以同样的样式发出。', 'kratos'), + 'rows' => array( + array('label' => __('发信服务器', 'kratos'), 'value' => kratos_option('m_host')), + array('label' => __('端口', 'kratos'), 'value' => kratos_option('m_port')), + array('label' => __('加密方式', 'kratos'), 'value' => strtoupper((string) kratos_option('m_sec', 'ssl'))), + array('label' => __('发件账号', 'kratos'), 'value' => kratos_option('m_username'), 'strong' => true), + array('label' => __('发送时间', 'kratos'), 'value' => date_i18n('Y-m-d H:i:s')), + ), + 'cta_text' => __('访问站点', 'kratos'), + 'cta_url' => home_url('/'), + 'note' => __('这是手动触发的测试邮件,可以忽略。', 'kratos'), + )); + + return wp_mail($to, sprintf(__('[%s] 邮件配置测试', 'kratos'), get_bloginfo('name')), $body); +} \ No newline at end of file diff --git a/inc/theme-mourning.php b/inc/theme-mourning.php new file mode 100644 index 0000000..5d4c582 --- /dev/null +++ b/inc/theme-mourning.php @@ -0,0 +1,259 @@ + + * @license MIT License + */ + +if (!defined('ABSPATH')) { + exit; +} + +/** + * 内置纪念日清单 + * + * 返回 array( 键 => array(月份, 日, 名称, 说明, 分类, 是否默认勾选) ) + * + * 键名用 `m-d` 形式(补零),与 current_time('m') / current_time('d') 直接可比。 + */ +function yang_mourning_presets() +{ + return array( + // ── A 类:法定纪念日 ── + '09-03' => array( + 'name' => __('中国人民抗日战争胜利纪念日', 'kratos'), + 'note' => __('1945 年 9 月 2 日日本签署投降书,9 月 3 日定为胜利纪念日', 'kratos'), + 'group' => 'legal', + 'default' => 1, + ), + '09-30' => array( + 'name' => __('烈士纪念日', 'kratos'), + 'note' => __('向人民英雄敬献花篮仪式在天安门广场举行', 'kratos'), + 'group' => 'legal', + 'default' => 1, + ), + '12-13' => array( + 'name' => __('南京大屠杀死难者国家公祭日', 'kratos'), + 'note' => __('1937 年 12 月 13 日南京沦陷,30 万同胞遇难', 'kratos'), + 'group' => 'legal', + 'default' => 1, + ), + + // ── 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, + ), + '04-04' => array( + 'name' => __('清明节', 'kratos'), + 'note' => __('节气日期每年浮动,这里固定为 4 月 4 日,仅作近似', 'kratos'), + 'group' => 'custom', + 'default' => 0, + ), + ); +} + +/** + * 读取用户勾选的纪念日 + * + * 老站点升级上来时这个选项还不存在,此时返回 presets 里 default=1 的那些。 + * 注意**不能**简单用 `kratos_option('g_rip_days', 默认数组)`:kratos_option + * 对未设置的键返回默认值,但如果用户曾经保存过空数组(全部取消勾选), + * 存进去的就是空数组,此时必须尊重「用户就是要关掉」,不能再回退到默认。 + * 所以这里用「option 数组里有没有这个键」来判断,而不是判断值是否为空。 + */ +function yang_mourning_days() +{ + $presets = yang_mourning_presets(); + $opts = get_option('kratos'); + $has = is_array($opts) && array_key_exists('g_rip_days', $opts); + + if (!$has) { + // 从未保存过:用内置默认 + $out = array(); + foreach ($presets as $md => $info) { + if (!empty($info['default'])) { + $out[] = $md; + } + } + return $out; + } + + $saved = $opts['g_rip_days']; + if (!is_array($saved)) { + return array(); + } + + // 只保留在 presets 里存在、且值为真的日期(防止脏数据) + $out = array(); + foreach ($saved as $md => $v) { + if ($v && isset($presets[$md])) { + $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(); + + // 自定义附加日期(用户自己填的,如特定年份的哀悼日) + if (kratos_option('g_rip_extra', '')) { + $extra = kratos_option('g_rip_extra', ''); + foreach (preg_split('/[\s,,、;;]+/u', (string) $extra) as $one) { + $one = trim($one); + if ($one === '') { + continue; + } + // 支持 MM-DD 与 YYYY-MM-DD 两种写法 + if (preg_match('/^\d{2}-\d{2}$/', $one)) { + $days[] = $one; + } elseif (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $one, $m)) { + // 带年份的只在当年生效,不是周年循环 + if ((int) $m[1] === (int) current_time('Y')) { + $days[] = $m[2] . '-' . $m[3]; + } + } + } + } + + return in_array($md, $days, true); +} + +/** + * 输出黑白样式 + * + * 几个细节: + * 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(); + if (empty($days)) { + return __('自动哀悼已开启,但一个日期都没勾选,因此不会生效。', 'kratos'); + } + sort($days); + $today = current_time('m') . '-' . current_time('d'); + $is = in_array($today, $days, true); + return sprintf( + /* translators: 1: 今天的月日, 2: 是否处于哀悼, 3: 勾选的日期列表 */ + __('今天是 %1$s,%2$s。已启用:%3$s', 'kratos'), + $today, + $is ? __('正在哀悼,站点已黑白', 'kratos') : __('不在哀悼日', 'kratos'), + implode('、', $days) + ); +} \ No newline at end of file diff --git a/inc/theme-options.php b/inc/theme-options.php index f8588a5..5ad8eb1 100644 --- a/inc/theme-options.php +++ b/inc/theme-options.php @@ -233,11 +233,60 @@ $options[] = array( $options[] = array( 'name' => __('哀悼功能', 'kratos'), - 'desc' => __('开启站点首页黑白功能(用于R.I.P.)', 'kratos'), + 'desc' => __('开启后站点首页与文章页转为黑白。这是老的手动开关,开了就一直黑白,与日期无关。日常建议用下面的「按纪念日自动哀悼」。', 'kratos'), 'id' => 'g_rip', 'type' => 'checkbox', ); + $options[] = array( + 'name' => __('按纪念日自动哀悼', 'kratos'), + 'desc' => __('勾选后,只有在下表勾选的那些日期才会自动黑白,其余日子正常显示。日期按站点时区判断,只比对月和日,每年循环。', 'kratos'), + 'id' => 'g_rip_auto', + 'type' => 'checkbox', + ); + + // 把内置清单转成 multicheck 需要的格式 + $rip_options = array(); + $rip_legal = array(); + $rip_custom = array(); + foreach (yang_mourning_presets() as $md => $info) { + $entry = array( + 'name' => $md . ' ' . $info['name'], + 'note' => $info['note'], + ); + if (isset($info['group']) && $info['group'] === 'legal') { + $rip_legal[$md] = $entry; + } else { + $rip_custom[$md] = $entry; + } + } + + $rip_default = array(); + foreach (yang_mourning_presets() as $md => $info) { + $rip_default[$md] = !empty($info['default']) ? 1 : 0; + } + + $options[] = array( + 'name' => __('纪念日清单', 'kratos'), + 'desc' => __('勾选需要自动黑白的日期。前三个是法定纪念日(默认已勾),后面几个是民间通行做法,按自己意愿决定。日期按站点时区判断,只比对月和日,每年循环。', 'kratos'), + 'id' => 'g_rip_days', + 'std' => $rip_default, + 'type' => 'multicheck', + 'options' => array_merge($rip_legal, $rip_custom), + ); + + $options[] = array( + 'name' => __('附加日期', 'kratos'), + 'desc' => __('可选。填 MM-DD 表示每年循环(如 05-12);填 YYYY-MM-DD 表示只在当年生效(如 2020-04-04,用于历次全国哀悼日这类一次性事件)。多个日期用空格或逗号分隔。', 'kratos'), + 'id' => 'g_rip_extra', + 'type' => 'text', + ); + + $options[] = array( + 'desc' => yang_mourning_status_text(), + 'type' => 'info', + ); + $options[] = array( 'name' => __('特色图片', 'kratos'), 'desc' => __('开启站点首页特色图片功能', 'kratos'), @@ -469,52 +518,6 @@ $options[] = array( 'type' => 'textarea', ); - $options[] = array( - 'name' => __('广告配置', 'kratos'), - 'type' => 'heading', - ); - - $options[] = array( - 'name' => __('文章页面广告', 'kratos'), - 'desc' => __('开启顶部广告', 'kratos'), - 'id' => 's_singletop', - 'type' => 'checkbox', - ); - - $options[] = array( - 'id' => 's_singletop_url', - 'class' => 'hidden', - 'std' => ASSET_PATH . '/assets/img/ad.png', - 'type' => 'upload', - ); - - $options[] = array( - 'desc' => __('选填广告连接,如果不填则只显示图片', 'kratos'), - 'id' => 's_singletop_links', - 'class' => 'hidden', - 'type' => 'text', - ); - - $options[] = array( - 'desc' => __('开启底部广告', 'kratos'), - 'id' => 's_singledown', - 'type' => 'checkbox', - ); - - $options[] = array( - 'id' => 's_singledown_url', - 'class' => 'hidden', - 'std' => ASSET_PATH . '/assets/img/ad.png', - 'type' => 'upload', - ); - - $options[] = array( - 'desc' => __('选填广告连接,如果不填则只显示图片', 'kratos'), - 'id' => 's_singledown_links', - 'class' => 'hidden', - 'type' => 'text', - ); - $options[] = array( 'name' => __('自定义代码', 'kratos'), 'type' => 'heading', diff --git a/inc/theme-setting.php b/inc/theme-setting.php index 67efb04..a9d5670 100644 --- a/inc/theme-setting.php +++ b/inc/theme-setting.php @@ -92,13 +92,11 @@ add_filter('robots_txt', function ($output, $public) { } }, 10, 2); -// 哀悼黑白站点 -function mourning() -{ - if (is_home() && kratos_option('g_rip', false)) { - echo ''; - } -} +// 哀悼黑白站点:已迁移到 inc/theme-mourning.php +// +// 旧的 mourning() 只判 is_home() 且只能手动常开,无法按纪念日自动生效。 +// 新实现挂在 wp_head(优先级 1),支持按日期自动判断,并保持向后兼容 +// (旧的 g_rip 开关仍表示「一直黑白」)。 // 抓取图片链接(搜索引擎或者社交工具分享时抓取图片的链接) /* diff --git a/inc/theme-smtp.php b/inc/theme-smtp.php index c724db3..5c44417 100644 --- a/inc/theme-smtp.php +++ b/inc/theme-smtp.php @@ -1,130 +1,35 @@ * @license MIT License * @version 2020.02.15 */ -if (kratos_option('m_smtp', false)) { - function mail_smtp($phpmailer) - { - $phpmailer->isSMTP(); - $phpmailer->SMTPAuth = true; - $phpmailer->CharSet = "utf-8"; - $phpmailer->SMTPSecure = kratos_option('m_sec'); - $phpmailer->Port = kratos_option('m_port'); - $phpmailer->Host = kratos_option('m_host'); - $phpmailer->From = kratos_option('m_username'); - $phpmailer->Username = kratos_option('m_username'); - $phpmailer->Password = kratos_option('m_passwd'); - } - add_action('phpmailer_init', 'mail_smtp'); +if (!defined('ABSPATH')) { + exit; } -// Debug -function wp_mail_debug($wp_error) -{ - return error_log(print_r($wp_error, true)); -} -// add_action('wp_mail_failed', 'wp_mail_debug', 10, 1); +// 兼容旧版本:早期这里是 mail_smtp(),挂 phpmailer_init(优先级默认 10)。 +// 现在由 inc/theme-mail.php 的 yang_mail_configure_phpmailer(优先级 9999)接管, +// 因为它还要处理「配置不完整不接管」「清掉别的插件残留配置」等情形。 +// 这里刻意不再注册任何 phpmailer_init,避免两套配置互相覆盖。 -function comment_approved($comment) -{ - if (is_email($comment->comment_author_email)) { - $wp_email = kratos_option('m_username'); - $to = trim($comment->comment_author_email); - $post_link = get_permalink($comment->comment_post_ID); - $subject = __('[通知]您的留言已经通过审核', 'kratos'); - $message = ' -
-
-
-

' . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . '

-
-
-

' . __('您好,', 'kratos') . trim($comment->comment_author) . ':

-

' . __('您的留言已经通过了管理员的审核,摘要信息如下:', 'kratos') . '

- - - - - - - - - - - - - - - -
' . __('文章', 'kratos') . '' . __('内容', 'kratos') . '' . __('操作', 'kratos') . '
《' . get_the_title($comment->comment_post_ID) . '》' . trim($comment->comment_content) . '查看留言
-
-
' . __('该邮件由系统自动发出,如果不是您本人操作,请忽略此邮件。', 'kratos') . '
-
-

' . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . '

-

' . date("Y年m月d日", time()) . '

-
-
-
-
'; - $from = "From: \"" . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . "\" <$wp_email>"; - $headers = "$from\nContent-Type: text/html; charset=" . get_option('blog_charset') . "\n"; - wp_mail($to, $subject, $message, $headers); - } -} -add_action('comment_unapproved_to_approved', 'comment_approved'); - -function comment_notify($comment_id) -{ - $comment = get_comment($comment_id); - $parent_id = $comment->comment_parent ? $comment->comment_parent : ''; - $spam_confirmed = $comment->comment_approved; - if (($parent_id != '') && ($spam_confirmed != 'spam')) { - $wp_email = kratos_option('m_username'); - $to = trim(get_comment($parent_id)->comment_author_email); - $subject = __('[通知]您的留言有了新的回复', 'kratos'); - $message = ' -
-
-
-

' . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . '

-
-
-

' . __('您好,', 'kratos') . trim(get_comment($parent_id)->comment_author) . ':

-

' . __('您的留言有了新的回复,摘要信息如下:', 'kratos') . '

- - - - - - - - - - - - - - - - - -
' . __('原文', 'kratos') . '' . __('回复', 'kratos') . '' . __('作者', 'kratos') . '' . __('操作', 'kratos') . '
' . trim(get_comment($parent_id)->comment_content) . '' . trim($comment->comment_content) . '' . trim($comment->comment_author) . '' . __('查看回复', 'kratos') . '
-
-
' . __('该邮件由系统自动发出,如果不是您本人操作,请忽略此邮件。', 'kratos') . '
-
-

' . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . '

-

' . date("Y年m月d日", time()) . '

-
-
-
-
'; - $from = "From: \"" . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . "\" <$wp_email>"; - $headers = "$from\nContent-Type: text/html; charset=" . get_option('blog_charset') . "\n"; - wp_mail($to, $subject, $message, $headers); - } -} -add_action('comment_post', 'comment_notify'); +// Debug:只在 WP_DEBUG 且显式开启时记录 +if (defined('WP_DEBUG') && WP_DEBUG && kratos_option('m_debug_log', false)) { + add_action('wp_mail_failed', function ($error) { + $data = $error->get_error_data(); + error_log('[yang_mail] 发送失败: ' . $error->get_error_message() + . ' | to=' . (isset($data['to']) && is_array($data['to']) ? implode(',', $data['to']) : '') + . ' | subject=' . (isset($data['subject']) ? $data['subject'] : '')); + }, 10, 1); +} \ No newline at end of file diff --git a/inc/update-checker/update.json b/inc/update-checker/update.json index 3bfa77a..59734e6 100644 --- a/inc/update-checker/update.json +++ b/inc/update-checker/update.json @@ -1,5 +1,5 @@ { - "version": "3.2.5", + "version": "3.2.6", "details_url": "https://yangyq.net/theme-update/readme.html", "download_url": "https://yangyq.net/theme-update/kratos_yang.zip" } \ No newline at end of file diff --git a/single.php b/single.php index c2cb289..1c67a78 100644 --- a/single.php +++ b/single.php @@ -57,15 +57,6 @@ $select_col = $col_array[kratos_option('g_article_widgets', 'two_side')];
'; - } - echo ''; - if(kratos_option('s_singletop_links')){ - echo ''; - } - } the_content(); wp_link_pages( array( @@ -94,15 +85,6 @@ $select_col = $col_array[kratos_option('g_article_widgets', 'two_side')]; 'nextpagelink' => __('下一页', 'kratos') ) ); - if(kratos_option('s_singledown',false)){ - if(kratos_option('s_singledown_links')){ - echo ''; - } - echo ''; - if(kratos_option('s_singledown_links')){ - echo ''; - } - } ?>