Files
FeedLite/thread.php
T
laoyang 55d2dfa46b 后台管理界面:软件/分类/状态可配,反馈可改挂归属 (#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 文件语法全过。
2026-11-01 12:03:11 +08:00

267 lines
12 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
/**
* 问题详情 + 回复
*/
require __DIR__ . '/lib/bootstrap.php';
$pdo = fb_db();
$id = isset($_GET['id']) ? (int)$_GET['id'] : (isset($_POST['id']) ? (int)$_POST['id'] : 0);
if ($id <= 0) { fb_redirect('index.php'); }
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$st->execute([$id]);
$thread = $st->fetch();
if (!$thread) {
http_response_code(404);
fb_layout_head('问题不存在');
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
fb_layout_foot();
exit;
}
// 审核(先审后显):未通过审核的问题对访客不可见
if (!fb_thread_public($thread)) {
$justSubmitted = (isset($_GET['submitted']) && (string)$_GET['submitted'] === '1');
if ($justSubmitted) {
fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']);
echo '<div class="empty">'
. '<h2 style="margin:0 0 10px">提交成功,等待审核</h2>'
. '<p>你的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>'
. '<p>· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;<br>'
. '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到你留下的邮箱。</p>'
. '<p class="muted">审核通常在 1~2 个工作日内完成,请留意邮箱。</p>'
. '<p><a class="btn primary" href="index.php">返回问题列表</a></p>'
. '</div>';
fb_layout_foot();
exit;
}
http_response_code(404);
fb_layout_head('问题不存在');
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
fb_layout_foot();
exit;
}
$statuses = fb_status_keys();
$err = '';
$atts = [];
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
/* ---------------- 处理回复提交 ---------------- */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 审核中(未公开)的问题不允许回复
if (!fb_thread_public($thread)) {
http_response_code(403);
fb_layout_head('暂不可回复');
echo '<div class="empty"><p>该问题还在审核中,暂时不能回复。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
fb_layout_foot();
exit;
}
$form['name'] = trim(isset($_POST['name']) ? (string)$_POST['name'] : '');
$form['email'] = trim(isset($_POST['email']) ? (string)$_POST['email'] : '');
$form['body'] = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
$adminPw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : '');
$isAdmin = ($adminPw !== '' && fb_admin_verify_password($adminPw));
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
$err = '会话已过期,请重新提交。';
} elseif ($hp !== '') {
$err = '提交未通过校验,请重试。';
} elseif ($t0 > 0 && (time() - $t0) < max(0, (int)fb_cfg('post_min_seconds', 3))) {
$err = '提交过于频繁,请稍候再试。';
} elseif (!fb_valid_name($form['name']) && !$isAdmin) {
$err = '请填写昵称(1~24 个字符)。';
} elseif (!$isAdmin && !filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
$err = '请填写有效的邮箱地址(用于接收回复通知)。';
} elseif (!fb_valid_body($form['body'])) {
$err = '回复内容请填写 2~5000 个字符。';
} elseif (!$attOk) {
$err = $attErr;
} elseif (!fb_rate_check('reply', (int)fb_cfg('rate_limit.reply_per_hour', 20))) {
$err = '回复过于频繁,请稍后再试。';
} else {
list($vOk, $vList, $vErr) = fb_attach_verify($attList);
if (!$vOk) {
$err = $vErr;
$atts = $attList;
} elseif (!fb_attach_cap_ok($pdo, $vList)) {
$err = '附件存储空间已满,请减少附件后重试。';
$atts = $vList;
} elseif (!fb_captcha_check($capInput)) {
$err = '人机验证未通过,请重新作答。';
$atts = $vList;
} else {
$now = time();
$replyName = $isAdmin ? '官方' : $form['name'];
$replyMail = $isAdmin ? null : $form['email'];
$ins = $pdo->prepare('INSERT INTO replies(thread_id, body, author_name, author_email, is_admin, created_at, ip_hash) VALUES(?,?,?,?,?,?,?)');
$ins->execute([$id, $form['body'], $replyName, $replyMail, $isAdmin ? 1 : 0, $now, fb_ip_hash()]);
$replyId = (int)$pdo->lastInsertId();
fb_attach_save($pdo, $id, $replyId, $vList);
// 更新主题:回复数 +1、活跃时间;管理员可同时调整状态
$newStatus = null;
if ($isAdmin && isset($_POST['status']) && isset($statuses[(string)$_POST['status']])) {
$newStatus = (string)$_POST['status'];
}
if ($newStatus !== null) {
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ?, status = ? WHERE id = ?');
$upd->execute([$now, $newStatus, $id]);
} else {
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?');
$upd->execute([$now, $id]);
}
fb_rate_hit('reply');
// 发送通知邮件给发帖人
$replyRow = [
'id' => $replyId,
'body' => $form['body'],
'author_name' => $replyName,
'author_email' => $replyMail,
'is_admin' => $isAdmin ? 1 : 0,
];
fb_notify_reply($pdo, $thread, $replyRow, $isAdmin);
fb_redirect('thread.php?id=' . $id . '#r' . $replyId);
}
}
}
/* ---------------- 读取数据 ---------------- */
$st = $pdo->prepare('SELECT id, body, author_name, is_admin, created_at FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
$st->execute([$id]);
$replies = $st->fetchAll();
// 重新读取主题(可能在 POST 中更新过状态)
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$st->execute([$id]);
$thread = $st->fetch();
// 附件(主题级 + 各回复)
$attMap = fb_attach_for_thread($pdo, $id);
fb_layout_head(fb_excerpt($thread['title'], 50));
?>
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
<article class="subject">
<div class="item-top">
<?php
$thSw = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
$thCat = !empty($thread['category_id']) ? fb_category((int)$thread['category_id']) : null;
?>
<?php if ($thSw): ?><span class="badge sw"><?php echo e((string)$thSw['name']); ?></span><?php endif; ?>
<?php if ($thCat): ?><span class="badge cat"><?php echo e((string)$thCat['name']); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span>
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>
</div>
<div class="item-meta">
<span class="who"><?php echo e($thread['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($thread['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo (int)$thread['reply_count']; ?> 条回复</span>
</div>
<div class="subject-body"><?php echo nl2br(e($thread['body'])); ?></div>
<?php echo fb_attach_render($attMap['main']); ?>
</article>
<section class="replies">
<h2>全部回复(<?php echo count($replies); ?>)</h2>
<?php if (!$replies): ?>
<div class="empty small"><p>还没有回复,欢迎在下方参与讨论。</p></div>
<?php else: ?>
<?php foreach ($replies as $r): ?>
<div class="reply <?php echo $r['is_admin'] ? 'admin' : ''; ?>" id="r<?php echo (int)$r['id']; ?>">
<div class="reply-head">
<span class="who"><?php echo e($r['is_admin'] ? '官方' : $r['author_name']); ?></span>
<?php if ($r['is_admin']): ?><span class="badge official">官方答复</span><?php endif; ?>
<span class="dot">·</span>
<span class="time"><?php echo e(fb_fmt_time($r['created_at'])); ?></span>
</div>
<div class="reply-body"><?php echo nl2br(e($r['body'])); ?></div>
<?php
$rid = (int)$r['id'];
if (!empty($attMap['replies'][$rid])) {
echo fb_attach_render($attMap['replies'][$rid], ['compact' => true]);
}
?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</section>
<section class="reply-form">
<h2>我要回复</h2>
<?php fb_flash_box($err, 'error'); ?>
<form method="post" action="thread.php?id=<?php echo (int)$id; ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="t0" value="<?php echo time(); ?>">
<div class="hp-field" aria-hidden="true">
<label>请勿填写此栏<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<div class="row2">
<label class="field">
<span>昵称 <b class="req">*</b></span>
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼你" required>
</label>
<label class="field" id="emailField">
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
<input type="email" name="email" maxlength="120" value="<?php echo e($form['email']); ?>" placeholder="you@example.com" required>
</label>
</div>
<label class="field">
<span>回复内容 <b class="req">*</b></span>
<textarea name="body" rows="6" maxlength="5000" placeholder="说说你的看法或补充…" required><?php echo e($form['body']); ?></textarea>
</label>
<?php fb_uploaders($atts); ?>
<div class="captcha">
<label class="field">
<span>人机验证 <b class="req">*</b> <i class="hint">防机器人,随手答一下</i></span>
<div class="captcha-line">
<span class="c-q"><?php echo e(fb_captcha_question()); ?></span>
<input type="text" name="captcha" value="" placeholder="答案" autocomplete="off" required>
<a class="reload" href="captcha.php" data-captcha-refresh>换一题</a>
</div>
</label>
</div>
<label class="field admin-pw">
<span>管理员口令 <i class="hint">选填;填写后将以「官方」身份回复</i></span>
<input type="password" name="admin_password" value="" autocomplete="off" placeholder="管理员专用">
</label>
<div class="field admin-status" hidden>
<span>处理状态 <i class="hint">仅管理员可见</i></span>
<select name="status">
<?php foreach ($statuses as $k => $v): ?>
<option value="<?php echo e($k); ?>" <?php echo $thread['status'] === $k ? 'selected' : ''; ?>><?php echo e($v); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-actions">
<button class="btn primary" type="submit">提交回复</button>
</div>
</form>
</section>
<?php
fb_layout_foot();