后台管理界面:软件/分类/状态可配,反馈可改挂归属 (#1)

## 新增 lib/admin_taxonomy.php
- 软件:增删改、排序、启停;新增时自动预置 Bug/功能建议/其他 三个分类
- 分类:在选定软件下增删改、排序、启停
- 状态:改名、改「给用户看的话」、排序、启停;标识不可改(历史数据靠它对应)
- 反馈改挂:th_move,可把反馈移到别的软件/分类,跨软件的非法分类会被拒

## 安全与边界
- 所有动作走 CSRF 校验(沿用现有机制)
- 不能删最后一个软件 / 没有分类的软件至少要留一个启用分类
- 有反馈挂着的软件或分类不能删,提示改为停用(停用后前台消失、数据保留)
- th_move 加入 fb_admin_is_taxonomy_act 白名单,否则会被「问题不存在」静默吞掉

## admin.php 接线
- 引入模块 + 动作分发 + tab 白名单加 taxonomy/statuses + 导航两项
- 前置检查放行不依赖 $t 的动作(否则新增软件报「问题不存在」)
- 全部问题列表:加软件筛选、分类跟着软件走;修掉三处仍在用旧
  fb_statuses()/fb_categories() 的遍历(那会渲染出错)
- 详情页加「归属」表单,软件徽章可显示

## 其他文件
- api.php:状态/分类改数据库;新增 ?sw= / ?software_id= / ?cid= 过滤,
  老的 ?category=<slug> 仍可用(在所属软件内解析)
- thread.php:详情页显示软件与分类徽章
- config.example.php:移除 categories / statuses,改由数据库管理

## 修掉两个真实 bug
1. 移除配置后出厂分类兜底只剩 4 个,漏了「同步问题」—— 建库后核对数量才发现
2. th_move 不在白名单,POST 后返回 200 却静默不生效

验证:后台 e2e 29/29;改挂与联动 13/13;PHP 8.5 全部入口 0 弃用;
19 个 PHP 文件语法全过。
This commit is contained in:
2026-11-01 12:03:11 +08:00
parent 59f7466ae4
commit 55d2dfa46b
8 changed files with 639 additions and 38 deletions
+89 -13
View File
@@ -12,6 +12,7 @@
* 入口:/feedback/admin.php
*/
require __DIR__ . '/lib/bootstrap.php';
require __DIR__ . '/lib/admin_taxonomy.php';
$pdo = fb_db();
@@ -93,7 +94,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$act = (string)$_POST['act'];
$t = fb_admin_thread($pdo, $id);
if (!$t && $act !== 'settings' && $act !== 'password') {
// 这些动作不针对某一条反馈(不依赖 $t),单独放行
$noThread = in_array($act, ['settings', 'password'], true)
|| fb_admin_is_taxonomy_act($act);
if (!$t && !$noThread) {
$msg = '问题不存在。';
$msgType = 'error';
@@ -182,6 +187,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
fb_mod_log('delete', $id, '删除问题:' . fb_excerpt((string)$t['title'], 40));
$msg = '#' . $id . ' 已删除。';
} elseif (fb_admin_is_taxonomy_act($act)) {
list($msg, $msgType) = fb_admin_taxonomy_action($pdo, $act, $_POST);
} elseif ($act === 'settings') {
$msg = fb_admin_save_settings();
@@ -300,7 +308,7 @@ function fb_admin_save_settings() {
* 页面
* ================================================================== */
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
if (!in_array($tab, ['pending', 'all', 'thread', 'settings', 'log'], true)) { $tab = 'pending'; }
if (!in_array($tab, ['pending', 'all', 'thread', 'taxonomy', 'statuses', 'settings', 'log'], true)) { $tab = 'pending'; }
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
?>
@@ -315,6 +323,8 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<nav class="tabs">
<a class="tab<?php echo $tab === 'pending' ? ' on' : ''; ?>" href="admin.php?tab=pending">待审核</a>
<a class="tab<?php echo $tab === 'all' ? ' on' : ''; ?>" href="admin.php?tab=all">全部问题</a>
<a class="tab<?php echo $tab === 'taxonomy' ? ' on' : ''; ?>" href="admin.php?tab=taxonomy">软件与分类</a>
<a class="tab<?php echo $tab === 'statuses' ? ' on' : ''; ?>" href="admin.php?tab=statuses">处理状态</a>
<a class="tab<?php echo $tab === 'settings' ? ' on' : ''; ?>" href="admin.php?tab=settings">站点设置</a>
<a class="tab<?php echo $tab === 'log' ? ' on' : ''; ?>" href="admin.php?tab=log">操作日志</a>
</nav>
@@ -343,9 +353,12 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
$visF = (string)(isset($_GET['vis']) ? $_GET['vis'] : '');
$catF = (string)(isset($_GET['cat']) ? $_GET['cat'] : '');
$qF = trim((string)(isset($_GET['q']) ? $_GET['q'] : ''));
$swF = isset($_GET['sw']) ? (int)$_GET['sw'] : 0;
$cidF = isset($_GET['cid']) ? (int)$_GET['cid'] : 0;
if ($stF !== '') { $where[] = 'status = ?'; $args[] = $stF; }
if ($visF === '1' || $visF === '0') { $where[] = 'visible = ?'; $args[] = (int)$visF; }
if ($catF !== '') { $where[] = 'category = ?'; $args[] = $catF; }
if ($swF > 0) { $where[] = 'software_id = ?'; $args[] = $swF; }
if ($cidF > 0) { $where[] = 'category_id = ?'; $args[] = $cidF; }
if ($qF !== '') { $where[] = '(title LIKE ? OR body LIKE ?)'; $args[] = '%' . $qF . '%'; $args[] = '%' . $qF . '%'; }
$wSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
@@ -364,17 +377,29 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<form class="filters" method="get" action="admin.php">
<input type="hidden" name="tab" value="all">
<input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
<select name="sw" onchange="this.form.submit()">
<option value="0">全部软件</option>
<?php foreach (fb_softwares(false) as $sw1): ?>
<option value="<?php echo (int)$sw1['id']; ?>"<?php echo $swF === (int)$sw1['id'] ? ' selected' : ''; ?>><?php echo e((string)$sw1['name']); ?></option>
<?php endforeach; ?>
</select>
<select name="status">
<option value="">全部状态</option>
<?php foreach (fb_statuses() as $k => $label): ?>
<?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo $stF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
<select name="cat">
<option value="">全部分类</option>
<?php foreach (fb_categories() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo $catF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
<select name="cid">
<option value="0">全部分类</option>
<?php
// 选了某个软件就只列它的分类;否则把所有软件的分类按软件分组列出
$catSwList = $swF > 0 ? [fb_software($swF)] : fb_softwares(false);
foreach ($catSwList as $csw1):
if (!$csw1) { continue; }
foreach (fb_categories((int)$csw1['id'], false) as $c1):
?>
<option value="<?php echo (int)$c1['id']; ?>"<?php echo $cidF === (int)$c1['id'] ? ' selected' : ''; ?>><?php echo e(($swF > 0 ? '' : (string)$csw1['name'] . ' · ') . (string)$c1['name']); ?></option>
<?php endforeach; endforeach; ?>
</select>
<select name="vis">
<option value="">公开与否不限</option>
@@ -437,7 +462,12 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<div class="card-form" style="margin:0 0 16px">
<div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
<?php
$dSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
$dCat = !empty($t['category_id']) ? fb_category((int)$t['category_id']) : null;
?>
<?php if ($dSw): ?><span class="badge sw"><?php echo e((string)$dSw['name']); ?></span><?php endif; ?>
<?php if ($dCat): ?><span class="badge cat"><?php echo e((string)$dCat['name']); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
@@ -469,6 +499,40 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
</div>
<?php endforeach; ?>
<h2 style="margin:24px 0 10px">归属</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<p class="muted" style="margin:0 0 10px;font-size:13.5px">
挂错软件或分类时在这里改正。换软件后分类选项会跟着变,需重新选择。
</p>
<div class="row2">
<label class="field">
<span>所属软件</span>
<select name="software_id" onchange="location.href='admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>&amp;sw='+this.value">
<?php foreach (fb_softwares(false) as $m1): ?>
<option value="<?php echo (int)$m1['id']; ?>"<?php echo (int)(isset($t['software_id']) ? $t['software_id'] : 0) === (int)$m1['id'] ? ' selected' : ''; ?>><?php echo e((string)$m1['name']); ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>分类</span>
<select name="category_id">
<option value="0">(不指定)</option>
<?php
$mvSw = isset($_GET['sw']) ? (int)$_GET['sw']
: (int)(isset($t['software_id']) ? $t['software_id'] : 0);
if ($mvSw <= 0) { $d0 = fb_default_software(); $mvSw = $d0 ? (int)$d0['id'] : 0; }
foreach (fb_categories($mvSw, false) as $m2):
?>
<option value="<?php echo (int)$m2['id']; ?>"<?php echo (int)(isset($t['category_id']) ? $t['category_id'] : 0) === (int)$m2['id'] ? ' selected' : ''; ?>><?php echo e((string)$m2['name']); ?></option>
<?php endforeach; ?>
</select>
</label>
</div>
<button class="btn" name="act" value="th_move" type="submit">保存归属</button>
</form>
<h2 style="margin:24px 0 10px">以官方身份回复</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
@@ -481,7 +545,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<span>同时把状态改为</span>
<select name="status">
<option value="">(不改)</option>
<?php foreach (fb_statuses() as $k => $label): ?>
<?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
@@ -499,7 +563,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
<label class="field">
<span>处理状态</span>
<select name="status">
<?php foreach (fb_statuses() as $k => $label): ?>
<?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo ((string)$t['status'] === $k) ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
@@ -542,6 +606,14 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
</form>
<?php endif; ?>
<?php elseif ($tab === 'taxonomy'): ?>
<?php fb_admin_taxonomy_page($pdo,
isset($_GET['edit_sw']) ? (int)$_GET['edit_sw'] : 0,
isset($_GET['edit_cat']) ? (int)$_GET['edit_cat'] : 0); ?>
<?php elseif ($tab === 'statuses'): ?>
<?php fb_admin_status_page($pdo, isset($_GET['edit_st']) ? (int)$_GET['edit_st'] : 0); ?>
<?php elseif ($tab === 'settings'): ?>
<?php
$mode = fb_store_mode();
@@ -761,7 +833,11 @@ function fb_admin_thread_card(array $t) {
ob_start(); ?>
<div class="card-form" style="margin:0 0 16px">
<div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
<?php $cSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null; ?>
<?php if ($cSw): ?><span class="badge sw"><?php echo e((string)$cSw['name']); ?></span><?php endif; ?>
<?php if (!empty($t['category_id'])): ?>
<span class="badge cat"><?php echo e(fb_category_label((int)$t['category_id'])); ?></span>
<?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>