Files
FeedLite/thread.php
T
laoyang c09a9875a3 标题收到最上顶头,标签下移一行;顶栏标题回当前软件;去掉跨软件引导
用户三个问题:

一、软件名/分类/状态三个标签挤在标题前面,把标题推到右边很难看
  列表页:item-top 里 badges 在前、标题在后 → 标题被挤到右边
  详情页:同一个毛病
  改法(两处一致):
    行1 标题(顶头、独占一行)
    行2 标签(分类/状态;详情页还有软件名)
    行3 摘要 / 作者信息
  列表页新增 .item-head + .item-tags;详情页新增 .subject-head + .subject-tags。
  ⚠️ .item-top 保留 —— admin.php 有三处内部列表还在用,删了后台会塌。

二、顶栏「XX 反馈系统」点完跳首页
  用户:「点完之后应该还是进入这个反馈系统,而不是进到首页。首页没有太大作用,
        只是演示说明用的。正常使用都是每个软件独立,要让用户觉得
        这套反馈系统就是专门为这个软件做的。」
  改法:brand-mini 的 href 从 "./" 改为 fb_software_list_url()。

三、软件内部不该引导去其他软件
  用户:「在一个反馈系统里面,我要反馈、问题列表、左上角那个链接,
        全部都是这个软件相关的内容,跟其他软件没关系。」
  - 提交成功页删掉整个「也可以去其他软件反馈」区块及其 CSS
  - 删掉成功页的「返回首页」按钮(已有「看该软件其他反馈」,重复且无意义)
  - 跨软件只剩顶栏三横线菜单一个入口

验证:列表页/详情页 DOM 顺序均为 标题→标签→作者(实测 item-title 58 < item-tags 171;
subject-title 49 < subject-tags 151 < item-meta 592);
浏览器实测标题 top=282 标签 top=315(列表)、155→194(详情),标签确实在下方;
标题左边缘距卡片边 18/23px,就是卡片自身 padding,没有额外缩进;
顶栏标题 href=index.php?sw=synccalendar,不再指向根路径;全站 8 个路由 200。
2026-11-03 13:43:15 +08:00

