Compare commits

..
30 Commits
Author SHA1 Message Date
laoyang 832f112843 筛选区改成固定两行,分类再多也不会把表单挤走
用户:「筛选的时候,反馈类型和后面的下拉框不在一条线上;
如果反馈类型比较多,下拉框就被挤到下一行去了。
建议统一成分两行,这样也就没有对齐的问题了。」

问题根因:
.filters 是 display:flex + flex-wrap,分类 chips 和筛选表单被当成
同一行的兄弟元素,分类一多就把表单挤下去;
而且两者高度形态不同(chips 是胶囊、表单是控件),本来也对不齐。

改法:固定两行,各管各的
  第 1 行 = 反馈类型 chips(内部可换行,不影响别的)
  第 2 行 = 状态 / 排序 / 搜索框 / 搜索按钮(同一行,高度一致)
.filters 改 flex-direction:column。

关键点:文件里有两处 .filters 定义,后面那处(管理后台用)
是 display:flex + flex-wrap,会把前台的两行布局覆盖回横向。
已删除那处的 flex 声明,只保留对内部控件的宽度约束。

后台筛选栏是纯控件横排、没有分类 chips,不能跟着变竖排,
所以给 admin.php 的筛选表单加了 .filters-row 类,
用 .filters.filters-row 覆盖回 row。

验证(真实页面 + 注入 16 个额外分类,共 21 个):
  视口 1280px:chips y 109→276,表单 y 286(在下方)✅ 表单内控件 1 行
  视口  900px:同上 ✅
  视口  700px:同上 ✅
  视口  500px:chips y 103→400,表单 y 410(在下方)✅
              表单内部换成 2 行 —— 窄屏放不下 4 个控件属正常响应式
  前台 .filters flex-direction = column
  后台 .filters.filters-row flex-direction = row,控件全在同一条线上
    (垂直中心统一 92.8)
  5 个路由 200

另外修掉一个隐患:改 .filters 时误删了 .chips 的基础 flex 样式,
已补回(.chips{display:flex;flex-wrap:wrap;gap:8px})。
2026-11-04 20:31:17 +08:00
laoyang 8db38a834d 下拉框改回软件主题色;修 color-mix 与变量配合失效的问题
用户:「下拉框的颜色就用那个软件的主题颜色,不行吗?现在怎么还是都是灰的呀?
我感觉用主题颜色就行,挺好看的。」

上一版我判断错了:看到同步日历主题色 #ff3f44 算出的 --brand-soft 是 #ffe8e9(粉),
就自己决定换成中性灰,理由写的是"工具控件不该跟主题色"。
这是替用户做审美决定,而且用户要的恰恰是「跟着软件变颜色」——
这正是这套系统区分不同软件的核心手段。已改回主题色系。

改了什么:
- 下拉项 hover  → --brand-tint-1(主题色与白混 91%)
- 下拉项选中    → --brand-tint-2(混 80%,比 hover 深一档)
- 选中项 hover  → --brand-tint-3(混 72%,再深一点)
- 按钮打开态:浅主题底 + 主题边框 + 主题文字
- 箭头打开时转 180 并染成主题色

