From 4033170ad70d47c730518183e9d42c940b7570c4 Mon Sep 17 00:00:00 2001 From: Yongquan Date: Mon, 2 Nov 2026 22:57:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=B7=A8=20tab=20?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=BA=92=E7=9B=B8=E6=B8=85=E7=A9=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=9B=E8=A1=A5=E9=BD=90=E5=85=A8=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E9=80=9A=E7=9F=A5=E4=B8=8E=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、用户报「我明明配过 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。 --- admin.php | 255 +++++++++++++-- assets/app.js | 27 ++ assets/style.css | 35 ++ data/.htaccess | 16 +- lib/bootstrap.php | 2 +- lib/db.php | 14 +- lib/mailer.php | 795 ++++++++++++++++++++++++++++++++++++++++++---- new.php | 21 ++ 8 files changed, 1090 insertions(+), 75 deletions(-) diff --git a/admin.php b/admin.php index a9b2862..39be23c 100644 --- a/admin.php +++ b/admin.php @@ -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 = ?') ->execute([$newStatus, time(), $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') { $note = trim(isset($_POST['note']) ? (string)$_POST['note'] : ''); @@ -141,7 +148,61 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) { } elseif ($act === 'hide') { $pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $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') { $statuses = fb_statuses(); @@ -150,11 +211,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) { $msg = '未知的状态。'; $msgType = 'error'; } 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 = ?') ->execute([$new, $vis, time(), $id]); 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') { @@ -268,16 +347,43 @@ function fb_admin_save_settings() { 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 的出厂值,便于恢复默认。 foreach (['site_name', 'site_desc'] as $k) { + if (!$has($k)) { continue; } $v = trim((string)$post($k, '')); if ($v === '') { fb_unset_setting($k); } else { fb_set_setting($k, $v); } } // 页脚:是否显示「由 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', '')); // 只接受 http/https:这条会被直接渲染成 ,不能让 javascript:/data: 混进来 if ($curl !== '' && !preg_match('~^https?://~i', $curl)) { @@ -296,13 +402,14 @@ function fb_admin_save_settings() { 'upload.image_max_side' => [480, 4096], ]; foreach ($ints as $k => $range) { + if (!$has($k)) { continue; } $v = (int)$post($k, 0); $v = max($range[0], min($range[1], $v)); fb_set_setting($k, $v); } // 图片/视频上传总开关(0=不限个数后,开关必须独立存在) - 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_image_on')) { fb_set_setting('upload.image_on', (int)(bool)$post('upload.image_on')); } + if ($has('upload_video_on')) { fb_set_setting('upload.video_on', (int)(bool)$post('upload.video_on')); } // MB → 字节 // 下限改 0 = 不限制(见 #4) @@ -312,6 +419,7 @@ function fb_admin_save_settings() { 'upload.total_cap_bytes' => [0, 1048576], ]; foreach ($mbs as $k => $range) { + if (!$has($k)) { continue; } // 没提交就不动(防跨 tab 清空) $mb = (float)$post($k, 0); $mb = max($range[0], min($range[1], $mb)); 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']; foreach ($texts as $k) { + if (!$has($k)) { continue; } // 没提交就不动 $v = trim((string)$post($k, '')); // 这两个留空都有"出厂默认"兜底,必须删键而不是存空串: // - 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.image_process', $post('oss.image_process') ? 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']; foreach ($smtpText as $k) { + if (!$has($k)) { continue; } // 没提交就不动它 fb_set_setting($k, trim((string)$post($k, ''))); } // 口令留空 = 不修改(避免每次保存都得重填) @@ -400,20 +526,32 @@ function fb_admin_save_settings() { $msgType = 'error'; } - /* ---------------- 通知开关 ---------------- */ - fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled')); - 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')); + /* ---------------- 通知开关 ---------------- + * 全是 checkbox:未提交会被当成"取消勾选"。 + * 所以只有本页确实提交了邮件表单时才写,否则在站点设置页保存一次 + * 就会把「有人回复时通知」这些开关全部关掉。 + */ + 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 是合法值(表示不限制), // 所以要用「空串」而不是「0」来区分"没填"和"填了 0" - $cdRaw = trim((string)$post('notify.cooldown_seconds', '')); - if ($cdRaw === '') { - fb_unset_setting('notify.cooldown_seconds'); - } else { - fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw))); + if ($has('notify_cooldown_seconds')) { + $cdRaw = trim((string)$post('notify.cooldown_seconds', '')); + if ($cdRaw === '') { + fb_unset_setting('notify.cooldown_seconds'); + } else { + fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw))); + } } - fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0); + } // ← end if ($isMailForm) + + // 审核开关属于「站点设置」,只在站点设置页提交时才写 + if ($has('moderation_enabled')) { + fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0); + } fb_mod_log('settings', null, '保存站点设置:存储=' . $storage); return '设置已保存。'; @@ -602,6 +740,87 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']); + 'doing', 'label' => '处理中', 'cls' => ''], + ['key' => 'resolved', 'label' => '已解决', 'cls' => 'ok'], + ['key' => 'wontfix', 'label' => '不予考虑', 'cls' => 'warn'], + ]; + ?> +
+
处理
+
+ +
+ + + + + +
+ + 已公开显示 + + + + + +
+ + + + +
+ + + + +
+ + + +
+ + + 点一下立刻生效并自动发邮件通知提交人 +
+ + + +
+
diff --git a/assets/app.js b/assets/app.js index c69fce8..b77c9d9 100644 --- a/assets/app.js +++ b/assets/app.js @@ -703,6 +703,32 @@ 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() { var rows = document.querySelectorAll('.color-row'); @@ -793,6 +819,7 @@ function boot() { bindCaptchaRefresh(); bindAdminToggle(); + bindWontfixPanel(); bindColorPicker(); bindMailTest(); bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前 diff --git a/assets/style.css b/assets/style.css index b585011..ee749e8 100644 --- a/assets/style.css +++ b/assets/style.css @@ -81,6 +81,41 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh} .page-head h1{margin-bottom:4px} .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{ display:inline-block; width:10px; height:10px; border-radius:50%; diff --git a/data/.htaccess b/data/.htaccess index f74a59f..4b3840a 100644 --- a/data/.htaccess +++ b/data/.htaccess @@ -1,4 +1,8 @@ -# 拒绝一切对数据目录的直接访问(数据库文件在此) +# 数据库目录(由程序自动生成,请勿删除) +# fb-data-guard: v1 +# ★ 一律禁止 Web 直接访问:数据库文件绝不能被下载 +Options -Indexes + Require all denied @@ -6,3 +10,13 @@ Order allow,deny Deny from all + + + + Require all denied + + + Order allow,deny + Deny from all + + diff --git a/lib/bootstrap.php b/lib/bootstrap.php index d9ae99f..b4c5e8e 100644 --- a/lib/bootstrap.php +++ b/lib/bootstrap.php @@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) { define('FB_ROOT', dirname(__DIR__)); // 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1, // 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。 - if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 15); } + if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 16); } } /* ------------------------------------------------------------------ diff --git a/lib/db.php b/lib/db.php index 34dac71..fe76f79 100644 --- a/lib/db.php +++ b/lib/db.php @@ -125,8 +125,9 @@ SQL CREATE TABLE IF NOT EXISTS notify_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, - reply_id INTEGER NOT NULL, + reply_id INTEGER, to_email TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT 'reply', created_at INTEGER NOT NULL, ok 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)'); + /* ---- 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(); $swNames = []; diff --git a/lib/mailer.php b/lib/mailer.php index c9a58f3..604269b 100644 --- a/lib/mailer.php +++ b/lib/mailer.php @@ -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 '

' + . (isset($b['html']) ? $b['html'] : fb_mail_esc(isset($b['text']) ? $b['text'] : '')) + . '

'; + } + + if ($type === 'quote') { + $who = isset($b['who']) ? (string)$b['who'] : ''; + $body = nl2br(fb_mail_esc(isset($b['text']) ? $b['text'] : '')); + return '' + . '
' + . ($who !== '' + ? '
' + . fb_mail_esc($who) . '
' : '') + . '
' . $body . '
' + . '
'; + } + + if ($type === 'field') { + $rows = isset($b['rows']) ? (array)$b['rows'] : []; + $out = ''; + $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 .= '' + . '' + . '' + . ''; + $i++; + } + return $out . '
' . fb_mail_esc($label) . '' . fb_mail_esc($value) . '
'; + } + + 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 '' + . '
' . $body . '
'; + } + + 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 = '
' + . '' . fb_mail_esc($badge['text']) . '' + . '
'; + } + + // ---- 按钮 ---- + $btnHtml = ''; + if ($button && !empty($button['url']) && !empty($button['text'])) { + $url = (string)$button['url']; + $btnHtml = '' + . '
' + . '' + . fb_mail_esc($button['text']) . '
'; + } + + // ---- 纯文本兜底链接 ---- + $linkHtml = ''; + if ($link !== '') { + $linkHtml = '

' + . '如果按钮无法点击,请复制下面的链接到浏览器打开:
' + . '' + . fb_mail_esc($link) . '

'; + } + + // ---- 收件人问候 ---- + $introHtml = ''; + if ($intro !== '') { + $introHtml = '

' + . fb_mail_esc($intro) . '

'; + } + + return '' + . '' + . '' + . '' + . '' . fb_mail_esc($title) . '' + . '' + + // 预处理器:收件箱里显示的那行摘要,兼带隐藏占位防止被正文抢先 + . ($preheader !== '' + ? '
' + . fb_mail_esc($preheader) . '
' : '') + + // 外层留白 + . '
' + + // 卡片 + . '' + + // 顶部色条 + . '' + + // 站名 + . '' + + // 标题 + . '' + + // 正文 + . '' + + // 页脚 + . '' + + . '
 
' + . '
' + . fb_mail_esc($site) . '
' . $badgeHtml + . '

' . fb_mail_esc($title) . '

' + . $introHtml . $body . $btnHtml . $linkHtml + . '
' + . '

' + . fb_mail_esc($footer) . '

' + . ($recipient !== '' + ? '

' + . '这封邮件发给了 ' . fb_mail_esc($recipient) . '。' + . '如果这不是你,忽略即可。

' : '') + . '

' + . '' + . fb_mail_esc($base) . '

' + . '
' + . '
' + . ''; + } +} function fb_notify_html($thread, $reply, $isAdmin) { $base = fb_base_url(); $url = $base . '/thread.php?id=' . (int)$thread['id']; @@ -144,40 +396,11 @@ function fb_notify_html($thread, $reply, $isAdmin) { * @return string 空串=未发送/已发送成功;非空=失败原因 */ function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) { - $cfg = fb_cfg(); - $nt = isset($cfg['notify']) ? (array)$cfg['notify'] : []; - if (empty($nt['enabled'])) { return ''; } - if (empty($nt['on_every_reply']) && !$isAdmin) { return ''; } - - $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); + // 转调统一入口(工单 #2):旧调用点保持不改,但行为、模板、日志全部统一。 + list($ok, $err) = fb_notify_event($pdo, 'reply', $thread, [ + 'reply' => $reply, + 'is_admin' => (bool)$isAdmin, + ]); return $ok ? '' : $err; } @@ -226,31 +449,495 @@ function fb_notify_closed_html(array $thread, string $note) { * @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)] */ function fb_notify_closed(PDO $pdo, array $thread, string $note) { - $cfg = fb_cfg(); - $sm = isset($cfg['smtp']) ? (array)$cfg['smtp'] : []; - if (empty($sm['enabled'])) { - // 仅记录,不算失败 + // 转调统一入口(工单 #2) + return fb_notify_event($pdo, 'close', $thread, ['note' => $note]); +} + +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 { - $st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)'); - $st->execute([(int)$thread['id'], 0, (string)$thread['author_email'], time(), 0, 'SMTP 未启用,关闭通知未发送']); - } catch (Exception $ex) { /* 静默 */ } - return [false, 'SMTP 未启用,邮件未发送(已在配置里关闭)']; + $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, ok, err, created_at) ' + . 'VALUES(?,?,?,?,?,?)') + ->execute([(int)$thread['id'], 0, (string)$to, (int)$ok, + $err === null ? null : (string)$err, time()]); + } catch (Throwable $e2) { + // 实在记不了就算了,不能因为记日志失败让用户的请求失败 + } } +}/** + * 各事件的邮件渲染(工单 #2) + * + * 每个事件返回 [主题, HTML]。 + * 全部套 fb_mail_layout(),视觉统一;文案按事件差异化。 + */ - $to = (string)$thread['author_email']; - if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { - return [false, '提问人邮箱不可用,未发送']; +/** + * 邮件通知事件(工单 #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, '收件邮箱无效']; + } + + // ---- 3. 自己通知自己就别发了 ---- + if (!$isAdminNotice && !empty($extra['is_admin']) === 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 未启用或未配置服务器']; + } + + // ---- 6. 组装主题与正文 ---- + list($subject, $html) = fb_notify_render($reason, $thread, $extra, $to); + + // ---- 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 { + $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, reason, ok, err, created_at) ' + . 'VALUES(?,?,?,?,?,?,?)') + ->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 = []; - $tpl = (string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》'); - $subject = sprintf($tpl, (string)$thread['title']); - $html = fb_notify_closed_html($thread, $note); + /* ---------------- 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 ? '官方回复' : '新回复'; - list($ok, $err) = fb_smtp_send($sm, $to, $subject, $html); - try { - $st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)'); - $st->execute([(int)$thread['id'], 0, $to, time(), $ok ? 1 : 0, $ok ? null : $err]); - } catch (Exception $ex) { /* 静默 */ } + $subject = '【' . $site . '】' . $isOff . ':' . $short; + + $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 [$ok, $ok ? '' : $err]; + return [$subject, fb_mail_layout($layout)]; + } } diff --git a/new.php b/new.php index 9bf9189..70ddc58 100644 --- a/new.php +++ b/new.php @@ -104,6 +104,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $newId = (int)$pdo->lastInsertId(); fb_attach_save($pdo, $newId, null, $vList); 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'); } }