版本号 3.2.5 -> 3.2.6(统一邮件出口 + 邮件模板 + 纪念日自动哀悼 + 去掉广告位)

评论邮件重复的根因是发信源不止一个,分两层处理:一层管「谁发」,一层管「发几封」。
只做前者的话邮件照样重复,只是都从同一个 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 会剥掉 <head> 里的 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 不溢出。
This commit is contained in:
2026-10-23 13:55:23 +08:00
parent 4f0d19fb91
commit 37c52948c4
14 changed files with 1157 additions and 195 deletions
+12 -1
View File
@@ -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';
+1 -1
View File
@@ -18,7 +18,7 @@
<meta name="description" itemprop="description" content="<?php echo description(); ?>">
<meta name="theme-color" content="<?php echo kratos_option('g_chrome', '#282a2c'); ?>">
<link rel="shortcut icon" href="<?php echo kratos_option('g_icon'); ?>">
<?php wp_head(); wp_print_scripts('jquery'); mourning(); ?>
<?php wp_head(); wp_print_scripts('jquery'); ?>
</head>
<?php flush(); ?>
<body>
@@ -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;
}
@@ -210,6 +210,46 @@ class Options_Framework_Interface
$output .= '<label class="explain" for="' . esc_attr($value['id']) . '">' . wp_kses($explain_value, $allowedtags) . '</label>';
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 .= '<fieldset class="of-multicheck">';
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 .= '<label class="of-multicheck-item" for="' . esc_attr($id) . '">';
$output .= '<input class="checkbox of-input" type="checkbox" '
. 'id="' . esc_attr($id) . '" '
. 'name="' . esc_attr($name . '[' . $key . ']') . '" '
. 'value="1" ' . checked($on, true, false) . ' />';
// 名称与说明必须包在同一个竖排容器里。
// 若让它们各自作为 label 的直接子项,flex 布局会把
// 说明挤到名称右边同一行(不是名称下方)。
$output .= '<span class="of-multicheck-text">';
$output .= '<span class="of-multicheck-label">' . esc_html($label) . '</span>';
if ($note !== '') {
$output .= '<span class="of-multicheck-note">' . esc_html($note) . '</span>';
}
$output .= '</span>';
$output .= '</label>';
}
$output .= '</fieldset>';
}
break;
// Color picker
case "color":
$default_color = '';
@@ -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.
*
+374
View File
@@ -0,0 +1,374 @@
<?php
/**
* 邮件模板渲染
*
* 设计约束(写这套模板的全部理由):
*
* 1. **必须是表格布局**。Outlook 2007-2019 用 Word 的排版引擎,完全不支持
* flex / grid,连 `max-width` 都不稳。只有 `<table>` + `cellpadding` 在老
* Outlook 里可靠。现在的 web 布局写法在邮件里必崩。
*
* 2. **样式必须内联**。Gmail 会把 `<head>` 里的 `<style>` 全部剥掉(含 media query),
* QQ 邮箱、网易邮箱同样。所以每个 `td` 都要带 `style="..."`,不能依赖 class。
*
* 3. **宽度 600px**。这是邮件客户端预览窗格的通用安全宽度。用
* `width="600"` 属性 + `style="width:600px;max-width:600px"` 双保险。
*
* 4. **不用外部图片做装饰**。邮件里的外链图默认被 Gmail / QQ 邮箱屏蔽,
* 用户看到的是一堆灰框。所有视觉元素用纯色块 + 圆角 + 文字实现。
*
* 5. **不用 CSS 变量**。邮件客户端不支持,写了就是丢失样式。
*
* 6. 深色模式:Android Gmail / Apple Mail 会反色。这里显式声明
* `color-scheme` 与 `supported-color-schemes`,并避免纯黑纯白。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* 站点主色(可被选项覆盖)
*
* 取主题的主色选项;取不到就用一个稳妥的深蓝灰。
* 不用亮色,因为邮件背景是浅灰,亮色正文对比度不够。
*/
function yang_mail_brand_color()
{
$c = kratos_option('top_color', '#24292e');
if (!preg_match('/^#[0-9a-fA-F]{6}$/', (string) $c)) {
$c = '#24292e';
}
return $c;
}
/**
* 把主色调暗一档,用作渐变末端 / 描边
*
* 邮件里不能用 `filter: brightness()`(不支持),只能算。
*/
function yang_mail_shade($hex, $ratio = 0.82)
{
$hex = ltrim($hex, '#');
if (strlen($hex) !== 6) {
return '#24292e';
}
$out = '#';
foreach (str_split($hex, 2) as $pair) {
$v = max(0, min(255, (int) round(hexdec($pair) * $ratio)));
$out .= str_pad(dechex($v), 2, '0', STR_PAD_LEFT);
}
return $out;
}
/**
* 判断一个颜色是深是浅,决定叠在上面的文字用白还是深色
*
* 用相对亮度公式(WCAG),不是简单取平均 —— 人眼对绿最敏感、蓝最不敏感,
* 直接平均会把 #00ff00 判成中等亮度。
*/
function yang_mail_is_dark($hex)
{
$hex = ltrim((string) $hex, '#');
if (strlen($hex) !== 6) {
return true;
}
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
$lin = function ($c) {
return $c <= 0.03928 ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4);
};
$L = 0.2126 * $lin($r) + 0.7152 * $lin($g) + 0.0722 * $lin($b);
return $L < 0.5;
}
/**
* 把可能含 HTML 的片段转成邮件安全的纯文本
*
* 邮件正文里塞用户提交的评论内容,必须防注入:邮件客户端也会执行
* `<script>`(部分 webmail 会),而且一旦渲染出畸形 HTML,整封邮件布局就崩。
*/
function yang_mail_text($html, $limit = 0)
{
$t = wp_strip_all_tags((string) $html, true);
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
$t = preg_replace('/\s+/u', ' ', $t);
$t = trim($t);
if ($limit > 0 && mb_strlen($t, 'UTF-8') > $limit) {
$t = mb_substr($t, 0, $limit, 'UTF-8') . '…';
}
return $t;
}
/**
* 把主色与白色按比例混合,得到一档浅色
*
* 为什么不用 rgba:Outlook(Word 引擎)完全不支持 rgba(),
* 写了会当成非法值丢弃 ⇒ 半透明背景变成完全透明,badge 直接"消失"。
* 所以这里预先算出实色。$ratio 是白色的占比(0=纯主色,1=纯白)。
*/
function yang_mail_tint($hex, $ratio = 0.18, $onto = '#ffffff')
{
$hex = ltrim((string) $hex, '#');
$onto = ltrim((string) $onto, '#');
if (strlen($hex) !== 6 || strlen($onto) !== 6) {
return '#ffffff';
}
$out = '#';
for ($i = 0; $i < 3; $i++) {
$a = hexdec(substr($hex, $i * 2, 2));
$b = hexdec(substr($onto, $i * 2, 2));
$v = (int) round($a + ($b - $a) * $ratio);
$out .= str_pad(dechex(max(0, min(255, $v))), 2, '0', STR_PAD_LEFT);
}
return $out;
}
/**
* 生成一个按钮(邮件里的按钮只能用表格单元格做,不能用 <button>)
*
* Outlook 不认 `<a>` 的 `padding`,必须把 padding 放在 `<td>` 上。
*/
function yang_mail_button($text, $url, $bg = null)
{
if ($bg === null) {
$bg = yang_mail_brand_color();
}
$fg = yang_mail_is_dark($bg) ? '#ffffff' : '#1a1a1a';
$url = esc_url($url);
$text = esc_html($text);
return '<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto;">'
. '<tr><td align="center" bgcolor="' . esc_attr($bg) . '" style="border-radius:6px;padding:13px 34px;">'
. '<a href="' . $url . '" target="_blank" rel="noopener" '
. 'style="display:inline-block;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;'
. 'font-size:15px;font-weight:600;line-height:1;color:' . $fg . ';text-decoration:none;letter-spacing:0.3px;">'
. $text . '</a></td></tr></table>';
}
/**
* 渲染整封邮件
*
* @param array $args {
* @type string $title 邮件主标题(收件箱预览也会看到)
* @type string $preheader 预读文字,显示在收件箱标题后面,不影响正文
* @type string $heading 正文第一行大字
* @type string $intro 正文引导段落(纯文本)
* @type array $rows 表格数据,每项 array('label'=>, 'value'=>, 'strong'=>bool)
* @type array $quote 引用块 array('label'=>, 'content'=>, 'meta'=>)
* @type string $cta_text 按钮文字
* @type string $cta_url 按钮链接
* @type string $note 底部提示(纯文本)
* @type string $badge 顶部小标签,如「新评论」
* }
* @return string 完整 HTML
*/
function yang_mail_render($args)
{
$d = wp_parse_args($args, array(
'title' => get_bloginfo('name'),
'preheader' => '',
'heading' => '',
'intro' => '',
'rows' => array(),
'quote' => array(),
'cta_text' => '',
'cta_url' => '',
'note' => '',
'badge' => '',
));
$brand = yang_mail_brand_color();
$brandD = yang_mail_shade($brand, 0.78);
$onBrand = yang_mail_is_dark($brand) ? '#ffffff' : '#1a1a1a';
// badge 的背景:把主色往白色方向提亮一档,得到比主色浅、但同色相的实色。
// 主色本身是深色时提亮 22%,浅色时压暗 —— 否则浅主色上叠浅 badge 会看不见。
$badgeBg = yang_mail_is_dark($brand)
? yang_mail_tint($brand, 0.22)
: yang_mail_shade($brand, 0.88);
$badgeBd = yang_mail_is_dark($brand)
? yang_mail_tint($brand, 0.34)
: yang_mail_shade($brand, 0.72);
$site = get_bloginfo('name');
$home = home_url('/');
$year = date('Y');
// 字体栈:中文优先 PingFang / 微软雅黑,英文走系统默认。
// 邮件里不能用 @font-face(Gmail 剥掉),只能靠系统字体。
$sans = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif";
ob_start();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title><?php echo esc_html($d['title']); ?></title>
<!--[if mso]>
<style type="text/css">
body,table,td,p,a{font-family:Arial,'Microsoft YaHei',sans-serif !important;}
</style>
<![endif]-->
</head>
<body style="margin:0;padding:0;width:100%;background-color:#eef1f4;">
<?php /* 预读文字:显示在收件箱列表里标题的后面。藏起来但必须真实存在,且要足够长,
否则客户端会把正文第一句抓上去当摘要。 */ ?>
<div style="display:none;font-size:1px;color:#eef1f4;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
<?php echo esc_html($d['preheader'] ? $d['preheader'] : $d['heading']); ?>
</div>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="background-color:#eef1f4;">
<tr><td align="center" style="padding:32px 12px;">
<?php /* 宽度写法是「混合式」:
· Outlook(Word 引擎)只认 HTML 属性 width="600",CSS 的 max-width 它当没看见;
· 其它客户端认 CSS,于是 width:100% + max-width:600px 让手机窄屏自动收窄。
两套并存,各取所需。只写死 600px 会让手机横向滚动——实测过。 */ ?>
<table border="0" cellpadding="0" cellspacing="0" width="600" role="presentation" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e3e7ec;">
<?php /* ── 顶部品牌条 ── */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($brand); ?>" style="background-color:<?php echo esc_attr($brand); ?>;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation">
<tr>
<td style="padding:26px 32px;">
<div style="font-family:<?php echo $sans; ?>;font-size:19px;font-weight:600;color:<?php echo esc_attr($onBrand); ?>;letter-spacing:0.2px;line-height:1.3;">
<?php echo esc_html($site); ?>
</div>
<?php if ($d['badge'] !== ''): ?>
<?php /* badge 用预算好的实色,不用 rgba —— Outlook 不认 rgba,会直接丢掉背景 */ ?>
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-top:10px;">
<tr>
<td bgcolor="<?php echo esc_attr($badgeBg); ?>" style="background-color:<?php echo esc_attr($badgeBg); ?>;border:1px solid <?php echo esc_attr($badgeBd); ?>;border-radius:20px;padding:4px 12px;font-family:<?php echo $sans; ?>;font-size:12px;font-weight:500;line-height:1.4;">
<span style="color:<?php echo esc_attr($onBrand); ?>;"><?php echo esc_html($d['badge']); ?></span>
</td>
</tr>
</table>
<?php endif; ?>
</td>
</tr>
</table>
</td>
</tr>
<?php /* 主色条下沿压一条深色,给扁平色块一点层次(渐变在 Outlook 里不生效,用纯色)。
height 必须**同时**写成 HTML 属性和 CSS:Outlook 只认属性,
只写 CSS 高度时这条 3px 带会撑成一行文字高(约 18px),比例就毁了。 */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($brandD); ?>" height="3" style="background-color:<?php echo esc_attr($brandD); ?>;height:3px;line-height:3px;font-size:0;mso-line-height-rule:exactly;">&nbsp;</td>
</tr>
<?php /* ── 正文 ── */ ?>
<tr>
<td style="padding:34px 32px 8px 32px;">
<?php if ($d['heading'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:21px;font-weight:600;color:#1a2028;line-height:1.45;margin:0 0 12px 0;">
<?php echo esc_html($d['heading']); ?>
</div>
<?php endif; ?>
<?php if ($d['intro'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:15px;color:#4a5560;line-height:1.7;margin:0 0 22px 0;">
<?php echo esc_html($d['intro']); ?>
</div>
<?php endif; ?>
<?php if (!empty($d['quote'])): ?>
<?php /* 引用块:左边一条主色竖线,用 border-left 而不是伪元素(邮件不支持 ::before) */ ?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="margin:0 0 22px 0;">
<tr>
<td style="border-left:3px solid <?php echo esc_attr($brand); ?>;background-color:#f6f8fa;padding:16px 18px;border-radius:0 8px 8px 0;">
<?php if (!empty($d['quote']['label'])): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:12px;font-weight:600;color:#5f6b78;letter-spacing:0.6px;text-transform:uppercase;margin:0 0 8px 0;">
<?php echo esc_html($d['quote']['label']); ?>
</div>
<?php endif; ?>
<div style="font-family:<?php echo $sans; ?>;font-size:15px;color:#2d3740;line-height:1.7;word-break:break-word;">
<?php echo nl2br(esc_html($d['quote']['content'])); ?>
</div>
<?php if (!empty($d['quote']['meta'])): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:13px;color:#5f6b78;line-height:1.6;margin:9px 0 0 0;">
<?php echo esc_html($d['quote']['meta']); ?>
</div>
<?php endif; ?>
</td>
</tr>
</table>
<?php endif; ?>
<?php if (!empty($d['rows'])): ?>
<?php /* 信息表:两列。label 列定宽 88px 并右对齐,value 左对齐,视觉上最稳。
不用 table-layout:fixed 之外的花样,Outlook 只认这个。 */ ?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="margin:0 0 24px 0;border-top:1px solid #edf0f3;">
<?php foreach ($d['rows'] as $row): ?>
<tr>
<td width="88" valign="top" style="width:88px;padding:11px 0;border-bottom:1px solid #edf0f3;font-family:<?php echo $sans; ?>;font-size:13px;color:#5f6b78;line-height:1.6;white-space:nowrap;">
<?php echo esc_html($row['label']); ?>
</td>
<td valign="top" style="padding:11px 0 11px 16px;border-bottom:1px solid #edf0f3;font-family:<?php echo $sans; ?>;font-size:14px;color:#2d3740;line-height:1.6;word-break:break-word;">
<?php
$v = isset($row['value']) ? $row['value'] : '';
if (!empty($row['strong'])) {
echo '<strong style="font-weight:600;">' . esc_html($v) . '</strong>';
} else {
echo esc_html($v);
}
?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php if ($d['cta_text'] !== '' && $d['cta_url'] !== ''): ?>
<div style="margin:0 0 28px 0;">
<?php echo yang_mail_button($d['cta_text'], $d['cta_url']); ?>
</div>
<?php endif; ?>
<?php if ($d['note'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:13px;color:#5f6b78;line-height:1.7;padding:16px 0 0 0;border-top:1px solid #edf0f3;">
<?php echo esc_html($d['note']); ?>
</div>
<?php endif; ?>
</td>
</tr>
<?php /* ── 页脚 ── */ ?>
<tr>
<td style="padding:24px 32px 30px 32px;">
<div style="border-top:1px solid #edf0f3;padding-top:20px;font-family:<?php echo $sans; ?>;font-size:12px;color:#5f6b78;line-height:1.9;">
<div style="margin:0 0 6px 0;">
<a href="<?php echo esc_url($home); ?>" target="_blank" rel="noopener" style="color:#3d4752;text-decoration:none;font-weight:600;"><?php echo esc_html($site); ?></a>
</div>
<div style="margin:0;">此邮件由系统自动发出,请勿直接回复。</div>
<div style="margin:6px 0 0 0;color:#6b7684;">&copy; <?php echo esc_html($year); ?> <?php echo esc_html($site); ?></div>
</div>
</td>
</tr>
</table>
<?php /* 底部说明:为什么收到这封邮件。放在卡片外面,视觉上属于系统信息 */ ?>
<div style="font-family:<?php echo $sans; ?>;font-size:12px;color:#5f6b78;line-height:1.7;padding:18px 8px 0 8px;max-width:600px;">
您收到这封邮件,是因为本站记录了与您邮箱相关的订阅或互动。
</div>
</td></tr>
</table>
</body>
</html>
<?php
return ob_get_clean();
}
+309
View File
@@ -0,0 +1,309 @@
<?php
/**
* 邮件治理:统一由主题接管
*
* ── 要解决的问题 ──────────────────────────────────────────────
* 一条评论提交后,收件人收到多封内容重叠的邮件。排查结论是**发信源不止一个**:
*
* 1. WordPress 核心
* · comments_notify = 1 ⇒ 有新评论就通知管理员
* · moderation_notify = 1 ⇒ 有评论待审就通知管理员
* 两者在「评论需审核」时会**同时**命中,管理员一次收到两封。
*
* 2. wpDiscuz 插件(class.WpdiscuzHelperOptimization.php:199-208)
* 一条回复会依次触发:
* sendMailToMentionedUsers 被 @ 的人
* notifyPostSubscribers 订阅了本文的人
* notifyFollowers 关注了作者的人
* notifyAllCommentSubscribers 订阅了全部评论的人
* notifyCommentSubscribers 被回复的那一层作者
* 同一个人如果既订阅了文章又被回复,就收到两封。
* 另外 subscriptionType = 3 表示「订阅全站」,范围最大。
*
* 3. 本主题自己(旧 inc/theme-smtp.php 里的 comment_notify / comment_approved)
* 直接挂在 comment_post 与 comment_unapproved_to_approved 上,
* 与 wpDiscuz 的 notifyCommentSubscribers **完全重复**。
* 而且它用的是一段硬编码 HTML,与插件模板风格完全不一致。
*
* 4. 插件兜底队列(Akismet 的 schedule_email_fallback / schedule_approval_fallback)
* 命中垃圾判定后异步补发审核结果,同样走 wp_mail。
*
* ── 处理原则 ──────────────────────────────────────────────────
* 用户要求「只要主题配置了 SMTP,就一律用主题发邮件,其他的都禁用掉」。
* 注意**不能只关发信通道**:把 phpmailer 抢过来只解决了「谁发」,
* 没解决「发几封」。所以这里分两层:
*
* 第一层 通道:拦住所有 wp_mail 调用,强制套用主题 SMTP 配置。
* 若站点装了别的 SMTP 插件,主题优先;主题没配则完全放手。
*
* 第二层 去重:在通道层做「同一收件人 + 同一事件」的短期去重,
* 并主动关掉重复的钩子、屏蔽核心的旧式通知。
*
* 第二层是关键。只做第一层的话,邮件依然会重复,只是都从同一个 SMTP 发出。
*
* @author Yongquan Yang <yangyq.net>
* @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);
}
+259
View File
@@ -0,0 +1,259 @@
<?php
/**
* 纪念日自动哀悼
*
* ── 依据 ─────────────────────────────────────────────────────
* 以下日期分三类,可信度不同,代码里也分开对待:
*
* A. 法定纪念日(全国人大常委会决定设立,每年国家举行纪念活动)
* · 09-03 中国人民抗日战争胜利纪念日
* 2014-02-27 十二届全国人大常委会第七次会议决定设立
* · 09-30 烈士纪念日
* 2014-08-31 十二届全国人大常委会第十次会议决定设立
* · 12-13 南京大屠杀死难者国家公祭日
* 2014-02-27 同一会议决定设立
* 依据:全国人大网《铭记历史坐标 凝聚前行力量》
* http://www.npc.gov.cn/c2/c30834/202510/t20251020_448426.html
* http://npc.gov.cn/c2/c12435/c12487/201905/t20190521_220992.html
*
* B. 民间广泛采用(非法定,但主流网站普遍黑白)
* · 05-12 汶川地震纪念(2008-05-12 14:28)
* · 09-18 九一八事变纪念(1931-09-18)
* 这两个日期黑白是网络惯例,但不是法定「哀悼日」,所以默认**不勾选**,
* 由用户自己决定。硬编码成默认开启会替用户做价值判断,不合适。
*
* C. 历史全国哀悼日(一次性事件,非每年)
* · 2008-05-19~21 汶川地震(国务院决定)
* · 2010-04-21 玉树地震(国务院决定)
* · 2010-08-15 舟曲泥石流(国务院决定)
* · 2020-04-04 新冠疫情牺牲烈士和逝世同胞(国务院决定)
* 这些是**特定年份的特定日期**,不是周年循环。所以代码里不把它们
* 当成"每年重复",而是保留为可自定义的具体日期,避免明年 4 月 4 日
* 又自动变黑白(那是错的)。
*
* ── 设计取舍 ──────────────────────────────────────────────────
* 1. **按站点时区判断**,不按服务器时区。用 current_time('md'),
* 它走 WordPress 设置的时区。否则服务器在 UTC,北京时间 9 月 18 日
* 早上 8 点前还算 17 日,哀悼会晚 8 小时生效。
*
* 2. **只判断月日,不判断年份**。纪念日是周年性质,每年的同一天都要生效。
*
* 3. **兼容旧的 g_rip 开关**。老版本是纯手动「一直黑白」。
* 若用户勾着旧的 g_rip,仍然一直黑白;新逻辑是叠加在上面的。
* 不能因为升级就让原本黑白中的站点突然恢复彩色。
*
* @author Yongquan Yang <yangyq.net>
* @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 '<style id="yang-mourning">'
. "\nhtml{filter:grayscale(100%);-webkit-filter:grayscale(100%);}"
. "\nhtml body{-webkit-transform:translateZ(0);}"
// 打印时不灰度,否则打印出来是灰的
. "\n@media print{html{filter:none!important;-webkit-filter:none!important;}}"
. "\n</style>\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)
);
}
+50 -47
View File
@@ -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',
+5 -7
View File
@@ -92,13 +92,11 @@ add_filter('robots_txt', function ($output, $public) {
}
}, 10, 2);
// 哀悼黑白站点
function mourning()
{
if (is_home() && kratos_option('g_rip', false)) {
echo '<style type="text/css">html{filter: grayscale(100%);-webkit-filter: grayscale(100%);-moz-filter: grayscale(100%);-ms-filter: grayscale(100%);-o-filter: grayscale(100%);filter: progid:DXImageTransform.Microsoft.BasicImage(grayscale=1);filter: gray;-webkit-filter: grayscale(1); } </style>';
}
}
// 哀悼黑白站点:已迁移到 inc/theme-mourning.php
//
// 旧的 mourning() 只判 is_home() 且只能手动常开,无法按纪念日自动生效。
// 新实现挂在 wp_head(优先级 1),支持按日期自动判断,并保持向后兼容
// (旧的 g_rip 开关仍表示「一直黑白」)。
// 抓取图片链接(搜索引擎或者社交工具分享时抓取图片的链接)
/*
+23 -118
View File
@@ -1,130 +1,35 @@
<?php
/**
* SMTP 配置
*
* ⚠️ 这个文件过去自己发过两类邮件(回复通知、审核通过通知),
* 与 wpDiscuz 插件的通知**完全重复**,是「一条评论收到多封邮件」的
* 直接原因之一。现已全部移除。
*
* 发信通道的接管逻辑统一在 inc/theme-mail.php,
* 邮件模板在 inc/theme-mail-template.php。
* 本文件只保留 SMTP 连通性相关的部分。
*
* @author Yongquan Yang <yangyq.net>
* @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 = '
<div style="background:#ececec;width: 100%;padding: 50px 0;text-align:center;">
<div style="background:#fff;width:750px;text-align:left;position:relative;margin:0 auto;font-size:14px;line-height:1.5;">
<div style="zoom:1;padding:25px 40px;background:#518bcb; border-bottom:1px solid #467ec3;">
<h1 style="color:#fff; font-size:25px;line-height:30px; margin:0;"><a href="' . get_option('home') . '" style="text-decoration: none;color: #FFF;">' . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . '</a></h1>
</div>
<div style="padding:35px 40px 30px;">
<h2 style="font-size:18px;margin:5px 0;">' . __('您好,', 'kratos') . trim($comment->comment_author) . ':</h2>
<p style="color:#313131;line-height:20px;font-size:15px;margin:20px 0;">' . __('您的留言已经通过了管理员的审核,摘要信息如下:', 'kratos') . '</p>
<table cellspacing="0" style="font-size:14px;text-align:center;border:1px solid #ccc;table-layout:fixed;width:500px;">
<thead>
<tr>
<th style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:normal;color:#a0a0a0;background:#eee;border-color:#dfdfdf;" width="280px;">' . __('文章', 'kratos') . '</th>
<th style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:normal;color:#a0a0a0;background:#eee;border-color:#dfdfdf;" width="270px;">' . __('内容', 'kratos') . '</th>
<th style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:normal;color:#a0a0a0;background:#eee;border-color:#dfdfdf;" width="110px;">' . __('操作', 'kratos') . '</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">《' . get_the_title($comment->comment_post_ID) . '》</td>
<td style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' . trim($comment->comment_content) . '</td>
<td style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"><a href="' . get_comment_link($comment->comment_ID) . '" style="color:#1E5494;text-decoration:none;vertical-align:middle;" target="_blank">查看留言</a></td>
</tr>
</tbody>
</table>
<br>
<div style="font-size:13px;color:#a0a0a0;padding-top:10px">' . __('该邮件由系统自动发出,如果不是您本人操作,请忽略此邮件。', 'kratos') . '</div>
<div class="qmSysSign" style="padding-top:20px;font-size:12px;color:#a0a0a0;">
<p style="color:#a0a0a0;line-height:18px;font-size:12px;margin:5px 0;">' . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . '</p>
<p style="color:#a0a0a0;line-height:18px;font-size:12px;margin:5px 0;"><span style="border-bottom:1px dashed #ccc;" t="5" times="">' . date("Y年m月d日", time()) . '</span></p>
</div>
</div>
</div>
</div>';
$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);
// 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);
}
}
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 = '
<div style="background:#ececec;width: 100%;padding: 50px 0;text-align:center;">
<div style="background:#fff;width:750px;text-align:left;position:relative;margin:0 auto;font-size:14px;line-height:1.5;">
<div style="zoom:1;padding:25px 40px;background:#518bcb; border-bottom:1px solid #467ec3;">
<h1 style="color:#fff; font-size:25px;line-height:30px; margin:0;"><a href="' . get_option('home') . '" style="text-decoration: none;color: #FFF;">' . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . '</a></h1>
</div>
<div style="padding:35px 40px 30px;">
<h2 style="font-size:18px;margin:5px 0;">' . __('您好,', 'kratos') . trim(get_comment($parent_id)->comment_author) . ':</h2>
<p style="color:#313131;line-height:20px;font-size:15px;margin:20px 0;">' . __('您的留言有了新的回复,摘要信息如下:', 'kratos') . '</p>
<table cellspacing="0" style="font-size:14px;text-align:center;border:1px solid #ccc;table-layout:fixed;width:500px;">
<thead>
<tr>
<th style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:normal;color:#a0a0a0;background:#eee;border-color:#dfdfdf;" width="235px;">' . __('原文', 'kratos') . '</th>
<th style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:normal;color:#a0a0a0;background:#eee;border-color:#dfdfdf;" width="235px;">' . __('回复', 'kratos') . '</th>
<th style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:normal;color:#a0a0a0;background:#eee;border-color:#dfdfdf;" width="100px;">' . __('作者', 'kratos') . '</th>
<th style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:normal;color:#a0a0a0;background:#eee;border-color:#dfdfdf;" width="90px;" >' . __('操作', 'kratos') . '</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' . trim(get_comment($parent_id)->comment_content) . '</td>
<td style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' . trim($comment->comment_content) . '</td>
<td style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' . trim($comment->comment_author) . '</td>
<td style="padding:5px 0;text-indent:8px;border:1px solid #eee;border-width:0 1px 1px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"><a href="' . get_comment_link($comment->comment_ID) . '" style="color:#1E5494;text-decoration:none;vertical-align:middle;" target="_blank">' . __('查看回复', 'kratos') . '</a></td>
</tr>
</tbody>
</table>
<br>
<div style="font-size:13px;color:#a0a0a0;padding-top:10px">' . __('该邮件由系统自动发出,如果不是您本人操作,请忽略此邮件。', 'kratos') . '</div>
<div class="qmSysSign" style="padding-top:20px;font-size:12px;color:#a0a0a0;">
<p style="color:#a0a0a0;line-height:18px;font-size:12px;margin:5px 0;">' . htmlspecialchars_decode(get_option('blogname'), ENT_QUOTES) . '</p>
<p style="color:#a0a0a0;line-height:18px;font-size:12px;margin:5px 0;"><span style="border-bottom:1px dashed #ccc;" t="5" times="">' . date("Y年m月d日", time()) . '</span></p>
</div>
</div>
</div>
</div>';
$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');
+1 -1
View File
@@ -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"
}
-18
View File
@@ -57,15 +57,6 @@ $select_col = $col_array[kratos_option('g_article_widgets', 'two_side')];
</div>
<div class="content">
<?php
if(kratos_option('s_singletop',false)){
if(kratos_option('s_singletop_links')){
echo '<a href="'. kratos_option('s_singletop_links') .'" target="_blank" rel="noreferrer">';
}
echo '<img src="'.kratos_option('s_singletop_url').'">';
if(kratos_option('s_singletop_links')){
echo '</a>';
}
}
the_content();
wp_link_pages(
array(
@@ -94,15 +85,6 @@ $select_col = $col_array[kratos_option('g_article_widgets', 'two_side')];
'nextpagelink' => __('<span>下一页</span>', 'kratos')
)
);
if(kratos_option('s_singledown',false)){
if(kratos_option('s_singledown_links')){
echo '<a href="'. kratos_option('s_singledown_links') .'" target="_blank" rel="noreferrer">';
}
echo '<img src="'.kratos_option('s_singledown_url').'">';
if(kratos_option('s_singledown_links')){
echo '</a>';
}
}
?>
</div>
<?php if(kratos_option('g_cc_switch', false)){
+1 -1
View File
@@ -1,7 +1,7 @@
/*
Theme Name: Kratos_Yang
Text Domain: kratos_Yang
Version: 3.2.5
Version: 3.2.6
Requires PHP: 7.0
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好