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

713 lines
40 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">';
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);
/* 🔴 必须用 fb_color_palette(),不要再在这里写死一份数组。
* 以前这里硬编码了另外 8 个色值,和 util.php 的调色板完全不同步 ——
* 结果后台只能选那 8 个,用户在后台挑不到新颜色,
* 而他从没配置过的软件却会被分到「调色板里另一批」颜色。 */
$presets = fb_color_palette();
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>';
}