多软件区分:邮件按软件换色+图标+软件名;补提交回执邮件

用户要求:多软件并存时,用「颜色 + 图标 + 名字」三重区分,
邮件也要跟着所属软件变,一眼能看出是哪款软件的来信。

1. software 表加 icon / download_url 字段(幂等迁移)
   - icon 存单个字符(emoji / 汉字 / 字母),不存图片路径:
     前端与邮件用同一个字符渲染,天然一致,不用再管一套上传与权限
   - 没配 icon 时取名首字兜底,任何地方都不出现空图标
   - 后台软件编辑加图标快选(12 个常用 emoji)

2. 邮件配色改为跟随软件(fb_mail_brand 取 fb_software_colors)
   - 头部色条、按钮、引用块色条、徽标全部跟着软件走
   - 邮件头部改为「图标色块 + 软件名」,下面再小字标站点名
   - 主题行统一加【软件名】前缀,不点开就能分辨

3. 新增 receipt 事件:提交后立即给提问人发「已收到、待审核、请耐心等待」
   - 独立开关 notify.receipt_enabled(与「回复通知」是两件事)
   - 新增 7 处调用前的静默 return 也一并写日志

4. reply 邮件补上下文:原提问摘要 + 本次回复,像博客评论通知
5. status=resolved 文案改为「已完成并上线」,配了下载地址时给下载按钮

验证:多软件配色/图标/前缀全部通过(含反向验证),XSS 转义通过,
空图标兜底通过;页面顶栏、页头、选择器图标渲染正确。
This commit is contained in:
2026-11-03 08:00:50 +08:00
parent e43f2ce67a
commit 738c6c09ff
12 changed files with 451 additions and 52 deletions
+83
View File
@@ -171,6 +171,20 @@ function fb_default_software() {
return $list ? $list[0] : null;
}
/**
* 由帖子行取它所属的软件行(邮件/页面要显示「这是对哪个软件提交的」都用它)。
* threads 只存 software_id,这里统一解析,避免各处自己写 SQL 漏掉。
* 取不到时回落到默认软件,保证邮件里永远有软件名可显示。
*/
function fb_software_of_thread(array $thread) {
$id = isset($thread['software_id']) ? (int)$thread['software_id'] : 0;
if ($id > 0) {
$sw = fb_software($id);
if ($sw) { return $sw; }
}
return fb_default_software();
}
/**
* 表单回显用:如果当前值等于「出厂默认」,就返回空串。
*
@@ -263,6 +277,74 @@ function fb_software_colors($software) {
return fb_color_variants($hex);
}
/**
* 规范化软件图标字符。
* 只留前几个字符,去掉换行与 HTML 尖括号 —— 这个值会直接输出到页面和邮件里,
* 放进 <span> 之间。虽然后面统一走 e() 转义,但源头收窄一层更稳。
*/
function fb_sw_icon_norm($s) {
$s = trim(strip_tags((string)$s));
$s = preg_replace('/[\x00-\x1F\x7F]+/u', '', $s);
if ($s === null || $s === '') { return ''; }
return mb_substr($s, 0, 4, 'UTF-8');
}
/** 规范化软件的下载地址:只接受 http/https,其它一律丢空 */
function fb_sw_url_norm($s) {
$s = trim((string)$s);
if ($s === '') { return ''; }
if (!preg_match('~^https?://~i', $s)) { return ''; }
return mb_substr($s, 0, 300, 'UTF-8');
}
/**
* 某软件的图标字符(单字符:emoji / 汉字 / 字母)。
* 没配就按软件名首字兜底 —— 多软件并存时,任何地方都不该出现「无图标」的空档。
*/
function fb_software_icon($software) {
$ic = '';
if (is_array($software)) {
$ic = trim((string)(isset($software['icon']) ? $software['icon'] : ''));
if ($ic === '') {
// 兜底:取名称首字(中文取第一个字,英文取首字母大写)
$nm = trim((string)(isset($software['name']) ? $software['name'] : ''));
if ($nm !== '') {
if (preg_match('/^[A-Za-z]/', $nm)) { $ic = strtoupper($nm[0]); }
else { $ic = mb_substr($nm, 0, 1, 'UTF-8'); }
}
}
}
if ($ic === '') { $ic = '·'; }
// 只允许极短内容,防止把整段 HTML 塞进图标位
return mb_substr($ic, 0, 4, 'UTF-8');
}
/**
* 图标 + 名字的组合标签(HTML)。
* 前端所有「这是哪个软件」的地方都用它,保证视觉一致。
*
* @param array $sw 软件行
* @param string $size 'sm' | 'md' | 'lg'
*/
function fb_software_badge($software, $size = 'md') {
if (!is_array($software) || empty($software['name'])) { return ''; }
list($c) = fb_software_colors($software);
if ($c === '') { $c = '#0057B8'; }
$icon = fb_software_icon($software);
$name = (string)$software['name'];
$px = ['sm' => 20, 'md' => 26, 'lg' => 34];
$fs = ['sm' => 11, 'md' => 14, 'lg' => 17];
$box = isset($px[$size]) ? $px[$size] : 26;
$fnt = isset($fs[$size]) ? $fs[$size] : 14;
return '<span class="sw-badge sw-badge-' . e($size) . '">'
. '<span class="sw-badge-ic" aria-hidden="true" style="width:' . $box . 'px;height:' . $box
. 'px;background:' . e($c) . ';font-size:' . $fnt . 'px">' . e($icon) . '</span>'
. '<span class="sw-badge-nm">' . e($name) . '</span>'
. '</span>';
}
/** 某软件下的分类;不传软件则返回空数组 */
function fb_categories($softwareId = null, $onlyEnabled = true) {
if ($softwareId === null) { return []; }
@@ -490,6 +572,7 @@ function fb_setting_keys() {
'smtp.timeout', 'smtp.verify_ssl',
'notify.enabled', 'notify.on_every_reply', 'notify.cooldown_seconds',
'notify.admin_new_thread',
'notify.receipt_enabled',
];
}