Files
FeedLite/lib/admin_taxonomy.php
T
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

703 lines
39 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 后台:软件 / 分类 / 状态 管理
* 全部存数据库,管理员在这里增删改排序启停,不用碰配置文件。
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/* ==================================================================
* 软件图标图片:上传 / 删除
* ------------------------------------------------------------------
* 安全边界(三条,缺一不可):
* ① 只有管理员能进(这个函数只从后台登录后的动作分支调用);
* ② 落盘前用 finfo 读真实文件头,必须是图片白名单里的类型 ——
* 与附件上传同一个教训:把 PHP 改名成 .png 就能写进目录,
* .htaccess 是唯一防线,换 nginx 就失守;
* ③ 键空间独立为 icons/,且形状受限(无穿越、无自由路径)。
*
* 存储方式跟附件完全一致:本机 → uploads/;OSS → 服务器代理 PUT 到私有桶。
* 对外一律 media.php?k=... 代理,不暴露真实位置。
* ================================================================== */
/** 图标允许的扩展名(比附件白名单窄:图标不需要 gif 动图,也不要 svg) */
function fb_icon_allowed_ext() {
// ⚠️ 刻意不收 svg:SVG 能内嵌 <script>,一旦被当图片直出到浏览器就是 XSS。
// 图标是管理员自己传的,但防线不该依赖「管理员不会传坏东西」。
return ['png', 'jpg', 'jpeg', 'webp'];
}
/**
* 保存一个上传的图标文件,返回 [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);
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 校验通过后调用)
* 返回 [消息, 类型],类型 ok / error
* ================================================================== */
function fb_admin_taxonomy_action($pdo, $act, $post) {
$now = time();
switch ($act) {
/* ---------------- 软件 ---------------- */
case 'sw_add': {
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
$note = trim((string)(isset($post['note']) ? $post['note'] : ''));
if ($name === '') { return ['软件名称不能为空。', 'error']; }
if (fb_len($name) > 40) { return ['软件名称最多 40 个字符。', 'error']; }
// 留空就自动生成。中文名生成不出 ASCII 时退化成 sw<序号>,不打扰用户。
if ($slug === '') {
$next = (int)$pdo->query('SELECT COALESCE(MAX(id),0)+1 FROM software')->fetchColumn();
$slug = fb_sw_slugify($name, 'sw', $next);
}
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
$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();
// 新软件默认给一组常见分类,省得空着没法发帖
$i = 0;
foreach ([['bug', 'Bug'], ['feature', '功能建议'], ['other', '其他']] as $c) {
$pdo->prepare('INSERT INTO categories(software_id,slug,name,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)')
->execute([$newId, $c[0], $c[1], $i++, $now]);
}
return ['已添加软件「' . $name . '」,并预置 3 个分类。', 'ok'];
}
case 'sw_edit': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$sw = fb_software($id);
if (!$sw) { return ['软件不存在。', 'error']; }
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
$note = trim((string)(isset($post['note']) ? $post['note'] : ''));
if ($name === '') { return ['软件名称不能为空。', 'error']; }
// 编辑时留空 = 保持原标识,避免改名把已有网址打散
if ($slug === '') { $slug = (string)$sw['slug']; }
$other = fb_software($slug);
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
$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]);
$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': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$sw = fb_software($id);
if (!$sw) { return ['软件不存在。', 'error']; }
$cnt = (int)$pdo->query('SELECT COUNT(*) FROM software')->fetchColumn();
if ($cnt <= 1) { return ['这是最后一个软件,不能删除(至少要保留一个)。', 'error']; }
$n = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE software_id = ' . $id)->fetchColumn();
if ($n > 0) {
return ['该软件下还有 ' . $n . ' 条反馈,不能删除。' .
'请先把这些反馈移到其他软件,或先停用该软件(停用后前台不再显示)。', 'error'];
}
$pdo->prepare('DELETE FROM categories WHERE software_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'];
}
case 'sw_toggle': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$sw = fb_software($id);
if (!$sw) { return ['软件不存在。', 'error']; }
$on = (int)$sw['enabled'] === 1 ? 0 : 1;
if ($on === 0) {
$en = (int)$pdo->query('SELECT COUNT(*) FROM software WHERE enabled = 1')->fetchColumn();
if ($en <= 1) { return ['至少要保留一个启用的软件。', 'error']; }
}
$pdo->prepare('UPDATE software SET enabled = ? WHERE id = ?')->execute([$on, $id]);
return ['软件「' . $sw['name'] . '」已' . ($on ? '启用' : '停用') . '。', 'ok'];
}
/* ---------------- 分类 ---------------- */
case 'cat_add': {
$swId = (int)(isset($post['software_id']) ? $post['software_id'] : 0);
if (!fb_software($swId)) { return ['请先选择软件。', 'error']; }
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
if ($name === '') { return ['分类名称不能为空。', 'error']; }
if (fb_len($name) > 30) { return ['分类名称最多 30 个字符。', 'error']; }
// 留空自动生成;中文名退化成 cat<序号>(在本软件内取号,保证不冲突)
if ($slug === '') {
$next = (int)$pdo->query('SELECT COALESCE(MAX(id),0)+1 FROM categories')->fetchColumn();
$slug = fb_sw_slugify($name, 'cat', $next);
}
if (fb_category_by_slug($swId, $slug)) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM categories WHERE software_id = ' . $swId)->fetchColumn();
$pdo->prepare('INSERT INTO categories(software_id,slug,name,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)')
->execute([$swId, $slug, $name, $sort, $now]);
return ['已添加分类「' . $name . '」。', 'ok'];
}
case 'cat_edit': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$cat = fb_category($id);
if (!$cat) { return ['分类不存在。', 'error']; }
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
if ($name === '') { return ['分类名称不能为空。', 'error']; }
// 编辑时留空 = 保持原标识,避免改名把已有链接打散
if ($slug === '') { $slug = (string)$cat['slug']; }
$other = fb_category_by_slug((int)$cat['software_id'], $slug);
if ($other && (int)$other['id'] !== $id) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; }
$pdo->prepare('UPDATE categories SET name=?, slug=?, sort_order=? WHERE id=?')
->execute([$name, $slug,
(int)(isset($post['sort_order']) ? $post['sort_order'] : $cat['sort_order']), $id]);
return ['分类「' . $name . '」已保存。', 'ok'];
}
case 'cat_del': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$cat = fb_category($id);
if (!$cat) { return ['分类不存在。', 'error']; }
$n = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE category_id = ' . $id)->fetchColumn();
if ($n > 0) {
return ['该分类下还有 ' . $n . ' 条反馈,不能删除。可以改为停用(停用后前台不再出现,但历史反馈保留)。', 'error'];
}
$pdo->prepare('DELETE FROM categories WHERE id = ?')->execute([$id]);
return ['已删除分类「' . $cat['name'] . '」。', 'ok'];
}
case 'cat_toggle': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$cat = fb_category($id);
if (!$cat) { return ['分类不存在。', 'error']; }
$on = (int)$cat['enabled'] === 1 ? 0 : 1;
if ($on === 0) {
$en = (int)$pdo->query('SELECT COUNT(*) FROM categories WHERE software_id = ' . (int)$cat['software_id'] . ' AND enabled = 1')->fetchColumn();
if ($en <= 1) { return ['每个软件至少要保留一个启用的分类。', 'error']; }
}
$pdo->prepare('UPDATE categories SET enabled = ? WHERE id = ?')->execute([$on, $id]);
return ['分类「' . $cat['name'] . '」已' . ($on ? '启用' : '停用') . '。', 'ok'];
}
/* ---------------- 状态 ---------------- */
case 'st_add': {
$key = fb_sw_slugify(isset($post['key_name']) ? $post['key_name'] : '');
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$note = trim((string)(isset($post['user_note']) ? $post['user_note'] : ''));
if ($key === '') { return ['状态标识不能为空(只能用字母、数字、连字符)。', 'error']; }
if ($name === '') { return ['状态名称不能为空。', 'error']; }
$st = $pdo->prepare('SELECT id FROM statuses WHERE key_name = ? LIMIT 1');
$st->execute([$key]);
if ($st->fetchColumn() !== false) { return ['状态标识「' . $key . '」已存在。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM statuses')->fetchColumn();
$pdo->prepare('INSERT INTO statuses(key_name,name,user_note,sort_order,enabled) VALUES(?,?,?,?,1)')
->execute([$key, $name, $note !== '' ? $note : null, $sort]);
return ['已添加状态「' . $name . '」。', 'ok'];
}
case 'st_edit': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$st = $pdo->prepare('SELECT * FROM statuses WHERE id = ? LIMIT 1');
$st->execute([$id]);
$row = $st->fetch();
if (!$row) { return ['状态不存在。', 'error']; }
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$note = trim((string)(isset($post['user_note']) ? $post['user_note'] : ''));
if ($name === '') { return ['状态名称不能为空。', 'error']; }
// key_name 不允许改:threads.status 里存的都是它,改了历史数据就失联
$pdo->prepare('UPDATE statuses SET name=?, user_note=?, sort_order=? WHERE id=?')
->execute([$name, $note !== '' ? $note : null,
(int)(isset($post['sort_order']) ? $post['sort_order'] : $row['sort_order']), $id]);
return ['状态「' . $name . '」已保存。', 'ok'];
}
case 'st_toggle': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$st = $pdo->prepare('SELECT * FROM statuses WHERE id = ? LIMIT 1');
$st->execute([$id]);
$row = $st->fetch();
if (!$row) { return ['状态不存在。', 'error']; }
$on = (int)$row['enabled'] === 1 ? 0 : 1;
if ($on === 0) {
$en = (int)$pdo->query('SELECT COUNT(*) FROM statuses WHERE enabled = 1')->fetchColumn();
if ($en <= 1) { return ['至少要保留一个启用的状态。', 'error']; }
}
$pdo->prepare('UPDATE statuses SET enabled = ? WHERE id = ?')->execute([$on, $id]);
return ['状态「' . $row['name'] . '」已' . ($on ? '启用' : '停用') . '。', 'ok'];
}
/* ---------------- 把反馈改挂到别的软件/分类 ---------------- */
case 'th_move': {
$id = (int)(isset($post['id']) ? $post['id'] : 0);
$swId = (int)(isset($post['software_id']) ? $post['software_id'] : 0);
$catId = (int)(isset($post['category_id']) ? $post['category_id'] : 0);
$sw = fb_software($swId);
if (!$sw) { return ['请选择目标软件。', 'error']; }
$cat = null;
if ($catId) {
$cat = fb_category($catId);
if (!$cat || (int)$cat['software_id'] !== $swId) {
return ['所选分类不属于目标软件,请重新选择。', 'error'];
}
}
$pdo->prepare('UPDATE threads SET software_id = ?, category_id = ?, category = ?, updated_at = ? WHERE id = ?')
->execute([$swId, $cat ? (int)$cat['id'] : null,
$cat ? (string)$cat['slug'] : 'other', time(), $id]);
return ['已把 #' . $id . ' 改挂到「' . $sw['name'] . '」' .
($cat ? ' / ' . $cat['name'] : '') . '。', 'ok'];
}
}
return ['未知操作。', 'error'];
}
/** 这个 act 是否属于本模块 */
function fb_admin_is_taxonomy_act($act) {
return in_array($act, [
'sw_add', 'sw_edit', 'sw_del', 'sw_toggle',
'cat_add', 'cat_edit', 'cat_del', 'cat_toggle',
'st_add', 'st_edit', 'st_toggle',
'th_move',
], true);
}
/* ==================================================================
* 页面:软件与分类
* ================================================================== */
function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
$swList = fb_softwares(false); // 含停用的,后台要看得到
// 当前选中的软件:URL 指定 > 第一个
$curSwId = isset($_GET['sw']) ? (int)$_GET['sw'] : 0;
if ($curSwId <= 0 && $swList) { $curSwId = (int)$swList[0]['id']; }
$curSw = $curSwId > 0 ? fb_software($curSwId) : null;
$curCats = $curSw ? fb_categories((int)$curSw['id'], false) : [];
echo '<h2 style="margin:22px 0 10px">软件</h2>';
echo '<p class="muted" style="margin-top:-4px;font-size:13.5px">'
. '前台按软件分栏显示。停用的软件前台不再出现,但它下面的反馈与分类都保留。'
. '</p>';
if (!$swList) {
echo '<div class="empty"><p>还没有软件,先添加一个。</p></div>';
} else {
echo '<div class="card-list">';
foreach ($swList as $sw) {
$sid = (int)$sw['id'];
$on = (int)$sw['enabled'] === 1;
$nTh = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE software_id = ' . $sid)->fetchColumn();
$nCat = (int)$pdo->query('SELECT COUNT(*) FROM categories WHERE software_id = ' . $sid)->fetchColumn();
$isCur = $sid === $curSwId;
echo '<div class="tax-row' . ($isCur ? ' on' : '') . ($on ? '' : ' off') . '">';
echo ' <div class="tax-main">';
list($swC, $swCd, $swCs) = fb_software_colors($sw);
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 . '">'
. fb_software_badge($sw, 'sm') . '</a>';
echo ' <span class="tax-slug">' . e((string)$sw['slug']) . '</span>';
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
echo ' <span class="tax-meta">' . $nCat . ' 个分类 · ' . $nTh . ' 条反馈</span>';
if (!empty($sw['note'])) { echo ' <div class="tax-note">' . e((string)$sw['note']) . '</div>'; }
echo ' </div>';
echo ' <div class="tax-acts">';
echo ' <a class="btn sm" href="admin.php?tab=taxonomy&sw=' . $sid . '">分类</a>';
echo ' <a class="btn sm" href="admin.php?tab=taxonomy&edit_sw=' . $sid . '">编辑</a>';
echo ' <form method="post" action="admin.php?tab=taxonomy&sw=' . $sid . '" class="inline-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $sid . '">';
echo ' <button class="btn sm" name="act" value="sw_toggle" type="submit">' . ($on ? '停用' : '启用') . '</button>';
echo ' </form>';
echo ' <form method="post" action="admin.php?tab=taxonomy&sw=' . $sid . '" class="inline-form"'
. ' onsubmit="return confirm(\'确定删除软件「' . e((string)$sw['name']) . '」?\n只有在该软件下没有反馈时才能删除。\');">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $sid . '">';
echo ' <button class="btn sm danger" name="act" value="sw_del" type="submit">删除</button>';
echo ' </form>';
echo ' </div>';
echo '</div>';
}
echo '</div>';
}
/* ---- 新增 / 编辑软件 ---- */
$esw = null;
if ($editSw > 0) { $esw = fb_software($editSw); }
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>';
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()) . '">';
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
echo ' <div class="row2">';
echo ' <label class="field"><span>名称 <b class="req">*</b></span>'
. '<input type="text" name="name" maxlength="40" required value="'
. e($esw ? (string)$esw['name'] : '') . '" placeholder="例如:同步日历"></label>';
echo ' <label class="field"><span>英文标识<i class="hint">网址里用,留空自动生成</i></span>'
. '<input type="text" name="slug" maxlength="40" value="'
. e($esw ? (string)$esw['slug'] : '') . '" placeholder="synccalendar"></label>';
echo ' </div>';
echo ' <label class="field"><span>一句话说明<i class="hint">显示在列表页标题下,可留空</i></span>'
. '<input type="text" name="note" maxlength="120" value="'
. 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">';
echo ' <input type="file" name="icon_file" accept="image/png,image/jpeg,image/webp" id="iconFile' . ($esw ? (int)$esw['id'] : 'new') . '">';
echo ' <i class="hint" style="margin-top:4px">建议 256×256 或 512×512 的方形图;带透明背景的 PNG 效果最好。</i>';
if ($curIconUrl !== '') {
echo ' <label class="check" style="margin-top:6px"><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>';
// 下载地址:状态改成「已解决」时,邮件里会给用户一个去下新版的按钮
echo ' <label class="field"><span>下载地址<i class="hint">可留空。填了之后「已解决」的通知邮件里会多一个「下载最新版」按钮</i></span>'
. '<input type="url" name="download_url" maxlength="300" value="'
. e($esw ? (string)(isset($esw['download_url']) ? $esw['download_url'] : '') : '')
. '" placeholder="https://..."></label>';
// 主题色:前台按软件换色,用短链接进不同软件一眼就能区分
$curColor = $esw ? fb_color_norm((string)$esw['color']) : '';
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
$presets = ['#0057B8', '#2E7D5B', '#B4553C', '#6A4C93', '#0E7C86', '#A02C5A', '#8A6D1F', '#3A5A8C'];
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
echo ' <div class="color-row">';
echo ' <input type="color" name="color" value="' . e($sugColor) . '" class="color-input">';
echo ' <div class="color-presets">';
foreach ($presets as $pc) {
$on = strcasecmp($pc, $sugColor) === 0 ? ' on' : '';
echo '<button type="button" class="color-preset' . $on . '" data-color="' . e($pc)
. '" style="background:' . e($pc) . '" title="' . e($pc) . '"></button>';
}
echo ' </div>';
echo ' <button type="button" class="btn sm color-reset">用默认</button>';
echo ' </div>';
echo ' </div>';
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> '
. '<a class="btn" href="admin.php?tab=taxonomy">取消</a>';
} else {
echo ' <button class="btn primary" name="act" value="sw_add" type="submit">添加软件</button>';
}
echo '</form></details>';
/* ---- 该软件下的分类 ---- */
if (!$curSw) { return; }
echo '<h2 style="margin:30px 0 10px">「' . e((string)$curSw['name']) . '」的分类</h2>';
echo '<p class="muted" style="margin-top:-4px;font-size:13.5px">'
. '每个软件有自己的一套分类,名称可以和其他软件重复。'
. '停用的分类前台不再出现,历史反馈照旧保留。'
. '</p>';
if (!$curCats) {
echo '<div class="empty"><p>这个软件还没有分类,先加一个。</p></div>';
} else {
echo '<div class="card-list">';
foreach ($curCats as $c) {
$cid = (int)$c['id'];
$on = (int)$c['enabled'] === 1;
$n = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE category_id = ' . $cid)->fetchColumn();
echo '<div class="tax-row' . ($on ? '' : ' off') . '">';
echo ' <div class="tax-main">';
echo ' <span class="tax-name plain">' . e((string)$c['name']) . '</span>';
echo ' <span class="tax-slug">' . e((string)$c['slug']) . '</span>';
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
echo ' <span class="tax-meta">' . $n . ' 条反馈</span>';
echo ' </div>';
echo ' <div class="tax-acts">';
echo ' <a class="btn sm" href="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '&edit_cat=' . $cid . '">编辑</a>';
echo ' <form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="inline-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $cid . '">';
echo ' <button class="btn sm" name="act" value="cat_toggle" type="submit">' . ($on ? '停用' : '启用') . '</button>';
echo ' </form>';
echo ' <form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="inline-form"'
. ' onsubmit="return confirm(\'确定删除分类「' . e((string)$c['name']) . '」?\n只有在该分类下没有反馈时才能删除。\');">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $cid . '">';
echo ' <button class="btn sm danger" name="act" value="cat_del" type="submit">删除</button>';
echo ' </form>';
echo ' </div>';
echo '</div>';
}
echo '</div>';
}
/* ---- 新增 / 编辑分类 ---- */
$ecat = $editCat > 0 ? fb_category($editCat) : null;
echo '<details class="tax-form-box"' . ($ecat ? ' open' : '') . '>';
echo '<summary>' . ($ecat ? '编辑分类「' . e((string)$ecat['name']) . '」' : '添加分类') . '</summary>';
echo '<form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="card-form sm-form" data-validate>';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="software_id" value="' . (int)$curSw['id'] . '">';
if ($ecat) { echo ' <input type="hidden" name="id" value="' . (int)$ecat['id'] . '">'; }
echo ' <div class="row2">';
echo ' <label class="field"><span>分类名称 <b class="req">*</b></span>'
. '<input type="text" name="name" maxlength="30" required value="'
. e($ecat ? (string)$ecat['name'] : '') . '" placeholder="例如:Bug"></label>';
echo ' <label class="field"><span>英文标识<i class="hint">网址里用,留空自动生成</i></span>'
. '<input type="text" name="slug" maxlength="40" value="'
. e($ecat ? (string)$ecat['slug'] : '') . '" placeholder="bug"></label>';
echo ' </div>';
if ($ecat) {
echo ' <label class="field"><span>排序<i class="hint">数字小的排前面</i></span>'
. '<input type="number" name="sort_order" value="' . (int)$ecat['sort_order'] . '"></label>';
echo ' <button class="btn primary" name="act" value="cat_edit" type="submit">保存</button> '
. '<a class="btn" href="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '">取消</a>';
} else {
echo ' <button class="btn primary" name="act" value="cat_add" type="submit">添加分类</button>';
}
echo '</form></details>';
}
/* ==================================================================
* 页面:状态
* ================================================================== */
function fb_admin_status_page($pdo, $editId) {
$rows = fb_statuses(false);
echo '<h2 style="margin:22px 0 10px">处理状态</h2>';
echo '<p class="muted" style="margin-top:-4px;font-size:13.5px">'
. '「给用户看的话」会写进发给提交人的邮件正文。改这里的文字,前台与邮件都会跟着变。'
. '</p>';
echo '<div class="card-list">';
foreach ($rows as $r) {
$rid = (int)$r['id'];
$on = (int)$r['enabled'] === 1;
$n = (int)$pdo->query('SELECT COUNT(*) FROM threads WHERE status = '
. $pdo->quote((string)$r['key_name']))->fetchColumn();
echo '<div class="tax-row' . ($on ? '' : ' off') . '">';
echo ' <div class="tax-main">';
echo ' <span class="badge st st-' . e((string)$r['key_name']) . '">' . e((string)$r['name']) . '</span>';
echo ' <span class="tax-slug">' . e((string)$r['key_name']) . '</span>';
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
echo ' <span class="tax-meta">' . $n . ' 条反馈</span>';
$note = (string)($r['user_note'] === null ? '' : $r['user_note']);
echo ' <div class="tax-note">' . ($note !== '' ? e($note) : '<i>(没有给用户看的说明)</i>') . '</div>';
echo ' </div>';
echo ' <div class="tax-acts">';
echo ' <a class="btn sm" href="admin.php?tab=statuses&edit_st=' . $rid . '">编辑</a>';
echo ' <form method="post" action="admin.php?tab=statuses" class="inline-form">';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="id" value="' . $rid . '">';
echo ' <button class="btn sm" name="act" value="st_toggle" type="submit">' . ($on ? '停用' : '启用') . '</button>';
echo ' </form>';
echo ' </div>';
echo '</div>';
}
echo '</div>';
/* ---- 新增 / 编辑状态 ---- */
$est = null;
if ($editId > 0) {
$q = $pdo->prepare('SELECT * FROM statuses WHERE id = ? LIMIT 1');
$q->execute([$editId]);
$est = $q->fetch();
}
echo '<details class="tax-form-box"' . ($est ? ' open' : '') . '>';
echo '<summary>' . ($est ? '编辑状态「' . e((string)$est['name']) . '」' : '添加状态') . '</summary>';
echo '<form method="post" action="admin.php?tab=statuses" class="card-form sm-form" data-validate>';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
if ($est) { echo ' <input type="hidden" name="id" value="' . (int)$est['id'] . '">'; }
echo ' <div class="row2">';
if ($est) {
echo ' <label class="field"><span>标识<i class="hint">不可修改(历史数据靠它对应)</i></span>'
. '<input type="text" value="' . e((string)$est['key_name']) . '" disabled></label>';
} else {
echo ' <label class="field"><span>标识 <b class="req">*</b><i class="hint">字母数字连字符</i></span>'
. '<input type="text" name="key_name" maxlength="30" required placeholder="wontfix"></label>';
}
echo ' <label class="field"><span>显示名称 <b class="req">*</b></span>'
. '<input type="text" name="name" maxlength="20" required value="'
. e($est ? (string)$est['name'] : '') . '" placeholder="不予考虑"></label>';
echo ' </div>';
echo ' <label class="field"><span>给用户看的话<i class="hint">会写进通知邮件</i></span>'
. '<textarea name="user_note" rows="2" maxlength="200">'
. e($est ? (string)($est['user_note'] === null ? '' : $est['user_note']) : '') . '</textarea></label>';
if ($est) {
echo ' <label class="field"><span>排序</span>'
. '<input type="number" name="sort_order" value="' . (int)$est['sort_order'] . '"></label>';
echo ' <button class="btn primary" name="act" value="st_edit" type="submit">保存</button> '
. '<a class="btn" href="admin.php?tab=statuses">取消</a>';
} else {
echo ' <button class="btn primary" name="act" value="st_add" type="submit">添加状态</button>';
}
echo '</form></details>';
}