关键技术问题:
最初想用 CSS 的 color-mix(in srgb, var(--brand) 9%, #fff) 动态算浅色。
实测在 Chrome 里虽然 CSS.supports 返回 true、规则也匹配上了,
但计算值却是 oklab(0 0 0 / 0)(完全透明)—— 变量在 color-mix 里解析失败。
改成在 PHP 里用 fb_color_tint() 把值算好,
以 --brand-tint-1/2/3 三个 CSS 变量输出。
好处:不依赖浏览器版本,也没有运行时开销。
默认(无当前软件的页面,如后台)在 :root 里给了蓝色的一套兜底值。

验证(真实页面 + 真实鼠标事件):
  feedlite      #0057b8 → 选中 rgb(184,208,235) 浅蓝
  synccalendar  #ff3f44 → 选中 rgb(255,201,203) 浅红
  simple_password #a556f7 → 选中 rgb(230,208,253) 浅紫
  simplenav     #3571fb → 选中 rgb(198,215,254) 亮蓝
  真实 mousemove 悬停:背景 rgb(255,238,238),:hover 匹配为 true
  选中项 rgb(255,201,203) 明显深于 hover rgb(255,238,238),两者可区分

踩坑记录:中途用 JS 加 is-active 类来测 hover 只能触发 .is-active,
必须发真实 mouseMoved 事件才能验证 :hover,否则会误判成"样式失效"。
2026-11-04 19:31:55 +08:00
laoyang 9b971a5b92 下拉框改为自绘列表;修复新建软件缺排序字段
用户两个反馈:
① 「排序那个怎么删了?排序还得留着呀」
② 「所有下拉框拉下来之后全是灰色,不好看,重新设计」

一、排序字段(是误解,但确实有缺陷)

排序字段没有被删,一直在 if ($esw) 分支里渲染。
用户看到"没有",是因为他打开的是「添加软件」——
新建时 $esw 为空,整块不渲染,所以确实没有这一项。
这是真实缺陷:新建软件时无法指定排序。

修法:
- 把排序字段移出 if ($esw),新建/编辑都显示
- 新建时默认值 = 当前最大 sort_order + 1(排到最后)
- 后端 sw_add 改为优先用表单值(用 isset 判断,
  用户明确填 0 是合法值——排最前面,不能被当成"没填")

与「下载地址」不同:排序是真实会被用到的字段
(前台软件列表、顶栏三横线菜单都按它排),不能删。

二、下拉框重设计

为什么必须自绘:原生 select 的展开列表由浏览器绘制,
CSS 只能改 option 的底色,改不了圆角/行高/hover 高亮,
各平台样式还不一样(Windows 是灰方框)。用户说的"全是灰色"就是这个。

但不重写 HTML:全站 16 个 select,逐个改容易漏,
也会破坏 onchange 提交这类内联逻辑。

做法:原生 select 保留(视觉隐藏,仍参与提交与 change 事件),
JS 在旁生成按钮 + 浮层列表。
- 浮层用 position:fixed 挂 body,避免被 overflow:hidden 裁(踩过)
- 位置自动避让视口,下方不够翻上方
- 支持键盘:上下键 Home End Enter Esc
- 选中项带对勾
- 无 JS 时原生 select 仍可用

配色踩坑:选中项最初用 var(--brand-soft),而该变量会被软件主题色覆盖 ——
同步日历主题色 #ff3f44 时 --brand-soft 成了 #ffe8e9(粉红),
下拉里一片粉很难看。下拉是工具性控件,不该跟软件换色,
改用中性色:#eef1f5 选中 / #f1f3f6 hover,四个软件下完全一致。

验证(真实页面实测):
- 2 个 select 均被增强,原生已隐藏
- 面板白底 rgb(255,255,255)、圆角 11px、双层柔和阴影、7 个列表项
- 选中项 rgb(238,241,245)、hover rgb(241,243,246)、带对勾
- 四个软件(#0057b8/#ff3f44/#a556f7/#3571fb)下选中项底色完全相同,
  不再随主题色变粉
- 键盘下方向键三次 → 高亮「处理中」,底色正确
- 选择后 selectedIndex/value 同步、面板自动关闭、change 事件已触发
- 浮层 fixed 定位、完整可见、不被裁切
- 排序字段 3 处(软件/分类/状态)齐全;5 个路由 200
2026-11-04 19:27:01 +08:00
laoyang 27614d8ca8 统一全部表单控件尺寸与外观;删除下载地址字段
用户:「输入框、按钮、下拉框特别丑,一点设计感都没有,高度太随意,
      全面检查一下。还有那个下载链接要它干什么?」

一、表单控件(这是主要工作量)
  原来样式散在 6 处(.field / .filter-row / .filters / .mail-test / .admin 各一套),
  padding 有 8/9/12px 三种,select 与 input 高度不等,并排放参差不齐。

  改成一套体系:
  - CSS 变量统一尺度:--ctl-h 38px / --ctl-h-sm 32px / --ctl-h-lg 42px,
    统一圆角 9px、统一左右内边距 12px、统一边框与阴影
  - 高度用「固定 height + line-height 居中」而不是 padding 撑
  - 🔴 选择器必须**宽松**(按 type 通吃,不用 .field 之类限定)。
     第一版限制了容器,导致后台零散布局里的裸 select/input 只有 19/21px,
     和旁边 38px 按钮并排,正是用户说的高度随意。
     放开后实测:19px → 38px,全部一致
  - 下拉框自绘箭头(内联 SVG),不再用各平台不一的原生箭头,右侧留 34px
  - 搜索框加左侧放大镜图标,并去掉 Chrome 自带清除按钮
  - 聚焦统一 brand 光晕,悬停边框加深
  - 按钮也改固定 height(原来 padding 撑出 38.3px,与输入框差 0.3px)
  - .btn.sm 必须显式改 height,否则 padding 被 height 吃掉、小按钮还是 38px

  文件选择按钮:原生控件重做成真按钮
    <label class=file-btn> 包住 input[type=file]
    input 用 clip 视觉隐藏(不能用 display:none,那样点不到也聚焦不了)
    按钮内显示「选择图片」+ 图标 + 选中后的文件名

二、删除「下载地址」字段
  它唯一用途:状态改为「已解决」时把邮件按钮换成「下载最新版本」。
  收益小、维护成本高(每个软件都要填一条 URL)。
  - 后台表单项删除
  - mailer 里对应的判断分支也删掉,按钮统一「查看详情」
  - ⚠️ 数据库 download_url 列**保留不删**:删列不可逆,万一要恢复还得迁一次

验证(用控件总览测试页 + 真实页面实测):
  10 类控件高度全部 38px(文本/数字/下拉/搜索/文件按钮/主按钮/小按钮…),
  文本域 110px,同一行垂直中心完全一致(935.3)
  裸 select/input 从 19/21px 修正到 38px
  下拉箭头 SVG 生效、右内边距 34px;搜索框放大镜生效、左内边距 36px
  真实三个页面(提交页/列表页/详情页)逐一检查:
    没有 checkbox/radio/file 被误改,所有可见控件高度收敛为 38(文本域 110)
  下载地址字段:admin.php 输出与源码中均无 download_url 表单项
  5 个路由 200
2026-11-04 19:17:20 +08:00
laoyang a2a3306169 修色板被裁切 + 让人看得出能选全色域
用户两个反馈:
① 色板直接被挡住了一半
② 它不是完全的颜色,好像只有蓝色

① 根因:色板用 position:absolute,而它在
   <details class="tax-form-box"> 里面,那个容器有 overflow:hidden。
   色相条正好在面板最底部 —— 被裁掉了,所以看不见。
   改 position:fixed,直接相对视口定位,任何祖先 overflow 都管不到。
   位置改由 JS 按触发按钮坐标算,并处理三种情况:
     右边超出→左移;下方不够→翻上方;都放不下→贴视口底边
   另加 resize/scroll 监听,避免滚动后色板漂走。

② 不是 bug,是 ① 的连带后果:
   HSV 色板本来就只显示当前色相的深浅变化,换颜色靠色相条。
   色相条被裁掉后,用户只看到一片蓝,自然以为只有蓝色。
   修好裁切后已实测 6 个色相各不相同:
     0°红 60°黄 120°绿 180°青 240°蓝 300°品红
   同时把色相条从 14px 加高到 18px 并加内描边,
   让它一眼就能认出是光谱条,而不是一条灰线。

验证(用真实后台容器结构复刻测试页,details 带 overflow:hidden):
  面板四边 elementFromPoint 都能命中自己 → 未被任何容器裁切
  色相条可见、高 18px;面板总高 367px
  滚到页面底部再开面板仍完整可见
  6 个色相取样互不相同,色板能覆盖全色域
  实际取色 #0ce779 正常
  4 个路由 200
2026-11-04 19:01:04 +08:00
laoyang 338a35cacd 主题色改为完整色板:画布 342x200,加明度条,删除右侧预设圆点
用户:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」

两个改动:
1. 删掉右侧那排 12 个预设圆点($presets 变量、CSS、JS 同步逻辑一并删)。
   色板本身能取任意颜色,再摆一排样例是重复,还挤占横向空间。
2. 色板做成「完整」的:
   面板 248×246 → 372×357
   画布 226×150 → 342×200(太小拖着不好定位颜色)
   新增明度条,底色随当前色相实时刷新 ——
   否则永远是灰阶,用户看不出拖到那里会得到什么颜色
   面板加标题「选择颜色」,按钮文案「确定」→「完成」

顺带简化光标定位:
原来 cursor 挂在 .cp 里,要靠两个 getBoundingClientRect 相减算偏移;
现在改挂到 .cp-sv-wrap(与 canvas 同级),坐标直接相对 canvas 算,
少一层偏移、少一个坑。

验证(浏览器实测):
  .color-preset 残留 0 个、.color-presets 容器 0 个
  面板 372×357、画布 342×200,完整在视口内
  色相 200° → 明度条背景 linear-gradient(#000, rgb(0,170,255)),确实跟着变
  画布 80%/20% 处取色 → #2996cc,光标落在画布范围内
  明度拖到 50% → #1a5e80(同色相压暗,符合预期)
  手输 #2E7D5B → 隐藏字段 #2e7d5b,点完成关闭
  4 个路由 200
2026-11-04 18:57:18 +08:00
laoyang ad1b0eedf4 主题色改为自绘取色面板(任意取色);图标上传加即时预览与状态提示
一、颜色不够丰富 → 点击弹取色面板
  用户:「还是不行,颜色还是不够丰富。让用户选择吧:点击之后弹出一个颜色选择器。」
  原来只有一个原生 <input type=color> 小方块,用户不知道能点,
  而且系统面板各浏览器样式不一、只能取纯色。
  改成自绘面板:
    色相/饱和度渐变板(Canvas)+ 明度滑块 + 十六进制输入 + 确定
    12 个预设圆点降级为快捷方式,真正的能力是任意取色
  交互:点色块弹出(下方空间不够自动翻上方)、板内拖动实时更新、
        十六进制可手输、点确定或面板外关闭、Esc 关闭

  ⚠️ 光标定位踩了两个坑,都写进注释了:
    ① 不能用 canvas.offsetLeft(相对 offsetParent,受父级 padding 影响,
       实测算出 236px 超出画布宽度 226px);要用两个 getBoundingClientRect 相减
    ② canvas 显示尺寸受 CSS width:100% 影响,≠ 内部像素 220×150,
       必须用 rect.width 而不是 canvas.width

二、图标上传没反馈
  用户:「上传的图标没有显示,也没有上传成功、失败之类的提示信息。」
  后端逻辑完整(格式/大小/尺寸/存储模式都校验了),问题在**前端零反馈**:
  裸 <input type=file>,选完文件什么都不发生,而提示条在页面顶部容易被错过。
  加了 bindIconUpload():
    ① 选中文件立刻 FileReader 本地预览,不等提交
    ② 显示文件名 + 尺寸 + 大小,让用户确认选对了
    ③ 选之前就拦掉明显不合格的(格式/超 2MB/小于 32×32/空文件)
    ④ 非正方形给出警告(显示时会被裁切)
  ⚠️ 前端校验只是体验优化,绝不能当安全边界 ——
     真正的校验仍在 PHP 的 fb_icon_store_upload() 里。

验证(用独立测试页 + 浏览器实测,不登录后台、不动数据):
  面板初始隐藏 → 点击后 248×246 出现
  Canvas 四角像素分别为灰白/蓝/黑 → 渐变确实画出来了
  板上取色 → 隐藏字段 #2178d9 同步到按钮文字/十六进制框/预览色
  点预设 → #2B822E;手输 → #a020f0;色相滑条 120° → #20f020
  光标中心 (238,54) 落在画布区域 (11..237, 11..161) 内
  4 个路由 200
2026-11-04 18:52:45 +08:00
laoyang a00e2221c4 调色板从 8 色扩到 12 色(按色相环重排);后台预设改为共用同一份
用户:「不同的软件要有不同的颜色,现在颜色有点少,快不够分了,
      至少应该有 10 个左右的颜色。」

调研发现的问题比「数量少」更严重:
🔴 调色板有两份独立硬编码 —— util.php 一份、admin_taxonomy.php 又一份,
   两边的色值还不一样,早就不同步了。用户在后台只能从 8 个里选,
   而实际分配用的却是另一批色值。

改法:
- 新增 fb_color_palette() 作为唯一出口,12 色
- fb_color_default_for() 改为调用它
- 后台  也改为调用它 —— 从此只有一处需要维护

12 色设计(360° 切 12 段,逐色验证,不是随手挑的):
  色相:2/32/62/92/122/152/178/200/232/266/293/330,**最小相邻间距 22°**
  对比度:白底全部落在 4.76~6.19,既都够看清(WCAG AA 需 ≥4.5),
          又不会某个颜色特别深显得突兀
  压制项:黄/黄绿饱和 50%(再高就荧光)、蓝/靛蓝压到 62%/50%
  避开项:纯红纯绿会与「待处理/已解决」状态色撞,所以红取 2°、绿取 122°

中途踩过的坑(记录下来避免重犯):
  第一版直接往原 8 色后面追加 6 个,实测发现 8 对颜色几乎同色 ——
  因为原调色板里 185°/212°/217° 三个蓝本就挤在一起。
  「塞进缝隙」这个思路是错的,必须重排整个色相环。

现有 5 个软件颜色**未改动**(实测仍是 #0057b8/#b4553c/#6a4c93/#0e7c86/#3a5a8c):
它们的色相分布本就合理(2°12°185°212°265°),没必要让已发布链接的配色突变。
新调色板只影响「新添加且未指定颜色」的软件。

验证:12 色无重复、对比度全 ≥4.5、最小色相间距 22°、
后台已改为调用 fb_color_palette() 且旧硬编码数组已消失、6 个路由 200。
2026-11-04 17:54:39 +08:00
laoyang 26d00ea981 后台顶栏去掉「我要反馈」「三横线选软件」「问题列表」
用户问:「管理后台还需要我要反馈这个按钮吗?还需要右上角那三个横线的设置按钮吗?
还需要拉下来后选择不同的软件吗?这些是不是都不需要了呀?」

确认都不需要,而且原来确实都在:
后台调 fb_layout_head() 时没传 software,走「无软件」分支,
结果顶栏照样渲染了问题列表 / 我要反馈 / 三横线,三横线里还列着
「轻反馈、同步日历、密码生成器、反馈测试」四个软件 —— 在后台点它毫无意义。

理由:
① 「我要反馈」是给访客的入口,管理员不该从后台跳去前台表单;
② 后台是跨软件视图(待审核/全部问题本身就带软件筛选器),
   再放切软件菜单是重复的,切了也不会改变后台视图;
③ 「问题列表」依赖 ?sw= 当前软件上下文,后台没有这个上下文,
   点了只会跳到前台某个软件,属于误导。

改法:fb_layout_head() 新增 [admin] 开关,admin.php 两处
(管理登录、管理后台主界面)都传 admin=true。
后台顶栏只留:站点标识(回首页)+「去前台看看」。
唯一的后台出口是「去前台看看」,管理员偶尔需要核对前台实际效果。

验证:
  后台顶栏链接只剩 brand-mini(href=./) 和 navlink(href=index.php, 去前台看看);
  sw-toggle / sw-panel 关键字均已不存在。
  前台顶栏三样仍在(问题列表 / 我要反馈 / 三横线),未被波及。
  6 个路由全 200。
2026-11-03 21:11:51 +08:00
laoyang 974dd52367 去掉软件标签的描边;修「全部问题」列表分类标签渲染成空
两个问题:

一、两个标签中间那道「线」
  不是 CSS 分隔符 —— 是 .badge.sw(软件徽章)带 border:1px solid var(--swc-line),
  而 .badge.cat / .badge.st 都没有边框。并排显示时那道描边看起来
  就像凭空插了一条分隔线。去掉边框,三个标签统一为无边框胶囊。

二、反馈类型(Bug / 功能建议)在后台「全部问题」列表里不显示
  🔴 根因:fb_category_label() 参数是**数字 id**,内部会 (int) 化。
     而全部问题列表传的是 $t["category"](老 slug 字段,值为 bug),
     (int)bug === 0 → 函数返回空串 → 渲染出一个**空的** badge:
     标签框在、里面什么都没有,看起来就像「类型没了」。
  修法:
    - 新增 fb_category_label_by_slug($softwareId, $slug) 给老数据兜底
      (slug 在不同软件下可能重名,必须带 software_id)
    - 全部问题列表改用 category_id 优先、slug 兜底
    - 前台列表同样处理,并且**取不到名字就不渲染这个标签**,
      绝不再输出空胶囊
    - 在 fb_category_label() 的注释里写明「必须传数字 id」,
      避免下次再踩

验证:
  函数对照跑通:#4 category_id=6 → Bug;#5 category_id=7 → 功能建议;
  旧写法 fb_category_label("bug") → (空),确认这就是根因。
  浏览器实测详情页三个标签 border-top-width 全部 0px / style none,
  圆角都是 999px,描边已消除。
  前台列表两个条目分类标签分别渲染出 Bug / 功能建议,不再是空。
  7 个路由全 200。
2026-11-03 20:50:00 +08:00
laoyang dcf510ef28 后台三处列表/详情:标题顶头独占一行,标签下移(与前台统一)
用户:「管理后台的待审核和全部问题那个列表里面也是一样的。
      标题应该顶头,然后在标题下面另起一行,写上来自于哪一个软件、
      它的状态是什么、它是什么分类。点到一个具体的反馈里面也是一样的,
      标题顶头,然后把同步日历、bug、待处理往下一行。管理后台也是全部按照这种方式去处理。」

改三处:
1. fb_admin_pending_row(待审核列表)
   prow-top(标签在前+标题)→ prow-head(标题独占一行)+ prow-tags(软件/分类/状态)
2. 全部问题列表
   item-top → item-head + item-tags
   🔴 同时补上软件名(fb_software_chip)—— 后台是跨软件视图,
      前台某个软件内部不需要显示软件名(满页同一个),后台必须显示。
3. 后台详情页头部
   item-top → item-head + item-tags,标题字号 19px 略大于列表

样式新增 .prow-head / .prow-tags(.prow-top 保留给操作日志,
日志条目只有「动作+编号」,单行反而合适)。

验证(真实渲染 + 浏览器测量,不登录后台、不动数据):
  待审核行  标题 top=87 left=222 / 标签 top=119 left=222 → 标签在下方、左对齐 0px 偏差
  全部问题  标题 top=318 left=223 / 标签 top=351 left=223 → 同上
  详情头    标题 top=541 left=228 / 标签 top=575 left=228 → 同上
  另外静态检查:三处的 head 段内均无状态标签,tags 段内均含软件/分类/状态。
  前台 5 个路由 200,未受影响。
2026-11-03 18:58:04 +08:00
laoyang 82f4fa3c49 顶栏图标改为与「标题+简介」整体垂直居中
用户:「图标应该在标题和它的简介的正中间…现在图标和上面的标题是对齐的,
      把图标往下放一放,让标题和它下面的简介合起来和图标高度一样,
      两个对齐上下。」

根因是结构问题,不是调 padding 能解决的:
以前图标和标题被 fb_software_badge() 包在同一个 .sw-badge 里(单行结构),
note 挂在它外面下面一层 ⇒ 图标天然只能对齐第一行文字,永远做不到两行居中。

改结构为「左图标 + 右文字列」两栏:
  <a class="brand-mini">
    <span class="brand-ic">图标</span>        ← flex:none 30×30
    <span class="brand-txt">                  ← flex column
      <span class="brand-title">标题</span>
      <span class="brand-mini-note">简介</span>
    </span>
  </a>
外层 .brand-mini 用 align-items:center ⇒ 图标中线对齐文字列中线。

图标不再走 fb_software_badge(),改用 fb_software_icon() / icon_key 自己拼,
因为 badge 那个函数是把图标和名字绑死在一行的。

实测中线偏差:
  桌面 1280:图标 18→48(中线 33.0),文字列 13.8→52.2(中线 33.0)→ 偏差 0.0px
  手机 390:图标 17→43(中线 30.0),文字列 12.3→47.7(中线 30.0)→ 偏差 0.0px
首页(无软件时)仍是原来的 logo-mark + brand-text,未受影响;6 个路由全 200。
2026-11-03 15:21:41 +08:00
laoyang 0ebf250c76 去掉内容区与顶栏重复的标题行和我要反馈按钮;简介移到顶栏标题下灰色小字
用户:上面已有标题和「我要反馈」,下面又出现一次,完全重复。
「下面这一行不要了…把那个软件简介放在最上面『同步日历反馈系统』下面那个位置,
  用个灰色的。我要反馈按钮也不要了,其他内容开始往前提,更简洁。」

改法:
- index.php 删掉整个 page-head 行(软件图标+名字 / 简介 / 我要反馈大按钮)
  → 列表内容直接从筛选器开始,上提一整行(实测内容起始 y=109)
- 顶栏品牌位改成两行:
    行1 图标 + 「XX 反馈系统」(软件主题色 17px)
    行2 一句话简介(software.note,灰色 #6b7280,12.5px,移动端 11.5px)
- 简介左对齐到标题文字(padding-left 41px = 图标 32 + gap 9),不是贴图标左边
- 顶栏高度从固定 66px 改为 min-height + padding,两行也放得下(实测 67px)

⚠️ 「我要反馈」按钮去掉了 hide-sm:
   列表页那个大按钮删掉后,顶栏这个是全站唯一入口,
   如果手机上隐藏掉,手机端就没有提交入口了。

清理 .page-sw-id 死样式。
验证:桌面/手机下简介都在标题下方、颜色灰色、内容区无重复、我要反馈只 1 个且两宽度都可见;
8 个路由全部 200;详情页顶栏同样显示简介。
2026-11-03 15:05:37 +08:00
laoyang c09a9875a3 标题收到最上顶头,标签下移一行;顶栏标题回当前软件;去掉跨软件引导
用户三个问题:

一、软件名/分类/状态三个标签挤在标题前面,把标题推到右边很难看
  列表页:item-top 里 badges 在前、标题在后 → 标题被挤到右边
  详情页:同一个毛病
  改法(两处一致):
    行1 标题(顶头、独占一行)
    行2 标签(分类/状态;详情页还有软件名)
    行3 摘要 / 作者信息
  列表页新增 .item-head + .item-tags;详情页新增 .subject-head + .subject-tags。
  ⚠️ .item-top 保留 —— admin.php 有三处内部列表还在用,删了后台会塌。

二、顶栏「XX 反馈系统」点完跳首页
  用户:「点完之后应该还是进入这个反馈系统,而不是进到首页。首页没有太大作用,
        只是演示说明用的。正常使用都是每个软件独立,要让用户觉得
        这套反馈系统就是专门为这个软件做的。」
  改法:brand-mini 的 href 从 "./" 改为 fb_software_list_url()。

三、软件内部不该引导去其他软件
  用户:「在一个反馈系统里面,我要反馈、问题列表、左上角那个链接,
        全部都是这个软件相关的内容,跟其他软件没关系。」
  - 提交成功页删掉整个「也可以去其他软件反馈」区块及其 CSS
  - 删掉成功页的「返回首页」按钮(已有「看该软件其他反馈」,重复且无意义)
  - 跨软件只剩顶栏三横线菜单一个入口

验证:列表页/详情页 DOM 顺序均为 标题→标签→作者(实测 item-title 58 < item-tags 171;
subject-title 49 < subject-tags 151 < item-meta 592);
浏览器实测标题 top=282 标签 top=315(列表)、155→194(详情),标签确实在下方;
标题左边缘距卡片边 18/23px,就是卡片自身 padding,没有额外缩进;
顶栏标题 href=index.php?sw=synccalendar,不再指向根路径;全站 8 个路由 200。
2026-11-03 13:43:15 +08:00
laoyang bec5b09bd6 提交成功后改为原地显示回执页(不再跳转);修提交页右侧缺块
用户两个问题:

一、「我要反馈」页面比正常页面窄很多、右侧缺一块
  根因:.narrow 只有 max-width:640px,**没有居中**(缺 margin auto)。
  在 880px 的内容区里左对齐,右侧空出 240px 白,看起来像页面被抠掉一块。
  改法:max-width 放到 720px + margin-left/right auto 居中。
  实测:容器 880px,表单 720px,左右间隙各 80px,完全对称。

二、提交完直接跳转太突兀
  用户:「应该给它一个提示,说反馈我们已经收到…里面有一个标志码,
        是你以后跟踪它的唯一凭据…下面再列出其他功能(返回首页、继续提交、
        其他软件链接、开源链接),不能直接跳转,这给人感觉很突兀。」
  改法:不再 302 跳转,改为原地渲染成功页 fb_render_submitted():
    ✓ 您的反馈我们已经收到
    → 详细信息已发送到 xxx@xxx(并提示看垃圾邮件)
    → 醒目的标志码区块(等宽字 + 虚线框 + 可整体选中)
      并说明:凭它可查看进度,未通过审核/已关闭也能看;每封邮件也会带
    → 勿外传提醒(等同于身份凭证)
    → 四个去处:查看这条反馈 / 继续提交 / 看该软件其他反馈 / 返回首页
    → 其他软件入口(排除当前软件,避免重复)
    → 开源仓库链接
  为什么要把 token 当场亮出来而不只是说已发到邮箱:
    邮件可能进垃圾箱或被误删,码亮出来用户至少能复制存档;
    这是唯一凭据,丢了找不回来(我们不做登录,不认人)。

验证(只在 feedlitetest 软件里做,不碰其他正式软件):
  真实提交一条 → 返回 200 且 URL 仍在 new.php(没跳走);
  成功页含回执/邮箱/唯一凭据/勿外传/四个按钮/其他软件/开源链接;
  提取到 32 位 hex 标志码,带它访问详情页 200,不带则 404。
  测试数据已清理,其他软件数据一条未动。
2026-11-03 13:19:14 +08:00
laoyang 0dc8033c6b 本人回复自己的反馈不发通知信
用户:「他自己回复他自己的反馈,不要再给他发邮件了 ——
      他自己说的,他发给他自己,不是很奇怪吗?但如果是别人回复他的,就可以发。」

原逻辑在 mailer 里有个「自己通知自己」判断,但写成了:
  !empty($extra["is_admin"]) === false
双重否定绕,读起来要停一下才知道在判什么,而且没有覆盖空格差异。

改法:
- 改成直接判 $isAdminNotice,条件意图一眼可见
- 邮箱比较前 trim + strcasecmp,覆盖 大小写不同 / 前后空格 两种写法
- 拦下时写 notify_log 并给出明确原因「本人回复自己的反馈,不发信」,
  以后排查「为什么没收到信」能直接看到

验证(5 种情形 + 完整 DB 路径):
  同邮箱 → 拦下;大小写不同 → 拦下;带空格 → 拦下;
  别人邮箱 → 正常发;管理员邮箱 → 正常通知提交人。
  另在库里真插一条本人回复走 fb_notify_event,同样被拦下。

关于「带 token 能否修改原反馈」:确认后决定不做。
理由:反馈一旦公开,别人已看过/回复过,改了会让讨论对不上号;
补充信息用回复完全够用。现状只能看和回复。
2026-11-03 10:48:07 +08:00
laoyang 8b3e49e091 邮件:带私人链接时统一加「这个链接只有您能打开,请不要转发」提醒
用户要求:每次发这种链接,都要在链接上/下给他一个提示 ——
这个链接是独有的,不要转给别人、不要让别人看到,是跟踪反馈的唯一凭证。

实现:在 fb_mail_layout() 里加 $privNoteHtml,
layout 传 personal=true 才渲染,位置在「按钮之后、纯文本链接之前」。
这样只要新增邮件是发给提交人的,标一下就自动带上,不会漏。

覆盖 7 处(全部发给提交人的):
  回复通知 / 状态变更 / 审核通过 / 转为不公开 / 关闭说明 / 提交回执 / 兜底
不覆盖 1 处(管理员通知,按钮指向后台,不是私人凭证)

提醒文案(斟酌过,讲清缘由而不只是禁止):
  标题:这个链接只有您能打开,请不要转发
  正文:它等同于您在这条反馈上的身份凭证 —— 谁拿到这个链接,
        谁就能查看和处理这条反馈。请只自己使用,不要发给别人,
        也不要发到公开的地方。如果不需要继续跟踪,删掉这封邮件即可。
  → 说清「是什么」而不是只喊「别外传」,用户才会真的照做;
    补一句退路(删掉邮件即可),避免像是免责声明。

验证:7 类邮件全部含提醒且都在页脚之前;管理员通知不含;
像素级检查确认视觉顺序为「蓝色按钮 y=719 → 黄色提醒块 y=874..982」,
即提醒在按钮下方、正文区内,不是埋在邮件底部。
2026-11-03 10:08:49 +08:00
laoyang 8763e8e17f 回复框去掉管理员口令与处理状态;带 token 时昵称/邮箱锁定为只读
用户三个问题,本质是同一件事:token 就是身份凭证,那就不该再让表单声明身份。

一、去掉管理员口令 / 处理状态
  用户:「回复这个地方,下面有个管理员口令,还有处理状态,都不要填了,
        只保留回复内容,加上人机验证就可以了」
  - 删掉表单里这两块,删掉 admin-pw/admin-status 的 CSS 与 bindAdminToggle 的 JS
  - 管理员身份只认后台登录态(fb_is_admin()),不再从公开表单收口令
  - 状态流转只在后台详情页那排按钮里做

二、带 token 时昵称/邮箱锁定为只读
  用户:「如果他带着 Token 进来之后邮箱又让他改了,我们又没办法知道谁是谁了」
  - 有 token = 已证明是提交人本人,表单不再让填昵称/邮箱,
    改为显示只读的「回复身份」块(原昵称 + 原邮箱)

三、服务端强制(关键)
  🔴 前端设成 hidden 是不够的 —— hidden 可以被改:
     谁拿到链接、改个 email 提交,这条反馈的联系人就变成他的了。
     所以服务端在 isOwnerView 时**直接丢弃 POST 里的 name/email**,
     一律以库里那条帖子的 author_name / author_email 为准。
     身份校验必须在服务端做。

验证(这次确保请求真的通过人机验证,不是被验证码挡住造成的假通过):
  题目「2 乘以 7」答 14 → 跳转到 #r3,回复确实入库;
  提交的是 黑客改名 / attacker@evil.example,
  实际入库 name=jervis email=zeussun@msn.com —— 伪造的两个字段都被丢弃。
  另外:无 token 的普通访客仍可自由填昵称邮箱;全站已无「管理员口令」残留。
  测试回复已清理,reply_count 已同步。
2026-11-03 10:02:57 +08:00
laoyang 8bcee46b8a 首页改介绍页;列表只服务当前软件;新增UUID追踪让提交人看得到未审核反馈
用户提的两个核心问题:

一、首页不该列全部问题
  场景:这套系统是给某个软件配反馈入口、把地址贴进那软件的「意见反馈」里。
  访客总是从某个软件过来的,看别的软件的问题对他没意义。
  ⇒ / 不带 sw 时渲染介绍页(特点介绍 + 「点右上角设置按钮选软件」+ 软件卡片)
     带 sw 才是该软件的问题列表,检索/筛选/分类全在本软件范围内

二、提交后未审核,用户点不进去(真实缺陷)
  邮件里给了「点击查看您刚才提交的反馈」,但 thread.php 只在 visible=1 时
  才给看,未审核的一律 404 / 只看到「等待审核」静态页 —— 链接等于白给。
  ⇒ threads 加 track_token(32位hex = 128位熵),提交时生成:
     - 邮件里所有链接一律带 ?t=<token>(不再是裸 id)
     - 带对 token:可看完整详情、可回复,哪怕未公开/已关闭
     - 没 token 或 token 错:对外一律 404,不泄露存在性
     - 公开列表仍用 id 链接(公开内容谁都能看,用于避免重复提交)

实现细节:
- threads.track_token + 唯一索引;幂等迁移,存量数据自动回填
- fb_track_new_token / fb_track_token_ok(hash_equals 防时序探测)
  / fb_thread_viewable / fb_thread_link(thread, personal)
- thread.php 权限从 fb_thread_public 改为 fb_thread_viewable
- 带 token 看自己未公开的反馈时,顶部给「私人跟踪链接」提示条
- 回复表单与回复后跳转都保留 token,否则回复完就失去访问权
- 提交完成后直接带 token 跳详情页,不再只给静态等待页
- close 类邮件补上「查看这条反馈」按钮(原来完全没链接)
- 顶栏「全部问题」改为「问题列表」且只在选中软件后显示
- 详情/提交页「返回问题列表」按所属软件返回,不再回介绍页
- new.php 无 sw 时不再默认落到第一个软件,改为让用户先选
  (默默替选会让反馈进错软件、通知发错人)

验证:首页 0 条问题卡片且未泄露任何标题、三个软件入口齐全;
未公开#4 无 token 404 / 错 token 404 / 正确 token 200 且显示完整详情
(不再卡在「等待审核」)、提示条与表单 token 都在;
已公开#2 无 token 可看;5 类邮件(回执/通过/回复/状态/关闭)链接全带 token;
两套回归各 30+ 项断言全过。
2026-11-03 09:54:12 +08:00
laoyang 2f28b1e3a9 下拉菜单改成极简小浮窗(参考家书 FamilyBook 的 popup 做法)
用户第三次指出这个按钮不对,原话:
  「你不用做那么复杂,就是参考一下 FamilyBook 里边那个设置按钮,
    非常简单的,点了之后就拉下来一个很窄的一个框,
    把所有的内容都铺在上面就可以了」

去看了 FamilyBook 的实现:顶栏那颗按钮是 bindSheet + SheetSize.FIT_CONTENT,
即「内容多高就多高」的小浮层,不是整幅面板。

改法:
- 面板从「left:0 right:0 铺满宽度 + 遮罩 + 锁滚动」
  改成「贴按钮右下、min-width 172px、内容撑高、圆角 12px 小浮窗」
- 删掉 sw-mask 遮罩、删掉 body overflow 锁定
- 面板移进 .topbar .wrap(flex 内),以它为定位参照,右对齐按钮
- 删掉菜单里「选择软件」标题(按钮本身已经说明用途)
- 条目字号降到 14px、图标 18px、内边距收紧,贴合窄框
- 「当前」标记从胶囊缩成 6px 小圆点,窄框里不占地方
- 按钮简化:去掉边框与三横线变×的动效,只留 hover 浅底

实测(CDP 真实浏览器):下拉框 172×138px,仅占视口宽 13.7%,
右边缘与按钮相差 4px;390px 与 1280px 两种宽度下各 15 项断言全过
(浮窗非整幅、无遮罩、未锁滚动、贴按钮下方、右对齐、
高度由内容决定、有圆角边框、条目纵排同x、y递增、点外部可关)。
2026-11-03 09:25:41 +08:00
laoyang 28d28ad51c 软件徽标带图标+主题色;标题用软件色;删冗余欢迎语;抽屉改竖排覆盖
用户四点要求:
1. 待审核标题前的软件名要带图标
2. 每个软件配了颜色,徽标与标题都应显示该颜色
3. 「欢迎在这里反馈使用问题、提出功能建议」这类话没意义,全部删掉
4. 右上角三横线抽屉:不要宽屏横排/窄屏竖排两套,
   统一竖排并把下面内容盖住,简单一点

改动:
- 新增 fb_software_chip():软件徽标带图标 + 该软件主题色
  (原来一律固定蓝色 #eef2f8/#2b4a74,等于白配了颜色)
  配 fb_color_soft()/fb_color_line() 由主色推导同色系浅底与描边
  替换 admin.php 两处、thread.php 一处(原来是 badge 套 badge,双重边框)
- 顶栏标题与内容区标识都用软件主题色(PHP 内联 --swc 变量)
- index.php 不再渲染 site_desc(配置项保留,后台仍可编辑)
- 抽屉改为统一竖排 absolute 浮层 + 遮罩盖住正文,删掉宽屏横排分支
  打开时锁背景滚动,点遮罩/Esc/再点按钮都能关
  .topbar 作为定位参照(sticky 即可)

顺手修:fb_hex_to_rgb 的三位简写展开前要先校验字符,
否则 zzz 这类非法值可能被误展开(本次测试用例选错才发现校验不够严)

验证:3 个软件 chip 的图标/名字/主色/浅底/描边全部正确,
无图标退回字符、XSS 转义通过、颜色工具函数 6 项通过;
CDP 真实浏览器在 390px 与 1280px 两种宽度下各 13 项断言全过
(绝对定位、在顶栏下方、铺满宽度、条目纵排同x、y递增、
层级高于正文、遮罩同步显隐、可关);
三个软件标题色分别 #b4553c / #6a4c93 / #0057b8;全部路由 200。
2026-11-03 09:20:14 +08:00
laoyang 314a1cf2a5 新增「未公开」状态;待审核改列表式,只留通过并公开
用户要求:
1. 待审核/全部问题都用列表形式
2. 待审核去掉「关闭说明」整块,后面只加一个「通过并公开」按钮
3. 标题本身就是进管理页的链接,去掉「进入管理」
4. 「未公开」应当是一种状态:提交后初始状态不是「待处理」而是「未公开」

状态流转(用户定义):
  提交 → 未公开
    ├─ 通过并公开 → 待处理 → 处理中 → 已解决 → 已关闭
    └─ 直接关闭   → 已关闭(不经过待处理)
  每次状态变更、每次有人回复,都给提问人发信

实现:
- statuses 新增 hidden「未公开」,sort=0 排最前,其余顺延
  幂等迁移:老库检测到没有 hidden 就整体后移一位再插入,
  并把 visible=0 且 status=open 的存量数据归到 hidden
- new.php 初始状态按可见性决定:未公开→hidden,公开→open
  (以前一律写 open,后台「待审核」里显示的全是「待处理」,状态反映不出事实)
- approve 动作:visible=1 且 hidden→open,并用更新后的行渲染邮件
- 详情页「通过并公开」按钮原来传原状态(未公开时通过后仍是未公开),改为固定传 open
- 待审核查询改为 status=hidden(兼容老数据 visible=0 且 open),
  不再用 visible=0 AND status<>closed —— 那个条件会把「已关闭但未公开」也收进来
- 新增 fb_admin_pending_row() 列表行渲染:标题即链接、右侧单个按钮
- 删除 fb_admin_thread_card() 及「关闭说明」表单
- 新增 .plist/.prow 列表样式与 .st-hidden 状态徽标(中性灰)
- mailer 补 hidden 状态的文案,避免落到兜底

验证(线上真实数据):6 个状态齐全、提交落 hidden、
待审核能查到且不含已公开、通过后转 open 并移出待审核、
后续 doing/resolved/closed 推进正常、6 个状态邮件均能渲染、
待审核行结构 10 项断言全过(无关闭说明/无 note/无进入管理/标题可点)。
测试数据已清理,用户原有 4 条数据完好。
2026-11-03 09:09:40 +08:00
laoyang db8c1969af 文案:全站「你」改为「您」
用户要求邮件模板统一用敬语,确认后网页也一并改。

范围:
- lib/mailer.php  61 处(全部邮件模板:回执/回复/状态/审核/关闭/不公开/新反馈)
- new.php          6 处(提交页提示、占位文字)
- thread.php       4 处(提交成功提示、回复框占位)
- lib/layout.php   1 处(页脚隐私说明)
- admin.php        5 处(测试邮件正文、关闭邮件主题模板、通知规则说明、
                          先审后显说明)

已核查无「您们」等机械替换产生的别扭表达;
邮件模板渲染后「你」零残留、「您」77 处,文案通顺。
线上四个页面可见文字均无「你」,全站路由 200。
2026-11-03 09:00:05 +08:00
laoyang 2ef119f7cb 标题字号折中;软件选择移入右上角汉堡菜单
用户反馈三点:
1. 顶栏图标太大(56px)
2. 顶栏文字反而比内容区还小 —— 主次颠倒
3. 不要再整页显示「选择软件:」,改成右上角三横线按钮,点开下拉选

字号标准(用户给的基准):
  「选择软件」标签 13.5px,比它大两号 ≈ 17px。
  → 顶栏 17px 字 / 30px 图标;内容区 15px 字 / 24px 图标。
  这样 17 > 15,层级正确。

   之前两轮都搞错了:
   - 先给顶栏 46px 图标 + 22px 字:图标过大
   - 再给 56px + 26px:更大
   - 而且内容区当时是 30px,比顶栏还大,视觉上「上面比下面小」

软件选择改造:
- 顶栏右侧新增汉堡按钮(三横线,展开变×)
- 抽屉面板放在 header 内、正文之上,默认 hidden
- 列出软件:图标 + 名字 + 当前项标记
- index.php 删除整行「选择软件:」+ chips
- new.php 表单内也删除那排 chip(同一选择不该出现两次)
- 交互:点击展开/再点收起/点外部关闭/Esc 关闭
- 宽屏抽屉横向排列,窄屏纵向

验证(CDP 驱动真实浏览器):
汉堡按钮存在、初始收起、点击展开、aria 同步、软件条目与当前标记齐全、
再点收起、点外部关闭 —— 全部 PASS;
顶栏字号 17px > 内容区 15px,页面无裸露 ?>;
全部路由 200;窄屏 15px/13px 层级仍正确。
2026-11-03 08:51:20 +08:00
laoyang 82484298e4 标题位置修正:顶栏即「XX反馈系统」,内容区只留图标+软件名
用户指出三处问题:
1. 最上面标题应显示「同步日历反馈系统」,而不是光秃秃的「同步日历」
2. 内容区那个重复的「同步日历反馈系统」不要再要了,
   那里只要「图标 + 同步日历」
3. 内容区下面多了一个裸露的 ?> 字符
4. 图标和标题文字都要再大一点

改动:
- 顶栏改为 fb_software_badge(sw, brand, fb_software_site_title(sw))
  图标 56px、文字 26px,成为页面唯一主标题
- 删除内容区的 h1.page-h1-sw(重复标题)
- 内容区改为 badge(lg):图标 40px + 文字 30px
- 清除 index.php 里多余的 ?> (上次编辑留下的残渣)
- badge 新增 brand 尺寸档与  参数,可覆盖显示文字
- 顶栏高度 72->84px 容纳更大的图标;窄屏仍有降级

验证:顶栏 title/文字正确、已无 page-h1-sw、
无裸露 ?>、内容区只显示「同步日历」、5 处图标全走 media.php
且均为有效 PNG、三个软件页与全部路由 200。
2026-11-03 08:34:03 +08:00
laoyang 669ab9e43e 修复图标显示不出来;标题改XX反馈系统;图标去掉背景色
一、图标显示不出来(根因)
用户上传图标时用的是修复前的代码,数据库里 icon_key 存的是
icons/2026/11/xxx.png —— 没有 local/ 也没有 oss.prefix 前缀。
上一轮我把键校验收紧成「必须带存储前缀」,于是这些键被判非法,
fb_icon_url() 返回空字符串,图标自然显示不出来。
而三个文件其实都真实存在于 OSS 上(13KB / 391KB / 379KB)。

修法两层:
1. fb_icon_key_ok / fb_icon_is_key 兼容历史无前缀形态,
   这些是有效资产,不能因为键格式不规范就让图标消失
2. 数据迁移:把对象复制到 feedlite/icons/… 并把数据库键改成规范形态,
   已对三个软件全部执行并验证(新键 200,大小与源文件一致)

二、标题改为「XX 反馈系统」
- 页头大标题 = 同步日历反馈系统(之前只有「同步日历」)
- 浏览器标签同步改
- 软件名本身含「反馈」时不叠加,避免「轻反馈反馈系统」

三、图标不要背景色
- 去掉色块底衬与圆角投影,直接「图标 + 文字」并排
- 前端 badge、提交页上下文条、邮件头部三处同步
- md 尺寸调到 28px,与 26px 标题文字视觉匹配

验证:三个软件页标题正确、图标全部 200 image/png、
全部页面 200、迁移后新键可读且大小一致。
2026-11-03 08:27:03 +08:00
laoyang 872baf4153 修复:图标存储未跟随后台配置,本地模式下图标显示不出来
用户指出图标应当与附件同一套逻辑:管理员在后台配 local 就存本机、
配 oss 就存 OSS,前台都一样走 media.php 代理。

原实现把图标键硬编码成 icons/…(不带存储前缀),导致:
- fb_attach_is_local() 恒为 false
- 本机模式下文件确实写进了 uploads/icons/,
  但 media.php 把它当 OSS 对象去读 → 503,图标完全显示不出来

修法:图标键与附件完全同规则,只是路径里多一段 icons/
  local 模式 → local/icons/YYYY/MM/<hex>.<ext>  落 uploads/icons/
  oss   模式 → <oss.prefix>/icons/YYYY/MM/<hex>.<ext> 落私有桶
前台地址两种模式一致(media.php?k=…),换存储方式旧图标不会失效。

同时:
- fb_icon_is_key/ok 改为同时识别两种前缀
- fb_icon_allowed_ext 移到 attach.php(media.php 与校验都会用,
  不能依赖 admin_taxonomy.php 被加载)
- 写入时加断言:键前缀必须与当前存储模式一致,脱钩就报错,
  不再静默存到错误位置

验证(线上真实环境,两种模式各跑一遍):
- local:键带 local/icons/ 前缀、写入 uploads/icons/ 成功、
  media.php 读回 200 image/png 501B、PNG 魔数正确、删除后 404
- oss:键带 fb/icons/ 前缀、is_local=false、URL 拼接正确
- 旧 bug 形态(icons/… 无前缀)现在被明确拒绝 400,不再误判成 OSS
- 测试后配置已复原为 oss,临时文件与诊断入口全部清理
2026-11-03 08:19:17 +08:00
laoyang 835c8c05da 软件图标支持上传图片:存 OSS/本机,media.php 代理读取
用户要求图标可以自己上传一张真实图片,而不是只能填一个字符。

设计:字符和图片并存,图片优先
- software 加 icon_key(图片对象键),icon(字符)保留作兜底
- 有图片用图片,删掉图片自动退回字符,任何时刻都不会没有图标
- 前端 badge 与邮件头部同一套判断,页面和邮件永远一致

存储与读取
- 图标走独立键空间 icons/YYYY/MM/<16hex>.<ext>
  不复用附件那套:附件必须挂在 thread 下才给读,图标不属于任何帖子
- 写:仅管理员(后台动作链)+ finfo 真实类型校验 + 尺寸下限 + 2MB 上限
- 读:media.php 对 icons/ 只校验键形状与文件存在(图标本就公开),
  写入侧仍然严格,不会让本站变成能读整个桶的开放代理
- 优先用 media.php?k= 代理;OSS 下顺便借图片处理缩放,省下行

顺手修掉两个真实安全问题
1. fb_icon_key_ok 原来用 [a-z0-9]{2,5} 匹配扩展名,.php 正好能过
   (3 个字母)—— 测试反向验证时发现,已改为图片扩展名白名单
2. fb_upload_key_ok 有同样的问题,附件键也能构造出 .php
   虽然真上传还有 finfo 兜底,但键本身就不该长这样,一并收紧
3. fb_icon_delete 原来用 @ 吞掉错误,删除失败仍返回「已保存」,
   用户会以为旧图清了、实际留了个可访问的孤儿图。改为返回 bool 并如实提示

验证:键校验 7 类非法输入全拒;图标写入 OSS 成功、media.php 读回 200 image/png、
PNG 魔数正确、缩放生效;越权读取(穿越/.php/未入库附件/不存在)全部按预期拒绝;
表单 enctype/文件框/accept/预览渲染齐全;仿真 PHP 伪图与 SVG 均被拒;
16x16 小图被拒。
2026-11-03 08:13:21 +08:00
laoyang 738c6c09ff 多软件区分:邮件按软件换色+图标+软件名;补提交回执邮件
用户要求:多软件并存时,用「颜色 + 图标 + 名字」三重区分,
邮件也要跟着所属软件变,一眼能看出是哪款软件的来信。

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 转义通过,
空图标兜底通过;页面顶栏、页头、选择器图标渲染正确。
2026-11-03 08:00:50 +08:00
laoyang e43f2ce67a 修复:邮件通知两处静默失效(测试暴露)
症状:notify_log 全表为空 → 用户报「完全没发邮件」,后台查不到任何线索。

1. 前 4 个前置判断(开关/收件人/自己回自己/冷却)直接 return 不写日志,
   等于「为什么没发」完全无痕。改为任何退出路径都写 notify_log,
   并带上可读原因。

2. new 事件(新反馈通知管理员)收件人只取 admin_email,该值为空时整条
   通知静默失效 —— 管理员永远收不到新反馈信,界面也无任何提示。
   改为回落 smtp.user / smtp.from;后台勾了「通知管理员」却没填邮箱时
   当场显示警告。

验证:走 fb_notify_event 全事件测试,6 个事件全部写日志、new 能送达。
2026-11-03 07:31:35 +08:00
15 changed files with 3642 additions and 410 deletions
+149 -63
View File
@@ -49,7 +49,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
} }
if (!fb_admin_logged_in()) { if (!fb_admin_logged_in()) {
fb_layout_head('管理登录', ['robots' => 'noindex,nofollow']); fb_layout_head('管理登录', ['robots' => 'noindex,nofollow', 'admin' => true]);
?> ?>
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div> <div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
<section class="narrow"> <section class="narrow">
@@ -116,17 +116,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$msgType = 'error'; $msgType = 'error';
} elseif ($act === 'approve') { } elseif ($act === 'approve') {
$newStatus = ((string)$t['status'] === 'closed') ? 'open' : (string)$t['status']; /* 「通过并公开」= 两件事一起做:
* ① 可见性:visible=1
* ② 状态:『未公开』→『待处理』
*
* 状态流转是用户在 2026-11-03 明确要求的:提交后先落「未公开」,
* 通过了才变「待处理」;如果判定不是问题,直接走 close 变「已关闭」,
* 中间不经过「待处理」。
*
* 边界:如果这条本来就已经是别的状态(比如以前误操作改成了「处理中」),
* 通过审核不该把状态倒退回去,所以只在「未公开」时才推进。
* 老数据可能还是 'open' + visible=0,也一并视作待推进。 */
$oldSt = (string)$t['status'];
$newStatus = ($oldSt === 'hidden' || $oldSt === 'open') ? 'open' : $oldSt;
$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, '通过审核并公开(状态:' . fb_status_label($newStatus) . ')');
// 通知提交人「已发布」(工单 #2) // 通知提交人「已发布」(工单 #2)
$errA = ''; $errA = '';
try { try {
list($okA, $errA) = fb_notify_event($pdo, 'approve', $t); // 用更新后的状态渲染邮件,否则信里还写着「未公开」
$fresh = fb_admin_thread($pdo, $id);
list($okA, $errA) = fb_notify_event($pdo, 'approve', $fresh ? $fresh : $t);
} catch (Throwable $e) { $errA = '发信异常'; } } catch (Throwable $e) { $errA = '发信异常'; }
$msg = '#' . $id . ' 已通过审核,现已公开显示。' $msg = '#' . $id . ' 已通过审核并公开,状态为「' . fb_status_label($newStatus) . '」。'
. ($errA === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errA . ')'); . ($errA === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errA . ')');
} elseif ($act === 'close') { } elseif ($act === 'close') {
@@ -280,21 +294,41 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$msg = '#' . $id . ' 已删除。'; $msg = '#' . $id . ' 已删除。';
} elseif ($act === 'mail_test') { } elseif ($act === 'mail_test') {
// 发送测试邮件:只验证 SMTP 通不通,不改任何配置 // 发送测试邮件:只验证 SMTP 通不通,不改任何配置。
// 可选 sw=<软件id>:按那个软件的样子渲染(配色 / 图标 / 软件名),
// 便于在后台直接预览「不同软件的邮件长什么样」。
$to = trim((string)(isset($_POST['to']) ? $_POST['to'] : '')); $to = trim((string)(isset($_POST['to']) ? $_POST['to'] : ''));
if ($to === '') { $to = trim((string)fb_cfg('admin_email', '')); } if ($to === '') { $to = trim((string)fb_cfg('admin_email', '')); }
if ($to === '') { $to = trim((string)fb_cfg('smtp.user', '')); }
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$msg = '请填写有效的收件邮箱。'; $msg = '请填写有效的收件邮箱。';
$msgType = 'error'; $msgType = 'error';
} else { } else {
$sc = (array)fb_cfg('smtp', []); $sc = (array)fb_cfg('smtp', []);
$site = (string)fb_cfg('site_name', '轻反馈'); $site = (string)fb_cfg('site_name', '轻反馈');
$body = '<p>这是一封来自 <b>' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8')
. '</b> 的测试邮件。</p>' // 选中的软件(没选就用默认软件,保证测试邮件也带软件标识)
. '<p>如果你收到了它,说明 SMTP 配置正确,回复通知可以正常发出。</p>' $swId = (int)(isset($_POST['sw']) ? $_POST['sw'] : 0);
. '<p style="color:#888;font-size:12px">发送时间:' $swRow = $swId > 0 ? fb_software($swId) : fb_default_software();
. date('Y-m-d H:i:s') . '</p>';
list($ok, $err) = fb_smtp_send($sc, $to, '【' . $site . '】邮件测试', $body); // 用真实的回执模板发,比一句「测试邮件」更能看出实际效果
$fake = [
'id' => 0,
'software_id' => $swRow ? (int)$swRow['id'] : 0,
'title' => '这是一封测试邮件,用来检查排版与配色',
'body' => "这是测试用的反馈正文。\n如果您收到这封信,说明 SMTP 配置正确,配色与图标也应当对应「"
. ($swRow ? (string)$swRow['name'] : '默认软件') . "」。\n\n发送时间:" . date('Y-m-d H:i:s'),
'author_name' => '测试',
'author_email' => $to,
'author_phone' => '',
'status' => 'open',
'visible' => 1,
];
list($subject, $html) = fb_notify_render('receipt', $fake,
['software' => $swRow], $to);
$subject = '【测试】' . $subject;
list($ok, $err) = fb_smtp_send($sc, $to, $subject, $html);
if ($ok) { if ($ok) {
$msg = '测试邮件已发出,请查收 ' . $to . '(也看一下垃圾箱)。'; $msg = '测试邮件已发出,请查收 ' . $to . '(也看一下垃圾箱)。';
$msgType = 'ok'; $msgType = 'ok';
@@ -574,6 +608,7 @@ function fb_admin_save_settings() {
$save('notify.enabled', (int)(bool)$post('notify.enabled')); $save('notify.enabled', (int)(bool)$post('notify.enabled'));
$save('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply')); $save('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
$save('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread')); $save('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
$save('notify.receipt_enabled', (int)(bool)$post('notify.receipt_enabled'));
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制), // 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0" // 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
@@ -603,7 +638,7 @@ function fb_admin_save_settings() {
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending'); $tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
if (!in_array($tab, ['pending', 'all', 'thread', 'taxonomy', 'statuses', 'settings', 'mail', 'log'], true)) { $tab = 'pending'; } if (!in_array($tab, ['pending', 'all', 'thread', 'taxonomy', 'statuses', 'settings', 'mail', 'log'], true)) { $tab = 'pending'; }
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']); fb_layout_head('管理后台', ['robots' => 'noindex,nofollow', 'admin' => true]);
?> ?>
<div class="page-head"> <div class="page-head">
<div> <div>
@@ -627,16 +662,23 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<?php if ($tab === 'pending'): ?> <?php if ($tab === 'pending'): ?>
<?php <?php
/* 「待审核」= 还没公开过的条目。
* 判据用 status='hidden'(未公开),不再用 visible=0 这种隐式条件 ——
* 已关闭的条目 visible 也是 0,但它是处理完了,不该出现在待审核里。
* 顺便兼容老数据:visible=0 且状态仍是 'open' 的也一并算待审核。 */
$pending = $pdo->query( $pending = $pdo->query(
"SELECT id, title, body, category, status, author_name, author_email, created_at "SELECT * FROM threads
FROM threads WHERE visible = 0 AND status <> 'closed' ORDER BY created_at DESC" WHERE status = 'hidden' OR (visible = 0 AND status = 'open')
ORDER BY created_at DESC"
)->fetchAll(); )->fetchAll();
?> ?>
<h2 style="margin:22px 0 10px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2> <h2 style="margin:22px 0 12px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2>
<?php if (!$pending): ?> <?php if (!$pending): ?>
<div class="empty"><p>当前没有待审核的反馈。</p></div> <div class="empty"><p>当前没有待审核的反馈。</p></div>
<?php else: ?> <?php else: ?>
<?php foreach ($pending as $t) { echo fb_admin_thread_card($t); } ?> <div class="plist">
<?php foreach ($pending as $t) { echo fb_admin_pending_row($t); } ?>
</div>
<?php endif; ?> <?php endif; ?>
<?php elseif ($tab === 'all'): ?> <?php elseif ($tab === 'all'): ?>
@@ -668,7 +710,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
$lst->execute($args); $lst->execute($args);
$rows = $lst->fetchAll(); $rows = $lst->fetchAll();
?> ?>
<form class="filters" method="get" action="admin.php"> <form class="filters filters-row" method="get" action="admin.php">
<input type="hidden" name="tab" value="all"> <input type="hidden" name="tab" value="all">
<input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文"> <input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
<select name="sw" onchange="this.form.submit()"> <select name="sw" onchange="this.form.submit()">
@@ -712,11 +754,30 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<div class="list"> <div class="list">
<?php foreach ($rows as $t): ?> <?php foreach ($rows as $t): ?>
<div class="item" style="cursor:default"> <div class="item" style="cursor:default">
<div class="item-top"> <?php
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span> /* 标题顶头独占一行,标签(软件 / 分类 / 状态)另起一行。
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span> * 后台是跨软件视图,所以这里必须带软件名 —— 前台某个软件内部
* 不需要显示软件名(满页都是同一个),后台需要。 */
$aSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
?>
<div class="item-head">
<a class="item-title" href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>"><?php echo e($t['title']); ?></a> <a class="item-title" href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>"><?php echo e($t['title']); ?></a>
<span class="item-id">#<?php echo (int)$t['id']; ?></span> <span class="item-id">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="item-tags">
<?php if ($aSw): ?><?php echo fb_software_chip($aSw); ?><?php endif; ?>
<?php
/* 🔴 这里必须用 category_id(数字),不能用 $t['category'](老 slug 字段)。
* fb_category_label() 内部会把参数 (int) 化,传 'bug' 会变成 0 → 返回空串,
* 结果就是渲染出一个**空的** <span class="badge cat"></span>。
* 用户 2026-11-03 看到的就是「反馈类型没了」。
* 优先 category_id,没有才回退 slug(老数据兼容)。 */
$aCat = !empty($t['category_id'])
? fb_category_label((int)$t['category_id'])
: fb_category_label_by_slug((int)$t['software_id'], (string)$t['category']);
?>
<?php if ($aCat !== ''): ?><span class="badge cat"><?php echo e($aCat); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<?php if ((int)$t['visible'] !== 1): ?><span class="badge st st-closed">未公开</span><?php endif; ?> <?php if ((int)$t['visible'] !== 1): ?><span class="badge st st-closed">未公开</span><?php endif; ?>
</div> </div>
<p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p> <p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
@@ -755,16 +816,21 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div> <div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div>
<div class="card-form" style="margin:0 0 16px"> <div class="card-form" style="margin:0 0 16px">
<div class="item-top">
<?php <?php
$dSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null; $dSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
$dCat = !empty($t['category_id']) ? fb_category((int)$t['category_id']) : null; $dCat = !empty($t['category_id']) ? fb_category((int)$t['category_id']) : null;
?> ?>
<?php if ($dSw): ?><span class="badge sw"><?php echo e((string)$dSw['name']); ?></span><?php endif; ?> <?php /* 标题顶头独占一行,标签另起一行 —— 与列表页、前台详情页统一。
用户 2026-11-03:「点到一个具体的反馈里面也是一样的,标题顶头,
然后把同步日历、bug、待处理往下一行,标签写到下面去。」 */ ?>
<div class="item-head">
<span class="item-title" style="font-size:19px"><?php echo e($t['title']); ?></span>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="item-tags">
<?php if ($dSw): ?><?php echo fb_software_chip($dSw); ?><?php endif; ?>
<?php if ($dCat): ?><span class="badge cat"><?php echo e((string)$dCat['name']); ?></span><?php endif; ?> <?php if ($dCat): ?><span class="badge cat"><?php echo e((string)$dCat['name']); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span> <span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
</div> </div>
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div> <div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
<?php if (!empty($atts['main'])) { echo fb_attach_render($atts['main']); } ?> <?php if (!empty($atts['main'])) { echo fb_attach_render($atts['main']); } ?>
@@ -807,7 +873,10 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-inline"> <form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-inline">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>"> <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="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="status" value="<?php echo e($curStatus === 'closed' ? 'open' : $curStatus); ?>"> <?php /* 通过并公开 = 可见 + 状态转「待处理」。
不能沿用原状态(未公开时原状态是 hidden,通过后还是未公开,
状态和可见性就对不上了)。 */ ?>
<input type="hidden" name="status" value="open">
<input type="hidden" name="also_show" value="1"> <input type="hidden" name="also_show" value="1">
<button class="btn primary act-btn" type="submit" name="act" value="setstatus">通过并公开</button> <button class="btn primary act-btn" type="submit" name="act" value="setstatus">通过并公开</button>
</form> </form>
@@ -1136,11 +1205,11 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<input type="hidden" name="oss_visibility" value="<?php echo e((string)fb_cfg('oss.visibility', 'private')); ?>"> <input type="hidden" name="oss_visibility" value="<?php echo e((string)fb_cfg('oss.visibility', 'private')); ?>">
<h2 style="margin:24px 0 10px">审核</h2> <h2 style="margin:24px 0 10px">审核</h2>
<label class="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需你通过后才显示)</label> <label class="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需您通过后才显示)</label>
<label class="field"><span>关闭通知邮件标题</span> <label class="field"><span>关闭通知邮件标题</span>
<input type="text" name="moderation_close_mail_subject" <input type="text" name="moderation_close_mail_subject"
placeholder="留空即用:关于你反馈的问题《%s》" placeholder="留空即用:关于您反馈的问题《%s》"
value="<?php echo e(fb_form_val(fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》'), '关于你反馈的问题《%s》', 'moderation.close_mail_subject')); ?>"> value="<?php echo e(fb_form_val(fb_cfg('moderation.close_mail_subject', '关于您反馈的问题《%s》'), '关于您反馈的问题《%s》', 'moderation.close_mail_subject')); ?>">
<i class="hint">%s 会替换成问题标题。留空则用默认模板。</i></label> <i class="hint">%s 会替换成问题标题。留空则用默认模板。</i></label>
<div class="form-actions"> <div class="form-actions">
@@ -1263,7 +1332,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
$smVerify= (bool)fb_cfg('smtp.verify_ssl', true); $smVerify= (bool)fb_cfg('smtp.verify_ssl', true);
?> ?>
<label class="check"><input type="checkbox" name="smtp_enabled" value="1"<?php echo $smOn ? ' checked' : ''; ?>> 开启邮件通知 <label class="check"><input type="checkbox" name="smtp_enabled" value="1"<?php echo $smOn ? ' checked' : ''; ?>> 开启邮件通知
<span class="hint" style="display:block;margin:2px 0 0 24px">开启后,有人回复你反馈的问题会发信通知提交人。填好下方参数并用「发送测试邮件」验证通了再勾。</span></label> <span class="hint" style="display:block;margin:2px 0 0 24px">开启后,有人回复您反馈的问题会发信通知提交人。填好下方参数并用「发送测试邮件」验证通了再勾。</span></label>
<div class="grid2"> <div class="grid2">
<label class="field"><span>SMTP 服务器</span> <label class="field"><span>SMTP 服务器</span>
@@ -1317,17 +1386,38 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<div class="mail-test"> <div class="mail-test">
<span class="muted" style="font-size:13.5px">配好后先发一封测试邮件确认能通:</span> <span class="muted" style="font-size:13.5px">配好后先发一封测试邮件确认能通:</span>
<input type="email" id="mailTestTo" placeholder="收件邮箱,默认发给自己" <input type="email" id="mailTestTo" placeholder="收件邮箱,默认发给自己"
value="<?php echo e((string)fb_cfg('admin_email', '')); ?>"> value="<?php echo e((string)fb_cfg('admin_email', '') !== '' ? (string)fb_cfg('admin_email', '') : (string)fb_cfg('smtp.user', '')); ?>">
<select id="mailTestSw" title="按哪个软件的样子发送">
<?php foreach (fb_softwares(true) as $swOne): ?>
<option value="<?php echo (int)$swOne['id']; ?>"><?php echo e((string)$swOne['name']); ?></option>
<?php endforeach; ?>
</select>
<button type="button" class="btn" id="mailTestBtn" data-csrf="<?php echo e(fb_csrf_token()); ?>">发送测试邮件</button> <button type="button" class="btn" id="mailTestBtn" data-csrf="<?php echo e(fb_csrf_token()); ?>">发送测试邮件</button>
<span id="mailTestOut" class="mail-test-out"></span> <span id="mailTestOut" class="mail-test-out"></span>
</div> </div>
<p class="hint" style="margin:6px 0 0">测试邮件用的是「提交回执」模板,会带上所选软件的图标与配色 —— 可以直接看出不同软件的邮件长什么样。</p>
<?php endif; ?> <?php endif; ?>
<h3 style="margin:20px 0 8px;font-size:15px">通知规则</h3> <h3 style="margin:20px 0 8px;font-size:15px">通知规则</h3>
<label class="check"><input type="checkbox" name="notify_receipt_enabled" value="1"<?php echo fb_cfg('notify.receipt_enabled', true) ? ' checked' : ''; ?>> 提交后立即给提交人发「已收到」回执
<span class="hint" style="display:block;margin-left:24px">告诉对方反馈已送达、正在等审核,请耐心等待。提交完就让用户干等着是最容易丢信任的环节。</span></label>
<label class="check"><input type="checkbox" name="notify_enabled" value="1"<?php echo fb_cfg('notify.enabled', true) ? ' checked' : ''; ?>> 有人回复时通知提交人</label> <label class="check"><input type="checkbox" name="notify_enabled" value="1"<?php echo fb_cfg('notify.enabled', true) ? ' checked' : ''; ?>> 有人回复时通知提交人</label>
<label class="check"><input type="checkbox" name="notify_on_every_reply" value="1"<?php echo fb_cfg('notify.on_every_reply', false) ? ' checked' : ''; ?>> 每次回复都通知(默认只在官方回复时通知,避免刷屏)</label> <label class="check"><input type="checkbox" name="notify_on_every_reply" value="1"<?php echo fb_cfg('notify.on_every_reply', false) ? ' checked' : ''; ?>> 每次回复都通知(默认只在官方回复时通知,避免刷屏)</label>
<label class="check"><input type="checkbox" name="notify_admin_new_thread" value="1"<?php echo fb_cfg('notify.admin_new_thread', false) ? ' checked' : ''; ?>> 有新反馈时通知管理员 <?php
<span class="hint" style="display:block;margin-left:24px">发到「站点信息」里填的官方邮箱。</span></label> // 勾了「有新反馈时通知管理员」却没填官方邮箱 —— 这条通知会发不出去。
// 之前这个组合完全是静默失效的,用户在后台看不出任何异常。
$adminMailNow = trim((string)fb_cfg('admin_email', ''));
$smtpUserNow = trim((string)fb_cfg('smtp.user', ''));
$newNotifyOn = (bool)fb_cfg('notify.admin_new_thread', false);
$mailMissing = ($adminMailNow === '' && $smtpUserNow === '');
?>
<label class="check"><input type="checkbox" name="notify_admin_new_thread" value="1"<?php echo $newNotifyOn ? ' checked' : ''; ?>> 有新反馈时通知管理员
<span class="hint" style="display:block;margin-left:24px">发到「站点信息」里填的官方邮箱;没填官方邮箱时退而发到 SMTP 发件地址。</span>
<?php if ($newNotifyOn && $mailMissing): ?>
<span class="hint" style="display:block;margin-left:24px;color:#b4553c;font-weight:600">⚠️ 官方邮箱和 SMTP 发件地址都是空的,这条通知目前发不出去。请在上方「官方邮箱」处填写。</span>
<?php elseif ($newNotifyOn && $adminMailNow === ''): ?>
<span class="hint" style="display:block;margin-left:24px;color:#8a6a15">当前会发到 SMTP 发件地址 <?php echo e($smtpUserNow); ?>。若想收到别的信箱,请在「站点信息」填写官方邮箱。</span>
<?php endif; ?></label>
<label class="field" style="max-width:280px"><span>同一帖同一收件人的最短发信间隔(秒)</span> <label class="field" style="max-width:280px"><span>同一帖同一收件人的最短发信间隔(秒)</span>
<?php /* 出厂默认是 300(见 config.example.php 的 notify.cooldown_seconds) */ ?> <?php /* 出厂默认是 300(见 config.example.php 的 notify.cooldown_seconds) */ ?>
<input type="number" name="notify_cooldown_seconds" min="0" max="86400" placeholder="300" <input type="number" name="notify_cooldown_seconds" min="0" max="86400" placeholder="300"
@@ -1369,53 +1459,49 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
fb_layout_foot(); fb_layout_foot();
/** /**
* 待审核卡片(含「通过」与「关闭并邮件告知」两个操作) * 待审核条目(列表式)
*
* 用户 2026-11-03 明确要求:
* ① 列表形式显示,不要每张卡片塞满表单
* ② 去掉「关闭说明」整块 —— 想关闭就点标题进管理页再填说明,
* 列表页不承担这个职责
* ③ 后面只留一个「通过并公开」按钮,点一下就通过
* ④ 标题自己就是进管理页的链接,别再额外挂「进入管理」
*/ */
function fb_admin_thread_card(array $t) { function fb_admin_pending_row(array $t) {
$url = 'admin.php?tab=thread&id=' . (int)$t['id'];
ob_start(); ?> ob_start(); ?>
<div class="card-form" style="margin:0 0 16px"> <div class="prow">
<div class="item-top"> <div class="prow-main">
<?php $cSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null; ?> <?php $cSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null; ?>
<?php if ($cSw): ?><span class="badge sw"><?php echo e((string)$cSw['name']); ?></span><?php endif; ?> <?php /* 标题顶头独占一行,标签(软件/分类/状态)另起一行放下面。
用户 2026-11-03:后台待审核、全部问题、详情页全都按这个方式处理 ——
标签放在标题前面会把标题挤到右边。 */ ?>
<div class="prow-head">
<a class="prow-title" href="<?php echo e($url); ?>"><?php echo e($t['title']); ?></a>
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="prow-tags">
<?php if ($cSw): ?><?php echo fb_software_chip($cSw); ?><?php endif; ?>
<?php if (!empty($t['category_id'])): ?> <?php if (!empty($t['category_id'])): ?>
<span class="badge cat"><?php echo e(fb_category_label((int)$t['category_id'])); ?></span> <span class="badge cat"><?php echo e(fb_category_label((int)$t['category_id'])); ?></span>
<?php endif; ?> <?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span> <span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
</div> </div>
<p class="prow-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div> <div class="prow-meta">
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span> <span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span> <span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo e($t['author_email']); ?></span> <span><?php echo e($t['author_email']); ?></span>
<span class="dot">·</span> <span class="dot">·</span>
<a href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>">进入管理</a> <span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
</div> </div>
</div>
<div class="row2" style="margin-top:14px"> <div class="prow-act">
<form method="post" action="admin.php?tab=pending"> <form method="post" action="admin.php?tab=pending">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>"> <input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>"> <input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="approve">通过并公开</button> <button class="btn primary" type="submit" name="act" value="approve">通过并公开</button>
</div>
</form>
<form method="post" action="admin.php?tab=pending">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
<label class="field">
<span>关闭说明 <b class="req">*</b> <i class="hint">判定"不是问题"时填写,会随邮件发给提问人</i></span>
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象是服务器端的 CalDAV 限制,并非应用问题;建议改用…"></textarea>
</label>
<div class="form-actions">
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
</div>
</form> </form>
</div> </div>
</div> </div>
+641 -49
View File
@@ -61,29 +61,9 @@
} }
} }
/* ---------- 管理员口令:填写后显示"处理状态",并解除邮箱必填 ---------- */ /* ---------- (已删)管理员口令切换 ----------
function bindAdminToggle() { * 回复表单里不再有「管理员口令」和「处理状态」——用户 2026-11-03 要求去掉。
var pw = document.querySelector('input[name=admin_password]'); * 管理员身份改由后台登录态判断,状态流转在后台详情页的按钮里做。 */
if (!pw) { return; }
var statusBox = document.querySelector('.admin-status');
var emailInput = document.querySelector('input[name=email]');
var emailField = document.getElementById('emailField');
function sync() {
var isAdmin = pw.value.trim() !== '';
if (statusBox) { statusBox.hidden = !isAdmin; }
if (emailInput) {
emailInput.required = !isAdmin;
if (isAdmin) { emailInput.removeAttribute('required'); }
}
if (emailField) {
var req = emailField.querySelector('.req');
if (req) { req.style.visibility = isAdmin ? 'hidden' : 'visible'; }
}
}
pw.addEventListener('input', sync);
sync();
}
/* ---------- 防止重复提交(上传未完成时阻止提交) ---------- */ /* ---------- 防止重复提交(上传未完成时阻止提交) ---------- */
function bindNoDoubleSubmit() { function bindNoDoubleSubmit() {
@@ -729,47 +709,654 @@
} }
} }
/* ---------- 主题色选择器(后台软件编辑) ---------- */ /* ---------- 主题色选择器(后台软件编辑) ----------
* 用户 2026-11-04:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」
*
* 现在的形态是一块**完整色板**(不是小浮窗):
* 340×200 的色相/饱和度画布 + 色相条 + 明度条 + 十六进制输入。
* 右侧那排预设圆点已删除 —— 既能任意取色,再摆一排样例就是重复。
*
* 交互约定:
* - 点色块 → 色板出现(下方空间不够自动翻到上方)
* - 画布内拖动 = 同时调饱和度与明度
* - 色相条单独调 H;明度条单独调 V
* - 十六进制框可手输,合法立即同步
* - 点「完成」/ 点色板外 / Esc → 关闭(值早已写入隐藏字段)
*/
function bindColorPicker() { function bindColorPicker() {
var rows = document.querySelectorAll('.color-row'); var rows = document.querySelectorAll('[data-colorpicker]');
for (var i = 0; i < rows.length; i++) { for (var i = 0; i < rows.length; i++) {
(function (row) { (function (row) {
var input = row.querySelector('input[type=color]'); var trigger = row.querySelector('[data-color-trigger]');
var presets = row.querySelectorAll('.color-preset'); var valueEl = row.querySelector('[data-color-value]');
var reset = row.querySelector('.color-reset'); var reset = row.querySelector('.color-reset');
var panel = row.querySelector('[data-color-panel]');
if (!trigger || !valueEl || !panel) { return; }
var canvas = panel.querySelector('[data-cp-sv]');
var cursor = panel.querySelector('[data-cp-cursor]');
var hueEl = panel.querySelector('[data-cp-hue]');
var valEl = panel.querySelector('[data-cp-val]'); // 明度条(可能不存在,做兼容)
var prevEl = panel.querySelector('[data-cp-prev]');
var hexEl = panel.querySelector('[data-cp-hex]');
var okBtn = panel.querySelector('[data-cp-ok]');
var hsv = { h: 0, s: 0, v: 0 };
function hexToRgb(h) {
h = (h || '').replace('#', '');
if (h.length === 3) { h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]; }
return [parseInt(h.substr(0,2),16) || 0, parseInt(h.substr(2,2),16) || 0, parseInt(h.substr(4,2),16) || 0];
}
function rgbToHex(r, g, b) {
function p(n) { n = Math.max(0, Math.min(255, Math.round(n))); return (n < 16 ? '0' : '') + n.toString(16); }
return '#' + p(r) + p(g) + p(b);
}
function rgbToHsv(r, g, b) {
r /= 255; g /= 255; b /= 255;
var mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, h = 0;
if (d !== 0) {
if (mx === r) { h = ((g - b) / d) % 6; }
else if (mx === g) { h = (b - r) / d + 2; }
else { h = (r - g) / d + 4; }
h *= 60; if (h < 0) { h += 360; }
}
return { h: h, s: mx === 0 ? 0 : d / mx, v: mx };
}
function hsvToRgb(h, s, v) {
var c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c, r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120){ r = x; g = c; }
else if (h < 180){ g = c; b = x; }
else if (h < 240){ g = x; b = c; }
else if (h < 300){ r = x; b = c; }
else { r = c; b = x; }
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
}
function curHex() {
var c = hsvToRgb(hsv.h, hsv.s, hsv.v);
return rgbToHex(c[0], c[1], c[2]);
}
/* 画色相/饱和度板:底色是当前色相的纯色,叠一层
* 左→右白到透明的横向渐变(饱和度)+ 上→下透明的纵向渐变(明度)。
* 这是取色器通用画法,比逐像素画快得多。 */
function drawSV() {
var ctx = canvas.getContext('2d');
var w = canvas.width, h = canvas.height;
var base = hsvToRgb(hsv.h, 1, 1);
ctx.fillStyle = rgbToHex(base[0], base[1], base[2]);
ctx.fillRect(0, 0, w, h);
var gx = ctx.createLinearGradient(0, 0, w, 0);
gx.addColorStop(0, 'rgba(255,255,255,1)');
gx.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gx; ctx.fillRect(0, 0, w, h);
var gy = ctx.createLinearGradient(0, 0, 0, h);
gy.addColorStop(0, 'rgba(0,0,0,0)');
gy.addColorStop(1, 'rgba(0,0,0,1)');
ctx.fillStyle = gy; ctx.fillRect(0, 0, w, h);
}
function moveCursor() {
/* cursor 现在是 .cp-sv-wrap 的子元素(和 canvas 同级),
* 所以坐标可以直接相对 canvas 自己算 —— 不用再管 .cp 的 padding:
* left/top 相对 wrap,wrap 和 canvas 左/上边重合。
* 但仍要注意:canvas 显示尺寸(CSS width:100%)≠ 内部像素宽高,
* 必须用 getBoundingClientRect() 的宽高。 */
var br = canvas.getBoundingClientRect();
var w = br.width || canvas.width;
var h = br.height || canvas.height;
cursor.style.left = (hsv.s * w) + 'px';
cursor.style.top = ((1 - hsv.v) * h) + 'px';
cursor.style.background = curHex();
}
function syncAll(skipHex) {
var hex = curHex();
valueEl.value = hex;
trigger.style.setProperty('--cur', hex);
var hexTxt = trigger.querySelector('.color-trigger-hex');
if (hexTxt) { hexTxt.textContent = hex.toUpperCase(); }
if (prevEl) { prevEl.style.background = hex; }
if (!skipHex && hexEl) { hexEl.value = hex.toUpperCase(); }
hueEl.value = Math.round(hsv.h);
if (valEl) { valEl.value = Math.round(hsv.v * 100); }
drawSV();
drawValTrack();
moveCursor();
}
/* 明度条的底色要跟着当前色相变 —— 否则永远是灰阶,
* 用户看不清"拖到这里会得到什么颜色"。 */
function drawValTrack() {
if (!valEl) { return; }
var pure = hsvToRgb(hsv.h, hsv.s, 1);
var top = rgbToHex(pure[0], pure[1], pure[2]);
valEl.style.background = 'linear-gradient(to right, #000, ' + top + ')';
}
function setHex(hex, skipHex) {
var c = hexToRgb(hex);
hsv = rgbToHsv(c[0], c[1], c[2]);
syncAll(skipHex);
}
// ---- 打开 / 关闭 ----
function openPanel() {
panel.hidden = false;
drawSV(); moveCursor();
positionPanel();
}
/* 色板是 position:fixed,位置必须自己算。
*
* 🔴 为什么不用 absolute 贴按钮:后台这个表单在
* <details class="tax-form-box"> 里,那个容器有 overflow:hidden,
* absolute 会被裁掉下半部分 —— 色相条正好在底部,连带看不见,
* 用户就以为"色板只有蓝色"。fixed 不受任何祖先 overflow 影响。
*
* 定位规则:
* ① 默认贴在触发按钮下方,左边缘对齐按钮
* ② 右边超出视口就往左挪
* ③ 下方空间不够就翻到按钮上方
* ④ 都放不下(屏幕太矮)就贴住视口上下边,保证能看全 */
function positionPanel() {
if (panel.hidden) { return; }
var r = trigger.getBoundingClientRect();
var pw = panel.offsetWidth;
var ph = panel.offsetHeight;
var vw = window.innerWidth;
var vh = window.innerHeight;
var gap = 8;
var margin = 10;
// 水平:默认左对齐按钮
var left = r.left;
if (left + pw + margin > vw) { left = vw - pw - margin; }
if (left < margin) { left = margin; }
// 垂直:优先下方
var top = r.bottom + gap;
if (top + ph + margin > vh) {
var above = r.top - gap - ph;
if (above >= margin) {
top = above; // 放上面
} else {
top = Math.max(margin, vh - ph - margin); // 都放不下:贴住底部
}
}
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
}
function closePanel() { panel.hidden = true; }
// 窗口尺寸变化 / 滚动时重新定位,避免色板"漂走"
window.addEventListener('resize', positionPanel);
window.addEventListener('scroll', positionPanel, true);
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (panel.hidden) { openPanel(); } else { closePanel(); }
});
document.addEventListener('click', function (e) {
if (!panel.hidden && !panel.contains(e.target) && e.target !== trigger && !trigger.contains(e.target)) {
closePanel();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) { closePanel(); }
});
// ---- 渐变板拖动 ----
var dragging = false;
function pickSV(ev) {
var rect = canvas.getBoundingClientRect();
var x = (ev.touches ? ev.touches[0].clientX : ev.clientX) - rect.left;
var y = (ev.touches ? ev.touches[0].clientY : ev.clientY) - rect.top;
hsv.s = Math.max(0, Math.min(1, x / rect.width));
hsv.v = Math.max(0, Math.min(1, 1 - y / rect.height));
syncAll();
}
canvas.addEventListener('mousedown', function (e) { dragging = true; pickSV(e); e.preventDefault(); });
canvas.addEventListener('touchstart', function (e) { dragging = true; pickSV(e); }, { passive: true });
document.addEventListener('mousemove', function (e) { if (dragging) { pickSV(e); } });
document.addEventListener('touchmove', function (e) { if (dragging) { pickSV(e); } }, { passive: true });
document.addEventListener('mouseup', function () { dragging = false; });
document.addEventListener('touchend', function () { dragging = false; });
// ---- 色相滑条 ----
hueEl.addEventListener('input', function () {
hsv.h = parseFloat(this.value) || 0;
syncAll();
});
// ---- 明度滑条 ----
if (valEl) {
valEl.addEventListener('input', function () {
hsv.v = (parseFloat(this.value) || 0) / 100;
syncAll();
});
}
// ---- 十六进制手输 ----
hexEl.addEventListener('input', function () {
var v = this.value.trim();
if (/^#?[0-9a-fA-F]{6}$/.test(v)) {
setHex(v[0] === '#' ? v : '#' + v, true);
}
});
okBtn.addEventListener('click', function () { closePanel(); });
// ---- 用默认(清空 = 保存后回落到按 id 取的默认色)----
if (reset) {
reset.addEventListener('click', function () {
valueEl.value = '';
valueEl.setAttribute('name', 'color');
trigger.style.setProperty('--cur', '#cccccc');
var hexTxt = trigger.querySelector('.color-trigger-hex');
if (hexTxt) { hexTxt.textContent = '跟随默认'; }
closePanel();
});
}
// 初始化
if (valueEl.value) { setHex(valueEl.value, false); } else { syncAll(); }
})(rows[i]);
}
}
/* ---------- 图标上传:选中即预览 + 即时校验 ----------
* 用户 2026-11-04:「上传的那个图标没有显示,也没有上传成功、失败之类的提示信息。」
*
* 原来就是一个裸 <input type="file">,选完文件什么都没发生 ——
* 用户不知道选没选中、图片对不对、会不会传不上去。表单整页提交后
* 提示条又在页面顶部,很容易错过。
*
* 这里做三件事(都在前端,最终仍以后端校验为准):
* ① 选中文件立刻用 FileReader 本地预览,不等提交
* ② 显示文件名 + 尺寸 + 大小,让用户确认选对了
* ③ 选之前就拦掉明显不合格的(格式/大小/太小),省一次白提交
* ⚠️ 前端校验只是体验优化,**绝不能**当作安全边界 ——
* 真正的类型/尺寸/大小校验仍在 php 的 fb_icon_store_upload() 里。
*/
function bindIconUpload() {
var inputs = document.querySelectorAll('input[type=file][name=icon_file]');
for (var i = 0; i < inputs.length; i++) {
(function (input) {
var wrap = input.closest('.icon-up');
if (!wrap) { return; }
var prev = wrap.querySelector('.icon-prev');
// 按钮上的文件名(可选,新样式才有)
var nameEl = wrap.querySelector('[data-file-name]');
/* 状态条插在**整个文件按钮之后**(不是 input 之后)。
* input 现在被包在 <label class="file-btn"> 里,如果插到 input.nextSibling
* 会跑到 label 内部,跟按钮文字挤在一起。 */
var btn = input.closest('.file-btn') || input;
var tip = document.createElement('span');
tip.className = 'icon-up-tip';
tip.setAttribute('role', 'status');
if (btn.parentNode) { btn.parentNode.insertBefore(tip, btn.nextSibling); }
function setTip(msg, kind) {
tip.textContent = msg || '';
tip.className = 'icon-up-tip' + (kind ? ' is-' + kind : '');
}
function setName(txt) {
if (nameEl) { nameEl.textContent = txt || ''; }
}
input.addEventListener('change', function () {
var f = this.files && this.files[0];
if (!f) { setTip(''); setName(''); return; }
var KB = Math.round(f.size / 1024);
var sizeTxt = KB >= 1024 ? (KB / 1024).toFixed(1) + ' MB' : KB + ' KB';
// ① 格式
var okType = /^image\/(png|jpeg|webp)$/.test(f.type);
if (!okType) {
setTip('✕ 只能选 PNG / JPG / WEBP 图片,当前是「' + (f.type || '未知类型') + '」', 'err');
setName('');
this.value = '';
return;
}
// ② 大小(后端限 2MB)
if (f.size > 2 * 1024 * 1024) {
setTip('✕ 图片 ' + sizeTxt + ',超过 2 MB 上限,请压缩后再传', 'err');
setName('');
this.value = '';
return;
}
if (f.size <= 0) {
setTip('✕ 这是个空文件', 'err');
setName('');
this.value = '';
return;
}
// ③ 本地预览 + 尺寸检查
var fr = new FileReader();
fr.onload = function (ev) {
var img = new Image();
img.onload = function () {
if (img.width < 32 || img.height < 32) {
setTip('✕ 图片只有 ' + img.width + '×' + img.height + ',至少 32×32', 'err');
setName('');
input.value = '';
return;
}
if (prev) {
prev.innerHTML = '';
var el = document.createElement('img');
el.src = ev.target.result;
el.alt = '';
el.width = 48; el.height = 48;
prev.appendChild(el);
}
var warn = (img.width !== img.height) ? ' ⚠ 不是正方形,显示时会被裁切' : '';
setName(f.name);
setTip('✓ ' + img.width + '×' + img.height + ',' + sizeTxt
+ warn + ' 保存后生效', 'ok');
};
img.onerror = function () {
setTip('✕ 这张图读不出来,可能已损坏', 'err');
setName('');
input.value = '';
};
img.src = ev.target.result;
};
fr.onerror = function () { setTip('✕ 读取文件失败', 'err'); setName(''); };
fr.readAsDataURL(f);
});
})(inputs[i]);
}
}
/* ---------- 图标快选(后台软件编辑) ---------- */
function bindIconPicker() {
var boxes = document.querySelectorAll('.icon-presets');
for (var i = 0; i < boxes.length; i++) {
(function (box) {
// 图标输入框是「图标快选」的前一个兄弟里的 input[name=icon]
var field = box.parentNode;
var input = field ? field.querySelector('input[name=icon]') : null;
if (!input) { return; } if (!input) { return; }
var btns = box.querySelectorAll('.icon-preset');
function syncOn(v) { function syncOn(v) {
for (var k = 0; k < presets.length; k++) { for (var k = 0; k < btns.length; k++) {
var same = (presets[k].getAttribute('data-color') || '').toLowerCase() === (v || '').toLowerCase(); var same = (btns[k].getAttribute('data-icon') || '') === v;
if (same) { presets[k].classList.add('on'); } else { presets[k].classList.remove('on'); } if (same) { btns[k].classList.add('on'); } else { btns[k].classList.remove('on'); }
} }
} }
for (var j = 0; j < btns.length; j++) {
for (var j = 0; j < presets.length; j++) { btns[j].addEventListener('click', function () {
presets[j].addEventListener('click', function () { input.value = this.getAttribute('data-icon') || '';
var c = this.getAttribute('data-color') || ''; syncOn(input.value);
input.value = c;
syncOn(c);
}); });
} }
input.addEventListener('input', function () { syncOn(this.value); }); input.addEventListener('input', function () { syncOn(this.value); });
syncOn(input.value);
})(boxes[i]);
}
}
if (reset) { /* ---------- 自定义下拉:替换原生 <select> 的展开列表 ----------
reset.addEventListener('click', function () { * 用户 2026-11-04:「所有下拉框拉下来之后怎么全是灰色的?不好看,重新设计。」
// 清空 color 字段 = 保存后回落到按 id 取的默认色 *
input.value = '#0057b8'; * 🔴 为什么必须自绘:原生 <select> 的展开列表是**浏览器渲染的原生控件**,
syncOn(''); * CSS 只能改 <option> 的 background/color,改不了圆角、行高、hover 高亮、
var hidden = document.createElement('input'); * 分组间距 —— 而且各平台样式完全不同(macOS 是圆角浮层、Windows 是灰方框)。
hidden.type = 'hidden'; * 想让"展开后"好看,只能自己画一个列表。
hidden.name = 'color'; *
hidden.value = ''; * 设计取舍:
row.appendChild(hidden); * - **保留原生 select 作为数据源**:不重写 HTML(全站 16 个 select,
input.removeAttribute('name'); // 提交时以隐藏字段为准 * 逐个改容易漏、也容易破坏 onchange 内联逻辑)。
* 把原生 select 视觉隐藏,在旁边放一个按钮 + 浮层列表。
* - 原生 select 仍参与表单提交(值同步),onchange 也照样触发
* —— 调用 this.form.submit() 的那些筛选下拉因此不用改动。
* - 无 JS 时原生 select 正常可用(只是样式朴素),不会白屏。
* - 浮层用 position:fixed 定位,避免被 overflow:hidden 容器裁掉(踩过)。
*/
function bindSelect() {
var sels = document.querySelectorAll('select');
for (var i = 0; i < sels.length; i++) {
(function (sel) {
// 已经增强过、或明确排除的跳过
if (sel.getAttribute('data-enhanced') === '1') { return; }
if (sel.hasAttribute('data-no-enhance')) { return; }
// 隐藏域 / 多选 / 尺寸>1 的列表框不动
if (sel.multiple || (parseInt(sel.getAttribute('size'), 10) || 0) > 1) { return; }
sel.setAttribute('data-enhanced', '1');
// 原生 select:保留在 DOM 里(提交用),但视觉隐藏
sel.classList.add('sel-native');
// 包一层,方便定位
var wrap = document.createElement('span');
wrap.className = 'sel';
sel.parentNode.insertBefore(wrap, sel);
wrap.appendChild(sel);
// 触发按钮
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sel-btn';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
wrap.appendChild(btn);
// 浮层列表(挂到 body,避开一切 overflow 裁剪)
var panel = document.createElement('div');
panel.className = 'sel-panel';
panel.hidden = true;
panel.setAttribute('role', 'listbox');
document.body.appendChild(panel);
var active = -1; // 键盘高亮项
function curText() {
var o = sel.options[sel.selectedIndex];
return o ? o.textContent : '';
}
function syncBtn() {
btn.innerHTML = '<span class="sel-txt"></span><span class="sel-caret" aria-hidden="true"></span>';
btn.querySelector('.sel-txt').textContent = curText();
btn.disabled = sel.disabled;
btn.classList.toggle('is-placeholder', sel.selectedIndex <= 0 && sel.options.length > 0
&& (sel.options[0].value === ''));
}
function buildList() {
panel.innerHTML = '';
for (var k = 0; k < sel.options.length; k++) {
(function (idx) {
var o = sel.options[idx];
if (o.hidden) { return; }
var item = document.createElement('button');
item.type = 'button';
item.className = 'sel-item';
item.setAttribute('role', 'option');
var on = idx === sel.selectedIndex;
if (on) { item.classList.add('on'); item.setAttribute('aria-selected', 'true'); }
var label = document.createElement('span');
label.className = 'sel-item-txt';
label.textContent = o.textContent;
item.appendChild(label);
if (on) {
var ck = document.createElement('span');
ck.className = 'sel-item-ck';
ck.setAttribute('aria-hidden', 'true');
item.appendChild(ck);
}
item.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
pick(idx);
}); });
panel.appendChild(item);
})(k);
} }
})(rows[i]);
} }
function pick(idx) {
if (idx < 0 || idx >= sel.options.length) { return; }
var changed = sel.selectedIndex !== idx;
sel.selectedIndex = idx;
syncBtn();
close();
// 触发原生 change,兼容那些 onchange="this.form.submit()" 的下拉
if (changed) {
var ev = document.createEvent('HTMLEvents');
ev.initEvent('change', true, false);
sel.dispatchEvent(ev);
}
}
function open() {
if (btn.disabled) { return; }
buildList();
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
btn.classList.add('is-open');
position();
// 打开后键盘可直接上下选
active = sel.selectedIndex;
highlight(active);
document.addEventListener('mousedown', outside, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
}
function close() {
if (panel.hidden) { return; }
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
btn.classList.remove('is-open');
document.removeEventListener('mousedown', outside, true);
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
}
function outside(e) {
if (!panel.contains(e.target) && !btn.contains(e.target)) { close(); }
}
/* 定位:fixed,挂在 body 上,不服从任何祖先的 overflow。
* 优先向下展开;下方空间不足且上方更宽裕时向上翻。 */
function position() {
if (panel.hidden) { return; }
var r = btn.getBoundingClientRect();
var vw = window.innerWidth, vh = window.innerHeight;
var pw = Math.max(panel.offsetWidth, r.width);
var ph = panel.offsetHeight;
var margin = 8;
var left = r.left;
if (left + pw + margin > vw) { left = Math.max(margin, vw - pw - margin); }
var below = vh - r.bottom - margin;
var above = r.top - margin;
var openUp = (ph > below) && (above > below);
var top = openUp ? (r.top - ph - 4) : (r.bottom + 4);
if (top < margin) { top = margin; }
if (top + ph > vh - margin) { top = Math.max(margin, vh - ph - margin); }
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
panel.style.minWidth = Math.round(r.width) + 'px';
panel.classList.toggle('is-up', openUp);
}
function highlight(idx) {
var items = panel.querySelectorAll('.sel-item');
for (var k = 0; k < items.length; k++) {
items[k].classList.toggle('is-active', k === idx);
}
if (idx >= 0 && items[idx]) {
var it = items[idx];
var t = it.offsetTop, b = t + it.offsetHeight;
if (t < panel.scrollTop) { panel.scrollTop = t; }
else if (b > panel.scrollTop + panel.clientHeight) {
panel.scrollTop = b - panel.clientHeight;
}
}
}
function onKey(e) {
var items = panel.querySelectorAll('.sel-item');
if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
active = Math.min(items.length - 1, active + 1); highlight(active);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
active = Math.max(0, active - 1); highlight(active);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (active >= 0) { pick(active); }
} else if (e.key === 'Home') {
e.preventDefault(); active = 0; highlight(active);
} else if (e.key === 'End') {
e.preventDefault(); active = items.length - 1; highlight(active);
}
}
btn.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
if (panel.hidden) { open(); } else { close(); }
});
// 原生 select 若在别处被程序改值,按钮文字要跟着变
sel.addEventListener('change', syncBtn);
syncBtn();
})(sels[i]);
}
}
/* ---------- 顶栏软件下拉菜单(右上角三横线) ----------
* 极简:点了就展开一个小浮窗,点外面 / Esc 就关。
* 不做遮罩、不锁背景滚动 —— 那套是侧边栏的做法,下拉菜单不需要。 */
function bindSoftwarePanel() {
var btn = document.getElementById('swToggle');
var panel = document.getElementById('swPanel');
if (!btn || !panel) { return; }
function close() {
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (panel.hidden) {
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
} else {
close();
}
});
// 点菜单内部不算点外面
panel.addEventListener('click', function (e) { e.stopPropagation(); });
document.addEventListener('click', function () {
if (!panel.hidden) { close(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) { close(); btn.focus(); }
});
} }
/* ---------- 邮件测试发送(后台设置页) ---------- */ /* ---------- 邮件测试发送(后台设置页) ---------- */
@@ -778,6 +1365,7 @@
if (!btn) { return; } if (!btn) { return; }
var out = document.getElementById('mailTestOut'); var out = document.getElementById('mailTestOut');
var to = document.getElementById('mailTestTo'); var to = document.getElementById('mailTestTo');
var swSel = document.getElementById('mailTestSw');
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var addr = to ? (to.value || '').trim() : ''; var addr = to ? (to.value || '').trim() : '';
if (!addr) { if (!addr) {
@@ -793,6 +1381,7 @@
fd.append('csrf', btn.getAttribute('data-csrf') || ''); fd.append('csrf', btn.getAttribute('data-csrf') || '');
fd.append('act', 'mail_test'); fd.append('act', 'mail_test');
fd.append('to', addr); fd.append('to', addr);
if (swSel && swSel.value) { fd.append('sw', swSel.value); }
fetch('admin.php', { method: 'POST', body: fd, credentials: 'same-origin' }) fetch('admin.php', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) { return r.text(); }) .then(function (r) { return r.text(); })
@@ -818,9 +1407,12 @@
function boot() { function boot() {
bindCaptchaRefresh(); bindCaptchaRefresh();
bindAdminToggle();
bindWontfixPanel(); bindWontfixPanel();
bindColorPicker(); bindColorPicker();
bindIconPicker();
bindIconUpload();
bindSelect();
bindSoftwarePanel();
bindMailTest(); bindMailTest();
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前 bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
bindNoDoubleSubmit(); bindNoDoubleSubmit();
+889 -36
View File
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* 启动引导:加载配置、注册自动加载、开启会话、初始化数据库。
* 所有页面(index/thread/new/api)都以此为入口。
*/
if (!defined('FB_ROOT')) {
define('FB_ROOT', dirname(__DIR__));
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 37); }
}
/* ------------------------------------------------------------------
* 错误显示策略必须最先设置:
* 任何被打印到页面的 Warning/Deprecated 都会让响应头提前发出,
* 从而令随后的 session_start() 失败(表现为「会话已过期」、CSRF 全部失效)。
* 所以这里在 require 任何库文件之前就把 display_errors 关掉。
* ------------------------------------------------------------------ */
@ini_set('display_errors', '0');
@ini_set('log_errors', '1');
error_reporting(E_ALL);
// 兜底:捕获致命错误时也不要向页面输出堆栈(生产环境)
if (!defined('FB_BOOTSTRAPPED')) {
define('FB_BOOTSTRAPPED', 1);
}
// 后台改设置后自增,用来让 fb_cfg 的进程内缓存失效
if (!isset($GLOBALS['__fb_cfg_stamp'])) {
$GLOBALS['__fb_cfg_stamp'] = 0;
}
if (!function_exists('fb_cfg')) {
/**
* 只读【配置文件】的值(config.php → config.example.php),
* **不叠加 settings 表、绝不触达数据库**。
*
* 为什么必须单独有这么一条通道:正常的 fb_cfg() 会去读 settings 表,
* 而读表要先 fb_db() —— 那一步会就地建出一个空数据库文件。
* 「是否已安装」的判定恰恰以「数据库文件在不在」为依据,
* 所以判定必须走这条不碰库的路径,否则第一秒就把安装态自己造出来了。
*/
function fb_cfg_file() {
static $file = null;
static $stamp = -1;
$now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0;
if ($file === null || $stamp !== $now) {
$local = FB_ROOT . '/config.php';
$example = FB_ROOT . '/config.example.php';
if (is_file($local)) { $file = (array)(require $local); }
elseif (is_file($example)) { $file = (array)(require $example); }
else { $file = []; }
$stamp = $now;
}
return $file;
}
/**
* 读取配置(点分路径,如 fb_cfg('smtp.host'))
* 取值顺序:settings 表(后台改过的)> config.php(本地配置)> config.example.php(出厂默认)> $default
*
* config.php 不参与版本管理(见 .gitignore),新部署时可从 config.example.php 复制一份;
* 若不存在则直接以 config.example.php 为默认值运行,保证 clone 下来即可跑。
*/
function fb_cfg($path = null, $default = null) {
static $merged = null;
static $stamp = -1;
$now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0;
if ($merged === null || $stamp !== $now) {
$cfg = fb_cfg_file();
// 后台设置以嵌套数组形式叠加在文件配置之上(只覆盖同名键)
$tree = function_exists('fb_setting_tree') ? fb_setting_tree() : [];
$merged = is_array($tree) && $tree ? array_replace_recursive($cfg, $tree) : $cfg;
$stamp = $now;
}
if ($path === null || $path === '') { return $merged; }
$cur = $merged;
foreach (explode('.', (string)$path) as $k) {
if (is_array($cur) && array_key_exists($k, $cur)) {
$cur = $cur[$k];
} else {
return $default;
}
}
return $cur;
}
}
/** 让 fb_cfg() 的进程内缓存失效(改了磁盘上的 config.php 后必须调用) */
function fb_cfg_flush() {
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
}
require_once FB_ROOT . '/lib/util.php';
require_once FB_ROOT . '/lib/db.php';
require_once FB_ROOT . '/lib/oss.php';
require_once FB_ROOT . '/lib/attach.php';
require_once FB_ROOT . '/lib/captcha.php';
require_once FB_ROOT . '/lib/mailer.php';
require_once FB_ROOT . '/lib/layout.php';
/* ------------------------------------------------------------------
* 安装引导:数据库还没生成(data/ 下没数据)就说明是全新部署,
* 一律先送去 install.php 填配置、建库、设口令,装完才进系统。
* 判据只看「数据库文件在不在」——绝不能在这里调 fb_db(),
* 因为那会就地建出一个空库、把安装态自己"装"出来,之后就再也进不了向导。
* ------------------------------------------------------------------ */
if (!defined('FB_SKIP_INSTALL_REDIRECT') && !fb_installed()) {
if (PHP_SAPI === 'cli') {
fwrite(STDERR, "FeedLite 尚未安装:请先在浏览器中打开本目录下的 install.php 完成安装。\n");
exit(1);
}
fb_redirect('install.php');
}
// 会话(CLI 下不启用)
if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
$p = session_get_cookie_params();
$https = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off');
@session_set_cookie_params([
'lifetime' => 0,
'path' => !empty($p['path']) ? $p['path'] : '/',
'domain' => '',
'secure' => $https,
'httponly' => true,
'samesite' => 'Lax',
]);
@session_start();
}
// 初始化数据库(建表)。
// ★ 只在【已安装】时做:未安装就建库,等于第一秒把这个状态自己抹掉,
// 安装向导(install.php)随后就会被判定成「已经装好了」而拒绝工作。
// install.php 在真正提交安装时会自己显式调用 fb_db()。
if (fb_installed()) {
fb_db();
// 顺手确保 uploads/ 目录与「禁止直读」规则在位且为最新版(local / oss 都需要)。
// ⚠️ 为什么放在这里:老站点上早已存在的旧版 .htaccess(曾允许直读)不会自己更新,
// 而部署账号通常对 uploads/ 无写权限 —— 只有 PHP(目录属主)能改。
// 函数内部只在「文件缺失或版本标记过期」时才写盘,命中后每次请求几乎零开销。
if (function_exists('fb_local_ensure_dir')) {
fb_local_ensure_dir();
}
}
+155 -30
View File
@@ -1,22 +1,44 @@
<?php <?php
/** /**
* 问题列表 * 首页 / 当前软件的问题列表
*
* 两种形态(用户 2026-11-03 定的产品逻辑):
* ① **不带 sw**:首页 = 轻反馈系统的介绍页。
* 不再列「全部问题」—— 多个软件的反馈混在一起,对访客毫无意义:
* 他不是所有软件的使用者,看别人的反馈没价值。
* ② **带 sw**:当前软件的问题列表。
* 检索/筛选/分类全部只在本软件范围内,弱化其他软件。
*/ */
require __DIR__ . '/lib/bootstrap.php'; require __DIR__ . '/lib/bootstrap.php';
$pdo = fb_db(); $pdo = fb_db();
/* ---- 软件切换:只配一个软件时自动落到它,不暴露软件选择器 ---- */ /* ---- 软件切换 ---- */
$swList = fb_softwares(true); $swList = fb_softwares(true);
$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : ''; $swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
$sw = null; $sw = null;
if ($swKey !== '') { if ($swKey !== '') {
$sw = fb_software($swKey); $sw = fb_software($swKey);
// 指定的软件不存在或已停用 → 退回默认,不报错(老链接要能打开) // 指定的软件不存在或已停用 → 退回首页(不自动落到第一个软件)
if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; } if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; }
} }
if ($sw === null && $swList) { $sw = $swList[0]; }
$swId = $sw ? (int)$sw['id'] : 0; /* ==================================================================
* ① 没有指定软件 → 首页介绍页
* ------------------------------------------------------------------
* 用户原话:「首页应该是对我们这个轻量级反馈系统的介绍,
* 说明它的特点是什么,然后告诉用户,如果现在想提交反馈,
* 可以点右上角的设置按钮,选择不同的系统,进去就可以了。」
*
* 🔴 不要在这里列问题。以前首页默认落到第一个软件并列出全部问题,
* 等于把 A 软件的反馈摊给 B 软件的用户看。
* ================================================================== */
if ($sw === null) {
fb_render_home($swList);
exit;
}
$swId = (int)$sw['id'];
$cats = fb_categories($swId); $cats = fb_categories($swId);
$statuses = fb_status_keys(); $statuses = fb_status_keys();
@@ -76,32 +98,31 @@ $attImgs = $tids ? fb_attach_map_images($pdo, $tids, 3) : [];
$attCount = $tids ? fb_attach_count_map($pdo, $tids) : []; $attCount = $tids ? fb_attach_count_map($pdo, $tids) : [];
$siteName = (string)fb_cfg('site_name'); $siteName = (string)fb_cfg('site_name');
$headTitle = $siteName !== '' ? $siteName . ' · 问题反馈' : '问题反馈'; /* 页面标题(浏览器标签 / 页面大标题):
fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]); * 选中某个软件时用「XX 反馈系统」,让用户一眼确认自己在这个软件的反馈区;
* 没选软件时退回站点名。 */
$headTitle = $sw ? fb_software_site_title($sw)
: ($siteName !== '' ? $siteName . ' · 问题反馈' : '问题反馈');
fb_layout_head($sw ? fb_software_site_title($sw) : $headTitle, ['software' => $sw]);
?> ?>
<?php /* 这一整行(软件图标+名字 / 简介 / 「我要反馈」按钮)已删除。
*
* 用户 2026-11-03:「上面已经有标题和『我要反馈』了,下面又出现一次,
* 完全重复。下面这一行不要了…其他内容开始往前提,往上提,这样更简洁。」
*
* 现在:
* - 「同步日历反馈系统」+ 灰色简介 → 只在顶栏出现一次
* - 「我要反馈」→ 顶栏右侧那个 btn-mini 就够了(列表页不再放第二个)
* - 列表内容直接从下面开始,省掉一整行高度
*/
if (!$sw):
?>
<div class="page-head"> <div class="page-head">
<div> <div><h1>全部问题</h1></div>
<h1><?php echo e($sw ? (string)$sw['name'] : '全部问题'); ?></h1>
<?php
$siteDesc = (string)fb_cfg('site_desc');
$swNote = ($sw && !empty($sw['note'])) ? (string)$sw['note'] : '';
?>
<?php if ($siteDesc !== ''): ?><p class="muted"><?php echo e($siteDesc); ?></p><?php endif; ?>
<?php if ($swNote !== ''): ?><p class="muted small" style="margin:2px 0 0"><?php echo e($swNote); ?></p><?php endif; ?>
</div>
<a class="btn primary" href="new.php<?php echo $sw ? '?sw=' . e(urlencode((string)$sw['slug'])) : ''; ?>">我要反馈</a>
</div>
<div class="software-bar">
<span class="sw-label">选择软件:</span>
<div class="chips">
<?php foreach ($swList as $s): ?>
<a class="chip <?php echo ($sw && (int)$s['id'] === (int)$sw['id']) ? 'on' : ''; ?>"
href="<?php echo e(fb_qs(['sw' => (string)$s['slug'], 'cat' => null, 'page' => null]) ?: ('?sw=' . e(urlencode((string)$s['slug'])))); ?>"><?php echo e((string)$s['name']); ?></a>
<?php endforeach; ?>
</div>
</div> </div>
<?php endif; ?>
<div class="filters"> <div class="filters">
<div class="chips"> <div class="chips">
@@ -137,12 +158,25 @@ fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]);
<?php else: ?> <?php else: ?>
<?php foreach ($threads as $t): ?> <?php foreach ($threads as $t): ?>
<a class="item" href="thread.php?id=<?php echo (int)$t['id']; ?>"> <a class="item" href="thread.php?id=<?php echo (int)$t['id']; ?>">
<div class="item-top"> <?php /* 标题单独占第一行、从最左边顶头开始 ——
<span class="badge cat"><?php echo e(fb_category_label($t['category_id'])); ?></span> 用户 2026-11-03:标签放在标题前面会把标题挤到右边,很难看。
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span> 顺序改为「标题 → 标签」:标题在上,分类/状态另起一行。 */ ?>
<div class="item-head">
<span class="item-title"><?php echo e($t['title']); ?></span> <span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span> <span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
</div> </div>
<div class="item-tags">
<?php
/* 分类标签必须有名字才渲染 —— 否则会输出一个空胶囊(什么都看不见),
* 用户会以为「反馈类型丢了」。老数据 category_id 可能为空,
* 这时按 slug 兜底;两者都取不到才不显示。 */
$lCat = !empty($t['category_id'])
? fb_category_label((int)$t['category_id'])
: fb_category_label_by_slug((int)$t['software_id'], (string)$t['category']);
?>
<?php if ($lCat !== ''): ?><span class="badge cat"><?php echo e($lCat); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
</div>
<p class="item-ex"><?php echo e(fb_excerpt($t['excerpt'], 120)); ?></p> <p class="item-ex"><?php echo e(fb_excerpt($t['excerpt'], 120)); ?></p>
<?php if (!empty($attImgs[(int)$t['id']])): ?> <?php if (!empty($attImgs[(int)$t['id']])): ?>
<div class="thumbs"> <div class="thumbs">
@@ -188,3 +222,94 @@ fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]);
<?php <?php
fb_layout_foot(); fb_layout_foot();
/* ==================================================================
* 首页介绍页
* ------------------------------------------------------------------
* 用户 2026-11-03 定的产品逻辑:首页不讲"有哪些问题",只讲"这是什么"。
*
* 场景说明(决定了下面文案的取舍):
* 这个系统一般是给某个软件配一个反馈入口,把地址贴进那个软件的
* 「意见反馈」里。所以访客**总是从某个软件过来的**,他心里已经有一个
* 明确的目标软件;首页要做的是「介绍 + 指路」,不是「陈列别人的问题」。
* ================================================================== */
function fb_render_home(array $swList) {
$siteName = (string)fb_cfg('site_name');
$title = $siteName !== '' ? $siteName : '轻反馈';
fb_layout_head($title . ' · 轻量级反馈系统');
$n = count($swList);
?>
<div class="home">
<section class="home-hero">
<h1>轻量级反馈系统</h1>
<p class="home-lead">给每个软件一个独立的反馈入口:用户不用注册,填个昵称和邮箱就能提问题;
所有进展都会自动发邮件通知他。</p>
</section>
<section class="home-feats">
<div class="home-feat">
<div class="home-feat-ic">📮</div>
<h3>不用注册</h3>
<p>填昵称和常用邮箱即可提交。后续每一条进展都会发邮件通知,不用反复回来刷。</p>
</div>
<div class="home-feat">
<div class="home-feat-ic">🔗</div>
<h3>私人追踪链接</h3>
<p>提交后会收到一条专属链接,凭它随时查看进度——哪怕这条反馈还没审核通过。</p>
</div>
<div class="home-feat">
<div class="home-feat-ic">🗂</div>
<h3>按软件分开</h3>
<p>每个软件一套独立的问题列表和分类,互不干扰。同一软件的其他人也能看到已解决的问题。</p>
</div>
<div class="home-feat">
<div class="home-feat-ic">💬</div>
<h3>公开讨论</h3>
<p>已公开的问题任何人都能查看和回复。提交前先搜一下,避免重复提同样的问题。</p>
</div>
</section>
<section class="home-enter">
<h2>进入某个软件的反馈系统</h2>
<?php if ($n === 0): ?>
<p class="muted">管理员还没有配置软件。</p>
<?php else: ?>
<p class="home-enter-tip">
点击右上角的<span class="home-menu-demo" aria-hidden="true"><i></i><i></i><i></i></span>
<b>设置按钮</b>,从列表里选择您要反馈的软件即可。
</p>
<div class="home-sw-list">
<?php foreach ($swList as $s): ?>
<?php
list($c) = fb_software_colors($s);
if ($c === '') { $c = '#0057B8'; }
?>
<a class="home-sw" href="?sw=<?php echo e(urlencode((string)$s['slug'])); ?>"
style="--swc:<?php echo e($c); ?>">
<span class="home-sw-ic">
<?php
$ik = trim((string)(isset($s['icon_key']) ? $s['icon_key'] : ''));
$iu = $ik !== '' ? fb_icon_url($ik, 72) : '';
?>
<?php if ($iu !== ''): ?>
<img src="<?php echo e($iu); ?>" alt="" width="36" height="36">
<?php else: ?>
<span><?php echo e(fb_software_icon($s)); ?></span>
<?php endif; ?>
</span>
<span class="home-sw-nm"><?php echo e((string)$s['name']); ?></span>
<span class="home-sw-go">进入 →</span>
</a>
<?php endforeach; ?>
</div>
<p class="home-enter-alt muted small">
也可以直接点上方的软件卡片进入。
</p>
<?php endif; ?>
</section>
</div>
<?php
fb_layout_foot();
}
+302 -20
View File
@@ -5,6 +5,129 @@
*/ */
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); } if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/* ==================================================================
* 软件图标图片:上传 / 删除
* ------------------------------------------------------------------
* 安全边界(三条,缺一不可):
* ① 只有管理员能进(这个函数只从后台登录后的动作分支调用);
* ② 落盘前用 finfo 读真实文件头,必须是图片白名单里的类型 ——
* 与附件上传同一个教训:把 PHP 改名成 .png 就能写进目录,
* .htaccess 是唯一防线,换 nginx 就失守;
* ③ 键空间独立为 icons/,且形状受限(无穿越、无自由路径)。
*
* 存储方式跟附件完全一致:本机 → uploads/;OSS → 服务器代理 PUT 到私有桶。
* 对外一律 media.php?k=... 代理,不暴露真实位置。
* ================================================================== */
/** 图标允许的扩展名 —— 定义在 lib/attach.php(fb_icon_allowed_ext),
* 因为 media.php 与 CLI 场景也会用到,不能依赖这个后台文件被加载。 */
/**
* 保存一个上传的图标文件,返回 [bool ok, string 值(键或错误信息), string mime]。
* 成功后值是【对象键】;失败时值是给用户看的错误原因。
*/
function fb_icon_store_upload(array $file) {
if (!isset($file['error']) || (int)$file['error'] !== UPLOAD_ERR_OK) {
$code = isset($file['error']) ? (int)$file['error'] : -1;
return [false, $code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE
? '图片太大,请换一张小一点的(建议 512×512 以内)。'
: '上传失败(错误码 ' . $code . ')。', ''];
}
$tmp = (string)(isset($file['tmp_name']) ? $file['tmp_name'] : '');
if ($tmp === '' || !is_uploaded_file($tmp)) {
return [false, '上传失败,请重试。', ''];
}
$size = (int)@filesize($tmp);
if ($size <= 0) { return [false, '图片是空文件。', '']; }
if ($size > 2 * 1024 * 1024) { return [false, '图标不能超过 2 MB。', '']; }
// 真实类型(不信扩展名、不信前端上报的 mime)
$mime = '';
if (function_exists('finfo_open')) {
$fi = @finfo_open(FILEINFO_MIME_TYPE);
if ($fi) { $mime = strtolower(trim((string)@finfo_file($fi, $tmp))); @finfo_close($fi); }
}
$mimeMap = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
];
if (!isset($mimeMap[$mime])) {
return [false, '只支持 PNG / JPG / WEBP 格式的图片(检测到 ' . ($mime === '' ? '未知' : $mime) . ')。', ''];
}
$ext = $mimeMap[$mime];
// 尺寸下限:太小的图放大会糊,直接拒绝比事后难看更好
if (function_exists('getimagesize')) {
$wh = @getimagesize($tmp);
if ($wh && isset($wh[0], $wh[1])) {
if ($wh[0] < 32 || $wh[1] < 32) {
return [false, '图片太小(' . (int)$wh[0] . '×' . (int)$wh[1] . '),至少 32×32。', ''];
}
}
}
$mode = fb_store_mode();
if ($mode === 'off') { return [false, '附件存储未启用,无法保存图标。请先在「站点设置」里启用存储。', '']; }
$key = fb_icon_make_key($ext);
/* 断言:键的前缀必须与当前存储方式一致。
* 这两者一旦脱钩,就会出现「文件写进 uploads/ 但 media.php 去 OSS 找」的 503 ——
* 早期把键硬编码成 icons/… 就是这么坏的。宁可当场报错,也不要静默存错位置。 */
if ($mode === 'local' && !fb_attach_is_local($key)) {
return [false, '内部错误:本地模式下生成了非本机路径的图标键。', ''];
}
if ($mode === 'oss' && fb_attach_is_local($key)) {
return [false, '内部错误:OSS 模式下生成了本机路径的图标键。', ''];
}
if ($mode === 'local') {
if (!fb_local_writable()) { return [false, '本机附件目录不可写(uploads/),无法保存图标。', '']; }
$path = fb_local_path_of($key);
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
if (!is_dir($dir) || !@move_uploaded_file($tmp, $path)) {
return [false, '保存图片失败,请稍后重试。', ''];
}
@chmod($path, 0644);
} else {
if (!fb_oss_enabled()) { return [false, '对象存储未配置完整,无法保存图标。', '']; }
list($ok, $err) = fb_oss_put_file($key, $tmp, $mime);
if (!$ok) { return [false, '写入对象存储失败:' . $err, '']; }
}
return [true, $key, $mime];
}
/**
* 删除一个图标实体(换成新图 / 清空图标时调用,避免孤儿文件堆积)。
*
* @return bool 是否已确认删除(文件不存在也算成功)
*
* 🔴 不要用 @ 把错误吞掉:删失败却返回「已保存」,
* 用户会以为旧图清掉了,实际 OSS 上还留着一个可访问的孤儿图。
* 这里失败就返回 false,调用方据此提示。
*/
function fb_icon_delete($key) {
$key = trim((string)$key);
if ($key === '' || !fb_icon_key_ok($key)) { return true; } // 没东西可删
if (fb_attach_is_local($key)) {
$p = fb_local_path_of($key);
if (!is_file($p)) { return true; }
return (bool)@unlink($p);
}
if (!fb_oss_enabled()) { return true; } // OSS 关掉了,对象已不可达,不必纠结
if (!function_exists('fb_oss_delete')) { return false; }
try {
return (bool)fb_oss_delete($key);
} catch (Throwable $e) {
return false;
}
}
/* ================================================================== /* ==================================================================
* 统一的表单处理入口(由 admin.php 在 CSRF 校验通过后调用) * 统一的表单处理入口(由 admin.php 在 CSRF 校验通过后调用)
* 返回 [消息, 类型],类型 ok / error * 返回 [消息, 类型],类型 ok / error
@@ -27,10 +150,27 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
$slug = fb_sw_slugify($name, 'sw', $next); $slug = fb_sw_slugify($name, 'sw', $next);
} }
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; } if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn(); /* 排序:优先用表单里填的值(新建表单现在也有这一项了),
* 没填才算「排到最后」。用 isset 判断而不是空判断 ——
* 用户明确填 0 也是合法值(排最前面),不能被当成"没填"。 */
$sort = isset($post['sort_order']) && $post['sort_order'] !== ''
? (int)$post['sort_order']
: (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
$color = fb_color_norm(isset($post['color']) ? $post['color'] : ''); $color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
$pdo->prepare('INSERT INTO software(slug,name,note,color,sort_order,enabled,created_at) VALUES(?,?,?,?,?,1,?)') $icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
->execute([$slug, $name, $note !== '' ? $note : null, $color, $sort, $now]); $dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
// 新建时也可以直接带上图标图片(先入库,再更新 icon_key)
$iconKeyNew = '';
if (isset($_FILES['icon_file']) && is_array($_FILES['icon_file'])
&& (int)(isset($_FILES['icon_file']['error']) ? $_FILES['icon_file']['error'] : UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
list($okUp, $valUp) = fb_icon_store_upload($_FILES['icon_file']);
if (!$okUp) { return ['图标上传失败:' . $valUp, 'error']; }
$iconKeyNew = $valUp;
}
$pdo->prepare('INSERT INTO software(slug,name,note,color,icon,icon_key,download_url,sort_order,enabled,created_at) VALUES(?,?,?,?,?,?,?,?,1,?)')
->execute([$slug, $name, $note !== '' ? $note : null, $color, $icon, $iconKeyNew, $dl, $sort, $now]);
$newId = (int)$pdo->lastInsertId(); $newId = (int)$pdo->lastInsertId();
// 新软件默认给一组常见分类,省得空着没法发帖 // 新软件默认给一组常见分类,省得空着没法发帖
$i = 0; $i = 0;
@@ -54,10 +194,51 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
$other = fb_software($slug); $other = fb_software($slug);
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; } if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
$color = fb_color_norm(isset($post['color']) ? $post['color'] : ''); $color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, sort_order=? WHERE id=?') $icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
->execute([$name, $slug, $note !== '' ? $note : null, $color, $dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
/* ---- 图标图片 ----
* 三种情况:
* ① 上传了新图 → 换掉旧图(旧实体删掉,不留孤儿文件)
* ② 勾了「移除图标」→ 删掉图片,退回字符图标
* ③ 都没动 → 保持原样(不能因为没传文件就当成清空,
* 这与「配置被跨页清空」是同一类错误) */
$oldIconKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
$iconKey = $oldIconKey;
$delFailed = '';
$hasNewFile = isset($_FILES['icon_file'])
&& is_array($_FILES['icon_file'])
&& (int)(isset($_FILES['icon_file']['error']) ? $_FILES['icon_file']['error'] : UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE;
if ($hasNewFile) {
list($okUp, $valUp) = fb_icon_store_upload($_FILES['icon_file']);
if (!$okUp) { return ['图标上传失败:' . $valUp, 'error']; }
$iconKey = $valUp;
if ($oldIconKey !== '' && $oldIconKey !== $iconKey) {
if (!fb_icon_delete($oldIconKey)) { $delFailed = $oldIconKey; }
}
} elseif (!empty($post['icon_clear'])) {
if ($oldIconKey !== '') {
if (!fb_icon_delete($oldIconKey)) { $delFailed = $oldIconKey; }
}
$iconKey = '';
}
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, icon=?, icon_key=?, download_url=?, sort_order=? WHERE id=?')
->execute([$name, $slug, $note !== '' ? $note : null, $color, $icon, $iconKey, $dl,
(int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]); (int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]);
return ['软件「' . $name . '」已保存。', 'ok'];
$extraMsg = '';
if ($hasNewFile) { $extraMsg = ',图标已更新'; }
elseif (!empty($post['icon_clear']) && $oldIconKey !== '') { $extraMsg = ',图标已移除'; }
// 旧图删不掉要如实说 —— 悄悄留下一个还能访问的孤儿图是安全隐患
if ($delFailed !== '') {
return ['软件「' . $name . '」已保存' . $extraMsg . ',但旧图标文件删除失败('
. $delFailed . '),它可能仍可被访问,建议手动到存储里删掉。', 'error'];
}
return ['软件「' . $name . '」已保存' . $extraMsg . '。', 'ok'];
} }
case 'sw_del': { case 'sw_del': {
@@ -73,6 +254,8 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
} }
$pdo->prepare('DELETE FROM categories WHERE software_id = ?')->execute([$id]); $pdo->prepare('DELETE FROM categories WHERE software_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM software WHERE id = ?')->execute([$id]); $pdo->prepare('DELETE FROM software WHERE id = ?')->execute([$id]);
// 顺手删掉它的图标文件,避免 icons/ 下堆孤儿图
if (isset($sw['icon_key'])) { fb_icon_delete((string)$sw['icon_key']); }
return ['已删除软件「' . $sw['name'] . '」。', 'ok']; return ['已删除软件「' . $sw['name'] . '」。', 'ok'];
} }
@@ -264,7 +447,8 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
echo ' <div class="tax-main">'; echo ' <div class="tax-main">';
list($swC, $swCd, $swCs) = fb_software_colors($sw); list($swC, $swCd, $swCs) = fb_software_colors($sw);
echo ' <span class="sw-dot" style="background:' . e($swC) . '" title="' . e($swC) . '"></span>'; echo ' <span class="sw-dot" style="background:' . e($swC) . '" title="' . e($swC) . '"></span>';
echo ' <a class="tax-name" href="admin.php?tab=taxonomy&sw=' . $sid . '">' . e((string)$sw['name']) . '</a>'; echo ' <a class="tax-name" href="admin.php?tab=taxonomy&sw=' . $sid . '">'
. fb_software_badge($sw, 'sm') . '</a>';
echo ' <span class="tax-slug">' . e((string)$sw['slug']) . '</span>'; echo ' <span class="tax-slug">' . e((string)$sw['slug']) . '</span>';
if (!$on) { echo ' <span class="badge st">已停用</span>'; } if (!$on) { echo ' <span class="badge st">已停用</span>'; }
echo ' <span class="tax-meta">' . $nCat . ' 个分类 · ' . $nTh . ' 条反馈</span>'; echo ' <span class="tax-meta">' . $nCat . ' 个分类 · ' . $nTh . ' 条反馈</span>';
@@ -295,7 +479,7 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
if ($editSw > 0) { $esw = fb_software($editSw); } if ($editSw > 0) { $esw = fb_software($editSw); }
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>'; echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>'; echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>';
echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form" data-validate>'; echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form" enctype="multipart/form-data" data-validate>';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">'; echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; } if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
echo ' <div class="row2">'; echo ' <div class="row2">';
@@ -310,26 +494,124 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
. '<input type="text" name="note" maxlength="120" value="' . '<input type="text" name="note" maxlength="120" value="'
. e($esw ? (string)($esw['note'] === null ? '' : $esw['note']) : '') . '"></label>'; . e($esw ? (string)($esw['note'] === null ? '' : $esw['note']) : '') . '"></label>';
/* ---- 图标 ----
* 两种方式并存,图片优先:
* ① 上传一张图片(存 OSS / 本机,前端经 media.php 代理显示)—— 推荐
* ② 不传图片时用下面那个字符(emoji / 汉字 / 字母)兜底
* 有了图片,列表、页头、邮件里全都自动换成图片,不用分别设置。 */
$curIcon = $esw ? (string)(isset($esw['icon']) ? $esw['icon'] : '') : '';
$curIconKey = $esw ? trim((string)(isset($esw['icon_key']) ? $esw['icon_key'] : '')) : '';
$curIconUrl = $curIconKey !== '' ? fb_icon_url($curIconKey, 96) : '';
echo ' <div class="field"><span>图标图片<i class="hint">上传一张图(PNG/JPG/WEBP,≤2MB,方形最佳)。传了图片就优先用图片,不再用下面的字符</i></span>';
echo ' <div class="icon-up">';
echo ' <div class="icon-prev">';
if ($curIconUrl !== '') {
echo '<img src="' . e($curIconUrl) . '" alt="" width="48" height="48">';
} else {
list($pc) = $esw ? fb_software_colors($esw) : ['#0057B8'];
echo '<span class="icon-prev-ph" style="background:' . e($pc) . '">'
. e(fb_software_icon($esw ? $esw : ['name' => ''])) . '</span>';
}
echo ' </div>';
echo ' <div class="icon-up-main">';
/* 文件选择按钮重做成真按钮(用户:原生那个太丑)。
* <label class="file-btn"> 包住 input[type=file],点 label 就等于点 input;
* input 用 clip 视觉隐藏但仍可聚焦(不能用 display:none,那样点不到)。 */
$ficonFileId = 'iconFile' . ($esw ? (int)$esw['id'] : 'new');
echo ' <label class="file-btn" for="' . $ficonFileId . '">'
. '<span class="file-btn-ic" aria-hidden="true"></span>'
. '<span class="file-btn-txt">选择图片</span>'
. '<span class="file-btn-name" data-file-name></span>'
. '<input type="file" name="icon_file" accept="image/png,image/jpeg,image/webp" id="' . $ficonFileId . '">'
. '</label>';
echo ' <i class="hint" style="margin-top:6px">建议 256×256 或 512×512 的方形图;带透明背景的 PNG 效果最好。</i>';
if ($curIconUrl !== '') {
echo ' <label class="check" style="margin-top:8px"><input type="checkbox" name="icon_clear" value="1"> 移除当前图片,改用下面的字符图标</label>';
}
echo ' </div>';
echo ' </div>';
echo ' </div>';
// 字符兜底:没有图片时用它
$iconSug = ['📅', '🔐', '🔑', '📝', '🎵', '📷', '🌤', '🧭', '📦', '⚙️', '🗂', '💬'];
echo ' <label class="field"><span>字符图标<i class="hint">没上传图片时用它,一个字符:emoji 或汉字/字母</i></span>'
. '<input type="text" name="icon" maxlength="8" value="' . e($curIcon) . '" placeholder="例如 📅">'
. '<span class="icon-presets">';
foreach ($iconSug as $ic) {
echo '<button type="button" class="icon-preset" data-icon="' . e($ic) . '">' . e($ic) . '</button>';
}
echo '</span></label>';
/* ── 「下载地址」字段已删除 ──
* 用户 2026-11-04:「有个什么下载链接,这个东西不要,要它干什么?」
*
* 它原本的唯一用途:当反馈状态改成「已解决」时,把通知邮件里的按钮
* 从「查看详情」换成「下载最新版本」。属于可有可无的小功能,
* 却要求管理员为每个软件再维护一条 URL —— 收益远小于维护成本。
*
* ⚠️ 数据库的 download_url 列**保留不删**:
* ① 删列会让历史数据不可逆丢失;
* ② 万一以后想恢复,不用再走一次迁移。
* 只是前台表单不再暴露,mailer 里也已改为始终用「查看详情」。 */
// 主题色:前台按软件换色,用短链接进不同软件一眼就能区分 // 主题色:前台按软件换色,用短链接进不同软件一眼就能区分
$curColor = $esw ? fb_color_norm((string)$esw['color']) : ''; $curColor = $esw ? fb_color_norm((string)$esw['color']) : '';
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0); $sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
$presets = ['#0057B8', '#2E7D5B', '#B4553C', '#6A4C93', '#0E7C86', '#A02C5A', '#8A6D1F', '#3A5A8C']; /* 颜色选择:**点色块弹出一整块色板**。
* 用户 2026-11-04:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」
* ⇒ ① 右侧那排预设圆点删除,界面更干净,能力靠色板本身(任意取色)覆盖;
* ② 色板要"完整":足够大的色相/饱和度画布 + 色相条 + 明度条 +
* 十六进制输入。太小会让人觉得是"玩具",拖着不好定位颜色。
* ⚠️ 不再需要 $presets:色板本身就能取任意颜色,
* 留着那排小圆点反而和色板重复、还占横向空间(用户明确要求去掉)。 */
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>'; echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
echo ' <div class="color-row">'; echo ' <div class="color-row" data-colorpicker>';
echo ' <input type="color" name="color" value="' . e($sugColor) . '" class="color-input">'; echo ' <button type="button" class="color-trigger" data-color-trigger'
echo ' <div class="color-presets">'; . ' style="--cur:' . e($sugColor) . '" title="点击选择颜色">'
foreach ($presets as $pc) { . ' <span class="color-trigger-chip"></span>'
$on = strcasecmp($pc, $sugColor) === 0 ? ' on' : ''; . ' <span class="color-trigger-hex">' . e(strtoupper($sugColor)) . '</span>'
echo '<button type="button" class="color-preset' . $on . '" data-color="' . e($pc) . ' <span class="color-trigger-caret">▾</span>'
. '" style="background:' . e($pc) . '" title="' . e($pc) . '"></button>'; . ' </button>';
} echo ' <input type="hidden" name="color" value="' . e($sugColor) . '" data-color-value>';
echo ' </div>';
echo ' <button type="button" class="btn sm color-reset">用默认</button>'; echo ' <button type="button" class="btn sm color-reset">用默认</button>';
/* 色板:默认隐藏,点色块才出现。 */
echo ' <div class="cp" data-color-panel hidden>';
echo ' <div class="cp-hd">选择颜色</div>';
// 画布做大:340×200,拖起来才好定位
echo ' <div class="cp-sv-wrap">';
echo ' <canvas class="cp-sv" width="340" height="200" data-cp-sv></canvas>';
echo ' <div class="cp-cursor" data-cp-cursor></div>';
echo ' </div>';
// 色相条
echo ' <div class="cp-slider cp-hue"><input type="range" min="0" max="360" value="0" data-cp-hue aria-label="色相"></div>';
// 明度条:背景由 JS 按当前色相刷新
echo ' <div class="cp-slider cp-val"><input type="range" min="0" max="100" value="100" data-cp-val aria-label="明度"></div>';
echo ' <div class="cp-foot">';
echo ' <span class="cp-preview" data-cp-prev></span>';
echo ' <input type="text" class="cp-hex" maxlength="7" value="' . e(strtoupper($sugColor)) . '" data-cp-hex aria-label="十六进制颜色值">';
echo ' <button type="button" class="btn sm cp-ok" data-cp-ok>完成</button>';
echo ' </div>'; echo ' </div>';
echo ' </div>'; echo ' </div>';
echo ' </div>';
echo ' </div>';
/* 排序字段:新建和编辑**都要有**。
* 之前只在 if ($esw) 分支里渲染,导致「添加软件」时根本没有这一项,
* 用户以为排序被删了。新建时给个默认值 = 当前最大排序+1(排到最后)。
*
* ⚠️ 与「下载地址」不同:排序是**真实会被用到**的字段
* (前台软件列表、顶栏三横线菜单都按它排),不能删。 */
$sortVal = 0;
if ($esw) {
$sortVal = (int)$esw['sort_order'];
} else {
$sortVal = (int)fb_db()->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
}
echo ' <label class="field field-narrow"><span>排序<i class="hint">数字小的排前面,同级按名称排</i></span>'
. '<input type="number" name="sort_order" min="0" max="999" value="' . $sortVal . '"></label>';
if ($esw) { if ($esw) {
echo ' <label class="field"><span>排序<i class="hint">数字小的排前面</i></span>'
. '<input type="number" name="sort_order" value="' . (int)$esw['sort_order'] . '"></label>';
echo ' <button class="btn primary" name="act" value="sw_edit" type="submit">保存</button> ' echo ' <button class="btn primary" name="act" value="sw_edit" type="submit">保存</button> '
. '<a class="btn" href="admin.php?tab=taxonomy">取消</a>'; . '<a class="btn" href="admin.php?tab=taxonomy">取消</a>';
} else { } else {
+114
View File
@@ -149,6 +149,110 @@ function fb_attach_is_local($key) {
return strpos((string)$key, 'local/') === 0; return strpos((string)$key, 'local/') === 0;
} }
/* ==================================================================
* 软件图标(管理员上传的图片)
* ------------------------------------------------------------------
* 🔴 存储位置【跟随后台配置】,与附件完全同一套规则:
* attachment.storage = local → local/icons/YYYY/MM/<hex>.<ext> 写 uploads/
* attachment.storage = oss → <oss.prefix>/icons/YYYY/MM/<hex>.<ext> 写私有桶
* 管理员在后台配的哪种,图标就存哪种;前台地址两种模式完全一致,
* 都是 media.php?k=... 代理,换存储方式不会让已有图标失效。
*
* 早期实现把图标键固定写成 `icons/...`(不带 local/ 前缀),
* 结果 fb_attach_is_local() 永远返回 false —— 本机模式下文件明明写进了
* uploads/icons/,media.php 却当成 OSS 对象去读,直接 503。
* 这是「本地存储时图标显示不出来」的根因,键必须带存储前缀。
*
* 为什么不复用普通附件:
* 普通附件必须挂在某条 thread 下才允许被 media.php 读出(否则本站就成了
* 「能读整个桶」的开放代理)。软件图标不属于任何帖子,用它那套鉴权会被拒。
* 于是键里多一段 `icons/`,media.php 据此识别并按公开资源处理(写入仍受控)。
* ================================================================== */
function fb_icon_key_prefix() {
return 'icons/';
}
/**
* 图标允许的扩展名(比附件白名单窄:图标不需要 gif 动图)。
* ⚠️ 刻意不收 svg:SVG 能内嵌 <script>,一旦被当图片直出到浏览器就是 XSS。
* 图标是管理员自己传的,但防线不该依赖「管理员不会传坏东西」。
*
* 定义在这里而不是 admin_taxonomy.php:media.php 与校验函数都会用到,
* 不能依赖后台那个文件被加载(它在 bootstrap 里并不 require)。
*/
function fb_icon_allowed_ext() {
return ['png', 'jpg', 'jpeg', 'webp'];
}
/**
* 这个键是不是软件图标。
* 同时认正常形态(local/icons/… 、<prefix>/icons/…)与历史形态(icons/…)。
* 历史形态必须认,否则用户早先上传、仍存在桶里的图标就成了读不到的孤儿。
*/
function fb_icon_is_key($key) {
return (bool)preg_match('#^(?:(?:local|[A-Za-z0-9][A-Za-z0-9._-]*)/)?icons/#', (string)$key);
}
/**
* 生成一个图标对象键 —— 存储位置由后台配置决定,与附件一致。
* local 模式带 `local/` 前缀,OSS 模式带 oss.prefix 前缀;
* 两种都能被 fb_attach_is_local() / fb_local_path_of() 正确解析。
*/
function fb_icon_make_key($ext) {
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
$leaf = fb_icon_key_prefix() . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
if (fb_store_mode() === 'local') {
return 'local/' . $leaf;
}
$prefix = trim((string)fb_cfg('oss.prefix', 'fb'), '/');
if ($prefix === '') { $prefix = 'fb'; }
return $prefix . '/' . $leaf;
}
/**
* 图标键是否合法。
* 接受两种形态:
* ① 正常形态:<local|oss.prefix>/icons/YYYY/MM/<16hex>.<ext>
* ② 历史形态:icons/YYYY/MM/<16hex>.<ext>(无存储前缀)
* —— 2026-11-03 之前的代码把键硬编码成这种样子,用户当时上传的图
* 真实存在于桶根下的 icons/ 目录。这些对象是有效资产,
* 不能因为键「格式不规范」就让图标显示不出来,所以这里认它。
* 新写入一律走正常形态,见 fb_icon_make_key()。
*
* 🔴 扩展名必须是白名单里的图片类型,不能只写 `[a-z0-9]{2,5}` ——
* 那样 `.php`(3 个字母)也会通过,等于给自己留了个写脚本的口子。
* 实际验证时就是这条先被漏掉了。
*/
function fb_icon_key_ok($key) {
$key = (string)$key;
if (strlen($key) < 16 || strlen($key) > 200) { return false; }
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
// 带存储前缀,或历史无前缀形态
if (!preg_match('#^(?:(?:local|[A-Za-z0-9][A-Za-z0-9._-]*)/)?icons/\d{4}/\d{2}/[a-f0-9]{16}\.([a-z0-9]{2,5})$#', $key, $m)) {
return false;
}
return in_array(strtolower($m[1]), fb_icon_allowed_ext(), true);
}
/**
* 图标图片的【对外地址】—— 一律走 media.php 代理。
* 与附件完全同一套约定:不暴露 uploads/ 真实路径,也不暴露任何 OSS 痕迹。
* 两种存储模式对外都是这一个形态,所以换存储方式不影响已显示的图标。
*
* @param string $key 图标对象键
* @param int $w 期望宽度(0=原图)。OSS 模式下交给服务端缩放;本机模式忽略。
* @return string 未经 HTML 转义的 URL(调用方自行 e() 转义)
*/
function fb_icon_url($key, $w = 0) {
$key = trim((string)$key);
if ($key === '' || !fb_icon_key_ok($key)) { return ''; }
if (!fb_attach_is_local($key) && function_exists('fb_oss_thumb_url')) {
return fb_oss_thumb_url($key, $w > 0 ? (int)$w : 96, 'image/png');
}
return fb_base_url() . '/media.php?k=' . rawurlencode($key);
}
/** key → 本机绝对路径(已做穿越防护) */ /** key → 本机绝对路径(已做穿越防护) */
function fb_local_path_of($key) { function fb_local_path_of($key) {
$rel = ltrim((string)$key, '/'); $rel = ltrim((string)$key, '/');
@@ -292,6 +396,16 @@ function fb_upload_key_ok($key) {
if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) { return false; } if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) { return false; }
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; } if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
/* 扩展名必须是本站认可的类型。
* 🔴 原先本机分支写的是 `[a-z0-9]{2,5}`,`.php`(3 个字母)正好能过 ——
* 虽然真上传还有 finfo 读魔数那道关,但「键本身就不该长这样」,
* 不该把安全押在下一层校验上(换 nginx、改配置都可能绕过那层)。
* OSS 分支同理:键的扩展名也要过白名单,否则 media.php 会照扩展名片面给 Content-Type。 */
$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'mp4', 'mov', 'webm'], true)) {
return false;
}
if (fb_attach_is_local($key)) { if (fb_attach_is_local($key)) {
// 本机存储:必须是 local/YYYY/MM/随机名.扩展名 // 本机存储:必须是 local/YYYY/MM/随机名.扩展名
return (bool)preg_match('#^local/\d{4}/\d{2}/[a-f0-9]{16}\.[a-z0-9]{2,5}$#', $key); return (bool)preg_match('#^local/\d{4}/\d{2}/[a-f0-9]{16}\.[a-z0-9]{2,5}$#', $key);
+1 -1
View File
@@ -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', 16); } if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 53); }
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
+65 -8
View File
@@ -94,17 +94,39 @@ CREATE TABLE IF NOT EXISTS threads (
author_name TEXT NOT NULL, author_name TEXT NOT NULL,
author_email TEXT NOT NULL, author_email TEXT NOT NULL,
author_phone TEXT, author_phone TEXT,
status TEXT NOT NULL DEFAULT 'open', status TEXT NOT NULL DEFAULT 'hidden',
reply_count INTEGER NOT NULL DEFAULT 0, reply_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
ip_hash TEXT ip_hash TEXT,
track_token TEXT NOT NULL DEFAULT ''
); );
SQL SQL
); );
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads(category)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads(category)');
/* ---- threads.track_token:提交人的「私人追踪钥匙」 ----
* 因为本站不做用户登录,没办法认人;但每个人都必须能跟踪自己提交的反馈,
* 哪怕是还没审核通过的。做法:提交时生成一串随机 token 只发到他的邮箱,
* 链接带 ?t=<token> 就能看 / 能回复,别人不知道这串东西就看不到。
*
* 🔴 不要用 id 代替:id 是自增的,任何人改个数字就能翻别人的私有反馈。
* token 必须是不可猜的随机值(这里用 32 位 hex = 128 位熵)。 */
$thCols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
$thNames = [];
foreach ($thCols as $c4) { if (isset($c4['name'])) { $thNames[(string)$c4['name']] = true; } }
if (!isset($thNames['track_token'])) {
$pdo->exec("ALTER TABLE threads ADD COLUMN track_token TEXT NOT NULL DEFAULT ''");
}
// 存量回填:给还没有 token 的帖子补一个,保证老数据的邮件链接也能用
$need = $pdo->query("SELECT id FROM threads WHERE track_token IS NULL OR track_token = ''")->fetchAll();
if ($need) {
$upd = $pdo->prepare('UPDATE threads SET track_token = ? WHERE id = ?');
foreach ($need as $r) { $upd->execute([bin2hex(random_bytes(16)), (int)$r['id']]); }
}
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_token ON threads(track_token)');
$pdo->exec(<<<'SQL' $pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS replies ( CREATE TABLE IF NOT EXISTS replies (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -324,6 +346,23 @@ SQL
if (!isset($swNames['color'])) { if (!isset($swNames['color'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''"); $pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''");
} }
/* ---- 软件增加图标(多软件并存时靠「图标 + 名字 + 颜色」三重区分)----
* icon 存一个字符(emoji / 汉字 / 字母)—— 轻量兜底,没有图片时用它;
* icon_key 存【上传图片的对象键】—— 有它就优先用图片。
* 图片走与附件相同的存储与代理(local/... 或 <prefix>/...),
* 前端只输出 media.php?k=... 代理地址,不暴露真实位置。
* 两者并存:先有字符兜底,用户上传图片后自动切到图片,删掉图片又退回字符,
* 任何时刻都不会出现「没有图标」。 */
if (!isset($swNames['icon'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN icon TEXT NOT NULL DEFAULT ''");
}
if (!isset($swNames['icon_key'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN icon_key TEXT NOT NULL DEFAULT ''");
}
/* ---- 软件增加下载地址(状态「已解决」的邮件里给用户一个去下新版的入口)---- */
if (!isset($swNames['download_url'])) {
$pdo->exec("ALTER TABLE software ADD COLUMN download_url TEXT NOT NULL DEFAULT ''");
}
/* ---- threads 增加两列(老 category 文本列保留,不删)---- */ /* ---- threads 增加两列(老 category 文本列保留,不删)---- */
$cols = $pdo->query('PRAGMA table_info(threads)')->fetchAll(); $cols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
@@ -382,18 +421,36 @@ SQL
} }
} }
// 3) 状态:空表时灌 5 个(4 个原有 + 新增「不予考虑」) // 3) 状态:空表时灌全部状态
if ((int)$pdo->query('SELECT COUNT(*) FROM statuses')->fetchColumn() === 0) { if ((int)$pdo->query('SELECT COUNT(*) FROM statuses')->fetchColumn() === 0) {
$rows = [ $rows = [
['open', '待处理', '您提交的反馈已收到,我们正在处理中。', 0], // key_name 名称 给用户看的说明(进邮件) 排序
['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 1], ['hidden', '未公开', '您的反馈已提交,正在等待管理员查看。', 0],
['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 2], ['open', '待处理', '您的反馈已公开,我们已收到,会尽快安排处理。', 1],
['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 3], ['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 2],
['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢你的建议。', 4], ['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 3],
['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 4],
['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢您的建议。', 5],
]; ];
$st = $pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled) $st = $pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled)
VALUES(?,?,?,?,1)'); VALUES(?,?,?,?,1)');
foreach ($rows as $r) { $st->execute($r); } foreach ($rows as $r) { $st->execute($r); }
} else {
/* 老库补「未公开」状态。
* 2026-11-03 新增:用户提交后先落「未公开」,审核通过才转「待处理」——
* 「未公开」本身就是一种状态,不该只靠 threads.visible=0 这个附加字段隐式表达。 */
$hasHidden = (int)$pdo->query("SELECT COUNT(*) FROM statuses WHERE key_name = 'hidden'")->fetchColumn();
if ($hasHidden === 0) {
// 先把现有状态整体后移一位,给「未公开」腾出 0 号位
$pdo->exec('UPDATE statuses SET sort_order = sort_order + 1');
$pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled)
VALUES(?,?,?,?,1)')
->execute(['hidden', '未公开', '您的反馈已提交,正在等待管理员查看。', 0]);
/* 存量数据:visible=0 且状态还是「待处理」的,语义上就是「没公开」,
* 归到「未公开」;visible=1 的保持「待处理」不动。 */
$pdo->exec("UPDATE threads SET status = 'hidden' WHERE visible = 0 AND status = 'open'");
}
} }
// 4) 回填老帖子:software_id / category_id(只填 NULL 的,重复执行无副作用) // 4) 回填老帖子:software_id / category_id(只填 NULL 的,重复执行无副作用)
+148 -5
View File
@@ -40,25 +40,168 @@ function fb_layout_head($title, array $opt = []) {
<link rel="manifest" href="assets/site.webmanifest"> <link rel="manifest" href="assets/site.webmanifest">
<link rel="stylesheet" href="assets/style.css?v=<?php echo (int)FB_ASSET_V; ?>"> <link rel="stylesheet" href="assets/style.css?v=<?php echo (int)FB_ASSET_V; ?>">
<?php if ($swColors): ?> <?php if ($swColors): ?>
<?php /* 按软件覆写主题色:只改这三个变量,其它样式自动跟着变 */ ?> <?php
/* 按软件覆写主题色。
* --brand-tint-1 / --brand-tint-2 是给下拉项用的两档浅色:
* tint-1(与白混 91%)→ hover
* tint-2(与白混 80%)→ 选中项,比 hover 更深一档,一眼能分开
* 在 PHP 里算好再输出,而不是 CSS 的 color-mix():
* 实测 color-mix 配 var(--brand) 在 Chrome 里解析成透明,规则匹配上却不生效。 */
$tint1 = fb_color_tint($swColors[0], 0.91);
$tint2 = fb_color_tint($swColors[0], 0.80);
$tint3 = fb_color_tint($swColors[0], 0.72); // 选中项 hover 时再深一点
?>
<style>:root{ <style>:root{
--brand:<?php echo e($swColors[0]); ?>; --brand:<?php echo e($swColors[0]); ?>;
--brand-600:<?php echo e($swColors[1]); ?>; --brand-600:<?php echo e($swColors[1]); ?>;
--brand-soft:<?php echo e($swColors[2]); ?>; --brand-soft:<?php echo e($swColors[2]); ?>;
--brand-tint-1:<?php echo e($tint1); ?>;
--brand-tint-2:<?php echo e($tint2); ?>;
--brand-tint-3:<?php echo e($tint3); ?>;
}</style> }</style>
<?php endif; ?> <?php endif; ?>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="wrap"> <div class="wrap">
<?php
/* 顶栏品牌位:当前在哪个软件下,就显示「图标 + XX 反馈系统」。
* 这是页面唯一的主标题 —— 多软件共用一套反馈系统,
* 用户要一眼看清「我现在是在给哪个软件的反馈系统提问题」。
* 内容区不再重复一遍同样的文字,只保留「图标 + 软件名」。
*
* 字号刻意比「选择软件」那个标签大一号左右(17px),
* 不再压过内容区标题 —— 之前给到 26px 反而比下面的字还小,
* 视觉上主次颠倒。 */
$curSw = isset($opt['software']) && is_array($opt['software']) ? $opt['software'] : null;
$swList = fb_softwares(true);
/* ---- 后台模式 ----
* 用户 2026-11-03 问:「管理后台还需要『我要反馈』吗?还需要右上角三横线吗?
* 还需要下拉选软件吗?这些是不是都不需要了?」
* ⇒ 都不需要,全去掉。理由:
* ① 「我要反馈」是给访客用的入口,管理员不该从后台跳去前台表单;
* ② 后台是**跨软件**视图(待审核/全部问题本身就带软件筛选器),
* 再放一个切软件的菜单是重复的,而且切了也不会改变后台视图;
* ③ 「问题列表」依赖 ?sw= 当前软件上下文,后台没有这个上下文,
* 点了只会跳到前台某个软件,属于误导。
* 后台保留的只有:站点标识(回首页)+ 管理后台 + 退出登录。 */
$isAdminBar = !empty($opt['admin']);
?>
<?php if ($curSw): ?>
<?php
/* 顶栏标题用该软件的主题色(用户 2026-11-03 要求):
* 每个软件都配了颜色,标题仍用统一黑色等于白配。
* --swc 传给 CSS,让 .sw-badge-brand 的文字取这个颜色。 */
list($curSwColor) = fb_software_colors($curSw);
if ($curSwColor === '') { $curSwColor = '#0057B8'; }
?>
<?php /* 🔴 点标题回到**当前软件**的列表,不是回首页。
用户 2026-11-03:「点完之后应该还是进入这个反馈系统,而不是进到首页,
首页其实没有太大作用 —— 它只是演示/说明用的。
正常人用的时候都是每个软件独立使用,要让用户觉得这套反馈系统
就是专门为这个软件做的。」
⇒ 在某个软件的反馈系统里,标题 / 「问题列表」/「我要反馈」
全都只跟这个软件有关,跟其他软件无关。
想切别的软件,只有右上角三横线那一个入口。 */ ?>
<?php
/* 软件的一句话简介(software.note)放在标题正下方,灰色小字。
* 用户 2026-11-03:「把「优秀的鸿蒙 CalDAV 同步客户端」放在最上面
* 那个「同步日历反馈系统」下面那个位置,用个灰色的。」
* 这样内容区就不用再重复一遍标题和简介了。 */
$swNoteTop = !empty($curSw['note']) ? trim((string)$curSw['note']) : '';
/* 图标要单独拿出来放在左边,才能与「标题 + 简介」两行**整体**垂直居中。
* 🔴 用户 2026-11-03:「图标应该在标题和简介的正中间…现在图标和上面的标题
* 是对齐的、在图标的正中间…把图标往下放一放,让标题和它下面的简介合起来
* 和图标高度一样,两个对齐上下。」
* ⇒ 结构必须是「图标 | 文字列」两栏,所以这里不用 fb_software_badge()
* (那个函数把图标和名字包在同一个 .sw-badge 里,天然是单行)。
* 改用 fb_software_icon()/icon_key 自己拼图标,名字单独放右侧。 */
$bIcon = fb_software_icon($curSw);
$bKey = trim((string)(isset($curSw['icon_key']) ? $curSw['icon_key'] : ''));
$bImg = $bKey !== '' ? fb_icon_url($bKey, 64) : '';
$bTitle = fb_software_site_title($curSw);
?>
<a class="brand-mini" href="<?php echo e(fb_software_list_url($curSw)); ?>"
style="--swc:<?php echo e($curSwColor); ?>"
title="<?php echo e($bTitle); ?>">
<span class="brand-ic" aria-hidden="true">
<?php if ($bImg !== ''): ?>
<img src="<?php echo e($bImg); ?>" alt="" width="30" height="30" loading="lazy">
<?php else: ?>
<span class="brand-ic-ch"><?php echo e($bIcon); ?></span>
<?php endif; ?>
</span>
<span class="brand-txt">
<span class="brand-title"><?php echo e($bTitle); ?></span>
<?php if ($swNoteTop !== ''): ?>
<span class="brand-mini-note"><?php echo e($swNoteTop); ?></span>
<?php endif; ?>
</span>
</a>
<?php else: ?>
<a class="brand-mini" href="./" title="<?php echo e($site); ?>"> <a class="brand-mini" href="./" title="<?php echo e($site); ?>">
<span class="logo-mark" role="img" aria-label="轻反馈图标"></span> <span class="logo-mark" role="img" aria-label="轻反馈图标"></span>
<span class="brand-text"><?php echo e($site); ?></span> <span class="brand-text"><?php echo e($site); ?></span>
</a> </a>
<?php endif; ?>
<div class="spacer"></div> <div class="spacer"></div>
<a class="navlink hide-sm" href="./">全部问题</a> <?php
<?php if (fb_admin_logged_in()): ?><a class="navlink" href="admin.php">管理后台</a><?php endif; ?> /* 🔴 后台模式:这三样全都不渲染。
<a class="btn-mini" href="new.php">我要反馈</a> * 用户 2026-11-03 问「后台还需要我要反馈吗?还需要三横线吗?还需要选软件吗?」
* ⇒ 都不需要,理由写在上面的 $isAdminBar 注释里。
*
* 后台顶栏只留「回前台看看」一个小按钮 —— 管理员有时需要确认
* 前台实际显示效果,这是唯一有意义的出口。
* 「管理后台」本身不标(已经在后台了,且下面的页头有大标题+退出登录),
* 避免一排没用的标记占位置。 */
if ($isAdminBar):
?>
<?php if ($curSw): ?>
<a class="navlink" href="<?php echo e(fb_software_list_url($curSw)); ?>">去前台看看</a>
<?php else: ?>
<a class="navlink" href="index.php">去前台看看</a>
<?php endif; ?>
<?php else: ?>
<?php
/* 「问题列表」只在进入某个软件之后才有意义 ——
* 首页现在是介绍页,没有"全部问题"可回。 */
if ($curSw):
?>
<a class="navlink hide-sm" href="?sw=<?php echo e(urlencode((string)$curSw['slug'])); ?>">问题列表</a>
<?php endif; ?>
<?php if (fb_admin_logged_in()): ?><a class="navlink hide-sm" href="admin.php">管理后台</a><?php endif; ?>
<?php /* ⚠️ 这里**不能**加 hide-sm:列表页那个「我要反馈」大按钮已删除,
「我要反馈」现在全站只剩顶栏这一个入口。
如果手机上把它隐藏掉,手机端就没有提交入口了。 */ ?>
<a class="btn-mini" href="new.php<?php echo $curSw ? '?sw=' . e(urlencode((string)$curSw['slug'])) : ''; ?>">我要反馈</a>
<?php endif; /* isAdminBar */ ?>
<?php if (!$isAdminBar && count($swList) > 1): ?>
<?php /* 右上角三横线:点开就是一个小下拉菜单。
不用整页平铺「选择软件:」,也不必做成侧边栏。 */ ?>
<button type="button" class="sw-toggle" id="swToggle"
aria-label="切换软件" aria-expanded="false" aria-controls="swPanel">
<span></span><span></span><span></span>
</button>
<?php /* 极简下拉:贴在按钮下方、右对齐,内容多高就多高。
参考家书 FamilyBook 顶栏那个小浮窗的做法。 */ ?>
<div class="sw-panel" id="swPanel" hidden>
<div class="sw-panel-list">
<?php foreach ($swList as $s): ?>
<?php
$on = $curSw && (int)$s['id'] === (int)$curSw['id'];
$href = fb_qs(['sw' => (string)$s['slug'], 'cat' => null, 'page' => null]);
if ($href === '') { $href = '?sw=' . urlencode((string)$s['slug']); }
?>
<a class="sw-item<?php echo $on ? ' on' : ''; ?>" href="<?php echo e($href); ?>">
<?php echo fb_software_badge($s, 'sm'); ?>
<?php if ($on): ?><span class="sw-item-cur">当前</span><?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div> </div>
</header> </header>
<main class="wrap"> <main class="wrap">
@@ -77,7 +220,7 @@ function fb_layout_foot() {
?> ?>
<footer class="foot"> <footer class="foot">
<div class="wrap"> <div class="wrap">
<p class="muted">本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p> <p class="muted">本站仅保存您提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
<p class="muted foot-line"> <p class="muted foot-line">
<?php if ($showCredit): ?><span><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 <a href="<?php echo e($creditUrl); ?>" target="_blank" rel="noopener">FeedLite</a> 驱动</span><?php endif; ?> <?php if ($showCredit): ?><span><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 <a href="<?php echo e($creditUrl); ?>" target="_blank" rel="noopener">FeedLite</a> 驱动</span><?php endif; ?>
<?php if ($showCredit): ?><span class="dot">·</span><?php endif; ?> <?php if ($showCredit): ?><span class="dot">·</span><?php endif; ?>
+351 -83
View File
@@ -128,8 +128,8 @@ function fb_smtp_send(array $s, $to, $subject, $html) {
* echo fb_mail_layout([ * echo fb_mail_layout([
* 'preheader' => '预览摘要(收件箱列表里显示的那行灰字)', * 'preheader' => '预览摘要(收件箱列表里显示的那行灰字)',
* 'badge' => ['text' => '处理中', 'color' => '#0a6cf5'], * 'badge' => ['text' => '处理中', 'color' => '#0a6cf5'],
* 'title' => '你的反馈已被受理', * 'title' => '您的反馈已被受理',
* 'intro' => '老杨,你好:', * 'intro' => '老杨,您好:',
* 'blocks' => [ ... ], // 内容块,见下 * 'blocks' => [ ... ], // 内容块,见下
* 'button' => ['text' => '查看详情', 'url' => 'https://...'], * 'button' => ['text' => '查看详情', 'url' => 'https://...'],
* 'footer' => '此邮件由系统自动发送。', * 'footer' => '此邮件由系统自动发送。',
@@ -144,14 +144,81 @@ if (!function_exists('fb_mail_esc')) {
} }
if (!function_exists('fb_mail_brand')) { if (!function_exists('fb_mail_brand')) {
/** 取站点主色,用于邮件头与按钮。跟随「软件主题色」体系,但邮件里只用主色与浅底 */ /**
* 邮件主色。
*
* 🔴 多软件站点的核心要求:邮件颜色必须跟「这条反馈属于哪个软件」一致,
* 否则用户在收件箱里根本分不清是哪个软件的来信。
* 取值顺序:
* ① 调用方显式传入的软件行(fb_mail_layout(['software' => ...]))
* ② 当前全局软件($GLOBALS['__fb_mail_sw'],由 fb_notify_render 设置)
* ③ 配置里的 mail_brand_color(单软件站点的老配置,保持兼容)
* ④ 兜底蓝
*/
function fb_mail_brand() { function fb_mail_brand() {
$sw = null;
if (isset($GLOBALS['__fb_mail_sw']) && is_array($GLOBALS['__fb_mail_sw'])) {
$sw = $GLOBALS['__fb_mail_sw'];
}
if ($sw) {
list($c) = fb_software_colors($sw);
if ($c !== '') { return $c; }
}
$c = fb_color_norm((string)fb_cfg('mail_brand_color', '')); $c = fb_color_norm((string)fb_cfg('mail_brand_color', ''));
if ($c === '') { $c = '#0057B8'; } if ($c === '') { $c = '#0057B8'; }
return $c; return $c;
} }
} }
if (!function_exists('fb_mail_sw_ctx')) {
/** 设置/读取「当前邮件所属软件」,返回之前的上下文(便于嵌套调用后还原) */
function fb_mail_sw_ctx($sw = null, $set = false) {
$prev = isset($GLOBALS['__fb_mail_sw']) ? $GLOBALS['__fb_mail_sw'] : null;
if ($set) { $GLOBALS['__fb_mail_sw'] = $sw; }
return $prev;
}
}
if (!function_exists('fb_mail_sw_header')) {
/**
* 邮件头部的「软件标识」:色块图标 + 软件名。
* 用 table + 内联样式写,兼容 Outlook 等不认 flex 的客户端。
*/
function fb_mail_sw_header($sw) {
if (!is_array($sw) || empty($sw['name'])) { return ''; }
list($c) = fb_software_colors($sw);
if ($c === '') { $c = '#0057B8'; }
$icon = fb_software_icon($sw);
$name = (string)$sw['name'];
/* 有上传的图标图片就用图片(管理员自己的 logo),没有才用字符。
* 一律不加背景色块 —— 图标本身已经能说明是哪个软件,
* 再垫个色块反而糊在一起(与前端 fb_software_badge 保持同一观感)。
* 邮件客户端对 flex 支持参差,这里一律用 table + 内联样式;
* 图片必须给死宽高,否则 Outlook 里会撑变形。 */
$imgKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, 60) : '';
$cell = '';
if ($imgUrl !== '') {
$cell = '<img src="' . fb_mail_esc($imgUrl) . '" alt="' . fb_mail_esc($name) . '"'
. ' width="30" height="30"'
. ' style="width:30px;height:30px;display:block;border:0;outline:none;text-decoration:none">';
} else {
$cell = '<div style="font-size:22px;line-height:30px;text-align:center;color:#1b2430">'
. '<span style="display:inline-block;line-height:30px">' . fb_mail_esc($icon) . '</span></div>';
}
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
. ' style="border-collapse:collapse"><tr>'
. '<td width="30" style="width:30px;vertical-align:middle">' . $cell . '</td>'
. '<td style="padding-left:9px;vertical-align:middle;font-size:15px;'
. 'font-weight:700;color:#1b2430;letter-spacing:.2px">'
. fb_mail_esc($name) . '</td>'
. '</tr></table>';
}
}
if (!function_exists('fb_mail_block')) { if (!function_exists('fb_mail_block')) {
/** /**
* 渲染一个内容块。 * 渲染一个内容块。
@@ -242,6 +309,10 @@ if (!function_exists('fb_mail_layout')) {
* @return string 完整 HTML(可直接作为 text/html 正文) * @return string 完整 HTML(可直接作为 text/html 正文)
*/ */
function fb_mail_layout(array $o) { function fb_mail_layout(array $o) {
// 软件上下文:显式传入优先,否则用全局上下文(fb_notify_render 会设好)
$sw = isset($o['software']) && is_array($o['software']) ? $o['software'] : null;
if ($sw) { fb_mail_sw_ctx($sw, true); } // 让本函数内 fb_mail_brand() 取到软件色
$brand = fb_mail_brand(); $brand = fb_mail_brand();
$site = (string)fb_cfg('site_name', '轻反馈'); $site = (string)fb_cfg('site_name', '轻反馈');
$base = fb_base_url(); $base = fb_base_url();
@@ -296,6 +367,36 @@ if (!function_exists('fb_mail_layout')) {
. fb_mail_esc($link) . '</span></p>'; . fb_mail_esc($link) . '</span></p>';
} }
/* ---- 私人链接安全提醒 ----
*
* 用户 2026-11-03:「每次给他发这种链接的时候,都要在链接下面或上面
* 给他一个提示:这个链接是你独有的,不要转给别人。」
*
* 为什么必须提醒:这个链接里带 track_token,它就是**唯一凭证**——
* 谁拿到这串东西,谁就能看(甚至回复)这条反馈。而本站不做登录,
* 我们没法事后判断"是本人还是捡到链接的人"。
* 只要邮件里带了私人链接(personal=true),这条提醒就必须出现。
*
* 措辞取舍:
* - 说清"是什么"(只有你能看的入口),而不只是"别外传"——只讲禁止,
* 用户会觉得是免责声明;讲清缘由他才会真的照做。
* - 不让用户恐慌:补一句"不需要的话可以删掉这封邮件",给出退路。
* - 管理员后台通知里的链接不是私人凭证,不加这段(personal=false)。 */
$privNoteHtml = '';
if (!empty($o['personal'])) {
$privNoteHtml = '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
. ' width="100%" style="margin:0 0 18px">'
. '<tr><td style="padding:11px 14px;background:#fff8e6;'
. 'border-left:3px solid #d99b1f;border-radius:6px">'
. '<p style="margin:0 0 5px;font-size:13px;font-weight:600;color:#6b5313">'
. '这个链接只有您能打开,请不要转发</p>'
. '<p style="margin:0;font-size:12.5px;line-height:1.7;color:#7a6420">'
. '它等同于您在这条反馈上的身份凭证 —— 谁拿到这个链接,谁就能查看和处理这条反馈。'
. '请只自己使用,不要发给别人,也不要发到公开的地方。'
. '如果不需要继续跟踪,删掉这封邮件即可。</p>'
. '</td></tr></table>';
}
// ---- 收件人问候 ---- // ---- 收件人问候 ----
$introHtml = ''; $introHtml = '';
if ($intro !== '') { if ($intro !== '') {
@@ -330,11 +431,18 @@ if (!function_exists('fb_mail_layout')) {
. '<tr><td style="height:4px;background:' . fb_mail_esc($brand) . ';font-size:0;' . '<tr><td style="height:4px;background:' . fb_mail_esc($brand) . ';font-size:0;'
. 'line-height:0">&nbsp;</td></tr>' . 'line-height:0">&nbsp;</td></tr>'
// 站名 // 站名 / 软件标识
// 有软件上下文时:上排是「图标 + 软件名」(这是用户最需要一眼认出的信息),
// 下排再补一行小字站点名。没有软件时退回原来的纯站名写法。
. '<tr><td style="padding:22px 32px 0">' . '<tr><td style="padding:22px 32px 0">'
. '<div style="font-size:13px;font-weight:700;letter-spacing:.6px;' . (isset($o['software']) && is_array($o['software'])
? fb_mail_sw_header($o['software'])
. '<div style="margin-top:7px;font-size:11.5px;letter-spacing:.4px;color:#9aa2ad">'
. fb_mail_esc($site) . '</div>'
: '<div style="font-size:13px;font-weight:700;letter-spacing:.6px;'
. 'color:' . fb_mail_esc($brand) . ';text-transform:uppercase">' . 'color:' . fb_mail_esc($brand) . ';text-transform:uppercase">'
. fb_mail_esc($site) . '</div></td></tr>' . fb_mail_esc($site) . '</div>')
. '</td></tr>'
// 标题 // 标题
. '<tr><td style="padding:12px 32px 4px">' . $badgeHtml . '<tr><td style="padding:12px 32px 4px">' . $badgeHtml
@@ -342,8 +450,10 @@ if (!function_exists('fb_mail_layout')) {
. 'color:#1b2430">' . fb_mail_esc($title) . '</h1></td></tr>' . 'color:#1b2430">' . fb_mail_esc($title) . '</h1></td></tr>'
// 正文 // 正文
// 顺序:按钮 → 私人链接提醒 → 纯文本兜底链接。
// 提醒要贴在按钮旁边(同一屏可见),不能埋到邮件最底部。
. '<tr><td style="padding:16px 32px 8px">' . '<tr><td style="padding:16px 32px 8px">'
. $introHtml . $body . $btnHtml . $linkHtml . $introHtml . $body . $btnHtml . $privNoteHtml . $linkHtml
. '</td></tr>' . '</td></tr>'
// 页脚 // 页脚
@@ -353,7 +463,7 @@ if (!function_exists('fb_mail_layout')) {
. ($recipient !== '' . ($recipient !== ''
? '<p style="margin:0;font-size:11.5px;line-height:1.6;color:#b3b9c2">' ? '<p style="margin:0;font-size:11.5px;line-height:1.6;color:#b3b9c2">'
. '这封邮件发给了 ' . fb_mail_esc($recipient) . '。' . '这封邮件发给了 ' . fb_mail_esc($recipient) . '。'
. '如果这不是你,忽略即可。</p>' : '') . '如果这不是您,忽略即可。</p>' : '')
. '<p style="margin:8px 0 0;font-size:11.5px;color:#b3b9c2">' . '<p style="margin:8px 0 0;font-size:11.5px;color:#b3b9c2">'
. '<a href="' . fb_mail_esc($base) . '" target="_blank"' . '<a href="' . fb_mail_esc($base) . '" target="_blank"'
. ' style="color:#9aa2ad;text-decoration:none">' . ' style="color:#9aa2ad;text-decoration:none">'
@@ -367,7 +477,8 @@ if (!function_exists('fb_mail_layout')) {
} }
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']; // 发给提问人的信,链接必须带 token,否则他看不了自己那条未公开的反馈
$url = fb_thread_link($thread, true);
$site = (string)fb_cfg('site_name'); $site = (string)fb_cfg('site_name');
$name = (string)$thread['author_name']; $name = (string)$thread['author_name'];
$title = (string)$thread['title']; $title = (string)$thread['title'];
@@ -378,8 +489,8 @@ function fb_notify_html($thread, $reply, $isAdmin) {
$safeRName = htmlspecialchars($rname, ENT_QUOTES, 'UTF-8'); $safeRName = htmlspecialchars($rname, ENT_QUOTES, 'UTF-8');
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">' return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
. '<p>' . $safeName . ',你好:</p>' . '<p>' . $safeName . ',您好:</p>'
. '<p>你在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>' . '<p>您在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>'
. '<blockquote style="margin:12px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">' . '<blockquote style="margin:12px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>' . '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>'
. $rbody . $rbody
@@ -426,19 +537,19 @@ function fb_notify_closed_html(array $thread, string $note) {
$mail = htmlspecialchars((string)fb_cfg('admin_email'), ENT_QUOTES, 'UTF-8'); $mail = htmlspecialchars((string)fb_cfg('admin_email'), ENT_QUOTES, 'UTF-8');
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">' return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
. '<p>' . $name . ',你好:</p>' . '<p>' . $name . ',您好:</p>'
. '<p>感谢你反馈 <strong>《' . $title . '》</strong>。我们已经核实过,' . '<p>感谢您反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
. '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>' . '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>'
. '<div style="margin:14px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">' . '<div style="margin:14px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">官方说明:</div>' . '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">官方说明:</div>'
. $noteH . $noteH
. '</div>' . '</div>'
. '<p style="color:#6b7280;font-size:13px">你当时反馈的内容:</p>' . '<p style="color:#6b7280;font-size:13px">您当时反馈的内容:</p>'
. '<div style="margin:8px 0 16px;padding:10px 14px;background:#f7f8fa;border-radius:6px;color:#4b5563;font-size:14px">' . '<div style="margin:8px 0 16px;padding:10px 14px;background:#f7f8fa;border-radius:6px;color:#4b5563;font-size:14px">'
. $body . $body
. '</div>' . '</div>'
. ($mail !== '' ? '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进;' . ($mail !== '' ? '<p>如果上面的说明没能解决您的疑问,可以直接回复本邮件,我们会继续跟进;'
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进。</p>') . '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决您的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">' . '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
. '<p style="color:#9aa2ad;font-size:12px">此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。</p>' . '<p style="color:#9aa2ad;font-size:12px">此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。</p>'
. '</div>'; . '</div>';
@@ -491,7 +602,7 @@ function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
* 邮件通知事件(工单 #2) * 邮件通知事件(工单 #2)
* *
* 全部事件: * 全部事件:
* reply 有人回复了你的反馈 → 发帖人 * reply 有人回复了您的反馈 → 发帖人
* status 处理状态变了 → 发帖人 * status 处理状态变了 → 发帖人
* approve 审核通过、已公开 → 发帖人 * approve 审核通过、已公开 → 发帖人
* hide 转为不公开 → 发帖人 * hide 转为不公开 → 发帖人
@@ -512,6 +623,7 @@ if (!function_exists('fb_notify_reason_label')) {
/** 事件的中文名,用于日志与模板徽标 */ /** 事件的中文名,用于日志与模板徽标 */
function fb_notify_reason_label($reason) { function fb_notify_reason_label($reason) {
$map = [ $map = [
'receipt' => '已收到反馈',
'reply' => '有新回复', 'reply' => '有新回复',
'status' => '状态更新', 'status' => '状态更新',
'approve' => '已发布', 'approve' => '已发布',
@@ -533,9 +645,15 @@ if (!function_exists('fb_notify_enabled_for')) {
* 它独立于前者 —— 就算关了给用户的信,管理员也可以想收通知 * 它独立于前者 —— 就算关了给用户的信,管理员也可以想收通知
*/ */
function fb_notify_enabled_for($reason) { function fb_notify_enabled_for($reason) {
// 「新反馈」按 admin_new_thread 独立开关;「提交回执」按 receipt_enabled 独立开关
// (回执是发给提问人的,跟「有人回复时通知提交人」是两件不同的事,
// 有人可能只想要回执不想要回复通知,所以不能共用一个开关)
if ($reason === 'new') { if ($reason === 'new') {
return (bool)fb_cfg('notify.admin_new_thread', false); return (bool)fb_cfg('notify.admin_new_thread', false);
} }
if ($reason === 'receipt') {
return (bool)fb_cfg('notify.receipt_enabled', true);
}
return (bool)fb_cfg('notify.enabled', true); return (bool)fb_cfg('notify.enabled', true);
} }
} }
@@ -581,8 +699,20 @@ if (!function_exists('fb_notify_event')) {
function fb_notify_event(PDO $pdo, $reason, array $thread, array $extra = []) { function fb_notify_event(PDO $pdo, $reason, array $thread, array $extra = []) {
$reason = (string)$reason; $reason = (string)$reason;
/* 🔴 铁律:任何一条退出路径都必须写 notify_log。
*
* 原先前 4 个判断(开关 / 收件人 / 自己回自己 / 冷却)直接 return 不记日志,
* 结果就是「用户说没收到信」时后台查不到任何记录 —— 因为压根就没进发信那一步。
* 2026-11-03 排查时 notify_log 全表为空,一度被误判成「发信链路坏了」,
* 实际是这些静默 return 造成的黑箱。日志必须回答「为什么没发」。
*/
$log = function ($to, $ok, $err) use ($pdo, $thread, $reason) {
fb_notify_log2($pdo, $thread, $to, $reason, $ok, $err);
};
// ---- 1. 开关 ---- // ---- 1. 开关 ----
if (!fb_notify_enabled_for($reason)) { if (!fb_notify_enabled_for($reason)) {
$log('', 0, '通知未启用(该事件的开关是关的)');
return [false, '通知未启用']; return [false, '通知未启用'];
} }
@@ -591,30 +721,56 @@ if (!function_exists('fb_notify_event')) {
if ($isAdminNotice) { if ($isAdminNotice) {
$to = trim((string)fb_cfg('admin_email', '')); $to = trim((string)fb_cfg('admin_email', ''));
if ($to === '') { $to = trim((string)fb_cfg('admin.email', '')); } if ($to === '') { $to = trim((string)fb_cfg('admin.email', '')); }
// 还没填官方邮箱时回落到 SMTP 发件地址 —— 至少信能到管理员手上,
// 而不是整个「新反馈通知」功能静默失效(实际就发生过:管理员从不收信,
// 后台也没有任何提示,直到来测才发现)。
if ($to === '') { $to = trim((string)fb_cfg('smtp.user', '')); }
if ($to === '') { $to = trim((string)fb_cfg('smtp.from', '')); }
} else { } else {
$to = trim((string)(isset($thread['author_email']) ? $thread['author_email'] : '')); $to = trim((string)(isset($thread['author_email']) ? $thread['author_email'] : ''));
} }
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
return [false, '收件邮箱无效']; $why = $isAdminNotice
? '收件邮箱无效:官方邮箱与 SMTP 发件地址都没填,新反馈通知发不出去'
: '收件邮箱无效:提交人没留邮箱或格式不对';
$log($to, 0, $why);
return [false, $why];
} }
// ---- 3. 自己通知自己就别发了 ---- /* ---- 3. 自己通知自己就别发了 ----
if (!$isAdminNotice && !empty($extra['is_admin']) === false *
&& !empty($extra['reply']['author_email'])) { * 用户 2026-11-03:「他自己回复他自己的反馈,不要再给他发邮件了 ——
if (strcasecmp(trim((string)$extra['reply']['author_email']), $to) === 0) { * 他自己说的,他发给他自己,不是很奇怪吗?但如果是别人回复他的,
return [false, '自己回复自己,不发']; * 就可以给他发邮件。」
*
* 覆盖三种「其实是同一个人」的情形:
* ① 提交人自己回复(带 token 进来,服务端会把邮箱强制设为原作者);
* ② 回复者填的邮箱与提交人相同(普通访客碰巧同邮箱);
* ③ 邮箱大小写 / 前后空格不同(DAO@x.com vs dao@x.com)。
*
* ⚠️ 旧写法是 `!empty($extra['is_admin']) === false` —— 双重否定绕,
* 读起来要停一下才知道在判什么。这里改成直接判 is_admin。
* ⚠️ 官方(管理员)回复不在豁免之列:管理员邮箱通常与提交人不同,
* 但万一相同(管理员给自己提的反馈回复),也不该给自己发信。 */
if (!$isAdminNotice) {
$replyMail = isset($extra['reply']['author_email'])
? trim((string)$extra['reply']['author_email']) : '';
if ($replyMail !== '' && strcasecmp($replyMail, $to) === 0) {
$log($to, 0, '本人回复自己的反馈,不发信');
return [false, '本人回复自己的反馈,不发信'];
} }
} }
// ---- 4. 节流 ---- // ---- 4. 节流 ----
if (fb_notify_throttled($pdo, (int)$thread['id'], $to, $reason)) { if (fb_notify_throttled($pdo, (int)$thread['id'], $to, $reason)) {
$log($to, 0, '冷却中跳过(notify.cooldown_seconds=' . (int)fb_cfg('notify.cooldown_seconds', 300) . ')');
return [false, '冷却中,跳过']; return [false, '冷却中,跳过'];
} }
// ---- 5. SMTP 就绪吗 ---- // ---- 5. SMTP 就绪吗 ----
$smtp = (array)fb_cfg('smtp', []); $smtp = (array)fb_cfg('smtp', []);
if (empty($smtp['enabled']) || trim((string)$smtp['host']) === '') { if (empty($smtp['enabled']) || trim((string)$smtp['host']) === '') {
fb_notify_log2($pdo, $thread, $to, $reason, 0, 'SMTP 未启用或未配置服务器'); $log($to, 0, 'SMTP 未启用或未配置服务器');
return [false, 'SMTP 未启用或未配置服务器']; return [false, 'SMTP 未启用或未配置服务器'];
} }
@@ -623,7 +779,7 @@ if (!function_exists('fb_notify_event')) {
// ---- 7. 发送 ---- // ---- 7. 发送 ----
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html); list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
fb_notify_log2($pdo, $thread, $to, $reason, $ok ? 1 : 0, $ok ? null : $err); $log($to, $ok ? 1 : 0, $ok ? null : $err);
return [$ok, $ok ? '' : (string)$err]; return [$ok, $ok ? '' : (string)$err];
} }
} }
@@ -662,8 +818,16 @@ if (!function_exists('fb_notify_log2')) {
} }
} }
if (!function_exists('fb_notify_thread_url')) { if (!function_exists('fb_notify_thread_url')) {
/**
* 邮件里给提问人的链接 —— **一律带 token**,不带 id 裸链。
*
* 为什么:邮件只发给提问人自己,而 token 只有他和我们(库里)知道。
* 带上它,即使这条反馈还没审核通过、甚至最终被关闭,他点进去也能看到
* 完整详情与进度。早期给的是 thread.php?id=N,未公开时点进去只看到
* 「不存在」或「等待审核」,等于邮件里的链接是废的。
*/
function fb_notify_thread_url(array $thread) { function fb_notify_thread_url(array $thread) {
return fb_base_url() . '/thread.php?id=' . (int)$thread['id']; return fb_thread_link($thread, true);
} }
} }
@@ -678,35 +842,44 @@ if (!function_exists('fb_notify_status_meta')) {
*/ */
function fb_notify_status_meta($key) { function fb_notify_status_meta($key) {
$map = [ $map = [
/* 「未公开」:用户刚提交时就是这个状态。
* 一般不单独发信(提交回执已经说了「等待审核」),
* 但万一从别的状态退回来,也要有话可说,不能落到兜底文案。 */
'hidden' => [
'badge' => '未公开', 'color' => '#6b7684', 'tone' => 'muted',
'title' => '您的反馈已提交,正在等待审核',
'intro' => '管理员会尽快查看您的反馈,确认后才会公开显示。',
'tip' => '审核结果我们会通过邮件通知您,无需反复回来查看。',
],
'open' => [ 'open' => [
'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted', 'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted',
'title' => '你的反馈已收到,正在等待处理', 'title' => '您的反馈已收到,正在等待处理',
'intro' => '我们已经收到你的反馈,会尽快安排处理。', 'intro' => '我们已经收到您的反馈,会尽快安排处理。',
'tip' => null, 'tip' => null,
], ],
'doing' => [ 'doing' => [
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info', 'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
'title' => '你反馈的问题正在处理中', 'title' => '您反馈的问题正在处理中',
'intro' => '我们已经开始跟进你反馈的问题,有进展会第一时间告诉你。', 'intro' => '我们已经开始跟进您反馈的问题,有进展会第一时间告诉您。',
'tip' => '感谢你的耐心等待,暂时不需要你做任何操作。', 'tip' => '感谢您的耐心等待,暂时不需要您做任何操作。',
], ],
'resolved' => [ 'resolved' => [
'badge' => '已解决', 'color' => '#1e7b3c', 'tone' => 'ok', 'badge' => '已完成', 'color' => '#1e7b3c', 'tone' => 'ok',
'title' => '你反馈的问题已解决', 'title' => '您反馈的问题已完成并上线',
'intro' => '好消息,你反馈的问题我们已经处理完成。', 'intro' => '好消息:您反馈的内容我们已经处理完成,并已发布上线。',
'tip' => '如果问题仍然存在,直接在本页回复即可,我们会继续跟进。', 'tip' => '建议更新到最新版本后试一下。如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
], ],
'closed' => [ 'closed' => [
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn', 'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
'title' => '你反馈的问题已关闭', 'title' => '您反馈的问题已关闭',
'intro' => '你反馈的问题已经关闭。', 'intro' => '您反馈的问题已经关闭。',
'tip' => null, 'tip' => null,
], ],
'wontfix' => [ 'wontfix' => [
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn', 'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
'title' => '关于你反馈的问题,我们的处理意见', 'title' => '关于您反馈的问题,我们的处理意见',
'intro' => '感谢你的反馈。经核实,这条反馈我们暂时不会改动,原因如下。', 'intro' => '感谢您的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
'tip' => '如果你认为这个判断有误,或者有补充信息,直接在本页回复即可。', 'tip' => '如果您认为这个判断有误,或者有补充信息,直接在本页回复即可。',
], ],
]; ];
return isset($map[$key]) ? $map[$key] : $map['open']; return isset($map[$key]) ? $map[$key] : $map['open'];
@@ -731,6 +904,24 @@ if (!function_exists('fb_notify_render')) {
$short = fb_excerpt($title, 42); $short = fb_excerpt($title, 42);
$isAdmin = !empty($extra['is_admin']); $isAdmin = !empty($extra['is_admin']);
/* ---- 软件上下文:整封邮件的配色、头部标识、主题前缀都靠它 ----
* 每条反馈都属于某个软件。邮件必须让用户一眼看出「这是哪个软件的来信」,
* 所以这里解析一次,后面所有分支共用:
* $swName 软件名(进主题行前缀)
* $sw 软件行(进邮件头标识与配色)
*
* 🔴 允许 $extra['software'] 显式指定软件行:
* ① 后台「发送测试邮件」可以指定「按某个软件的样子发」,便于预览配色;
* ② 测试脚本能在不往库里插数据的前提下验证配色切换;
* ③ 万一 threads.software_id 指向已被删除的软件,调用方仍能给出正确上下文。
* 没有显式指定时,才按 software_id 回查、再回落默认软件。 */
$sw = (isset($extra['software']) && is_array($extra['software'])) ? $extra['software'] : null;
if (!$sw) { $sw = fb_software_of_thread($thread); }
$swName = $sw ? (string)$sw['name'] : '';
fb_mail_sw_ctx($sw, true);
// 主题统一加软件名前缀,收件箱列表里不点开就能分辨
$pfx = ($swName !== '') ? '【' . $swName . '】' : '';
$subject = ''; $subject = '';
$layout = []; $layout = [];
@@ -741,26 +932,36 @@ if (!function_exists('fb_notify_render')) {
$rbody = (string)(isset($reply['body']) ? $reply['body'] : ''); $rbody = (string)(isset($reply['body']) ? $reply['body'] : '');
$isOff = $isAdmin ? '官方回复' : '新回复'; $isOff = $isAdmin ? '官方回复' : '新回复';
$subject = '【' . $site . '】' . $isOff . ':' . $short; $subject = $pfx . $isOff . ':' . $short;
/* 像博客评论通知那样带上上下文:
* 用户可能同时提过好几条反馈,只给「回复内容」根本想不起来是哪一条。
* 所以固定三段:① 您反馈了什么 ② 谁回复了什么 ③ 去哪里继续。 */
$origBody = (string)(isset($thread['body']) ? $thread['body'] : '');
$blocks = [ $blocks = [
['type' => 'text', 'text' => '你在「' . $site . '」提交的反馈有新的回复。'], ['type' => 'text', 'text' => ($swName !== '' ? '您在「' . $swName . '」提交的反馈' : '您提交的反馈')
. '有新的回复。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]], ['type' => 'field', 'rows' => ['反馈标题' => $title]],
['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody],
]; ];
if (trim($origBody) !== '') {
$blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => fb_excerpt($origBody, 220)];
}
$blocks[] = ['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody];
if ($isAdmin) { if ($isAdmin) {
$blocks[] = ['type' => 'callout', 'tone' => 'info', $blocks[] = ['type' => 'callout', 'tone' => 'info',
'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。']; 'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。'];
} }
$layout = [ $layout = [
'software' => $sw,
'preheader' => $rname . ':' . fb_excerpt($rbody, 60), 'preheader' => $rname . ':' . fb_excerpt($rbody, 60),
'badge' => ['text' => $isOff, 'color' => $isAdmin ? '#0057B8' : '#5c6572'], 'badge' => ['text' => $isOff, 'color' => $isAdmin ? fb_mail_brand() : '#5c6572'],
'title' => $isAdmin ? '官方回复了你的反馈' : '有人回复了你的反馈', 'title' => $isAdmin ? '官方回复了您的反馈' : '有人回复了您的反馈',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'), 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks, 'blocks' => $blocks,
'button' => ['text' => '查看并回复', 'url' => $url], 'button' => ['text' => '查看并回复', 'url' => $url],
'link' => $url, 'link' => $url,
'personal' => true, // 发给提交人:带 token 的私人链接,需提醒勿外传
'recipient' => $to, 'recipient' => $to,
]; ];
} }
@@ -775,9 +976,10 @@ if (!function_exists('fb_notify_render')) {
$label = fb_status_label($newKey); $label = fb_status_label($newKey);
if ($label === '') { $label = $meta['badge']; } if ($label === '') { $label = $meta['badge']; }
$subject = '【' . $site . '】' . $short . ' —— ' . $label; $subject = $pfx . $short . ' —— ' . $label;
$rows = ['反馈标题' => $title]; $rows = ['反馈标题' => $title];
if ($swName !== '') { $rows['所属软件'] = $swName; }
if ($oldKey !== '' && $oldKey !== $newKey) { if ($oldKey !== '' && $oldKey !== $newKey) {
$oldLabel = fb_status_label($oldKey); $oldLabel = fb_status_label($oldKey);
if ($oldLabel !== '') { $rows['原状态'] = $oldLabel; } if ($oldLabel !== '') { $rows['原状态'] = $oldLabel; }
@@ -795,34 +997,50 @@ if (!function_exists('fb_notify_render')) {
$blocks[] = ['type' => 'callout', 'tone' => $meta['tone'], 'text' => $meta['tip']]; $blocks[] = ['type' => 'callout', 'tone' => $meta['tone'], 'text' => $meta['tip']];
} }
/* 「已解决」时给用户一个按钮去验证。
*
* ⚠️ 2026-11-04:后台的「下载地址」字段已删除,
* 所以这里不再判断 $sw['download_url'] —— 那个值只可能是历史遗留。
* 按钮统一为「查看详情」,不再有「下载最新版本」分支。
* 想要恢复的话:后台加回字段 + 这里加回判断即可。 */
$btnText = '查看详情';
$btnUrl = $url;
$layout = [ $layout = [
'software' => $sw,
'preheader' => '状态更新为「' . $label . '」', 'preheader' => '状态更新为「' . $label . '」',
'badge' => ['text' => $meta['badge'], 'color' => $meta['color']], 'badge' => ['text' => $meta['badge'], 'color' => $meta['color']],
'title' => $meta['title'], 'title' => $meta['title'],
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'), 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks, 'blocks' => $blocks,
'button' => ['text' => '查看详情', 'url' => $url], 'button' => ['text' => $btnText, 'url' => $btnUrl],
'link' => $url, // 下载按钮之外,始终保留「查看这条反馈」的原文链接
'link' => ($btnUrl === $url) ? $url
: ('下载地址:' . $dlUrl . "\n" . '查看反馈:' . $url),
'personal' => true, // 发给提交人
'recipient' => $to, 'recipient' => $to,
]; ];
} }
/* ---------------- 3. 审核通过 ---------------- */ /* ---------------- 3. 审核通过 ---------------- */
elseif ($reason === 'approve') { elseif ($reason === 'approve') {
$subject = '【' . $site . '】你的反馈已发布:' . $short; $subject = $pfx . '您的反馈已发布:' . $short;
$layout = [ $layout = [
'preheader' => '你的反馈已经公开可见', 'software' => $sw,
'preheader' => '您的反馈已经公开可见',
'badge' => ['text' => '已发布', 'color' => '#1e7b3c'], 'badge' => ['text' => '已发布', 'color' => '#1e7b3c'],
'title' => '你的反馈已通过审核并公开', 'title' => '您的反馈已通过审核并公开',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'), 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => [ 'blocks' => [
['type' => 'text', 'text' => '你提交的反馈已通过审核,现在可以在公开列表中看到了。'], ['type' => 'text', 'text' => '您提交的反馈已通过审核,现在可以在'
. ($swName !== '' ? '「' . $swName . '」的' : '') . '公开列表中看到了。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]], ['type' => 'field', 'rows' => ['反馈标题' => $title]],
['type' => 'callout', 'tone' => 'info', ['type' => 'callout', 'tone' => 'info',
'text' => '后续的处理进展与回复,我们会通过邮件通知你。'], 'text' => '后续的处理进展与回复,我们会通过邮件通知您。'],
], ],
'button' => ['text' => '查看这条反馈', 'url' => $url], 'button' => ['text' => '查看并跟踪这条反馈', 'url' => $url],
'link' => $url, 'link' => $url,
'personal' => true, // 发给提交人
'recipient' => $to, 'recipient' => $to,
]; ];
} }
@@ -830,26 +1048,28 @@ if (!function_exists('fb_notify_render')) {
/* ---------------- 4. 转为不公开 ---------------- */ /* ---------------- 4. 转为不公开 ---------------- */
elseif ($reason === 'hide') { elseif ($reason === 'hide') {
$note = trim((string)(isset($extra['note']) ? $extra['note'] : '')); $note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
$subject = '【' . $site . '】你的反馈已转为不公开:' . $short; $subject = $pfx . '您的反馈已转为不公开:' . $short;
$blocks = [ $blocks = [
['type' => 'text', 'text' => '你提交的反馈已从公开列表中撤下,不再对其他人可见。'], ['type' => 'text', 'text' => '您提交的反馈已从公开列表中撤下,不再对其他人可见。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]], ['type' => 'field', 'rows' => ['反馈标题' => $title]],
]; ];
if ($note !== '') { if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note]; $blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
} }
$blocks[] = ['type' => 'callout', 'tone' => 'muted', $blocks[] = ['type' => 'callout', 'tone' => 'muted',
'text' => '这条反馈本身仍然保留,你依然可以通过原链接访问和处理进展。']; 'text' => '这条反馈本身仍然保留,您依然可以通过原链接访问和处理进展。'];
$layout = [ $layout = [
'software' => $sw,
'preheader' => '这条反馈已不对其他人可见', 'preheader' => '这条反馈已不对其他人可见',
'badge' => ['text' => '不公开', 'color' => '#5c6572'], 'badge' => ['text' => '不公开', 'color' => '#5c6572'],
'title' => '你的反馈已转为不公开', 'title' => '您的反馈已转为不公开',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'), 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks, 'blocks' => $blocks,
'button' => ['text' => '查看这条反馈', 'url' => $url], 'button' => ['text' => '查看这条反馈', 'url' => $url],
'link' => $url, 'link' => $url,
'personal' => true, // 发给提交人(转为不公开)
'recipient' => $to, 'recipient' => $to,
]; ];
} }
@@ -860,30 +1080,38 @@ if (!function_exists('fb_notify_render')) {
$body = (string)$thread['body']; $body = (string)$thread['body'];
$mail = trim((string)fb_cfg('admin_email', '')); $mail = trim((string)fb_cfg('admin_email', ''));
$subject = '【' . $site . '】关于你反馈的问题《' . $short . '》'; $subject = $pfx . '关于您反馈的问题《' . $short . '》';
$blocks = [ $blocks = [
['type' => 'text', ['type' => 'text',
'text' => '感谢你的反馈。我们已经核实过,这个情况并不是应用本身的问题,因此这条反馈已关闭(不会公开显示)。'], 'text' => '感谢您的反馈。我们已经核实过,这个情况并不是'
. ($swName !== '' ? '「' . $swName . '」' : '应用') . '本身的问题,因此这条反馈已关闭(不会公开显示)。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]], ['type' => 'field', 'rows' => ['反馈标题' => $title]],
]; ];
if ($note !== '') { if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note]; $blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
} }
$blocks[] = ['type' => 'quote', 'who' => '你当时反馈的内容:', 'text' => $body]; $blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => $body];
$blocks[] = [ $blocks[] = [
'type' => 'callout', 'tone' => 'info', 'type' => 'callout', 'tone' => 'info',
'text' => ($mail !== '' 'text' => ($mail !== ''
? '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。' ? '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
: '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进。'), : '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进。'),
]; ];
/* 这条已经「已关闭 + 不公开」,不带 token 的链接打不开 ——
* 必须给带 token 的私人链接,用户才能回来看上下文、继续补充。
* 注意:button 是 layout 层属性,不是 block 类型。 */
$closeBtnUrl = fb_thread_link($thread, true);
$layout = [ $layout = [
'preheader' => '关于你反馈的问题,我们的说明', 'software' => $sw,
'preheader' => '关于您反馈的问题,我们的说明',
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'], 'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
'title' => '关于你反馈的问题,我们的说明', 'title' => '关于您反馈的问题,我们的说明',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'), 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks, 'blocks' => $blocks,
'button' => ['text' => '查看这条反馈', 'url' => $closeBtnUrl],
'personal' => true, // 发给提交人(关闭说明)
'recipient' => $to, 'recipient' => $to,
]; ];
} }
@@ -893,18 +1121,24 @@ if (!function_exists('fb_notify_render')) {
$body = (string)$thread['body']; $body = (string)$thread['body'];
$mail = (string)(isset($thread['author_email']) ? $thread['author_email'] : ''); $mail = (string)(isset($thread['author_email']) ? $thread['author_email'] : '');
$phone = trim((string)(isset($thread['author_phone']) ? $thread['author_phone'] : '')); $phone = trim((string)(isset($thread['author_phone']) ? $thread['author_phone'] : ''));
$swName = (string)(isset($extra['software_name']) ? $extra['software_name'] : ''); // 软件名优先取实际软件行(比 extra 里传进来的更可靠)
$swName = $swName !== '' ? $swName
: (string)(isset($extra['software_name']) ? $extra['software_name'] : '');
$subject = '【' . $site . '】新反馈:' . $short; $subject = $pfx . '新反馈:' . $short;
$rows = ['标题' => $title]; $rows = [];
if ($swName !== '') { $rows['所属软件'] = $swName; } if ($swName !== '') { $rows['所属软件'] = $swName; }
$rows['标题'] = $title;
$rows['提交人'] = $name !== '' ? $name : '(未留名)'; $rows['提交人'] = $name !== '' ? $name : '(未留名)';
if ($mail !== '') { $rows['邮箱'] = $mail; } if ($mail !== '') { $rows['邮箱'] = $mail; }
if ($phone !== '') { $rows['电话'] = $phone; } if ($phone !== '') { $rows['电话'] = $phone; }
$adminUrl = fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id'];
$blocks = [ $blocks = [
['type' => 'text', 'text' => '有新的反馈提交,请及时处理。'], ['type' => 'text', 'text' => ($swName !== '' ? '「' . $swName . '」' : '')
. '有一条新的反馈提交,请到管理后台处理。'],
['type' => 'field', 'rows' => $rows], ['type' => 'field', 'rows' => $rows],
['type' => 'quote', 'who' => '反馈内容:', 'text' => $body], ['type' => 'quote', 'who' => '反馈内容:', 'text' => $body],
['type' => 'callout', 'tone' => 'warn', ['type' => 'callout', 'tone' => 'warn',
@@ -912,28 +1146,62 @@ if (!function_exists('fb_notify_render')) {
]; ];
$layout = [ $layout = [
'software' => $sw,
'preheader' => $name . ':' . fb_excerpt($body, 60), 'preheader' => $name . ':' . fb_excerpt($body, 60),
'badge' => ['text' => '新反馈', 'color' => '#b4553c'], 'badge' => ['text' => '新反馈', 'color' => '#b4553c'],
'title' => '收到一条新反馈', 'title' => ($swName !== '' ? $swName . ' 收到一条新反馈' : '收到一条新反馈'),
'intro' => '你好:', 'intro' => '您好:',
'blocks' => $blocks, 'blocks' => $blocks,
'button' => ['text' => '去后台处理', 'url' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id']], 'button' => ['text' => '去后台处理这条反馈', 'url' => $adminUrl],
'link' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id'], 'link' => $adminUrl,
'recipient' => $to, 'recipient' => $to,
'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。', 'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。',
]; ];
} }
/* ---------------- 7. 提交回执(发给提问人) ---------------- */
elseif ($reason === 'receipt') {
$body = (string)$thread['body'];
$subject = $pfx . '已收到您的反馈:' . $short;
$blocks = [
['type' => 'text', 'text' => '我们已收到您在'
. ($swName !== '' ? '「' . $swName . '」' : '本站')
. '提交的反馈,感谢您的反馈!'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
['type' => 'quote', 'who' => '您提交的内容:', 'text' => fb_excerpt($body, 400)],
['type' => 'callout', 'tone' => 'info',
'text' => '这条反馈正在等待管理员审核,请耐心等待。审核通过后会在列表中公开显示。'],
['type' => 'text', 'text' => '审核结果与后续处理进展,我们都会通过邮件通知您,无需反复回来查看。'],
];
$layout = [
'software' => $sw,
'preheader' => '已收到您的反馈,正在等待审核',
'badge' => ['text' => '已收到', 'color' => fb_mail_brand()],
'title' => '已收到您的反馈',
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks,
'button' => ['text' => '查看这条反馈', 'url' => $url],
'link' => $url,
'personal' => true, // 发给提交人(回执)
'recipient' => $to,
];
}
/* ---------------- 兜底 ---------------- */ /* ---------------- 兜底 ---------------- */
else { else {
$subject = '【' . $site . '】' . $short; $subject = $pfx . $short;
$layout = [ $layout = [
'preheader' => '你有一条新的通知', 'software' => $sw,
'title' => '你有一条新的通知', 'preheader' => '您有一条新的通知',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'), 'title' => '您有一条新的通知',
'blocks' => [['type' => 'text', 'text' => '你关注的反馈有更新。']], 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => [['type' => 'text', 'text' => '您关注的反馈有更新。']],
'button' => ['text' => '查看详情', 'url' => $url], 'button' => ['text' => '查看详情', 'url' => $url],
'link' => $url, 'link' => $url,
'personal' => true, // 兜底同样是发给提交人的
'recipient' => $to, 'recipient' => $to,
]; ];
} }
+349 -4
View File
@@ -171,6 +171,20 @@ function fb_default_software() {
return $list ? $list[0] : null; 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();
}
/** /**
* 表单回显用:如果当前值等于「出厂默认」,就返回空串。 * 表单回显用:如果当前值等于「出厂默认」,就返回空串。
* *
@@ -240,11 +254,68 @@ function fb_color_variants($hex) {
} }
/** /**
* 给某个软件挑一个默认主题色(未配置时用)。 * 按「与白色混合的比例」生成主题色的浅色档位。
* 按软件的排序位次从调色板里取,保证同一站点里不同软件颜色不撞。 *
* 🔴 为什么在 PHP 里算,而不用 CSS 的 color-mix():
* 实测 Chrome 里 `color-mix(in srgb, var(--brand) 9%, #fff)` 配合
* 自定义属性会解析成透明(oklab(0 0 0 / 0))—— 规则匹配上了却不生效。
* 直接在 PHP 把值算好、以 CSS 变量输出最稳,也不依赖浏览器版本。
*
* @param string $hex 主题色
* @param float $ratio 与白色混合的比例(0=原色 1=纯白)
* @return string
*/ */
function fb_color_tint($hex, $ratio) {
$hex = fb_color_norm($hex);
if ($hex === '') { return ''; }
$r = hexdec(substr($hex, 1, 2));
$g = hexdec(substr($hex, 3, 2));
$b = hexdec(substr($hex, 5, 2));
$ratio = max(0.0, min(1.0, (float)$ratio));
return sprintf('#%02x%02x%02x',
(int)round($r + (255 - $r) * $ratio),
(int)round($g + (255 - $g) * $ratio),
(int)round($b + (255 - $b) * $ratio));
}
/**
* 给某个软件挑一个默认主题色(未配置时用)。
* 按软件的 id 从调色板里取,保证同一站点里不同软件颜色不撞。
*
* 🔴 2026-11-04 按色相环**整体重排**为 12 色(用户:颜色快不够分了)。
*
* 选色规则,改之前先读:
* ① **色相均匀分布**:360° 切成 12 段,相邻至少差 22°(实测最小 22°)。
* 旧调色板有 3 个蓝挤在 185°/212°/217°,并排看几乎同色 —— 这是重排的原因;
* ② **对比度落在 4.76~6.19 的窄带**:白底上文字都够看清(WCAG AA 要 ≥4.5),
* 又不会某个颜色特别深、显得比其他刺眼;
* ③ **避开纯红/纯绿**:会与「待处理/已解决」状态色打架,所以红取 2°、绿取 122°;
* ④ 黄/黄绿饱和度压到 50%(再高就荧光),蓝/靛蓝也压到 62%/50%。
*
* ⚠️ 这个数组和 lib/admin_taxonomy.php 里的 $presets **必须保持一致**。
* 以前是两份独立硬编码,早就不同步了 —— 后台只给 8 个可选色,
* 实际调色板却是另一批,用户在后台根本选不到新增颜色。
* 改这里记得同步改那边(见 fb_color_palette() 统一出口)。
*/
function fb_color_palette() {
return [
'#B53530', // 1 红 2°
'#9D6525', // 2 橙 32°
'#747728', // 3 黄 62°
'#517E2A', // 4 黄绿 92°
'#2B822E', // 5 绿 122°
'#2D8059', // 6 青绿 152°
'#267E7B', // 7 青 178°
'#2678A1', // 8 天蓝 200°
'#4154D2', // 9 蓝 232°
'#7A44C1', // 10 靛蓝 266°
'#943BA0', // 11 紫 293°
'#AD3470', // 12 玫红 330°
];
}
function fb_color_default_for($softwareId) { function fb_color_default_for($softwareId) {
$palette = ['#0057B8', '#2E7D5B', '#B4553C', '#6A4C93', '#0E7C86', '#A02C5A', '#8A6D1F', '#3A5A8C']; $palette = fb_color_palette();
$id = max(0, (int)$softwareId); $id = max(0, (int)$softwareId);
return $palette[$id % count($palette)]; return $palette[$id % count($palette)];
} }
@@ -263,6 +334,185 @@ function fb_software_colors($software) {
return fb_color_variants($hex); 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)。
* 前端所有「这是哪个软件」的地方都用它,保证视觉一致。
*
* 🔴 不要背景色:图标本身就是一张图/一个 emoji,再垫一个色块反而糊在一起。
* 直接「图标 + 文字」并排,识别度最高(用户 2026-11-03 明确要求)。
*
* @param array $sw 软件行
* @param string $size 'sm' | 'md' | 'lg' | 'brand'(brand = 顶栏主标题)
* @param string $label 覆盖显示文字(默认=软件名)。顶栏用它传「XX 反馈系统」。
*/
function fb_software_badge($software, $size = 'md', $label = '') {
if (!is_array($software) || empty($software['name'])) { return ''; }
$icon = fb_software_icon($software);
$name = $label !== '' ? (string)$label : (string)$software['name'];
/* 尺寸档位(以「选择软件」标签的 13.5px 为基准):
* sm 18px 图标 / 14px 字 小标签
* md 24px 图标 / 16px 字 抽屉里的条目
* lg 24px 图标 / 15px 字 内容区「图标 + 软件名」
* brand 30px 图标 / 17px 字 顶栏主标题
*
* 🔴 两条硬约束(都踩过):
* 1. 顶栏标题 = 比「选择软件」的 13.5px 大两号 ≈ 17px,不要再放大到 26px;
* 2. **顶栏必须比内容区大**。之前顶栏 26px、内容区 30px,
* 主次直接颠倒,看着就是「上面的字比下面的小」。
* 现在 17 > 15,层级正确;内容区只是标识,不该抢标题的戏。 */
$px = ['sm' => 18, 'md' => 24, 'lg' => 24, 'brand' => 30];
$fs = ['sm' => 14, 'md' => 16, 'lg' => 15, 'brand' => 17];
$box = isset($px[$size]) ? $px[$size] : 24;
$fnt = isset($fs[$size]) ? $fs[$size] : 16;
/* 有上传的图标图片就用图片(管理员自己传的 logo),没有才用字符。
* 两者外框尺寸一致,换用图片不会让布局跳动。 */
$imgKey = isset($software['icon_key']) ? trim((string)$software['icon_key']) : '';
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, $box * 2) : '';
if ($imgUrl !== '') {
return '<span class="sw-badge sw-badge-' . e($size) . '">'
. '<img class="sw-badge-img" src="' . e($imgUrl) . '" alt=""'
. ' width="' . $box . '" height="' . $box . '" loading="lazy"'
. ' style="width:' . $box . 'px;height:' . $box . 'px">'
. '<span class="sw-badge-nm">' . e($name) . '</span>'
. '</span>';
}
return '<span class="sw-badge sw-badge-' . e($size) . '">'
. '<span class="sw-badge-ic" aria-hidden="true" style="font-size:' . $fnt . 'px">'
. e($icon) . '</span>'
. '<span class="sw-badge-nm">' . e($name) . '</span>'
. '</span>';
}
/**
* 「XX 反馈系统」—— 当前软件下的站点标题。
* 多软件共用一个反馈站时,标题必须点明是哪个软件的反馈系统,
* 用户才知道自己没走错地方。没传软件时退回站点名。
*
* ⚠️ 软件名本身已含「反馈」二字时不再叠加,避免出现「轻反馈反馈系统」。
*/
function fb_software_site_title($software) {
if (!is_array($software) || empty($software['name'])) {
return (string)fb_cfg('site_name', '轻反馈');
}
$name = trim((string)$software['name']);
if ($name === '') { return (string)fb_cfg('site_name', '轻反馈'); }
if (mb_strpos($name, '反馈', 0, 'UTF-8') !== false) { return $name; }
return $name . '反馈系统';
}
/**
* 软件徽标(后台/前台的小标签)—— 带图标,且用该软件自己的主题色。
*
* 为什么不复用 .badge 的固定蓝色:每个软件都配了主题色,
* 徽标却一律蓝色,等于把「颜色区分软件」这条设计浪费掉了。
*
* @param array $sw 软件行
* @param bool $icon 是否显示图标(默认显示)
*/
function fb_software_chip($sw, $withIcon = true) {
if (!is_array($sw) || empty($sw['name'])) { return ''; }
$name = (string)$sw['name'];
list($c) = fb_software_colors($sw);
if ($c === '') { $c = '#0057B8'; }
$soft = fb_color_soft($c); // 同色系浅底
$line = fb_color_line($c); // 同色系描边
$icHtml = '';
if ($withIcon) {
$imgKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, 30) : '';
if ($imgUrl !== '') {
$icHtml = '<span class="badge-sw-ic"><img src="' . e($imgUrl) . '" alt=""></span>';
} else {
$icHtml = '<span class="badge-sw-ic" aria-hidden="true">' . e(fb_software_icon($sw)) . '</span>';
}
}
return '<span class="badge sw"'
. ' style="--swc:' . e($c) . ';--swc-soft:' . e($soft) . ';--swc-line:' . e($line) . '">'
. $icHtml
. '<span class="badge-sw-nm">' . e($name) . '</span>'
. '</span>';
}
/** 把主色调淡成浅底(与主色同色系,避免灰化) */
function fb_color_soft($hex) {
$rgb = fb_hex_to_rgb($hex);
if (!$rgb) { return '#eef2f8'; }
return sprintf('#%02x%02x%02x',
(int)round($rgb[0] + (255 - $rgb[0]) * 0.90),
(int)round($rgb[1] + (255 - $rgb[1]) * 0.90),
(int)round($rgb[2] + (255 - $rgb[2]) * 0.90));
}
/** 把主色调成描边色(略深于浅底) */
function fb_color_line($hex) {
$rgb = fb_hex_to_rgb($hex);
if (!$rgb) { return '#cfdcee'; }
return sprintf('#%02x%02x%02x',
(int)round($rgb[0] + (255 - $rgb[0]) * 0.72),
(int)round($rgb[1] + (255 - $rgb[1]) * 0.72),
(int)round($rgb[2] + (255 - $rgb[2]) * 0.72));
}
/** #rrggbb -> [r,g,b],非法返回 null */
function fb_hex_to_rgb($hex) {
$h = ltrim((string)$hex, '#');
// 三位简写只接受纯十六进制字符(#abc -> #aabbcc);
// 先做字符校验再展开,否则 "bad" 这种非色值会被当成 #bbaadd
if (strlen($h) === 3 && preg_match('/^[0-9a-fA-F]{3}$/', $h)) {
$h = $h[0].$h[0].$h[1].$h[1].$h[2].$h[2];
}
if (!preg_match('/^[0-9a-fA-F]{6}$/', $h)) { return null; }
return [hexdec(substr($h, 0, 2)), hexdec(substr($h, 2, 2)), hexdec(substr($h, 4, 2))];
}
/** 某软件下的分类;不传软件则返回空数组 */ /** 某软件下的分类;不传软件则返回空数组 */
function fb_categories($softwareId = null, $onlyEnabled = true) { function fb_categories($softwareId = null, $onlyEnabled = true) {
if ($softwareId === null) { return []; } if ($softwareId === null) { return []; }
@@ -293,7 +543,11 @@ function fb_category_by_slug($softwareId, $slug) {
return $r ? $r : null; return $r ? $r : null;
} }
/** 分类显示名:只给 id 就能拿到名字(列表渲染用) */ /** 分类显示名:只给 id 就能拿到名字(列表渲染用)
*
* ⚠️ 参数必须是**数字 id**。传字符串 slug(如 'bug')会被 (int) 成 0 而返回空串,
* 调用方如果不判空就会渲染出一个空标签 —— 这个坑踩过(见 admin.php 全部问题列表)。
* 需要按 slug 取名字请用 fb_category_label_by_slug()。 */
function fb_category_label($id) { function fb_category_label($id) {
if (!$id) { return ''; } if (!$id) { return ''; }
static $cache = []; static $cache = [];
@@ -305,6 +559,24 @@ function fb_category_label($id) {
return $cache[$id]; return $cache[$id];
} }
/**
* 按「软件 + slug」取分类显示名 —— 给只存了老 slug 字段的历史数据兜底。
* slug 在不同软件下可能重名,所以必须带上 software_id 才能定位。
*/
function fb_category_label_by_slug($softwareId, $slug) {
$slug = trim((string)$slug);
if ($slug === '') { return ''; }
if ((int)$softwareId > 0) {
$c = fb_category_by_slug((int)$softwareId, $slug);
if ($c) { return (string)$c['name']; }
}
// 实在定不到软件(老数据 software_id 为空)时,按 slug 全局找一个
$st = fb_db()->prepare('SELECT name FROM categories WHERE slug = ? LIMIT 1');
$st->execute([$slug]);
$nm = $st->fetchColumn();
return $nm !== false ? (string)$nm : '';
}
/** 全部状态(按排序) */ /** 全部状态(按排序) */
function fb_statuses($onlyEnabled = true) { function fb_statuses($onlyEnabled = true) {
$sql = 'SELECT * FROM statuses'; $sql = 'SELECT * FROM statuses';
@@ -439,6 +711,78 @@ function fb_thread_public(array $thread) {
return fb_is_admin(); return fb_is_admin();
} }
/**
* 生成一个新的追踪 token(提交反馈时调用)。
* 32 位 hex = 128 位熵,猜不出来;这个值只存在自己库里、只发给提问人邮箱。
*/
function fb_track_new_token() {
return bin2hex(random_bytes(16));
}
/** 这个请求带来的 token 是否与帖子的匹配(常数时间比较,防时序探测) */
function fb_track_token_ok(array $thread, $given) {
$given = trim((string)$given);
if ($given === '') { return false; }
$real = isset($thread['track_token']) ? trim((string)$thread['track_token']) : '';
if ($real === '') { return false; }
return hash_equals($real, $given);
}
/**
* 当前访问者是否「有权跟踪」这条帖子 —— 即带对了 token。
* 审核后台的管理员也算有权(不然审核时看不了)。
*/
function fb_thread_trackable(array $thread, $given = null) {
if ($given === null) { $given = isset($_GET['t']) ? (string)$_GET['t'] : ''; }
if (fb_track_token_ok($thread, $given)) { return true; }
return fb_is_admin();
}
/**
* 当前访问者能否【查看】这条帖子。
*
* 公开的(visible=1)所有人可看;
* 未公开的只有两种人能看:审核后台管理员、拿着正确 token 的提交人。
* 这就是「不登录也能跟踪自己那条」的实现方式,不靠任何登录态。
*/
function fb_thread_viewable(array $thread, $given = null) {
if (fb_thread_public($thread)) { return true; }
return fb_thread_trackable($thread, $given);
}
/**
* 回「某个软件的问题列表」的链接。
*
* 首页现在是介绍页,不带 sw 的回首页等于把用户扔回介绍页 ——
* 从详情/提交页点「返回问题列表」应该回到**当前软件**的列表。
*/
function fb_software_list_url($sw) {
if (!is_array($sw) || empty($sw['slug'])) { return 'index.php'; }
return 'index.php?sw=' . urlencode((string)$sw['slug']);
}
/**
* 这条帖子的对外链接。
*
* 🔴 用 token 不用 id:
* - 邮件里发给提问人的链接一律带 ?t=<token>,他才能看未公开的那条;
* - 公开列表里给所有人看的链接照旧用 id(公开内容本来就谁都能看)。
* 早期所有链接都是 thread.php?id=N,导致「未审核就点不进去」——
* id 是自增的,用它当权限等于没有权限。
*
* @param array $thread 帖子行
* @param bool $personal true = 带 token 的私人链接;false = 公开链接
*/
function fb_thread_link(array $thread, $personal = false) {
$id = (int)(isset($thread['id']) ? $thread['id'] : 0);
$url = fb_base_url() . '/thread.php?id=' . $id;
if ($personal) {
$tok = isset($thread['track_token']) ? trim((string)$thread['track_token']) : '';
if ($tok !== '') { $url .= '&t=' . rawurlencode($tok); }
}
return $url;
}
/* ---------------- 安装状态 ---------------- */ /* ---------------- 安装状态 ---------------- */
/** FeedLite 开源仓库地址(页脚版权链接的默认值,可在后台改) */ /** FeedLite 开源仓库地址(页脚版权链接的默认值,可在后台改) */
@@ -490,6 +834,7 @@ function fb_setting_keys() {
'smtp.timeout', 'smtp.verify_ssl', 'smtp.timeout', 'smtp.verify_ssl',
'notify.enabled', 'notify.on_every_reply', 'notify.cooldown_seconds', 'notify.enabled', 'notify.on_every_reply', 'notify.cooldown_seconds',
'notify.admin_new_thread', 'notify.admin_new_thread',
'notify.receipt_enabled',
]; ];
} }
+34 -3
View File
@@ -28,7 +28,11 @@ function fb_media_fail($code, $msg) {
$key = isset($_GET['k']) ? trim((string)$_GET['k']) : ''; $key = isset($_GET['k']) ? trim((string)$_GET['k']) : '';
if ($key === '') { fb_media_fail(400, '缺少附件标识'); } if ($key === '') { fb_media_fail(400, '缺少附件标识'); }
if (!fb_upload_key_ok($key)) { fb_media_fail(400, '附件标识不合法'); } /* 合法的键有两种:普通附件(local/… 或 <oss.prefix>/…)与软件图标(icons/…)。
* 图标有自己的键空间与校验规则,不能塞进 fb_upload_key_ok 里判断(它只认附件前缀)。*/
if (!fb_upload_key_ok($key) && !fb_icon_key_ok($key)) {
fb_media_fail(400, '附件标识不合法');
}
$process = isset($_GET['p']) ? trim((string)$_GET['p']) : ''; $process = isset($_GET['p']) ? trim((string)$_GET['p']) : '';
// 图片处理串只允许本站自己生成的形态:image/resize,w_NNN/quality,q_NN // 图片处理串只允许本站自己生成的形态:image/resize,w_NNN/quality,q_NN
@@ -41,6 +45,31 @@ if ($process !== '') {
} }
} }
/* ---------------- 软件图标:独立的公开读取通道 ----------------
* 图标不属于任何帖子,用它那套「帖子可见才给看」的鉴权会被一律拒绝。
* 图标本身是公开信息(每个访客都要看到),所以这里只校验:
* ① 键的形状合法(icons/YYYY/MM/<hex>.<ext>,无法穿越);
* ② 文件确实存在。
* 写入侧仍然是严格的:只有登录管理员、且经过真实 MIME 校验才能落到 icons/ 下。
* 也就是说「读公开、写受控」,不会让本站变成能读整个桶的开放代理。 */
if (fb_icon_is_key($key)) {
if (!fb_icon_key_ok($key)) { fb_media_fail(400, '图标标识不合法'); }
if (fb_attach_is_local($key)) {
$path = fb_local_path_of($key);
if (!is_file($path)) { fb_media_fail(404, '图标不存在'); }
$size = (int)@filesize($path);
$mime = fb_media_local_mime($path, $key);
header('Content-Type: ' . $mime);
header('Content-Length: ' . $size);
header('Cache-Control: public, max-age=604800'); // 图标变动很少,缓存一周
header('X-Content-Type-Options: nosniff');
fb_media_send_local($path, $size);
exit;
}
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
}
/* ---------------- 本机存储:直接读磁盘并流式输出 ---------------- /* ---------------- 本机存储:直接读磁盘并流式输出 ----------------
* 与前台的统一代理地址配套:local 附件也走这里,uploads/ 目录本身禁止直连。 */ * 与前台的统一代理地址配套:local 附件也走这里,uploads/ 目录本身禁止直连。 */
if (fb_attach_is_local($key)) { if (fb_attach_is_local($key)) {
@@ -60,8 +89,10 @@ if (fb_attach_is_local($key)) {
exit; exit;
} }
/* ---------------- OSS 存储 ---------------- */ /* ---------------- OSS 存储 ----------------
if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); } * 图标已在上面单独鉴权过(读公开、写受控),到这里就不再走帖子可见性判断;
* 普通附件仍然必须过 fb_media_authorized —— 否则本站会变成能读整个桶的开放代理。 */
if (!fb_icon_is_key($key) && !fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); } if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
$range = isset($_SERVER['HTTP_RANGE']) ? (string)$_SERVER['HTTP_RANGE'] : ''; $range = isset($_SERVER['HTTP_RANGE']) ? (string)$_SERVER['HTTP_RANGE'] : '';
+160 -24
View File
@@ -8,7 +8,11 @@ $pdo = fb_db();
$err = ''; $err = '';
$atts = []; // 校验失败时保留已上传的附件 $atts = []; // 校验失败时保留已上传的附件
/* ---- 软件:URL 带 sw 就用它,否则落到默认软件 ---- */ /* ---- 软件:URL 带 sw 就用它 ----
* 🔴 不再「没带 sw 就默认落到第一个软件」:
* 首页现在是介绍页,直接访问 new.php 没有软件上下文,
* 默默替他选一个软件会让反馈进错地方、发错通知邮件。
* 没有软件时给一句引导,让用户先选软件。 */
$swList = fb_softwares(true); $swList = fb_softwares(true);
$sw = null; $sw = null;
$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : ''; $swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
@@ -16,8 +20,32 @@ if ($swKey !== '') {
$sw = fb_software($swKey); $sw = fb_software($swKey);
if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; } if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; }
} }
if ($sw === null && $swList) { $sw = $swList[0]; } if ($sw === null) {
$swId = $sw ? (int)$sw['id'] : 0; fb_layout_head('请先选择软件', ['robots' => 'noindex,follow']);
?>
<div class="empty">
<h2 style="margin:0 0 12px">请先选择要给哪个软件提反馈</h2>
<p class="muted" style="margin:0 0 16px">不同软件的问题列表是分开的,请先选一个。</p>
<div class="home-sw-list" style="max-width:420px;margin:0 auto 18px">
<?php foreach ($swList as $s): ?>
<?php
list($c) = fb_software_colors($s);
if ($c === '') { $c = '#0057B8'; }
?>
<a class="home-sw" href="new.php?sw=<?php echo e(urlencode((string)$s['slug'])); ?>"
style="--swc:<?php echo e($c); ?>">
<span class="home-sw-nm"><?php echo e((string)$s['name']); ?></span>
<span class="home-sw-go">去反馈 →</span>
</a>
<?php endforeach; ?>
</div>
<p><a class="btn" href="index.php">返回首页</a></p>
</div>
<?php
fb_layout_foot();
exit;
}
$swId = (int)$sw['id'];
$cats = fb_categories($swId); $cats = fb_categories($swId);
$catKeys = []; $catKeys = [];
@@ -89,9 +117,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$atts = $vList; $atts = $vList;
} else { } else {
$now = time(); $now = time();
// 审核:开启后新提交默认不公开(visible=0),管理员通过后才会出现在列表 /* 审核:开启后新提交默认不公开(visible=0),管理员通过后才出现在列表。
*
* 🔴 状态与可见性必须同时设,两者是同一件事的两个侧面:
* 未公开 → status='hidden'(未公开),公开 → status='open'(待处理)。
* 以前一律写 'open',导致后台「待审核」里全是「待处理」,
* 状态根本反映不出"还没公开"这个事实。 */
$visible = fb_moderation_on() ? 0 : 1; $visible = fb_moderation_on() ? 0 : 1;
$ins = $pdo->prepare('INSERT INTO threads(title, body, category, category_id, software_id, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?)'); $initStatus = $visible === 1 ? 'open' : 'hidden';
/* track_token:提交人的私人追踪钥匙,只发到他的邮箱。
* 有了它,即使这条还没审核通过,他也能凭链接看到进度。 */
$trackToken = fb_track_new_token();
$ins = $pdo->prepare('INSERT INTO threads(title, body, category, category_id, software_id, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash, track_token) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?,?)');
$ins->execute([ $ins->execute([
$form['title'], $form['body'], $form['title'], $form['body'],
$curCat ? (string)$curCat['slug'] : 'other', // 老列留个值,便于回退与检索 $curCat ? (string)$curCat['slug'] : 'other', // 老列留个值,便于回退与检索
@@ -99,7 +136,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$swId > 0 ? $swId : null, $swId > 0 ? $swId : null,
$form['name'], $form['email'], $form['name'], $form['email'],
$form['phone'] !== '' ? $form['phone'] : null, $form['phone'] !== '' ? $form['phone'] : null,
'open', $visible, $now, $now, fb_ip_hash(), $initStatus, $visible, $now, $now, fb_ip_hash(), $trackToken,
]); ]);
$newId = (int)$pdo->lastInsertId(); $newId = (int)$pdo->lastInsertId();
fb_attach_save($pdo, $newId, null, $vList); fb_attach_save($pdo, $newId, null, $vList);
@@ -119,13 +156,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$swRow = fb_software((int)$swId); $swRow = fb_software((int)$swId);
if ($swRow) { $swName = (string)$swRow['name']; } if ($swRow) { $swName = (string)$swRow['name']; }
} }
/* ① 先给提交人发「已收到」回执 —— 让他知道东西送达了、正在等审核。
* 这一步是用户最在意的:提交完页面跳走了,没有回执就等于石沉大海。
* 放在管理员通知之前,是因为回执对人的价值更高,且失败互不影响。 */
fb_notify_event($pdo, 'receipt', $newThread, ['software_name' => $swName]);
/* ② 再通知管理员「有新反馈」 */
fb_notify_event($pdo, 'new', $newThread, ['software_name' => $swName]); fb_notify_event($pdo, 'new', $newThread, ['software_name' => $swName]);
} }
} catch (Throwable $e) { } catch (Throwable $e) {
// 发信失败绝不能让用户提交失败 —— 反馈已经存库了 // 发信失败绝不能让用户提交失败 —— 反馈已经存库了
} }
fb_redirect('thread.php?id=' . $newId . '&submitted=1'); /* 提交完成 → 原地渲染「已收到」页面,不跳转。
*
* 用户 2026-11-03:「提交完怎么直接跳转到首页去了?应该给它一个提示,
* 说你反馈我们已经收到…里面有一个标志码,是你以后跟踪它的唯一凭据…
* 然后下面再列出来其他功能(返回首页、继续提交、其他软件链接、开源链接),
* 不能直接跳转,这给人感觉很突兀。」
*
* 🔴 关键取舍:**不跳转、不用 302**。跳转会让用户失去"我刚提交成功"这一帧,
* 而且详情页此刻还是「未公开」状态,跳过去反而像出错了。
* 这里直接把成功页渲染出来,把 token 明明白白摆在眼前。 */
fb_render_submitted($sw, $newId, $trackToken, $form);
exit;
} }
} }
} }
@@ -133,11 +188,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]); fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]);
?> ?>
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div> <div class="crumbs"><a href="<?php echo e(fb_software_list_url($sw)); ?>">← 返回问题列表</a></div>
<section class="narrow"> <section class="narrow">
<h1>我要反馈</h1> <h1>我要反馈</h1>
<p class="muted">请描述你遇到的问题或想要的功能。提交后会先进入审核,确认为有效问题后才会公开显示;<b>若经确认不是应用的问题,我们会把结论与说明通过邮件发送给你</b>(请填写常用邮箱)。</p> <?php if ($sw): ?>
<div class="sw-context"><?php echo fb_software_badge($sw, 'md'); ?>
<span class="muted small">您正在为「<?php echo e(fb_software_site_title($sw)); ?>」提交反馈</span></div>
<?php endif; ?>
<p class="muted">请描述您遇到的问题或想要的功能。提交后会先进入审核,确认为有效问题后才会公开显示;<b>若经确认不是应用的问题,我们会把结论与说明通过邮件发送给您</b>(请填写常用邮箱)。</p>
<?php fb_flash_box($err, 'error'); ?> <?php fb_flash_box($err, 'error'); ?>
@@ -150,17 +209,8 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
<?php if ($sw): ?> <?php if ($sw): ?>
<input type="hidden" name="software_id" value="<?php echo (int)$sw['id']; ?>"> <input type="hidden" name="software_id" value="<?php echo (int)$sw['id']; ?>">
<div class="field"> <?php /* 软件切换统一收到顶栏的汉堡菜单里,表单内不再平铺一排 chip ——
<span>所属软件 <b class="req">*</b><i class="hint">换软件会刷新下面的分类</i></span> 免得同一个选择在两个地方出现,用户不知道该点哪个。 */ ?>
<div class="chips">
<?php foreach ($swList as $one): ?>
<a class="chip <?php echo ((int)$one['id'] === $swId) ? 'on' : ''; ?>"
href="new.php?sw=<?php echo e(urlencode((string)$one['slug'])); ?>"><?php echo e((string)$one['name']); ?></a>
<?php endforeach; ?>
</div>
</div>
<?php elseif ($sw): ?>
<input type="hidden" name="software_id" value="<?php echo (int)$sw['id']; ?>">
<?php endif; ?> <?php endif; ?>
<?php if ($cats): ?> <?php if ($cats): ?>
@@ -176,18 +226,18 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
<label class="field"> <label class="field">
<span>标题 <b class="req">*</b></span> <span>标题 <b class="req">*</b></span>
<input type="text" name="title" maxlength="80" value="<?php echo e($form['title']); ?>" placeholder="一句话概括你的问题" required> <input type="text" name="title" maxlength="80" value="<?php echo e($form['title']); ?>" placeholder="一句话概括您的问题" required>
</label> </label>
<label class="field"> <label class="field">
<span>详细描述 <b class="req">*</b></span> <span>详细描述 <b class="req">*</b></span>
<textarea name="body" rows="9" maxlength="5000" placeholder="请尽量说明:你做了什么、期望的结果、实际的结果;如是同步问题,可说明使用的邮箱 / 服务器类型。" required><?php echo e($form['body']); ?></textarea> <textarea name="body" rows="9" maxlength="5000" placeholder="请尽量说明:您做了什么、期望的结果、实际的结果;如是同步问题,可说明使用的邮箱 / 服务器类型。" required><?php echo e($form['body']); ?></textarea>
</label> </label>
<div class="row2"> <div class="row2">
<label class="field"> <label class="field">
<span>昵称 <b class="req">*</b></span> <span>昵称 <b class="req">*</b></span>
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼你" required> <input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼您" required>
</label> </label>
<label class="field"> <label class="field">
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span> <span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
@@ -218,8 +268,94 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
</div> </div>
</form> </form>
<p class="muted small">提示:本页面不会公开展示你的邮箱与手机号。请勿在内容中填写密码等敏感信息。可上传截图或录屏帮助说明问题;文件会直接传到对象存储,不占用本站服务器空间。</p> <p class="muted small">提示:本页面不会公开展示您的邮箱与手机号。请勿在内容中填写密码等敏感信息。可上传截图或录屏帮助说明问题;文件会直接传到对象存储,不占用本站服务器空间。</p>
</section> </section>
<?php <?php
fb_layout_foot(); fb_layout_foot();
/* ==================================================================
* 提交成功页
* ------------------------------------------------------------------
* 用户 2026-11-03 要求:提交完不要直接跳走,要给一个明确回执。
* 页面结构(按他的话组织):
* ① 你的反馈我们已经收到
* ② 详细信息已发到你邮箱
* ③ 下面这个标志码是**你以后跟踪它的唯一凭据**,请保存好
* ④ 我们会尽快处理,你可以凭它持续关注进展
* ⑤ 再往下给出路:查看这条反馈 / 继续提交 / 返回列表 / 其他软件 / 开源仓库
*
* 为什么要显式展示 token(而不只是说"已发到邮箱"):
* 邮件可能进垃圾箱、也可能被误删。把码当场亮出来,用户至少能复制存档,
* 不至于彻底失去跟踪能力。这是"唯一凭据",丢了就找不回来(我们不认人)。
* ================================================================== */
function fb_render_submitted($sw, $newId, $token, array $form) {
$swName = $sw ? (string)$sw['name'] : '';
fb_layout_head('反馈已提交', ['robots' => 'noindex,nofollow', 'software' => $sw]);
?>
<section class="narrow">
<div class="submitted">
<div class="submitted-ok" aria-hidden="true">✓</div>
<h1>您的反馈我们已经收到</h1>
<p class="submitted-lead">
我们已经把这条反馈存下来了,会尽快安排查看与处理。
<?php if (trim((string)$form['email']) !== ''): ?>
这条反馈的详细信息也已发送到 <b><?php echo e((string)$form['email']); ?></b>,
请留意收件箱(如果没看到,可以看一下垃圾邮件)。
<?php endif; ?>
</p>
<div class="token-box">
<div class="token-box-hd">您跟踪这条反馈的唯一凭据</div>
<div class="token-code" id="trackToken"><?php echo e((string)$token); ?></div>
<p class="token-box-tip">
请把这段标志码保存好。今后凭它就能查看这条反馈的处理进度 ——
即使它还没有通过审核、甚至最终被关闭,也依然能看。
我们也会在每封通知邮件里带上它,所以务必留意邮箱。
</p>
<p class="token-box-warn">
这段码等同于您的身份凭证:谁拿到它,谁就能查看这条反馈。
请不要发给别人,也不要发到公开的地方。
</p>
</div>
<p class="submitted-next">接下来您可以:</p>
<div class="submitted-actions">
<a class="btn primary" href="thread.php?id=<?php echo (int)$newId; ?>&amp;t=<?php echo e(rawurlencode((string)$token)); ?>">
查看这条反馈
</a>
<a class="btn" href="new.php<?php echo $sw ? '?sw=' . e(urlencode((string)$sw['slug'])) : ''; ?>">继续提交一条</a>
<?php /* 不再放「返回首页」—— 在某个软件的反馈系统里,首页(全局介绍页)
对用户没有意义。要回就回这个软件的问题列表。
想看别的软件,只有右上角三横线一个入口。 */ ?>
<a class="btn" href="<?php echo e(fb_software_list_url($sw)); ?>">
<?php echo $swName !== '' ? '看看「' . e($swName) . '」的其他反馈' : '返回问题列表'; ?>
</a>
</div>
<?php
/* ⚠️ 这里原来有一个「也可以去其他软件反馈」的列表,已删除。
*
* 用户 2026-11-03:「在某个软件的反馈系统里面,什么我要反馈、问题列表、
* 还有左上角的那个链接,全部都是这个软件相关的内容,跟其他软件没关系 ——
* 相对来讲它是独立的。他如果想进别的软件,只有右上角三个横线点开。」
*
* 所以在软件内部(列表 / 详情 / 提交页 / 成功页)都不引导用户去别的软件。
* 唯一的跨软件入口就是顶栏那个三横线菜单。
*/
?>
<?php
$creditUrl = (string)fb_cfg('footer.credit_url', fb_credit_url_default());
if ($creditUrl !== ''):
?>
<p class="submitted-credit">
这套反馈系统是开源的:<a href="<?php echo e($creditUrl); ?>" target="_blank" rel="noopener"><?php echo e($creditUrl); ?></a>
</p>
<?php endif; ?>
</div>
</section>
<?php
fb_layout_foot();
}
+102 -47
View File
@@ -11,49 +11,69 @@ if ($id <= 0) { fb_redirect('index.php'); }
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?'); $st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$st->execute([$id]); $st->execute([$id]);
$thread = $st->fetch(); $thread = $st->fetch();
/* 这条帖子所属的软件 —— 「返回问题列表」要回到它的列表,
* 而不是回到首页(首页现在是介绍页)。 */
$backUrl = fb_software_list_url(
$thread && !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null
);
if (!$thread) { if (!$thread) {
http_response_code(404); http_response_code(404);
fb_layout_head('问题不存在'); fb_layout_head('问题不存在');
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>'; echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
fb_layout_foot(); fb_layout_foot();
exit; exit;
} }
// 审核(先审后显):未通过审核的问题对访客不可见 /* 审核(先审后显)+ 提交人追踪:
if (!fb_thread_public($thread)) { *
* 可见性三档 ——
* ① 已公开:所有人可看
* ② 未公开 + 带对 token:提交人本人可看(这是他的私人进度页)
* ③ 未公开 + 没 token:对外一律装作「不存在」,不泄露标题也不泄露状态
*
* 🔴 关键:带对 token 时**绝不能**再走「提交成功,等待审核」那个提示页。
* 用户之前就卡在这里 —— 邮件里给了链接让他看进度,点进来却只看到
* 「等待审核」,等于链接白给。带 token 进来就应该看到完整详情。 */
$hasToken = isset($_GET['t']) ? trim((string)$_GET['t']) : '';
$isOwnerView = $hasToken !== '' && fb_track_token_ok($thread, $hasToken);
if (!fb_thread_viewable($thread)) {
$justSubmitted = (isset($_GET['submitted']) && (string)$_GET['submitted'] === '1'); $justSubmitted = (isset($_GET['submitted']) && (string)$_GET['submitted'] === '1');
if ($justSubmitted) { if ($justSubmitted) {
fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']); fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']);
echo '<div class="empty">' echo '<div class="empty">'
. '<h2 style="margin:0 0 10px">提交成功,等待审核</h2>' . '<h2 style="margin:0 0 10px">提交成功,等待审核</h2>'
. '<p>你的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>' . '<p>您的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>'
. '<p>· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;<br>' . '<p>· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;<br>'
. '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到你留下的邮箱。</p>' . '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到您留下的邮箱。</p>'
. '<p class="muted">审核通常在 1~2 个工作日内完成,请留意邮箱。</p>' . '<p class="muted">审核通常在 1~2 个工作日内完成。'
. '<p><a class="btn primary" href="index.php">返回问题列表</a></p>' . '我们会发一封邮件给您,里面的链接可以随时查看这条反馈的处理进度。</p>'
. '<p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p>'
. '</div>'; . '</div>';
fb_layout_foot(); fb_layout_foot();
exit; exit;
} }
http_response_code(404); http_response_code(404);
fb_layout_head('问题不存在'); fb_layout_head('问题不存在');
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>'; echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
fb_layout_foot(); fb_layout_foot();
exit; exit;
} }
$statuses = fb_status_keys();
$err = ''; $err = '';
$atts = []; $atts = [];
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => '']; $form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
/* ---------------- 处理回复提交 ---------------- */ /* ---------------- 处理回复提交 ---------------- */
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 审核中(未公开)的问题不允许回复 /* 未公开的帖子,只有带对 token 的提交人(或管理员)能回复。
if (!fb_thread_public($thread)) { * 别人连看都看不到,自然也不该能回。 */
if (!fb_thread_public($thread) && !$isOwnerView) {
http_response_code(403); http_response_code(403);
fb_layout_head('暂不可回复'); fb_layout_head('暂不可回复');
echo '<div class="empty"><p>该问题还在审核中,暂时不能回复。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>'; echo '<div class="empty"><p>该问题还在审核中,暂时不能回复。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
fb_layout_foot(); fb_layout_foot();
exit; exit;
} }
@@ -61,14 +81,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$form['name'] = trim(isset($_POST['name']) ? (string)$_POST['name'] : ''); $form['name'] = trim(isset($_POST['name']) ? (string)$_POST['name'] : '');
$form['email'] = trim(isset($_POST['email']) ? (string)$_POST['email'] : ''); $form['email'] = trim(isset($_POST['email']) ? (string)$_POST['email'] : '');
$form['body'] = trim(isset($_POST['body']) ? (string)$_POST['body'] : ''); $form['body'] = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
/* 🔴 带 token(= 提交人本人)时,身份以库里那条帖子为准,**不采信 POST**。
* 前端已经把这两个字段设成 hidden 了,但 hidden 是可以被改的:
* 谁拿到链接、改个 email 提交,这条反馈的联系人就变成他的了。
* 身份校验必须在服务端做,不能只靠前端不给输入框。 */
if ($isOwnerView) {
$form['name'] = (string)$thread['author_name'];
$form['email'] = (string)$thread['author_email'];
}
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : ''; $capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
$adminPw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : ''); $hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0; $t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : ''); list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : '');
$isAdmin = ($adminPw !== '' && fb_admin_verify_password($adminPw)); /* 管理员身份只认「后台登录态」,不再从回复表单收口令。
* 用户 2026-11-03:「管理员口令、处理状态都不要填了,
* 用户进来之后就没有这些东西,只保留回复内容 + 人机验证」。
* 状态流转在后台详情页那排按钮里做,不该混在公开的回复框里。 */
$isAdmin = fb_is_admin();
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) { if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
$err = '会话已过期,请重新提交。'; $err = '会话已过期,请重新提交。';
@@ -107,18 +140,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$replyId = (int)$pdo->lastInsertId(); $replyId = (int)$pdo->lastInsertId();
fb_attach_save($pdo, $id, $replyId, $vList); fb_attach_save($pdo, $id, $replyId, $vList);
// 更新主题:回复数 +1、活跃时间;管理员可同时调整状态 /* 更新主题:回复数 +1、活跃时间。
$newStatus = null; * 回复框不再承担改状态的职责 —— 状态流转在后台详情页那排按钮里做。 */
if ($isAdmin && isset($_POST['status']) && isset($statuses[(string)$_POST['status']])) {
$newStatus = (string)$_POST['status'];
}
if ($newStatus !== null) {
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ?, status = ? WHERE id = ?');
$upd->execute([$now, $newStatus, $id]);
} else {
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?'); $upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?');
$upd->execute([$now, $id]); $upd->execute([$now, $id]);
}
fb_rate_hit('reply'); fb_rate_hit('reply');
// 发送通知邮件给发帖人 // 发送通知邮件给发帖人
@@ -131,7 +156,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
]; ];
fb_notify_reply($pdo, $thread, $replyRow, $isAdmin); fb_notify_reply($pdo, $thread, $replyRow, $isAdmin);
fb_redirect('thread.php?id=' . $id . '#r' . $replyId); /* 回复后跳回去时要带上 token,否则提交人对未公开反馈的访问权会当场丢掉 */
fb_redirect('thread.php?id=' . $id
. ($isOwnerView ? '&t=' . rawurlencode($hasToken) : '')
. '#r' . $replyId);
} }
} }
} }
@@ -153,20 +181,36 @@ $thSwForColor = !empty($thread['software_id']) ? fb_software((int)$thread['softw
fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]); fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
?> ?>
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div> <div class="crumbs"><a href="<?php echo e($backUrl); ?>">← 返回问题列表</a></div>
<?php if ($isOwnerView && !fb_thread_public($thread)): ?>
<?php /* 提交人凭 token 进来看到自己的未公开反馈时,要明确告诉他:
这条还没公开、别人看不到,但他可以一直用这个链接跟踪。
否则他会以为「既然能打开,是不是已经公开了」。 */ ?>
<div class="owner-note">
<b>这是您的私人跟踪链接</b>
<span>这条反馈还没通过审核,其他人看不到。请保留本页链接(或邮件里的链接),
审核结果与后续进展都能在这里查看。</span>
</div>
<?php endif; ?>
<article class="subject"> <article class="subject">
<div class="item-top">
<?php <?php
$thSw = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null; $thSw = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
$thCat = !empty($thread['category_id']) ? fb_category((int)$thread['category_id']) : null; $thCat = !empty($thread['category_id']) ? fb_category((int)$thread['category_id']) : null;
?> ?>
<?php if ($thSw): ?><span class="badge sw"><?php echo e((string)$thSw['name']); ?></span><?php endif; ?> <?php /* 标题在最上面、顶头开始;软件名/分类/状态另起一行放下面。
<?php if ($thCat): ?><span class="badge cat"><?php echo e((string)$thCat['name']); ?></span><?php endif; ?> 用户 2026-11-03:这三个标签挤在标题前面,把标题推到右边很难看。
<span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span> ⚠️ 顺序是「行1 标题 → 行2 标签 → 行3 作者信息」,别把标签挪回前面。 */ ?>
<div class="subject-head">
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1> <h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span> <span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>
</div> </div>
<div class="subject-tags">
<?php if ($thSw): ?><?php echo fb_software_chip($thSw); ?><?php endif; ?>
<?php if ($thCat): ?><span class="badge cat"><?php echo e((string)$thCat['name']); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span>
</div>
<div class="item-meta"> <div class="item-meta">
<span class="who"><?php echo e($thread['author_name']); ?></span> <span class="who"><?php echo e($thread['author_name']); ?></span>
<span class="dot">·</span> <span class="dot">·</span>
@@ -206,8 +250,11 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
<section class="reply-form"> <section class="reply-form">
<h2>我要回复</h2> <h2>我要回复</h2>
<?php fb_flash_box($err, 'error'); ?> <?php fb_flash_box($err, 'error'); ?>
<form method="post" action="thread.php?id=<?php echo (int)$id; ?>"> <?php /* 表单要带上 token:否则提交后跳转回不带 t 的地址,
提交人会立刻失去对自己未公开反馈的访问权,体验上就是「回复完就看不见了」。 */ ?>
<form method="post" action="thread.php?id=<?php echo (int)$id; ?><?php echo $isOwnerView ? '&amp;t=' . e(rawurlencode($hasToken)) : ''; ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>"> <input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="t" value="<?php echo e($isOwnerView ? $hasToken : ''); ?>">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>"> <input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="t0" value="<?php echo time(); ?>"> <input type="hidden" name="t0" value="<?php echo time(); ?>">
<div class="hp-field" aria-hidden="true"> <div class="hp-field" aria-hidden="true">
@@ -215,19 +262,41 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
</div> </div>
<div class="row2"> <div class="row2">
<?php if ($isOwnerView): ?>
<?php /* 带 token 进来 = 已经证明是提交人本人。
*
* 🔴 昵称/邮箱在这里**只读**,理由:
* 1. token 就是身份凭证,再让他在表单里声明一次身份,自相矛盾;
* 2. 邮箱是唯一能联系到提交人的途径。如果能随手改,
* 谁拿到这条链接,填个自己的邮箱,后续所有通知就归他了 ——
* 等于把这条反馈「过户」给别人,我们再也找不到原作者。
* 要改联系方式,是另一件事(见下方说明),不该藏在回复框里顺手改掉。 */ ?>
<div class="field">
<span>回复身份</span>
<div class="reply-identity">
<span class="reply-identity-nm"><?php echo e((string)$thread['author_name']); ?></span>
<span class="reply-identity-mail"><?php echo e((string)$thread['author_email']); ?></span>
</div>
<i class="hint">这是您提交时用的身份。回复会以它显示,通知也会发到这个邮箱。</i>
</div>
<?php /* 表单仍要带这两个值,服务端才能落库;但不给用户改的机会 */ ?>
<input type="hidden" name="name" value="<?php echo e((string)$thread['author_name']); ?>">
<input type="hidden" name="email" value="<?php echo e((string)$thread['author_email']); ?>">
<?php else: ?>
<label class="field"> <label class="field">
<span>昵称 <b class="req">*</b></span> <span>昵称 <b class="req">*</b></span>
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼你" required> <input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼您" required>
</label> </label>
<label class="field" id="emailField"> <label class="field" id="emailField">
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span> <span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
<input type="email" name="email" maxlength="120" value="<?php echo e($form['email']); ?>" placeholder="you@example.com" required> <input type="email" name="email" maxlength="120" value="<?php echo e($form['email']); ?>" placeholder="you@example.com" required>
</label> </label>
<?php endif; ?>
</div> </div>
<label class="field"> <label class="field">
<span>回复内容 <b class="req">*</b></span> <span>回复内容 <b class="req">*</b></span>
<textarea name="body" rows="6" maxlength="5000" placeholder="说说你的看法或补充…" required><?php echo e($form['body']); ?></textarea> <textarea name="body" rows="6" maxlength="5000" placeholder="说说您的看法或补充…" required><?php echo e($form['body']); ?></textarea>
</label> </label>
<?php fb_uploaders($atts); ?> <?php fb_uploaders($atts); ?>
@@ -243,20 +312,6 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
</label> </label>
</div> </div>
<label class="field admin-pw">
<span>管理员口令 <i class="hint">选填;填写后将以「官方」身份回复</i></span>
<input type="password" name="admin_password" value="" autocomplete="off" placeholder="管理员专用">
</label>
<div class="field admin-status" hidden>
<span>处理状态 <i class="hint">仅管理员可见</i></span>
<select name="status">
<?php foreach ($statuses as $k => $v): ?>
<option value="<?php echo e($k); ?>" <?php echo $thread['status'] === $k ? 'selected' : ''; ?>><?php echo e($v); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-actions"> <div class="form-actions">
<button class="btn primary" type="submit">提交回复</button> <button class="btn primary" type="submit">提交回复</button>
</div> </div>