323 lines
16 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();
/* 这条帖子所属的软件 —— 「返回问题列表」要回到它的列表,
* 而不是回到首页(首页现在是介绍页)。 */
$backUrl = fb_software_list_url(
$thread && !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null
);
if (!$thread) {
http_response_code(404);
fb_layout_head('问题不存在');
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
fb_layout_foot();
exit;
}
/* 审核(先审后显)+ 提交人追踪:
*
* 可见性三档 ——
* ① 已公开:所有人可看
* ② 未公开 + 带对 token:提交人本人可看(这是他的私人进度页)
* ③ 未公开 + 没 token:对外一律装作「不存在」,不泄露标题也不泄露状态
*
* 🔴 关键:带对 token 时**绝不能**再走「提交成功,等待审核」那个提示页。
* 用户之前就卡在这里 —— 邮件里给了链接让他看进度,点进来却只看到
* 「等待审核」,等于链接白给。带 token 进来就应该看到完整详情。 */
$hasToken = isset($_GET['t']) ? trim((string)$_GET['t']) : '';
$isOwnerView = $hasToken !== '' && fb_track_token_ok($thread, $hasToken);
if (!fb_thread_viewable($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="' . e($backUrl) . '">返回问题列表</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="' . e($backUrl) . '">返回问题列表</a></p></div>';
fb_layout_foot();
exit;
}
$err = '';
$atts = [];
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
/* ---------------- 处理回复提交 ---------------- */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
/* 未公开的帖子,只有带对 token 的提交人(或管理员)能回复。
* 别人连看都看不到,自然也不该能回。 */
if (!fb_thread_public($thread) && !$isOwnerView) {
http_response_code(403);
fb_layout_head('暂不可回复');
echo '<div class="empty"><p>该问题还在审核中,暂时不能回复。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</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'] : '');
/* 🔴 带 token(= 提交人本人)时,身份以库里那条帖子为准,**不采信 POST**。
* 前端已经把这两个字段设成 hidden 了,但 hidden 是可以被改的:
* 谁拿到链接、改个 email 提交,这条反馈的联系人就变成他的了。
* 身份校验必须在服务端做,不能只靠前端不给输入框。 */
if ($isOwnerView) {
$form['name'] = (string)$thread['author_name'];
$form['email'] = (string)$thread['author_email'];
}
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
$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'] : '');
/* 管理员身份只认「后台登录态」,不再从回复表单收口令。
* 用户 2026-11-03:「管理员口令、处理状态都不要填了,
* 用户进来之后就没有这些东西,只保留回复内容 + 人机验证」。
* 状态流转在后台详情页那排按钮里做,不该混在公开的回复框里。 */
$isAdmin = fb_is_admin();
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、活跃时间。
* 回复框不再承担改状态的职责 —— 状态流转在后台详情页那排按钮里做。 */
$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);
/* 回复后跳回去时要带上 token,否则提交人对未公开反馈的访问权会当场丢掉 */
fb_redirect('thread.php?id=' . $id
. ($isOwnerView ? '&t=' . rawurlencode($hasToken) : '')
. '#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);
$thSwForColor = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
?>
<div class="crumbs"><a href="<?php echo e($backUrl); ?>">← 返回问题列表</a></div>
<?php if ($isOwnerView && !fb_thread_public($thread)): ?>
<?php /* 提交人凭 token 进来看到自己的未公开反馈时,要明确告诉他:
这条还没公开、别人看不到,但他可以一直用这个链接跟踪。
否则他会以为「既然能打开,是不是已经公开了」。 */ ?>
<div class="owner-note">
<b>这是您的私人跟踪链接</b>
<span>这条反馈还没通过审核,其他人看不到。请保留本页链接(或邮件里的链接),
审核结果与后续进展都能在这里查看。</span>
</div>
<?php endif; ?>
<article class="subject">
<?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 /* 标题在最上面、顶头开始;软件名/分类/状态另起一行放下面。
用户 2026-11-03:这三个标签挤在标题前面,把标题推到右边很难看。
⚠️ 顺序是「行1 标题 → 行2 标签 → 行3 作者信息」,别把标签挪回前面。 */ ?>
<div class="subject-head">
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>
</div>
<div class="subject-tags">
<?php if ($thSw): ?><?php echo fb_software_chip($thSw); ?><?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>
</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'); ?>
<?php /* 表单要带上 token:否则提交后跳转回不带 t 的地址,
提交人会立刻失去对自己未公开反馈的访问权,体验上就是「回复完就看不见了」。 */ ?>
<form method="post" action="thread.php?id=<?php echo (int)$id; ?><?php echo $isOwnerView ? '&amp;t=' . e(rawurlencode($hasToken)) : ''; ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="t" value="<?php echo e($isOwnerView ? $hasToken : ''); ?>">
<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">
<?php if ($isOwnerView): ?>
<?php /* 带 token 进来 = 已经证明是提交人本人。
*
* 🔴 昵称/邮箱在这里**只读**,理由:
* 1. token 就是身份凭证,再让他在表单里声明一次身份,自相矛盾;
* 2. 邮箱是唯一能联系到提交人的途径。如果能随手改,
* 谁拿到这条链接,填个自己的邮箱,后续所有通知就归他了 ——
* 等于把这条反馈「过户」给别人,我们再也找不到原作者。
* 要改联系方式,是另一件事(见下方说明),不该藏在回复框里顺手改掉。 */ ?>
<div class="field">
<span>回复身份</span>
<div class="reply-identity">
<span class="reply-identity-nm"><?php echo e((string)$thread['author_name']); ?></span>
<span class="reply-identity-mail"><?php echo e((string)$thread['author_email']); ?></span>
</div>
<i class="hint">这是您提交时用的身份。回复会以它显示,通知也会发到这个邮箱。</i>
</div>
<?php /* 表单仍要带这两个值,服务端才能落库;但不给用户改的机会 */ ?>
<input type="hidden" name="name" value="<?php echo e((string)$thread['author_name']); ?>">
<input type="hidden" name="email" value="<?php echo e((string)$thread['author_email']); ?>">
<?php else: ?>
<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>
<?php endif; ?>
</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>
<div class="form-actions">
<button class="btn primary" type="submit">提交回复</button>
</div>
</form>
</section>
<?php
fb_layout_foot();