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

750 lines
43 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=... 代理,不暴露真实位置。
* ================================================================== */
/** 图标允许的扩展名 —— 定义在 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 校验通过后调用)
* 返回 [消息, 类型],类型 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">';
/* 文件选择按钮重做成真按钮(用户:原生那个太丑)。
* <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']) : '';
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
/* 颜色选择:**点色块弹出一整块色板**。
* 用户 2026-11-04:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」
* ⇒ ① 右侧那排预设圆点删除,界面更干净,能力靠色板本身(任意取色)覆盖;
* ② 色板要"完整":足够大的色相/饱和度画布 + 色相条 + 明度条 +
* 十六进制输入。太小会让人觉得是"玩具",拖着不好定位颜色。
* ⚠️ 不再需要 $presets:色板本身就能取任意颜色,
* 留着那排小圆点反而和色板重复、还占横向空间(用户明确要求去掉)。 */
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
echo ' <div class="color-row" data-colorpicker>';
echo ' <button type="button" class="color-trigger" data-color-trigger'
. ' style="--cur:' . e($sugColor) . '" title="点击选择颜色">'
. ' <span class="color-trigger-chip"></span>'
. ' <span class="color-trigger-hex">' . e(strtoupper($sugColor)) . '</span>'
. ' <span class="color-trigger-caret">▾</span>'
. ' </button>';
echo ' <input type="hidden" name="color" value="' . e($sugColor) . '" data-color-value>';
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>';
if ($esw) {
/* 排序是 1-2 位的小整数,占满整行很难看 —— 收窄成定宽。
* 用 .field-narrow 限制宽度,而不是让它跟着 .field 铺满。 */
echo ' <label class="field field-narrow"><span>排序<i class="hint">数字小的排前面,同级按名称排</i></span>'
. '<input type="number" name="sort_order" min="0" max="999" 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>';
}