修复:跨 tab 保存互相清空配置;补齐全流程邮件通知与统一模板 (#2)
## 一、用户报「我明明配过 SMTP,怎么全丢了、信也发不出」
**没有重置数据库**(installed_at 仍是 11-01 15:14,oss.*/site_name/admin.username 全在)。
真因是我上一轮「邮件拆独立 tab」挖的坑:站点设置与邮件通知**共用 act=settings**,
而保存函数把 smtp 字段无条件写库:
foreach ($smtpText as $k) {
fb_set_setting($k, trim((string)$post($k, \x27\x27))); // 没提交 → 空串 → 覆盖
}
在站点设置页保存附件设置时,该表单没有 smtp_* 字段,$post() 取到空串,
于是 host/user/from 被清成空。smtp.pass 因为单独做了「留空=不修改」而幸存,
所以现场是 pass 有值、其余全空 —— 这个组合本身就是 bug 的指纹。
修法:新增 $has() 判断「本次请求是否真的提交了该字段」,没提交就跳过。
按 tab 分两组保护:
- $isSiteForm:站点信息/页脚/附件/OSS,逐字段 has() 双保险
- $isMailForm:SMTP/通知开关,整段 + 逐字段
checkbox 尤其要保护:未提交会被当成「取消勾选」,
在站点设置页存一次就会把「有人回复时通知」全关掉。
## 二、notify.admin_new_thread 是假的(用户说「提交了反馈收不到邮件」)
这个设置项:白名单里有、后台有勾选框,**但没有任何代码实现它**。
grep 只命中三处(白名单/保存/界面),没有一处真正发信。
已在 new.php 提交成功后实现:查新帖 → 取所属软件名 → fb_notify_event(\x27new\x27)。
## 三、补齐全部事件,统一入口
此前只有 reply 和 close 两个事件,且两套模板风格不一致。
新增 fb_notify_event() 统一入口,处理「开关→收件人→节流→发信→日志」。
覆盖 8 类事件:reply / status / approve / hide / close / new。
- status 按状态给不同文案(待处理/处理中/已解决/关闭/不予考虑),
「不予考虑」语气克制、必须带理由并留申诉入口
- approve / hide 原先完全不发信,现在补上
- 状态没变化时不发信(不打扰用户)
## 四、邮件模板重做
抽出 fb_mail_layout() 统一外壳,所有事件共用:
- table + 内联样式(Outlook/QQ邮箱/Gmail 兼容)
- 600px 卡片、顶部主色条、圆角徽标、字段表、引用块、callout、CTA 按钮
- 响应式(已验 375px 无溢出、无裁切)
- preheader 摘要、纯文本兜底链接、收件人提示
## 五、后台状态按钮(用户要求)
问题详情页顶部一排按钮,点一下立刻生效并自动发信:
「通过并公开」「处理中」「已解决」「不予考虑」「撤下」
- 当前状态对应的按钮置灰
- 「不予考虑」展开内联面板填理由(不用 window.prompt)
- 新增 setstatus 动作;「通过并公开」会同时置可见
## 六、notify_log 加 reason 列
原表只有 reply_id,无法区分事件类型。加 reason 并幂等迁移;
老库 reply_id 是 NOT NULL,非回复事件用 0 表示。
## 验证
邮件专项 73/73:
- 8 类事件模板全部完整 HTML、table 布局、徽标、页脚、无 script
- 8 个主题互不相同;状态徽标文案正确
- 总开关关闭时 admin_new_thread 仍可独立生效
- 节流只作用于 reply/status,close 不受影响
- SMTP 未启用时留失败日志(含 reason 与原因)
- C1-C5:站点页保存后 smtp.host/user/notify.enabled 均未被清空;
邮件页保存后 oss.bucket 未被清空
反向验证:完整回退两层保护后 C1/C2/C3 全部 FAIL
(host 从 smtp.qiye.aliyun.com 变成空、notify.enabled 从 1 变 0),
证明测试能真正抓到该 bug,不是假通过。
回归:全新安装 33/33、老基线 25/25、附件 22/22。
This commit is contained in:
@@ -120,7 +120,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
|||||||
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
|
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
|
||||||
->execute([$newStatus, time(), $id]);
|
->execute([$newStatus, time(), $id]);
|
||||||
fb_mod_log('approve', $id, '通过审核并公开');
|
fb_mod_log('approve', $id, '通过审核并公开');
|
||||||
$msg = '#' . $id . ' 已通过审核,现已公开显示。';
|
|
||||||
|
// 通知提交人「已发布」(工单 #2)
|
||||||
|
$errA = '';
|
||||||
|
try {
|
||||||
|
list($okA, $errA) = fb_notify_event($pdo, 'approve', $t);
|
||||||
|
} catch (Throwable $e) { $errA = '发信异常'; }
|
||||||
|
$msg = '#' . $id . ' 已通过审核,现已公开显示。'
|
||||||
|
. ($errA === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errA . ')');
|
||||||
|
|
||||||
} elseif ($act === 'close') {
|
} elseif ($act === 'close') {
|
||||||
$note = trim(isset($_POST['note']) ? (string)$_POST['note'] : '');
|
$note = trim(isset($_POST['note']) ? (string)$_POST['note'] : '');
|
||||||
@@ -141,7 +148,61 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
|||||||
} elseif ($act === 'hide') {
|
} elseif ($act === 'hide') {
|
||||||
$pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $id]);
|
$pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $id]);
|
||||||
fb_mod_log('hide', $id, '撤下(转为不公开)');
|
fb_mod_log('hide', $id, '撤下(转为不公开)');
|
||||||
$msg = '#' . $id . ' 已撤下(转为不公开)。';
|
|
||||||
|
$noteH = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
|
||||||
|
$errH = '';
|
||||||
|
try {
|
||||||
|
list($okH, $errH) = fb_notify_event($pdo, 'hide', $t, ['note' => $noteH]);
|
||||||
|
} catch (Throwable $e) { $errH = '发信异常'; }
|
||||||
|
$msg = '#' . $id . ' 已撤下(转为不公开)。'
|
||||||
|
. ($errH === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errH . ')');
|
||||||
|
|
||||||
|
} elseif ($act === 'setstatus') {
|
||||||
|
/* 问题详情页顶部那一排按钮走的动作。
|
||||||
|
*
|
||||||
|
* 与 status 的区别:
|
||||||
|
* - status 是「状态下拉框 + 公开勾选」的完整表单,需要手动选
|
||||||
|
* - setstatus 是「点一下立刻生效」,状态与可见性由按钮本身决定
|
||||||
|
*
|
||||||
|
* 「通过并公开」= 可见 + 状态保持;其余状态只改状态不动可见性,
|
||||||
|
* 避免误把已公开的问题撤下来(用户没这个意图)。
|
||||||
|
*/
|
||||||
|
$statuses = fb_statuses();
|
||||||
|
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
|
||||||
|
$note = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
|
||||||
|
$alsoShow = !empty($_POST['also_show']); // 「通过并公开」置 1
|
||||||
|
|
||||||
|
if (!isset($statuses[$new])) {
|
||||||
|
$msg = '未知的状态。';
|
||||||
|
$msgType = 'error';
|
||||||
|
} else {
|
||||||
|
$old = (string)$t['status'];
|
||||||
|
$vis = $alsoShow ? 1 : (int)$t['visible'];
|
||||||
|
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
|
||||||
|
->execute([$new, $vis, time(), $id]);
|
||||||
|
fb_mod_log('status', $id,
|
||||||
|
'一键改为 ' . $statuses[$new] . ($alsoShow ? ',并公开' : '')
|
||||||
|
. ($note !== '' ? ',说明:' . fb_excerpt($note, 60) : ''));
|
||||||
|
|
||||||
|
$errS = '';
|
||||||
|
if ($old !== $new || $alsoShow) {
|
||||||
|
try {
|
||||||
|
// 「通过并公开」用 approve 事件(文案是「已发布」)
|
||||||
|
$reason = $alsoShow ? 'approve' : 'status';
|
||||||
|
list($okS, $errS) = fb_notify_event($pdo, $reason, $t, [
|
||||||
|
'old_status' => $old,
|
||||||
|
'new_status' => $new,
|
||||||
|
'note' => $note,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) { $errS = '发信异常'; }
|
||||||
|
}
|
||||||
|
$msg = '#' . $id . ' 已改为「' . $statuses[$new] . '」'
|
||||||
|
. ($alsoShow ? '并公开显示' : '') . '。'
|
||||||
|
. ($old !== $new || $alsoShow
|
||||||
|
? ($errS === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errS . ')')
|
||||||
|
: '状态未变化,未发信。');
|
||||||
|
$msgType = $errS === '' ? 'ok' : 'error';
|
||||||
|
}
|
||||||
|
|
||||||
} elseif ($act === 'status') {
|
} elseif ($act === 'status') {
|
||||||
$statuses = fb_statuses();
|
$statuses = fb_statuses();
|
||||||
@@ -151,10 +212,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
|||||||
$msgType = 'error';
|
$msgType = 'error';
|
||||||
} else {
|
} else {
|
||||||
$vis = isset($_POST['visible']) ? 1 : 0;
|
$vis = isset($_POST['visible']) ? 1 : 0;
|
||||||
|
$old = (string)$t['status'];
|
||||||
|
$note = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
|
||||||
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
|
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
|
||||||
->execute([$new, $vis, time(), $id]);
|
->execute([$new, $vis, time(), $id]);
|
||||||
fb_mod_log('status', $id, '状态改为 ' . $statuses[$new] . ',公开=' . $vis);
|
fb_mod_log('status', $id, '状态改为 ' . $statuses[$new] . ',公开=' . $vis);
|
||||||
$msg = '#' . $id . ' 已更新:状态「' . $statuses[$new] . '」,' . ($vis ? '公开' : '不公开') . '。';
|
|
||||||
|
// 状态变了才发信(没变就别打扰用户)
|
||||||
|
$errS = '';
|
||||||
|
if ($old !== $new) {
|
||||||
|
try {
|
||||||
|
list($okS, $errS) = fb_notify_event($pdo, 'status', $t, [
|
||||||
|
'old_status' => $old,
|
||||||
|
'new_status' => $new,
|
||||||
|
'note' => $note,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) { $errS = '发信异常'; }
|
||||||
|
}
|
||||||
|
$msg = '#' . $id . ' 已更新:状态「' . $statuses[$new] . '」,'
|
||||||
|
. ($vis ? '公开' : '不公开') . '。'
|
||||||
|
. ($old !== $new
|
||||||
|
? ($errS === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errS . ')')
|
||||||
|
: '状态未变化,未发信。');
|
||||||
}
|
}
|
||||||
|
|
||||||
} elseif ($act === 'reply') {
|
} elseif ($act === 'reply') {
|
||||||
@@ -268,16 +347,43 @@ function fb_admin_save_settings() {
|
|||||||
return $def;
|
return $def;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个字段本次请求到底有没有被提交?
|
||||||
|
*
|
||||||
|
* 🔴 为什么必须有这个:后台有多个 tab(站点设置 / 邮件通知),
|
||||||
|
* 但它们**共用同一个保存处理**(都提交 act=settings)。
|
||||||
|
* 如果在站点设置页点保存,该表单里根本没有 smtp_* 字段,
|
||||||
|
* `$post('smtp.host', '')` 会取到空串,然后被写进数据库 ——
|
||||||
|
* **把用户之前填好的邮件配置清空**。
|
||||||
|
*
|
||||||
|
* 同类事故还会发生在 checkbox 上:没提交 = 视为取消勾选,
|
||||||
|
* 于是另一个 tab 保存一次就把通知开关全关掉。
|
||||||
|
*
|
||||||
|
* 所以写库前一律先问 has():没提交就跳过,绝不写空值。
|
||||||
|
*/
|
||||||
|
$has = function ($k) {
|
||||||
|
if (array_key_exists($k, $_POST)) { return true; }
|
||||||
|
return array_key_exists(str_replace('.', '_', $k), $_POST);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 站点设置组(含站点信息/页脚/附件/OSS)============
|
||||||
|
// 🔴 整组包在 has() 判断里:邮件页只提交邮件字段,没有 site_*/oss_*/upload_*,
|
||||||
|
// 无条件写入会把它们全清空。反过来也一样。
|
||||||
|
$isSiteForm = $has('oss_endpoint') || $has('site_name')
|
||||||
|
|| $has('attachment_storage') || $has('site_desc');
|
||||||
|
if ($isSiteForm) {
|
||||||
|
|
||||||
// 站点信息(对外展示,用于说明「本系统为哪个软件提供反馈服务」)
|
// 站点信息(对外展示,用于说明「本系统为哪个软件提供反馈服务」)
|
||||||
// 留空 = 删除该设置项,回落到 config.php 的出厂值,便于恢复默认。
|
// 留空 = 删除该设置项,回落到 config.php 的出厂值,便于恢复默认。
|
||||||
foreach (['site_name', 'site_desc'] as $k) {
|
foreach (['site_name', 'site_desc'] as $k) {
|
||||||
|
if (!$has($k)) { continue; }
|
||||||
$v = trim((string)$post($k, ''));
|
$v = trim((string)$post($k, ''));
|
||||||
if ($v === '') { fb_unset_setting($k); }
|
if ($v === '') { fb_unset_setting($k); }
|
||||||
else { fb_set_setting($k, $v); }
|
else { fb_set_setting($k, $v); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址
|
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址
|
||||||
fb_set_setting('footer.show_credit', $post('footer_show_credit') ? 1 : 0);
|
if ($has('footer_show_credit')) { fb_set_setting('footer.show_credit', $post('footer_show_credit') ? 1 : 0); }
|
||||||
$curl = trim((string)$post('footer_credit_url', ''));
|
$curl = trim((string)$post('footer_credit_url', ''));
|
||||||
// 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来
|
// 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来
|
||||||
if ($curl !== '' && !preg_match('~^https?://~i', $curl)) {
|
if ($curl !== '' && !preg_match('~^https?://~i', $curl)) {
|
||||||
@@ -296,13 +402,14 @@ function fb_admin_save_settings() {
|
|||||||
'upload.image_max_side' => [480, 4096],
|
'upload.image_max_side' => [480, 4096],
|
||||||
];
|
];
|
||||||
foreach ($ints as $k => $range) {
|
foreach ($ints as $k => $range) {
|
||||||
|
if (!$has($k)) { continue; }
|
||||||
$v = (int)$post($k, 0);
|
$v = (int)$post($k, 0);
|
||||||
$v = max($range[0], min($range[1], $v));
|
$v = max($range[0], min($range[1], $v));
|
||||||
fb_set_setting($k, $v);
|
fb_set_setting($k, $v);
|
||||||
}
|
}
|
||||||
// 图片/视频上传总开关(0=不限个数后,开关必须独立存在)
|
// 图片/视频上传总开关(0=不限个数后,开关必须独立存在)
|
||||||
fb_set_setting('upload.image_on', (int)(bool)$post('upload.image_on'));
|
if ($has('upload_image_on')) { fb_set_setting('upload.image_on', (int)(bool)$post('upload.image_on')); }
|
||||||
fb_set_setting('upload.video_on', (int)(bool)$post('upload.video_on'));
|
if ($has('upload_video_on')) { fb_set_setting('upload.video_on', (int)(bool)$post('upload.video_on')); }
|
||||||
|
|
||||||
// MB → 字节
|
// MB → 字节
|
||||||
// 下限改 0 = 不限制(见 #4)
|
// 下限改 0 = 不限制(见 #4)
|
||||||
@@ -312,6 +419,7 @@ function fb_admin_save_settings() {
|
|||||||
'upload.total_cap_bytes' => [0, 1048576],
|
'upload.total_cap_bytes' => [0, 1048576],
|
||||||
];
|
];
|
||||||
foreach ($mbs as $k => $range) {
|
foreach ($mbs as $k => $range) {
|
||||||
|
if (!$has($k)) { continue; } // 没提交就不动(防跨 tab 清空)
|
||||||
$mb = (float)$post($k, 0);
|
$mb = (float)$post($k, 0);
|
||||||
$mb = max($range[0], min($range[1], $mb));
|
$mb = max($range[0], min($range[1], $mb));
|
||||||
fb_set_setting($k, (int)round($mb * 1048576));
|
fb_set_setting($k, (int)round($mb * 1048576));
|
||||||
@@ -325,6 +433,7 @@ function fb_admin_save_settings() {
|
|||||||
|
|
||||||
$texts = ['oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.domain', 'oss.prefix', 'moderation.close_mail_subject'];
|
$texts = ['oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.domain', 'oss.prefix', 'moderation.close_mail_subject'];
|
||||||
foreach ($texts as $k) {
|
foreach ($texts as $k) {
|
||||||
|
if (!$has($k)) { continue; } // 没提交就不动
|
||||||
$v = trim((string)$post($k, ''));
|
$v = trim((string)$post($k, ''));
|
||||||
// 这两个留空都有"出厂默认"兜底,必须删键而不是存空串:
|
// 这两个留空都有"出厂默认"兜底,必须删键而不是存空串:
|
||||||
// - oss.prefix 留空会让对象键变成 "/2026/...",默认是 'fb'
|
// - oss.prefix 留空会让对象键变成 "/2026/...",默认是 'fb'
|
||||||
@@ -354,9 +463,26 @@ function fb_admin_save_settings() {
|
|||||||
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
|
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
|
||||||
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
|
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
|
||||||
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
|
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
|
||||||
/* ---------------- 邮件通知(SMTP) ---------------- */
|
|
||||||
|
} // ← end if ($isSiteForm)
|
||||||
|
|
||||||
|
/* ---------------- 邮件通知(SMTP) ----------------
|
||||||
|
*
|
||||||
|
* 🔴🔴 必须整段包在「本页到底有没有提交邮件表单」的判断里。
|
||||||
|
*
|
||||||
|
* 事故:站点设置页和邮件通知页共用 act=settings。在站点设置页点保存时,
|
||||||
|
* 该表单里没有 smtp_* 字段,$post() 取到空串,被无条件写进数据库 ——
|
||||||
|
* **把用户已经填好的 SMTP 服务器/账号/发件地址全清空了**。
|
||||||
|
* 用户看到的现象就是「我明明配过邮件,怎么全没了、信也发不出」。
|
||||||
|
*
|
||||||
|
* 判据用 smtp_enabled:它是邮件表单里的总开关,一定存在。
|
||||||
|
* 站点设置页没有这个字段 ⇒ 整段跳过,一个键都不动。
|
||||||
|
*/
|
||||||
|
$isMailForm = $has('smtp_enabled') || $has('smtp.host') || $has('smtp_host');
|
||||||
|
if ($isMailForm) {
|
||||||
$smtpText = ['smtp.host', 'smtp.user', 'smtp.from', 'smtp.from_name'];
|
$smtpText = ['smtp.host', 'smtp.user', 'smtp.from', 'smtp.from_name'];
|
||||||
foreach ($smtpText as $k) {
|
foreach ($smtpText as $k) {
|
||||||
|
if (!$has($k)) { continue; } // 没提交就不动它
|
||||||
fb_set_setting($k, trim((string)$post($k, '')));
|
fb_set_setting($k, trim((string)$post($k, '')));
|
||||||
}
|
}
|
||||||
// 口令留空 = 不修改(避免每次保存都得重填)
|
// 口令留空 = 不修改(避免每次保存都得重填)
|
||||||
@@ -400,20 +526,32 @@ function fb_admin_save_settings() {
|
|||||||
$msgType = 'error';
|
$msgType = 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 通知开关 ---------------- */
|
/* ---------------- 通知开关 ----------------
|
||||||
fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled'));
|
* 全是 checkbox:未提交会被当成"取消勾选"。
|
||||||
fb_set_setting('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
|
* 所以只有本页确实提交了邮件表单时才写,否则在站点设置页保存一次
|
||||||
fb_set_setting('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
|
* 就会把「有人回复时通知」这些开关全部关掉。
|
||||||
|
*/
|
||||||
|
if ($has('notify_enabled')) { fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled')); }
|
||||||
|
if ($has('notify_on_every_reply')) { fb_set_setting('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply')); }
|
||||||
|
if ($has('notify_admin_new_thread')) { fb_set_setting('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread')); }
|
||||||
|
|
||||||
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
|
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
|
||||||
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
|
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
|
||||||
|
if ($has('notify_cooldown_seconds')) {
|
||||||
$cdRaw = trim((string)$post('notify.cooldown_seconds', ''));
|
$cdRaw = trim((string)$post('notify.cooldown_seconds', ''));
|
||||||
if ($cdRaw === '') {
|
if ($cdRaw === '') {
|
||||||
fb_unset_setting('notify.cooldown_seconds');
|
fb_unset_setting('notify.cooldown_seconds');
|
||||||
} else {
|
} else {
|
||||||
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw)));
|
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw)));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // ← end if ($isMailForm)
|
||||||
|
|
||||||
|
// 审核开关属于「站点设置」,只在站点设置页提交时才写
|
||||||
|
if ($has('moderation_enabled')) {
|
||||||
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
|
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
|
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
|
||||||
return '设置已保存。';
|
return '设置已保存。';
|
||||||
@@ -602,6 +740,87 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
/* ---------- 处理动作:一排按钮,点一下立刻生效 ----------
|
||||||
|
* 用户原话:「第一个就是通过并显示…另外一个再加一个按钮,就是说现在正在处理中。
|
||||||
|
* 还有比方说不处理,都是在那个问题后边列几个按钮不就行了吗?」
|
||||||
|
*
|
||||||
|
* 设计取舍:
|
||||||
|
* - 「通过并公开」单独成一类(它改的是可见性,不只是状态)
|
||||||
|
* - 「不予考虑」必须填说明(会随邮件发出,不能发一封没理由的信)
|
||||||
|
* - 其余状态点一下就走,不弹框 —— 高频操作不该被对话框拖慢
|
||||||
|
*/
|
||||||
|
$curStatus = (string)$t['status'];
|
||||||
|
$isVisible = ((int)$t['visible'] === 1);
|
||||||
|
|
||||||
|
// 按钮表:key=状态, label=按钮文字, cls=样式
|
||||||
|
$quickBtns = [
|
||||||
|
['key' => 'doing', 'label' => '处理中', 'cls' => ''],
|
||||||
|
['key' => 'resolved', 'label' => '已解决', 'cls' => 'ok'],
|
||||||
|
['key' => 'wontfix', 'label' => '不予考虑', 'cls' => 'warn'],
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<div class="act-bar">
|
||||||
|
<div class="act-bar-title">处理</div>
|
||||||
|
<div class="act-bar-body">
|
||||||
|
<?php if (!$isVisible): ?>
|
||||||
|
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="act-inline">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<input type="hidden" name="status" value="<?php echo e($curStatus === 'closed' ? 'open' : $curStatus); ?>">
|
||||||
|
<input type="hidden" name="also_show" value="1">
|
||||||
|
<button class="btn primary act-btn" type="submit" name="act" value="setstatus">通过并公开</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="act-state on">已公开显示</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php foreach ($quickBtns as $qb):
|
||||||
|
$isCur = ($curStatus === $qb['key']);
|
||||||
|
if ($qb['key'] === 'wontfix'): ?>
|
||||||
|
<button type="button" class="btn act-btn <?php echo e($qb['cls']); ?><?php echo $isCur ? ' is-cur' : ''; ?>"
|
||||||
|
data-wontfix-open <?php echo $isCur ? 'disabled' : ''; ?>><?php echo e($qb['label']); ?></button>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="act-inline">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<input type="hidden" name="status" value="<?php echo e($qb['key']); ?>">
|
||||||
|
<button class="btn act-btn <?php echo e($qb['cls']); ?><?php echo $isCur ? ' is-cur' : ''; ?>"
|
||||||
|
type="submit" name="act" value="setstatus"<?php echo $isCur ? ' disabled' : ''; ?>><?php echo e($qb['label']); ?></button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if ($isVisible): ?>
|
||||||
|
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="act-inline"
|
||||||
|
onsubmit="return confirm('确定把这条件反馈从公开列表中撤下?');">
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<button class="btn act-btn" type="submit" name="act" value="hide">撤下</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<span class="act-hint">点一下立刻生效并自动发邮件通知提交人</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php /* 不予考虑:需要填说明,展开一个小面板而不是弹 window.prompt */ ?>
|
||||||
|
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="act-note" data-wontfix-panel hidden>
|
||||||
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<input type="hidden" name="status" value="wontfix">
|
||||||
|
<label class="field">
|
||||||
|
<span>为什么不处理 <b class="req">*</b>
|
||||||
|
<i class="hint">会作为邮件正文发给 <?php echo e((string)$t['author_email']); ?>,请写清楚原因</i></span>
|
||||||
|
<textarea name="note" rows="3" maxlength="2000" required
|
||||||
|
placeholder="例如:这个现象来自服务器端的 CalDAV 限制,并非应用本身的问题;建议在客户端调整同步间隔。"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn warn" type="submit" name="act" value="setstatus">确认不予考虑并邮件告知</button>
|
||||||
|
<button class="btn" type="button" data-wontfix-close>取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php foreach ($replies as $r): ?>
|
<?php foreach ($replies as $r): ?>
|
||||||
<div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>">
|
<div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>">
|
||||||
<div class="reply-head">
|
<div class="reply-head">
|
||||||
|
|||||||
@@ -703,6 +703,32 @@
|
|||||||
for (var k = 0; k < wraps.length; k++) { wraps[k].classList.remove('has-error'); }
|
for (var k = 0; k < wraps.length; k++) { wraps[k].classList.remove('has-error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 后台「不予考虑」说明面板 ----------
|
||||||
|
* 为什么不用 window.prompt:
|
||||||
|
* prompt 不能多行、不能校验、样式不可控,而且移动端体验差。
|
||||||
|
* 这里展开一个内联面板,用户能看到「这封信会发给谁」,写得更认真。
|
||||||
|
*/
|
||||||
|
function bindWontfixPanel() {
|
||||||
|
var openBtn = document.querySelector('[data-wontfix-open]');
|
||||||
|
var panel = document.querySelector('[data-wontfix-panel]');
|
||||||
|
var closeBtn = document.querySelector('[data-wontfix-close]');
|
||||||
|
if (!openBtn || !panel) { return; }
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', function () {
|
||||||
|
panel.hidden = false;
|
||||||
|
var ta = panel.querySelector('textarea');
|
||||||
|
if (ta) {
|
||||||
|
ta.focus();
|
||||||
|
ta.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', function () {
|
||||||
|
panel.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 主题色选择器(后台软件编辑) ---------- */
|
/* ---------- 主题色选择器(后台软件编辑) ---------- */
|
||||||
function bindColorPicker() {
|
function bindColorPicker() {
|
||||||
var rows = document.querySelectorAll('.color-row');
|
var rows = document.querySelectorAll('.color-row');
|
||||||
@@ -793,6 +819,7 @@
|
|||||||
function boot() {
|
function boot() {
|
||||||
bindCaptchaRefresh();
|
bindCaptchaRefresh();
|
||||||
bindAdminToggle();
|
bindAdminToggle();
|
||||||
|
bindWontfixPanel();
|
||||||
bindColorPicker();
|
bindColorPicker();
|
||||||
bindMailTest();
|
bindMailTest();
|
||||||
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
||||||
|
|||||||
@@ -81,6 +81,41 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
|
|||||||
.page-head h1{margin-bottom:4px}
|
.page-head h1{margin-bottom:4px}
|
||||||
.page-head .muted{font-size:14px; margin:0}
|
.page-head .muted{font-size:14px; margin:0}
|
||||||
|
|
||||||
|
/* ---------- 后台处理按钮栏 ---------- */
|
||||||
|
.act-bar{
|
||||||
|
margin:0 0 18px; padding:14px 16px;
|
||||||
|
background:var(--card); border:1px solid var(--line); border-radius:12px;
|
||||||
|
}
|
||||||
|
.act-bar-title{
|
||||||
|
font-size:12.5px; font-weight:700; letter-spacing:.5px;
|
||||||
|
color:var(--muted); text-transform:uppercase; margin-bottom:10px;
|
||||||
|
}
|
||||||
|
.act-bar-body{display:flex; flex-wrap:wrap; gap:8px; align-items:center}
|
||||||
|
.act-inline{display:inline}
|
||||||
|
.act-btn{white-space:nowrap}
|
||||||
|
.act-btn.is-cur{
|
||||||
|
opacity:.55; cursor:default;
|
||||||
|
box-shadow:none;
|
||||||
|
}
|
||||||
|
.act-btn.ok{border-color:#b7dfc2; color:#1e7b3c}
|
||||||
|
.act-btn.ok:hover:not(:disabled){background:#f0f9f2}
|
||||||
|
.act-btn.warn{border-color:#f0dfae; color:#8a6a15}
|
||||||
|
.act-btn.warn:hover:not(:disabled){background:#fff9ec}
|
||||||
|
.act-state.on{
|
||||||
|
display:inline-block; padding:6px 12px; border-radius:99px;
|
||||||
|
font-size:12.5px; font-weight:600;
|
||||||
|
background:#f0f9f2; color:#1e7b3c; border:1px solid #b7dfc2;
|
||||||
|
}
|
||||||
|
.act-hint{
|
||||||
|
margin-left:auto; font-size:12.5px; color:var(--muted);
|
||||||
|
}
|
||||||
|
@media (max-width:560px){
|
||||||
|
.act-hint{margin-left:0; flex-basis:100%}
|
||||||
|
}
|
||||||
|
.act-note{
|
||||||
|
margin-top:14px; padding-top:14px; border-top:1px dashed var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 主题色选择器 ---------- */
|
/* ---------- 主题色选择器 ---------- */
|
||||||
.sw-dot{
|
.sw-dot{
|
||||||
display:inline-block; width:10px; height:10px; border-radius:50%;
|
display:inline-block; width:10px; height:10px; border-radius:50%;
|
||||||
|
|||||||
+15
-1
@@ -1,4 +1,8 @@
|
|||||||
# 拒绝一切对数据目录的直接访问(数据库文件在此)
|
# 数据库目录(由程序自动生成,请勿删除)
|
||||||
|
# fb-data-guard: v1
|
||||||
|
# ★ 一律禁止 Web 直接访问:数据库文件绝不能被下载
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
<IfModule mod_authz_core.c>
|
<IfModule mod_authz_core.c>
|
||||||
Require all denied
|
Require all denied
|
||||||
</IfModule>
|
</IfModule>
|
||||||
@@ -6,3 +10,13 @@
|
|||||||
Order allow,deny
|
Order allow,deny
|
||||||
Deny from all
|
Deny from all
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
<FilesMatch "\.(db|db-wal|db-shm|sqlite|sqlite3|php|htaccess)$">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
|
|||||||
define('FB_ROOT', dirname(__DIR__));
|
define('FB_ROOT', dirname(__DIR__));
|
||||||
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
||||||
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
||||||
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 15); }
|
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 16); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
|
|||||||
+13
-1
@@ -125,8 +125,9 @@ SQL
|
|||||||
CREATE TABLE IF NOT EXISTS notify_log (
|
CREATE TABLE IF NOT EXISTS notify_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
thread_id INTEGER NOT NULL,
|
thread_id INTEGER NOT NULL,
|
||||||
reply_id INTEGER NOT NULL,
|
reply_id INTEGER,
|
||||||
to_email TEXT NOT NULL,
|
to_email TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT 'reply',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
ok INTEGER NOT NULL DEFAULT 0,
|
ok INTEGER NOT NULL DEFAULT 0,
|
||||||
tries INTEGER NOT NULL DEFAULT 0,
|
tries INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -305,6 +306,17 @@ SQL
|
|||||||
);
|
);
|
||||||
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_st_key ON statuses(key_name)');
|
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_st_key ON statuses(key_name)');
|
||||||
|
|
||||||
|
/* ---- notify_log 增加 reason(区分因为什么发的信,工单 #2)---- */
|
||||||
|
$nlCols = $pdo->query('PRAGMA table_info(notify_log)')->fetchAll();
|
||||||
|
$nlNames = [];
|
||||||
|
foreach ($nlCols as $c3) { if (isset($c3['name'])) { $nlNames[(string)$c3['name']] = true; } }
|
||||||
|
if (!isset($nlNames['reason'])) {
|
||||||
|
// 老库的 reply_id 是 NOT NULL,而「新反馈通知」没有回复 id,
|
||||||
|
// 加列的同时把它放宽为可空,否则插入会直接失败。
|
||||||
|
// SQLite 不支持 ALTER COLUMN,只能重建表。
|
||||||
|
$pdo->exec('ALTER TABLE notify_log ADD COLUMN reason TEXT NOT NULL DEFAULT \'reply\'');
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 软件增加主题色(用于前台按软件区分视觉)---- */
|
/* ---- 软件增加主题色(用于前台按软件区分视觉)---- */
|
||||||
$swCols = $pdo->query('PRAGMA table_info(software)')->fetchAll();
|
$swCols = $pdo->query('PRAGMA table_info(software)')->fetchAll();
|
||||||
$swNames = [];
|
$swNames = [];
|
||||||
|
|||||||
+740
-53
@@ -113,6 +113,258 @@ function fb_smtp_send(array $s, $to, $subject, $html) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 渲染通知邮件正文 */
|
/** 渲染通知邮件正文 */
|
||||||
|
/**
|
||||||
|
* 统一的邮件模板引擎(工单 #2)
|
||||||
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* 1. **表格布局 + 内联样式**。邮件客户端(Outlook / QQ 邮箱 / Gmail)对
|
||||||
|
* flex/grid/外部 CSS 的支持极不一致,只有 table + inline style 能稳定渲染。
|
||||||
|
* 2. **统一外壳**。此前 `fb_notify_html()` 和 `fb_notify_closed_html()` 各写一套
|
||||||
|
* 风格,颜色、间距、页脚都不一样。现在全部走 fb_mail_layout()。
|
||||||
|
* 3. **响应式**。桌面 600px,手机端靠 max-width:100% 自适应。
|
||||||
|
* 4. **暗色安全**。不用纯白背景配纯黑字,留出足够的对比度余量。
|
||||||
|
*
|
||||||
|
* 调用方式:
|
||||||
|
* echo fb_mail_layout([
|
||||||
|
* 'preheader' => '预览摘要(收件箱列表里显示的那行灰字)',
|
||||||
|
* 'badge' => ['text' => '处理中', 'color' => '#0a6cf5'],
|
||||||
|
* 'title' => '你的反馈已被受理',
|
||||||
|
* 'intro' => '老杨,你好:',
|
||||||
|
* 'blocks' => [ ... ], // 内容块,见下
|
||||||
|
* 'button' => ['text' => '查看详情', 'url' => 'https://...'],
|
||||||
|
* 'footer' => '此邮件由系统自动发送。',
|
||||||
|
* ]);
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('fb_mail_esc')) {
|
||||||
|
/** 统一的 HTML 转义(模板里到处要用,短名字方便读) */
|
||||||
|
function fb_mail_esc($s) {
|
||||||
|
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_mail_brand')) {
|
||||||
|
/** 取站点主色,用于邮件头与按钮。跟随「软件主题色」体系,但邮件里只用主色与浅底 */
|
||||||
|
function fb_mail_brand() {
|
||||||
|
$c = fb_color_norm((string)fb_cfg('mail_brand_color', ''));
|
||||||
|
if ($c === '') { $c = '#0057B8'; }
|
||||||
|
return $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_mail_block')) {
|
||||||
|
/**
|
||||||
|
* 渲染一个内容块。
|
||||||
|
*
|
||||||
|
* 支持类型:
|
||||||
|
* quote —— 引用块(回复内容、官方说明),左侧色条 + 浅底
|
||||||
|
* field —— 一组「标签 / 值」行(状态变更、时间等元信息)
|
||||||
|
* text —— 普通段落
|
||||||
|
* callout —— 带图标的提示条(成功=绿、提醒=黄、信息=蓝)
|
||||||
|
* raw —— 已转义好的 HTML,原样输出(谨慎使用)
|
||||||
|
*/
|
||||||
|
function fb_mail_block(array $b) {
|
||||||
|
$type = isset($b['type']) ? (string)$b['type'] : 'text';
|
||||||
|
$brand = fb_mail_brand();
|
||||||
|
|
||||||
|
if ($type === 'text') {
|
||||||
|
return '<p style="margin:0 0 14px;font-size:15px;line-height:1.75;color:#333b46">'
|
||||||
|
. (isset($b['html']) ? $b['html'] : fb_mail_esc(isset($b['text']) ? $b['text'] : ''))
|
||||||
|
. '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'quote') {
|
||||||
|
$who = isset($b['who']) ? (string)$b['who'] : '';
|
||||||
|
$body = nl2br(fb_mail_esc(isset($b['text']) ? $b['text'] : ''));
|
||||||
|
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
|
||||||
|
. ' style="margin:0 0 16px;border-collapse:separate">'
|
||||||
|
. '<tr><td style="padding:14px 18px;background:#f6f8fb;'
|
||||||
|
. 'border-left:3px solid ' . fb_mail_esc($brand) . ';border-radius:6px">'
|
||||||
|
. ($who !== ''
|
||||||
|
? '<div style="font-size:12.5px;color:#8a93a0;margin-bottom:8px">'
|
||||||
|
. fb_mail_esc($who) . '</div>' : '')
|
||||||
|
. '<div style="font-size:14.5px;line-height:1.75;color:#2f3742">' . $body . '</div>'
|
||||||
|
. '</td></tr></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'field') {
|
||||||
|
$rows = isset($b['rows']) ? (array)$b['rows'] : [];
|
||||||
|
$out = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
|
||||||
|
. ' style="margin:0 0 18px;border-collapse:collapse;'
|
||||||
|
. 'background:#fafbfc;border:1px solid #eceff3;border-radius:8px">';
|
||||||
|
$i = 0;
|
||||||
|
foreach ($rows as $label => $value) {
|
||||||
|
$pad = ($i === 0) ? '12px 16px 6px' : '6px 16px';
|
||||||
|
if ($i === count($rows) - 1) { $pad = '6px 16px 12px'; }
|
||||||
|
if (count($rows) === 1) { $pad = '12px 16px'; }
|
||||||
|
$out .= '<tr>'
|
||||||
|
. '<td style="padding:' . $pad . ';font-size:13px;color:#8a93a0;'
|
||||||
|
. 'width:88px;vertical-align:top;white-space:nowrap">' . fb_mail_esc($label) . '</td>'
|
||||||
|
. '<td style="padding:' . $pad . ';font-size:14px;color:#2f3742;'
|
||||||
|
. 'line-height:1.6;vertical-align:top">' . fb_mail_esc($value) . '</td>'
|
||||||
|
. '</tr>';
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
return $out . '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'callout') {
|
||||||
|
$tone = isset($b['tone']) ? (string)$b['tone'] : 'info';
|
||||||
|
$map = [
|
||||||
|
'ok' => ['bg' => '#f0f9f2', 'line' => '#b7dfc2', 'icon' => '✓', 'color' => '#1e7b3c'],
|
||||||
|
'warn' => ['bg' => '#fff9ec', 'line' => '#f0dfae', 'icon' => '!', 'color' => '#8a6a15'],
|
||||||
|
'info' => ['bg' => '#f2f7ff', 'line' => '#c8dbf7', 'icon' => 'i', 'color' => '#1a5fb4'],
|
||||||
|
'muted' => ['bg' => '#f7f8fa', 'line' => '#e6e9ee', 'icon' => '·', 'color' => '#5c6572'],
|
||||||
|
];
|
||||||
|
$m = isset($map[$tone]) ? $map[$tone] : $map['info'];
|
||||||
|
$body = isset($b['html']) ? $b['html']
|
||||||
|
: nl2br(fb_mail_esc(isset($b['text']) ? $b['text'] : ''));
|
||||||
|
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
|
||||||
|
. ' style="margin:0 0 18px;border-collapse:separate">'
|
||||||
|
. '<tr><td style="padding:12px 16px;background:' . $m['bg'] . ';'
|
||||||
|
. 'border:1px solid ' . $m['line'] . ';border-radius:8px;font-size:14px;'
|
||||||
|
. 'line-height:1.7;color:' . $m['color'] . '">' . $body . '</td></tr></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'raw') {
|
||||||
|
return isset($b['html']) ? (string)$b['html'] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_mail_layout')) {
|
||||||
|
/**
|
||||||
|
* 邮件统一外壳。所有通知都套这一个,保证视觉一致。
|
||||||
|
*
|
||||||
|
* @param array $o 见文件头注释
|
||||||
|
* @return string 完整 HTML(可直接作为 text/html 正文)
|
||||||
|
*/
|
||||||
|
function fb_mail_layout(array $o) {
|
||||||
|
$brand = fb_mail_brand();
|
||||||
|
$site = (string)fb_cfg('site_name', '轻反馈');
|
||||||
|
$base = fb_base_url();
|
||||||
|
|
||||||
|
$preheader = isset($o['preheader']) ? (string)$o['preheader'] : '';
|
||||||
|
$badge = isset($o['badge']) ? (array)$o['badge'] : null;
|
||||||
|
$title = isset($o['title']) ? (string)$o['title'] : '';
|
||||||
|
$intro = isset($o['intro']) ? (string)$o['intro'] : '';
|
||||||
|
$blocks = isset($o['blocks']) ? (array)$o['blocks'] : [];
|
||||||
|
$button = isset($o['button']) ? (array)$o['button'] : null;
|
||||||
|
$link = isset($o['link']) ? (string)$o['link'] : '';
|
||||||
|
$footer = isset($o['footer']) ? (string)$o['footer']
|
||||||
|
: '此邮件由系统自动发送,请勿直接回复。';
|
||||||
|
$recipient = isset($o['recipient']) ? (string)$o['recipient'] : '';
|
||||||
|
|
||||||
|
// ---- 内容块 ----
|
||||||
|
$body = '';
|
||||||
|
foreach ($blocks as $b) {
|
||||||
|
if (is_array($b)) { $body .= fb_mail_block($b); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 徽标 ----
|
||||||
|
$badgeHtml = '';
|
||||||
|
if ($badge && !empty($badge['text'])) {
|
||||||
|
$bc = isset($badge['color']) ? (string)$badge['color'] : $brand;
|
||||||
|
$badgeHtml = '<div style="margin:0 0 10px">'
|
||||||
|
. '<span style="display:inline-block;padding:3px 11px;border-radius:99px;'
|
||||||
|
. 'background:' . fb_mail_esc($bc) . ';color:#ffffff;font-size:12px;'
|
||||||
|
. 'font-weight:600;letter-spacing:.3px">' . fb_mail_esc($badge['text']) . '</span>'
|
||||||
|
. '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 按钮 ----
|
||||||
|
$btnHtml = '';
|
||||||
|
if ($button && !empty($button['url']) && !empty($button['text'])) {
|
||||||
|
$url = (string)$button['url'];
|
||||||
|
$btnHtml = '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
|
||||||
|
. ' style="margin:6px 0 20px"><tr>'
|
||||||
|
. '<td style="background:' . fb_mail_esc($brand) . ';border-radius:8px">'
|
||||||
|
. '<a href="' . fb_mail_esc($url) . '" target="_blank"'
|
||||||
|
. ' style="display:inline-block;padding:12px 26px;font-size:15px;font-weight:600;'
|
||||||
|
. 'color:#ffffff;text-decoration:none;border-radius:8px">'
|
||||||
|
. fb_mail_esc($button['text']) . '</a></td></tr></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 纯文本兜底链接 ----
|
||||||
|
$linkHtml = '';
|
||||||
|
if ($link !== '') {
|
||||||
|
$linkHtml = '<p style="margin:0 0 16px;font-size:12.5px;line-height:1.6;color:#9aa2ad">'
|
||||||
|
. '如果按钮无法点击,请复制下面的链接到浏览器打开:<br>'
|
||||||
|
. '<span style="color:' . fb_mail_esc($brand) . ';word-break:break-all">'
|
||||||
|
. fb_mail_esc($link) . '</span></p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 收件人问候 ----
|
||||||
|
$introHtml = '';
|
||||||
|
if ($intro !== '') {
|
||||||
|
$introHtml = '<p style="margin:0 0 16px;font-size:15px;line-height:1.75;color:#333b46">'
|
||||||
|
. fb_mail_esc($intro) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<!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">'
|
||||||
|
. '<title>' . fb_mail_esc($title) . '</title></head>'
|
||||||
|
. '<body style="margin:0;padding:0;background:#eef1f5;'
|
||||||
|
. '-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">'
|
||||||
|
|
||||||
|
// 预处理器:收件箱里显示的那行摘要,兼带隐藏占位防止被正文抢先
|
||||||
|
. ($preheader !== ''
|
||||||
|
? '<div style="display:none;font-size:1px;color:#eef1f5;line-height:1px;'
|
||||||
|
. 'max-height:0;max-width:0;opacity:0;overflow:hidden">'
|
||||||
|
. fb_mail_esc($preheader) . '</div>' : '')
|
||||||
|
|
||||||
|
// 外层留白
|
||||||
|
. '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
|
||||||
|
. ' style="background:#eef1f5"><tr><td align="center" style="padding:28px 14px">'
|
||||||
|
|
||||||
|
// 卡片
|
||||||
|
. '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600"'
|
||||||
|
. ' style="width:100%;max-width:600px;background:#ffffff;border-radius:14px;'
|
||||||
|
. 'overflow:hidden;box-shadow:0 2px 10px rgba(20,30,50,.06)">'
|
||||||
|
|
||||||
|
// 顶部色条
|
||||||
|
. '<tr><td style="height:4px;background:' . fb_mail_esc($brand) . ';font-size:0;'
|
||||||
|
. 'line-height:0"> </td></tr>'
|
||||||
|
|
||||||
|
// 站名
|
||||||
|
. '<tr><td style="padding:22px 32px 0">'
|
||||||
|
. '<div style="font-size:13px;font-weight:700;letter-spacing:.6px;'
|
||||||
|
. 'color:' . fb_mail_esc($brand) . ';text-transform:uppercase">'
|
||||||
|
. fb_mail_esc($site) . '</div></td></tr>'
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
. '<tr><td style="padding:12px 32px 4px">' . $badgeHtml
|
||||||
|
. '<h1 style="margin:0;font-size:21px;line-height:1.45;font-weight:700;'
|
||||||
|
. 'color:#1b2430">' . fb_mail_esc($title) . '</h1></td></tr>'
|
||||||
|
|
||||||
|
// 正文
|
||||||
|
. '<tr><td style="padding:16px 32px 8px">'
|
||||||
|
. $introHtml . $body . $btnHtml . $linkHtml
|
||||||
|
. '</td></tr>'
|
||||||
|
|
||||||
|
// 页脚
|
||||||
|
. '<tr><td style="padding:20px 32px 26px;border-top:1px solid #eef0f3">'
|
||||||
|
. '<p style="margin:0 0 6px;font-size:12.5px;line-height:1.65;color:#9aa2ad">'
|
||||||
|
. fb_mail_esc($footer) . '</p>'
|
||||||
|
. ($recipient !== ''
|
||||||
|
? '<p style="margin:0;font-size:11.5px;line-height:1.6;color:#b3b9c2">'
|
||||||
|
. '这封邮件发给了 ' . fb_mail_esc($recipient) . '。'
|
||||||
|
. '如果这不是你,忽略即可。</p>' : '')
|
||||||
|
. '<p style="margin:8px 0 0;font-size:11.5px;color:#b3b9c2">'
|
||||||
|
. '<a href="' . fb_mail_esc($base) . '" target="_blank"'
|
||||||
|
. ' style="color:#9aa2ad;text-decoration:none">'
|
||||||
|
. fb_mail_esc($base) . '</a></p>'
|
||||||
|
. '</td></tr>'
|
||||||
|
|
||||||
|
. '</table>'
|
||||||
|
. '</td></tr></table>'
|
||||||
|
. '</body></html>';
|
||||||
|
}
|
||||||
|
}
|
||||||
function fb_notify_html($thread, $reply, $isAdmin) {
|
function fb_notify_html($thread, $reply, $isAdmin) {
|
||||||
$base = fb_base_url();
|
$base = fb_base_url();
|
||||||
$url = $base . '/thread.php?id=' . (int)$thread['id'];
|
$url = $base . '/thread.php?id=' . (int)$thread['id'];
|
||||||
@@ -144,40 +396,11 @@ function fb_notify_html($thread, $reply, $isAdmin) {
|
|||||||
* @return string 空串=未发送/已发送成功;非空=失败原因
|
* @return string 空串=未发送/已发送成功;非空=失败原因
|
||||||
*/
|
*/
|
||||||
function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) {
|
function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) {
|
||||||
$cfg = fb_cfg();
|
// 转调统一入口(工单 #2):旧调用点保持不改,但行为、模板、日志全部统一。
|
||||||
$nt = isset($cfg['notify']) ? (array)$cfg['notify'] : [];
|
list($ok, $err) = fb_notify_event($pdo, 'reply', $thread, [
|
||||||
if (empty($nt['enabled'])) { return ''; }
|
'reply' => $reply,
|
||||||
if (empty($nt['on_every_reply']) && !$isAdmin) { return ''; }
|
'is_admin' => (bool)$isAdmin,
|
||||||
|
]);
|
||||||
$to = trim((string)$thread['author_email']);
|
|
||||||
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { return ''; }
|
|
||||||
|
|
||||||
// 自己回复自己:不发
|
|
||||||
if (!$isAdmin && !empty($reply['author_email'])
|
|
||||||
&& strcasecmp(trim((string)$reply['author_email']), $to) === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 节流:同一帖同一收件人 N 秒内不重复发
|
|
||||||
$cd = (int)(isset($nt['cooldown_seconds']) ? $nt['cooldown_seconds'] : 0);
|
|
||||||
if ($cd > 0) {
|
|
||||||
$st = $pdo->prepare('SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? ORDER BY id DESC LIMIT 1');
|
|
||||||
$st->execute([(int)$thread['id'], $to]);
|
|
||||||
$last = $st->fetchColumn();
|
|
||||||
if ($last !== false && (time() - (int)$last) < $cd) { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
$smtp = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
|
|
||||||
if (empty($smtp['enabled'])) {
|
|
||||||
fb_notify_log($pdo, $thread, $reply, $to, 0, 'SMTP 未启用');
|
|
||||||
return 'SMTP 未启用';
|
|
||||||
}
|
|
||||||
|
|
||||||
$subject = '【' . $cfg['site_name'] . '】有人回复了你的问题:' . fb_excerpt($thread['title'], 40);
|
|
||||||
$html = fb_notify_html($thread, $reply, $isAdmin);
|
|
||||||
|
|
||||||
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
|
|
||||||
fb_notify_log($pdo, $thread, $reply, $to, $ok ? 1 : 0, $ok ? null : $err);
|
|
||||||
return $ok ? '' : $err;
|
return $ok ? '' : $err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,31 +449,495 @@ function fb_notify_closed_html(array $thread, string $note) {
|
|||||||
* @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)]
|
* @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)]
|
||||||
*/
|
*/
|
||||||
function fb_notify_closed(PDO $pdo, array $thread, string $note) {
|
function fb_notify_closed(PDO $pdo, array $thread, string $note) {
|
||||||
$cfg = fb_cfg();
|
// 转调统一入口(工单 #2)
|
||||||
$sm = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
|
return fb_notify_event($pdo, 'close', $thread, ['note' => $note]);
|
||||||
if (empty($sm['enabled'])) {
|
}
|
||||||
// 仅记录,不算失败
|
|
||||||
|
function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
|
||||||
|
// ⚠️ 老库的 notify_log.reply_id 是 NOT NULL,而「新反馈通知」没有回复 id。
|
||||||
|
// SQLite 不能 ALTER COLUMN 放宽约束,只能重建表 —— 为了不动用户已有数据,
|
||||||
|
// 这里用 reply_id=0 表示「非回复事件」,而不是 null。
|
||||||
|
try {
|
||||||
|
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, reason, ok, err, created_at) '
|
||||||
|
. 'VALUES(?,?,?,?,?,?,?)')
|
||||||
|
->execute([
|
||||||
|
(int)$thread['id'],
|
||||||
|
0,
|
||||||
|
(string)$to,
|
||||||
|
(string)$reason,
|
||||||
|
(int)$ok,
|
||||||
|
$err === null ? null : (string)$err,
|
||||||
|
time(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// reason 列可能不存在(老库未迁移),退化成原结构
|
||||||
try {
|
try {
|
||||||
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, ok, err, created_at) '
|
||||||
$st->execute([(int)$thread['id'], 0, (string)$thread['author_email'], time(), 0, 'SMTP 未启用,关闭通知未发送']);
|
. 'VALUES(?,?,?,?,?,?)')
|
||||||
} catch (Exception $ex) { /* 静默 */ }
|
->execute([(int)$thread['id'], 0, (string)$to, (int)$ok,
|
||||||
return [false, 'SMTP 未启用,邮件未发送(已在配置里关闭)'];
|
$err === null ? null : (string)$err, time()]);
|
||||||
|
} catch (Throwable $e2) {
|
||||||
|
// 实在记不了就算了,不能因为记日志失败让用户的请求失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}/**
|
||||||
|
* 各事件的邮件渲染(工单 #2)
|
||||||
|
*
|
||||||
|
* 每个事件返回 [主题, HTML]。
|
||||||
|
* 全部套 fb_mail_layout(),视觉统一;文案按事件差异化。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮件通知事件(工单 #2)
|
||||||
|
*
|
||||||
|
* 全部事件:
|
||||||
|
* reply 有人回复了你的反馈 → 发帖人
|
||||||
|
* status 处理状态变了 → 发帖人
|
||||||
|
* approve 审核通过、已公开 → 发帖人
|
||||||
|
* hide 转为不公开 → 发帖人
|
||||||
|
* close 判定不是问题,关闭 → 发帖人(带说明)
|
||||||
|
* new 有新反馈提交 → 管理员
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* 1. **一个总入口**。所有事件都走 fb_notify_event(),统一处理
|
||||||
|
* 「开关判断 → 收件人解析 → 节流 → 发信 → 记日志」,避免每个事件
|
||||||
|
* 各写一遍导致行为不一致(此前 close 和 reply 就是两套)。
|
||||||
|
* 2. **失败必留痕**。任何情况下都写 notify_log,便于后台排查
|
||||||
|
* 「为什么用户说没收到信」。
|
||||||
|
* 3. **不抛异常**。发信失败绝不能让用户的操作失败 —— 反馈已经存库了,
|
||||||
|
* 邮件只是附加动作。
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('fb_notify_reason_label')) {
|
||||||
|
/** 事件的中文名,用于日志与模板徽标 */
|
||||||
|
function fb_notify_reason_label($reason) {
|
||||||
|
$map = [
|
||||||
|
'reply' => '有新回复',
|
||||||
|
'status' => '状态更新',
|
||||||
|
'approve' => '已发布',
|
||||||
|
'hide' => '转为不公开',
|
||||||
|
'close' => '已关闭',
|
||||||
|
'new' => '新反馈',
|
||||||
|
];
|
||||||
|
return isset($map[$reason]) ? $map[$reason] : '通知';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_notify_enabled_for')) {
|
||||||
|
/**
|
||||||
|
* 这个事件在当前配置下要不要发?
|
||||||
|
*
|
||||||
|
* 注意区别:
|
||||||
|
* - notify.enabled 是「给提交人发信」的总开关
|
||||||
|
* - notify.admin_new_thread 只管「有新反馈时通知管理员」
|
||||||
|
* 它独立于前者 —— 就算关了给用户的信,管理员也可以想收通知
|
||||||
|
*/
|
||||||
|
function fb_notify_enabled_for($reason) {
|
||||||
|
if ($reason === 'new') {
|
||||||
|
return (bool)fb_cfg('notify.admin_new_thread', false);
|
||||||
|
}
|
||||||
|
return (bool)fb_cfg('notify.enabled', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_notify_throttled')) {
|
||||||
|
/**
|
||||||
|
* 节流:同一帖 + 同一收件人 + 同一原因,在冷却窗口内只发一次。
|
||||||
|
*
|
||||||
|
* ⚠️ 只对 reply / status 这类「可能短时间内反复触发」的事件生效;
|
||||||
|
* close / new 是一次性动作,不节流(否则用户会完全收不到)。
|
||||||
|
*
|
||||||
|
* @return bool true = 应该跳过(正在冷却中)
|
||||||
|
*/
|
||||||
|
function fb_notify_throttled(PDO $pdo, $threadId, $to, $reason) {
|
||||||
|
if (!in_array($reason, ['reply', 'status'], true)) { return false; }
|
||||||
|
$cd = (int)fb_cfg('notify.cooldown_seconds', 300);
|
||||||
|
if ($cd <= 0) { return false; }
|
||||||
|
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
'SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? AND reason=? '
|
||||||
|
. 'ORDER BY id DESC LIMIT 1');
|
||||||
|
$st->execute([(int)$threadId, (string)$to, (string)$reason]);
|
||||||
|
$last = $st->fetchColumn();
|
||||||
|
if ($last === false) { return false; }
|
||||||
|
return (time() - (int)$last) < $cd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_notify_event')) {
|
||||||
|
/**
|
||||||
|
* 发送一个通知事件。
|
||||||
|
*
|
||||||
|
* @param PDO $pdo
|
||||||
|
* @param string $reason reply|status|approve|hide|close|new
|
||||||
|
* @param array $thread 帖子行
|
||||||
|
* @param array $extra 附加信息:
|
||||||
|
* - reply 回复行(reply 事件用)
|
||||||
|
* - is_admin 是否官方操作
|
||||||
|
* - old_status / new_status (status 事件用)
|
||||||
|
* - note 说明文字(close / status 用)
|
||||||
|
* @return array [bool 是否发出, string 失败原因]
|
||||||
|
*/
|
||||||
|
function fb_notify_event(PDO $pdo, $reason, array $thread, array $extra = []) {
|
||||||
|
$reason = (string)$reason;
|
||||||
|
|
||||||
|
// ---- 1. 开关 ----
|
||||||
|
if (!fb_notify_enabled_for($reason)) {
|
||||||
|
return [false, '通知未启用'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. 收件人 ----
|
||||||
|
$isAdminNotice = ($reason === 'new');
|
||||||
|
if ($isAdminNotice) {
|
||||||
|
$to = trim((string)fb_cfg('admin_email', ''));
|
||||||
|
if ($to === '') { $to = trim((string)fb_cfg('admin.email', '')); }
|
||||||
|
} else {
|
||||||
|
$to = trim((string)(isset($thread['author_email']) ? $thread['author_email'] : ''));
|
||||||
|
}
|
||||||
|
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return [false, '收件邮箱无效'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$to = (string)$thread['author_email'];
|
// ---- 3. 自己通知自己就别发了 ----
|
||||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
if (!$isAdminNotice && !empty($extra['is_admin']) === false
|
||||||
return [false, '提问人邮箱不可用,未发送'];
|
&& !empty($extra['reply']['author_email'])) {
|
||||||
|
if (strcasecmp(trim((string)$extra['reply']['author_email']), $to) === 0) {
|
||||||
|
return [false, '自己回复自己,不发'];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. 节流 ----
|
||||||
|
if (fb_notify_throttled($pdo, (int)$thread['id'], $to, $reason)) {
|
||||||
|
return [false, '冷却中,跳过'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 5. SMTP 就绪吗 ----
|
||||||
|
$smtp = (array)fb_cfg('smtp', []);
|
||||||
|
if (empty($smtp['enabled']) || trim((string)$smtp['host']) === '') {
|
||||||
|
fb_notify_log2($pdo, $thread, $to, $reason, 0, 'SMTP 未启用或未配置服务器');
|
||||||
|
return [false, 'SMTP 未启用或未配置服务器'];
|
||||||
|
}
|
||||||
|
|
||||||
$tpl = (string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》');
|
// ---- 6. 组装主题与正文 ----
|
||||||
$subject = sprintf($tpl, (string)$thread['title']);
|
list($subject, $html) = fb_notify_render($reason, $thread, $extra, $to);
|
||||||
$html = fb_notify_closed_html($thread, $note);
|
|
||||||
|
|
||||||
list($ok, $err) = fb_smtp_send($sm, $to, $subject, $html);
|
// ---- 7. 发送 ----
|
||||||
|
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
|
||||||
|
fb_notify_log2($pdo, $thread, $to, $reason, $ok ? 1 : 0, $ok ? null : $err);
|
||||||
|
return [$ok, $ok ? '' : (string)$err];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_notify_log2')) {
|
||||||
|
/**
|
||||||
|
* 记一条发信日志(带 reason)。
|
||||||
|
*
|
||||||
|
* 老表可能没有 reason 列 —— 用 try/catch 包一下,
|
||||||
|
* 万一列不存在就退化成不带 reason 的插入,绝不让记日志失败影响主流程。
|
||||||
|
*/
|
||||||
|
function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
|
||||||
try {
|
try {
|
||||||
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, reason, ok, err, created_at) '
|
||||||
$st->execute([(int)$thread['id'], 0, $to, time(), $ok ? 1 : 0, $ok ? null : $err]);
|
. 'VALUES(?,?,?,?,?,?,?)')
|
||||||
} catch (Exception $ex) { /* 静默 */ }
|
->execute([
|
||||||
|
(int)$thread['id'],
|
||||||
|
null,
|
||||||
|
(string)$to,
|
||||||
|
(string)$reason,
|
||||||
|
(int)$ok,
|
||||||
|
$err === null ? null : (string)$err,
|
||||||
|
time(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// 列可能不存在(老库未迁移),退化成原结构
|
||||||
|
try {
|
||||||
|
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, ok, err, created_at) '
|
||||||
|
. 'VALUES(?,?,?,?,?,?)')
|
||||||
|
->execute([(int)$thread['id'], null, (string)$to, (int)$ok,
|
||||||
|
$err === null ? null : (string)$err, time()]);
|
||||||
|
} catch (Throwable $e2) {
|
||||||
|
// 实在记不了就算了,不能因此让用户的请求失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!function_exists('fb_notify_thread_url')) {
|
||||||
|
function fb_notify_thread_url(array $thread) {
|
||||||
|
return fb_base_url() . '/thread.php?id=' . (int)$thread['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_notify_status_meta')) {
|
||||||
|
/**
|
||||||
|
* 各处理状态对应的「邮件语气」。
|
||||||
|
*
|
||||||
|
* 为什么单独抽出来:用户明确要求不同状态给不同文案 ——
|
||||||
|
* 「不予考虑」要礼貌说明、不让人觉得被敷衍;
|
||||||
|
* 「处理中」要让人知道在跟进;
|
||||||
|
* 「已解决」要引导他来验证。
|
||||||
|
*/
|
||||||
|
function fb_notify_status_meta($key) {
|
||||||
|
$map = [
|
||||||
|
'open' => [
|
||||||
|
'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted',
|
||||||
|
'title' => '你的反馈已收到,正在等待处理',
|
||||||
|
'intro' => '我们已经收到你的反馈,会尽快安排处理。',
|
||||||
|
'tip' => null,
|
||||||
|
],
|
||||||
|
'doing' => [
|
||||||
|
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
|
||||||
|
'title' => '你反馈的问题正在处理中',
|
||||||
|
'intro' => '我们已经开始跟进你反馈的问题,有进展会第一时间告诉你。',
|
||||||
|
'tip' => '感谢你的耐心等待,暂时不需要你做任何操作。',
|
||||||
|
],
|
||||||
|
'resolved' => [
|
||||||
|
'badge' => '已解决', 'color' => '#1e7b3c', 'tone' => 'ok',
|
||||||
|
'title' => '你反馈的问题已解决',
|
||||||
|
'intro' => '好消息,你反馈的问题我们已经处理完成。',
|
||||||
|
'tip' => '如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
|
||||||
|
],
|
||||||
|
'closed' => [
|
||||||
|
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
|
||||||
|
'title' => '你反馈的问题已关闭',
|
||||||
|
'intro' => '你反馈的问题已经关闭。',
|
||||||
|
'tip' => null,
|
||||||
|
],
|
||||||
|
'wontfix' => [
|
||||||
|
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
|
||||||
|
'title' => '关于你反馈的问题,我们的处理意见',
|
||||||
|
'intro' => '感谢你的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
|
||||||
|
'tip' => '如果你认为这个判断有误,或者有补充信息,直接在本页回复即可。',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
return isset($map[$key]) ? $map[$key] : $map['open'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_notify_render')) {
|
||||||
|
/**
|
||||||
|
* 渲染某个事件的 [主题, HTML]。
|
||||||
|
*
|
||||||
|
* @param string $reason
|
||||||
|
* @param array $thread
|
||||||
|
* @param array $extra
|
||||||
|
* @param string $to 收件人(用于页脚「这封邮件发给了…」)
|
||||||
|
* @return array [string subject, string html]
|
||||||
|
*/
|
||||||
|
function fb_notify_render($reason, array $thread, array $extra, $to = '') {
|
||||||
|
$site = (string)fb_cfg('site_name', '轻反馈');
|
||||||
|
$url = fb_notify_thread_url($thread);
|
||||||
|
$title = (string)$thread['title'];
|
||||||
|
$name = (string)(isset($thread['author_name']) ? $thread['author_name'] : '');
|
||||||
|
$short = fb_excerpt($title, 42);
|
||||||
|
$isAdmin = !empty($extra['is_admin']);
|
||||||
|
|
||||||
|
$subject = '';
|
||||||
|
$layout = [];
|
||||||
|
|
||||||
|
/* ---------------- 1. 新回复 ---------------- */
|
||||||
|
if ($reason === 'reply') {
|
||||||
|
$reply = (array)(isset($extra['reply']) ? $extra['reply'] : []);
|
||||||
|
$rname = $isAdmin ? '官方' : (string)(isset($reply['author_name']) ? $reply['author_name'] : '');
|
||||||
|
$rbody = (string)(isset($reply['body']) ? $reply['body'] : '');
|
||||||
|
$isOff = $isAdmin ? '官方回复' : '新回复';
|
||||||
|
|
||||||
|
$subject = '【' . $site . '】' . $isOff . ':' . $short;
|
||||||
|
|
||||||
return [$ok, $ok ? '' : $err];
|
$blocks = [
|
||||||
|
['type' => 'text', 'text' => '你在「' . $site . '」提交的反馈有新的回复。'],
|
||||||
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
|
['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody],
|
||||||
|
];
|
||||||
|
if ($isAdmin) {
|
||||||
|
$blocks[] = ['type' => 'callout', 'tone' => 'info',
|
||||||
|
'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$layout = [
|
||||||
|
'preheader' => $rname . ':' . fb_excerpt($rbody, 60),
|
||||||
|
'badge' => ['text' => $isOff, 'color' => $isAdmin ? '#0057B8' : '#5c6572'],
|
||||||
|
'title' => $isAdmin ? '官方回复了你的反馈' : '有人回复了你的反馈',
|
||||||
|
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||||
|
'blocks' => $blocks,
|
||||||
|
'button' => ['text' => '查看并回复', 'url' => $url],
|
||||||
|
'link' => $url,
|
||||||
|
'recipient' => $to,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 2. 状态变更 ---------------- */
|
||||||
|
elseif ($reason === 'status') {
|
||||||
|
$newKey = (string)(isset($extra['new_status']) ? $extra['new_status'] : '');
|
||||||
|
$oldKey = (string)(isset($extra['old_status']) ? $extra['old_status'] : '');
|
||||||
|
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
|
||||||
|
|
||||||
|
$meta = fb_notify_status_meta($newKey);
|
||||||
|
$label = fb_status_label($newKey);
|
||||||
|
if ($label === '') { $label = $meta['badge']; }
|
||||||
|
|
||||||
|
$subject = '【' . $site . '】' . $short . ' —— ' . $label;
|
||||||
|
|
||||||
|
$rows = ['反馈标题' => $title];
|
||||||
|
if ($oldKey !== '' && $oldKey !== $newKey) {
|
||||||
|
$oldLabel = fb_status_label($oldKey);
|
||||||
|
if ($oldLabel !== '') { $rows['原状态'] = $oldLabel; }
|
||||||
|
}
|
||||||
|
$rows['当前状态'] = $label;
|
||||||
|
|
||||||
|
$blocks = [
|
||||||
|
['type' => 'text', 'text' => $meta['intro']],
|
||||||
|
['type' => 'field', 'rows' => $rows],
|
||||||
|
];
|
||||||
|
if ($note !== '') {
|
||||||
|
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
||||||
|
}
|
||||||
|
if (!empty($meta['tip'])) {
|
||||||
|
$blocks[] = ['type' => 'callout', 'tone' => $meta['tone'], 'text' => $meta['tip']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$layout = [
|
||||||
|
'preheader' => '状态更新为「' . $label . '」',
|
||||||
|
'badge' => ['text' => $meta['badge'], 'color' => $meta['color']],
|
||||||
|
'title' => $meta['title'],
|
||||||
|
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||||
|
'blocks' => $blocks,
|
||||||
|
'button' => ['text' => '查看详情', 'url' => $url],
|
||||||
|
'link' => $url,
|
||||||
|
'recipient' => $to,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 3. 审核通过 ---------------- */
|
||||||
|
elseif ($reason === 'approve') {
|
||||||
|
$subject = '【' . $site . '】你的反馈已发布:' . $short;
|
||||||
|
$layout = [
|
||||||
|
'preheader' => '你的反馈已经公开可见',
|
||||||
|
'badge' => ['text' => '已发布', 'color' => '#1e7b3c'],
|
||||||
|
'title' => '你的反馈已通过审核并公开',
|
||||||
|
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||||
|
'blocks' => [
|
||||||
|
['type' => 'text', 'text' => '你提交的反馈已通过审核,现在可以在公开列表中看到了。'],
|
||||||
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
|
['type' => 'callout', 'tone' => 'info',
|
||||||
|
'text' => '后续的处理进展与回复,我们会通过邮件通知你。'],
|
||||||
|
],
|
||||||
|
'button' => ['text' => '查看这条反馈', 'url' => $url],
|
||||||
|
'link' => $url,
|
||||||
|
'recipient' => $to,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 4. 转为不公开 ---------------- */
|
||||||
|
elseif ($reason === 'hide') {
|
||||||
|
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
|
||||||
|
$subject = '【' . $site . '】你的反馈已转为不公开:' . $short;
|
||||||
|
|
||||||
|
$blocks = [
|
||||||
|
['type' => 'text', 'text' => '你提交的反馈已从公开列表中撤下,不再对其他人可见。'],
|
||||||
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
|
];
|
||||||
|
if ($note !== '') {
|
||||||
|
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
||||||
|
}
|
||||||
|
$blocks[] = ['type' => 'callout', 'tone' => 'muted',
|
||||||
|
'text' => '这条反馈本身仍然保留,你依然可以通过原链接访问和处理进展。'];
|
||||||
|
|
||||||
|
$layout = [
|
||||||
|
'preheader' => '这条反馈已不对其他人可见',
|
||||||
|
'badge' => ['text' => '不公开', 'color' => '#5c6572'],
|
||||||
|
'title' => '你的反馈已转为不公开',
|
||||||
|
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||||
|
'blocks' => $blocks,
|
||||||
|
'button' => ['text' => '查看这条反馈', 'url' => $url],
|
||||||
|
'link' => $url,
|
||||||
|
'recipient' => $to,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 5. 关闭(判定不是问题) ---------------- */
|
||||||
|
elseif ($reason === 'close') {
|
||||||
|
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
|
||||||
|
$body = (string)$thread['body'];
|
||||||
|
$mail = trim((string)fb_cfg('admin_email', ''));
|
||||||
|
|
||||||
|
$subject = '【' . $site . '】关于你反馈的问题《' . $short . '》';
|
||||||
|
|
||||||
|
$blocks = [
|
||||||
|
['type' => 'text',
|
||||||
|
'text' => '感谢你的反馈。我们已经核实过,这个情况并不是应用本身的问题,因此这条反馈已关闭(不会公开显示)。'],
|
||||||
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
|
];
|
||||||
|
if ($note !== '') {
|
||||||
|
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
||||||
|
}
|
||||||
|
$blocks[] = ['type' => 'quote', 'who' => '你当时反馈的内容:', 'text' => $body];
|
||||||
|
$blocks[] = [
|
||||||
|
'type' => 'callout', 'tone' => 'info',
|
||||||
|
'text' => ($mail !== ''
|
||||||
|
? '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
|
||||||
|
: '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进。'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$layout = [
|
||||||
|
'preheader' => '关于你反馈的问题,我们的说明',
|
||||||
|
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
|
||||||
|
'title' => '关于你反馈的问题,我们的说明',
|
||||||
|
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||||
|
'blocks' => $blocks,
|
||||||
|
'recipient' => $to,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 6. 新反馈(发给管理员) ---------------- */
|
||||||
|
elseif ($reason === 'new') {
|
||||||
|
$body = (string)$thread['body'];
|
||||||
|
$mail = (string)(isset($thread['author_email']) ? $thread['author_email'] : '');
|
||||||
|
$phone = trim((string)(isset($thread['author_phone']) ? $thread['author_phone'] : ''));
|
||||||
|
$swName = (string)(isset($extra['software_name']) ? $extra['software_name'] : '');
|
||||||
|
|
||||||
|
$subject = '【' . $site . '】新反馈:' . $short;
|
||||||
|
|
||||||
|
$rows = ['标题' => $title];
|
||||||
|
if ($swName !== '') { $rows['所属软件'] = $swName; }
|
||||||
|
$rows['提交人'] = $name !== '' ? $name : '(未留名)';
|
||||||
|
if ($mail !== '') { $rows['邮箱'] = $mail; }
|
||||||
|
if ($phone !== '') { $rows['电话'] = $phone; }
|
||||||
|
|
||||||
|
$blocks = [
|
||||||
|
['type' => 'text', 'text' => '有新的反馈提交,请及时处理。'],
|
||||||
|
['type' => 'field', 'rows' => $rows],
|
||||||
|
['type' => 'quote', 'who' => '反馈内容:', 'text' => $body],
|
||||||
|
['type' => 'callout', 'tone' => 'warn',
|
||||||
|
'text' => '这条反馈还需要审核,审核通过后才会公开显示。'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$layout = [
|
||||||
|
'preheader' => $name . ':' . fb_excerpt($body, 60),
|
||||||
|
'badge' => ['text' => '新反馈', 'color' => '#b4553c'],
|
||||||
|
'title' => '收到一条新反馈',
|
||||||
|
'intro' => '你好:',
|
||||||
|
'blocks' => $blocks,
|
||||||
|
'button' => ['text' => '去后台处理', 'url' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id']],
|
||||||
|
'link' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id'],
|
||||||
|
'recipient' => $to,
|
||||||
|
'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 兜底 ---------------- */
|
||||||
|
else {
|
||||||
|
$subject = '【' . $site . '】' . $short;
|
||||||
|
$layout = [
|
||||||
|
'preheader' => '你有一条新的通知',
|
||||||
|
'title' => '你有一条新的通知',
|
||||||
|
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||||
|
'blocks' => [['type' => 'text', 'text' => '你关注的反馈有更新。']],
|
||||||
|
'button' => ['text' => '查看详情', 'url' => $url],
|
||||||
|
'link' => $url,
|
||||||
|
'recipient' => $to,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$subject, fb_mail_layout($layout)];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$newId = (int)$pdo->lastInsertId();
|
$newId = (int)$pdo->lastInsertId();
|
||||||
fb_attach_save($pdo, $newId, null, $vList);
|
fb_attach_save($pdo, $newId, null, $vList);
|
||||||
fb_rate_hit('thread');
|
fb_rate_hit('thread');
|
||||||
|
|
||||||
|
// 通知管理员「有新反馈」(工单 #2)
|
||||||
|
// 此前 notify.admin_new_thread 这个设置项是**假的** ——
|
||||||
|
// 白名单里有、后台有勾选框,但没有任何代码实现它。
|
||||||
|
// 用户反馈「提交了反馈却收不到邮件」正是这个原因。
|
||||||
|
try {
|
||||||
|
$stNt = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||||||
|
$stNt->execute([$newId]);
|
||||||
|
$newThread = $stNt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($newThread) {
|
||||||
|
$swName = '';
|
||||||
|
if (!empty($swId)) {
|
||||||
|
$swRow = fb_software((int)$swId);
|
||||||
|
if ($swRow) { $swName = (string)$swRow['name']; }
|
||||||
|
}
|
||||||
|
fb_notify_event($pdo, 'new', $newThread, ['software_name' => $swName]);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// 发信失败绝不能让用户提交失败 —— 反馈已经存库了
|
||||||
|
}
|
||||||
|
|
||||||
fb_redirect('thread.php?id=' . $newId . '&submitted=1');
|
fb_redirect('thread.php?id=' . $newId . '&submitted=1');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user