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 校验通过后调用) * 返回 [消息, 类型],类型 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']; } /* 排序:优先用表单里填的值(新建表单现在也有这一项了), * 没填才算「排到最后」。用 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'] : ''); $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 '
' . '前台按软件分栏显示。停用的软件前台不再出现,但它下面的反馈与分类都保留。' . '
'; if (!$swList) { echo '还没有软件,先添加一个。
' . '每个软件有自己的一套分类,名称可以和其他软件重复。' . '停用的分类前台不再出现,历史反馈照旧保留。' . '
'; if (!$curCats) { echo '这个软件还没有分类,先加一个。
' . '「给用户看的话」会写进发给提交人的邮件正文。改这里的文字,前台与邮件都会跟着变。' . '
'; echo '