Files
FeedLite/admin.php
T
laoyang 832f112843 筛选区改成固定两行,分类再多也不会把表单挤走
用户:「筛选的时候,反馈类型和后面的下拉框不在一条线上;
如果反馈类型比较多,下拉框就被挤到下一行去了。
建议统一成分两行,这样也就没有对齐的问题了。」

问题根因:
.filters 是 display:flex + flex-wrap,分类 chips 和筛选表单被当成
同一行的兄弟元素,分类一多就把表单挤下去;
而且两者高度形态不同(chips 是胶囊、表单是控件),本来也对不齐。

改法:固定两行,各管各的
  第 1 行 = 反馈类型 chips(内部可换行,不影响别的)
  第 2 行 = 状态 / 排序 / 搜索框 / 搜索按钮(同一行,高度一致)
.filters 改 flex-direction:column。

关键点:文件里有两处 .filters 定义,后面那处(管理后台用)
是 display:flex + flex-wrap,会把前台的两行布局覆盖回横向。
已删除那处的 flex 声明,只保留对内部控件的宽度约束。

后台筛选栏是纯控件横排、没有分类 chips,不能跟着变竖排,
所以给 admin.php 的筛选表单加了 .filters-row 类,
用 .filters.filters-row 覆盖回 row。

验证(真实页面 + 注入 16 个额外分类,共 21 个):
  视口 1280px:chips y 109→276,表单 y 286(在下方)✅ 表单内控件 1 行
  视口  900px:同上 ✅
  视口  700px:同上 ✅
  视口  500px:chips y 103→400,表单 y 410(在下方)✅
              表单内部换成 2 行 —— 窄屏放不下 4 个控件属正常响应式
  前台 .filters flex-direction = column
  后台 .filters.filters-row flex-direction = row,控件全在同一条线上
    (垂直中心统一 92.8)
  5 个路由 200

另外修掉一个隐患:改 .filters 时误删了 .chips 的基础 flex 样式,
已补回(.chips{display:flex;flex-wrap:wrap;gap:8px})。
2026-11-04 20:31:17 +08:00

1511 lines
85 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
/**
* 反馈管理后台
* ------------------------------------------------------------------
* 管理员用口令登录后可以:
* · 审核:通过(公开)/ 关闭(判定不是问题,邮件告知提问人)/ 撤下
* · 管理:以官方身份回复、改处理状态与可见性、删除问题
* · 设置:附件数量与大小上限、附件存储方式(关闭 / 本机硬盘 / 阿里云 OSS)、
* OSS 桶信息与鉴权方式(ECS 实例 RAM 角色 或 静态 AccessKey)
* · 修改自己的登录口令
*
* 入口:/feedback/admin.php
*/
require __DIR__ . '/lib/bootstrap.php';
require __DIR__ . '/lib/admin_taxonomy.php';
$pdo = fb_db();
/* ---------------- 退出 ---------------- */
if (isset($_GET['logout'])) {
unset($_SESSION['fb_admin'], $_SESSION['fb_admin_at']);
fb_redirect('admin.php');
}
/* ---------------- 登录 ---------------- */
$loginErr = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
$user = isset($_POST['admin_user']) ? (string)$_POST['admin_user'] : '';
$pw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
$cs = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
if (!fb_csrf_ok($cs)) {
$loginErr = '会话已过期,请重试。';
} elseif (!fb_rate_check('admin_login', 10)) {
$loginErr = '尝试次数过多,请一小时后再试。';
} else {
$rc = fb_admin_verify_login($user, $pw);
if ($rc === 0) {
fb_admin_login();
fb_mod_log('login');
fb_redirect('admin.php');
} elseif ($rc === 2) {
$loginErr = '本系统还没有配置管理员账号,请先运行安装向导(install.php)完成设置。';
} else {
fb_rate_hit('admin_login');
// 不区分「用户名错」还是「口令错」,避免被用来探测账号
$loginErr = '用户名或口令不正确。';
}
}
}
if (!fb_admin_logged_in()) {
fb_layout_head('管理登录', ['robots' => 'noindex,nofollow', 'admin' => true]);
?>
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
<section class="narrow">
<h1>管理登录</h1>
<p class="muted">请输入管理员用户名与口令<?php if (fb_admin_password_source() === 'file'): ?>(当前口令仍是 <code>config.php</code> 里的明文,登录后请到「站点设置」改成加密存储)<?php endif; ?>。</p>
<?php if (fb_admin_password_source() === 'none'): ?>
<div class="flash error">尚未配置管理员账号,且出厂占位口令已被禁用。请运行 <a href="install.php">install.php</a> 完成初始化。</div>
<?php endif; ?>
<?php if (fb_admin_username() === '' && fb_admin_password_is_set()): ?>
<div class="flash error">本站只设了口令、没有用户名(旧版安装)。请运行 <a href="install.php">install.php</a> 补一个用户名;在那之前用户名可留空、只填口令。</div>
<?php endif; ?>
<?php fb_flash_box($loginErr, 'error'); ?>
<form method="post" action="admin.php" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<label class="field">
<span>管理员用户名</span>
<input type="text" name="admin_user" autocomplete="username" required
value="<?php echo e(isset($_POST['admin_user']) ? (string)$_POST['admin_user'] : ''); ?>">
</label>
<label class="field">
<span>管理员口令</span>
<input type="password" name="admin_password" autocomplete="current-password" required>
</label>
<div class="form-actions">
<button class="btn primary" type="submit" name="login" value="1">登录</button>
</div>
</form>
</section>
<?php
fb_layout_foot();
exit;
}
/* ==================================================================
* 已登录:处理各种操作
* ================================================================== */
$msg = '';
$msgType = 'ok';
/** 取一条主题 */
function fb_admin_thread(PDO $pdo, $id) {
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$st->execute([(int)$id]);
$t = $st->fetch();
return is_array($t) ? $t : null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$cs = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
if (!fb_csrf_ok($cs)) {
$msg = '会话已过期,请重试。';
$msgType = 'error';
} else {
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
$act = (string)$_POST['act'];
$t = fb_admin_thread($pdo, $id);
// 这些动作不针对某一条反馈(不依赖 $t),单独放行
$noThread = in_array($act, ['settings', 'password', 'mail_test'], true)
|| fb_admin_is_taxonomy_act($act);
if (!$t && !$noThread) {
$msg = '问题不存在。';
$msgType = 'error';
} elseif ($act === 'approve') {
/* 「通过并公开」= 两件事一起做:
* ① 可见性:visible=1
* ② 状态:『未公开』→『待处理』
*
* 状态流转是用户在 2026-11-03 明确要求的:提交后先落「未公开」,
* 通过了才变「待处理」;如果判定不是问题,直接走 close 变「已关闭」,
* 中间不经过「待处理」。
*
* 边界:如果这条本来就已经是别的状态(比如以前误操作改成了「处理中」),
* 通过审核不该把状态倒退回去,所以只在「未公开」时才推进。
* 老数据可能还是 'open' + visible=0,也一并视作待推进。 */
$oldSt = (string)$t['status'];
$newStatus = ($oldSt === 'hidden' || $oldSt === 'open') ? 'open' : $oldSt;
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
->execute([$newStatus, time(), $id]);
fb_mod_log('approve', $id, '通过审核并公开(状态:' . fb_status_label($newStatus) . ')');
// 通知提交人「已发布」(工单 #2)
$errA = '';
try {
// 用更新后的状态渲染邮件,否则信里还写着「未公开」
$fresh = fb_admin_thread($pdo, $id);
list($okA, $errA) = fb_notify_event($pdo, 'approve', $fresh ? $fresh : $t);
} catch (Throwable $e) { $errA = '发信异常'; }
$msg = '#' . $id . ' 已通过审核并公开,状态为「' . fb_status_label($newStatus) . '」。'
. ($errA === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errA . ')');
} elseif ($act === 'close') {
$note = trim(isset($_POST['note']) ? (string)$_POST['note'] : '');
if ($note === '') {
$msg = '关闭前请先填写给提问人的说明(这段内容会随邮件发出)。';
$msgType = 'error';
} else {
$pdo->prepare("UPDATE threads SET status = 'closed', visible = 0, mod_note = ?, moderated_at = ? WHERE id = ?")
->execute([$note, time(), $id]);
list($sent, $err) = fb_notify_closed($pdo, $t, $note);
fb_mod_log('close', $id, '关闭并邮件告知:' . ($sent ? '已发信' : '未发信(' . $err . ')'));
$msg = $sent
? ('#' . $id . ' 已关闭,说明邮件已发送给 ' . (string)$t['author_email'] . '。')
: ('#' . $id . ' 已关闭,但邮件未发出:' . $err);
$msgType = $sent ? 'ok' : 'error';
}
} elseif ($act === 'hide') {
$pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $id]);
fb_mod_log('hide', $id, '撤下(转为不公开)');
$noteH = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
$errH = '';
try {
list($okH, $errH) = fb_notify_event($pdo, 'hide', $t, ['note' => $noteH]);
} catch (Throwable $e) { $errH = '发信异常'; }
$msg = '#' . $id . ' 已撤下(转为不公开)。'
. ($errH === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errH . ')');
} elseif ($act === 'setstatus') {
/* 问题详情页顶部那一排按钮走的动作。
*
* 与 status 的区别:
* - status 是「状态下拉框 + 公开勾选」的完整表单,需要手动选
* - setstatus 是「点一下立刻生效」,状态与可见性由按钮本身决定
*
* 「通过并公开」= 可见 + 状态保持;其余状态只改状态不动可见性,
* 避免误把已公开的问题撤下来(用户没这个意图)。
*/
$statuses = fb_statuses();
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
$note = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
$alsoShow = !empty($_POST['also_show']); // 「通过并公开」置 1
if (!isset($statuses[$new])) {
$msg = '未知的状态。';
$msgType = 'error';
} else {
$old = (string)$t['status'];
$vis = $alsoShow ? 1 : (int)$t['visible'];
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
->execute([$new, $vis, time(), $id]);
fb_mod_log('status', $id,
'一键改为 ' . $statuses[$new] . ($alsoShow ? ',并公开' : '')
. ($note !== '' ? ',说明:' . fb_excerpt($note, 60) : ''));
$errS = '';
if ($old !== $new || $alsoShow) {
try {
// 「通过并公开」用 approve 事件(文案是「已发布」)
$reason = $alsoShow ? 'approve' : 'status';
list($okS, $errS) = fb_notify_event($pdo, $reason, $t, [
'old_status' => $old,
'new_status' => $new,
'note' => $note,
]);
} catch (Throwable $e) { $errS = '发信异常'; }
}
$msg = '#' . $id . ' 已改为「' . $statuses[$new] . '」'
. ($alsoShow ? '并公开显示' : '') . '。'
. ($old !== $new || $alsoShow
? ($errS === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errS . ')')
: '状态未变化,未发信。');
$msgType = $errS === '' ? 'ok' : 'error';
}
} elseif ($act === 'status') {
$statuses = fb_statuses();
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
if (!isset($statuses[$new])) {
$msg = '未知的状态。';
$msgType = 'error';
} else {
$vis = isset($_POST['visible']) ? 1 : 0;
$old = (string)$t['status'];
$note = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
->execute([$new, $vis, time(), $id]);
fb_mod_log('status', $id, '状态改为 ' . $statuses[$new] . ',公开=' . $vis);
// 状态变了才发信(没变就别打扰用户)
$errS = '';
if ($old !== $new) {
try {
list($okS, $errS) = fb_notify_event($pdo, 'status', $t, [
'old_status' => $old,
'new_status' => $new,
'note' => $note,
]);
} catch (Throwable $e) { $errS = '发信异常'; }
}
$msg = '#' . $id . ' 已更新:状态「' . $statuses[$new] . '」,'
. ($vis ? '公开' : '不公开') . '。'
. ($old !== $new
? ($errS === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errS . ')')
: '状态未变化,未发信。');
}
} elseif ($act === 'reply') {
$body = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
if (!fb_valid_body($body)) {
$msg = '回复内容需要 2~5000 个字符。';
$msgType = 'error';
} else {
$now = time();
$statuses = fb_statuses();
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
$setStatus = isset($statuses[$new]) ? $new : null;
$ins = $pdo->prepare('INSERT INTO replies(thread_id, body, author_name, author_email, is_admin, created_at, ip_hash) VALUES(?,?,?,?,?,?,?)');
$ins->execute([$id, $body, '官方', null, 1, $now, fb_ip_hash()]);
$replyId = (int)$pdo->lastInsertId();
if ($setStatus !== null) {
$pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ?, status = ? WHERE id = ?')
->execute([$now, $setStatus, $id]);
} else {
$pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?')
->execute([$now, $id]);
}
$replyRow = [
'id' => $replyId,
'body' => $body,
'author_name' => '官方',
'author_email' => null,
'is_admin' => 1,
];
$err2 = fb_notify_reply($pdo, $t, $replyRow, true);
fb_mod_log('reply', $id, '官方回复' . ($setStatus !== null ? ',状态改为 ' . $statuses[$setStatus] : ''));
$msg = '#' . $id . ' 已以官方身份回复' . ($err2 === '' ? '' : '(邮件:' . $err2 . ')') . '。';
}
} elseif ($act === 'delete') {
$st = $pdo->prepare('SELECT oss_key FROM attachments WHERE thread_id = ?');
$st->execute([$id]);
foreach ($st->fetchAll() as $r) { fb_attach_delete((string)$r['oss_key']); }
$pdo->prepare('DELETE FROM threads WHERE id = ?')->execute([$id]);
fb_mod_log('delete', $id, '删除问题:' . fb_excerpt((string)$t['title'], 40));
$msg = '#' . $id . ' 已删除。';
} elseif ($act === 'mail_test') {
// 发送测试邮件:只验证 SMTP 通不通,不改任何配置。
// 可选 sw=<软件id>:按那个软件的样子渲染(配色 / 图标 / 软件名),
// 便于在后台直接预览「不同软件的邮件长什么样」。
$to = trim((string)(isset($_POST['to']) ? $_POST['to'] : ''));
if ($to === '') { $to = trim((string)fb_cfg('admin_email', '')); }
if ($to === '') { $to = trim((string)fb_cfg('smtp.user', '')); }
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$msg = '请填写有效的收件邮箱。';
$msgType = 'error';
} else {
$sc = (array)fb_cfg('smtp', []);
$site = (string)fb_cfg('site_name', '轻反馈');
// 选中的软件(没选就用默认软件,保证测试邮件也带软件标识)
$swId = (int)(isset($_POST['sw']) ? $_POST['sw'] : 0);
$swRow = $swId > 0 ? fb_software($swId) : fb_default_software();
// 用真实的回执模板发,比一句「测试邮件」更能看出实际效果
$fake = [
'id' => 0,
'software_id' => $swRow ? (int)$swRow['id'] : 0,
'title' => '这是一封测试邮件,用来检查排版与配色',
'body' => "这是测试用的反馈正文。\n如果您收到这封信,说明 SMTP 配置正确,配色与图标也应当对应「"
. ($swRow ? (string)$swRow['name'] : '默认软件') . "」。\n\n发送时间:" . date('Y-m-d H:i:s'),
'author_name' => '测试',
'author_email' => $to,
'author_phone' => '',
'status' => 'open',
'visible' => 1,
];
list($subject, $html) = fb_notify_render('receipt', $fake,
['software' => $swRow], $to);
$subject = '【测试】' . $subject;
list($ok, $err) = fb_smtp_send($sc, $to, $subject, $html);
if ($ok) {
$msg = '测试邮件已发出,请查收 ' . $to . '(也看一下垃圾箱)。';
$msgType = 'ok';
} else {
$msg = '发送失败:' . $err;
$msgType = 'error';
}
}
} 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();
} elseif ($act === 'password') {
$old = (string)(isset($_POST['old_password']) ? $_POST['old_password'] : '');
$n1 = (string)(isset($_POST['new_password']) ? $_POST['new_password'] : '');
$n2 = (string)(isset($_POST['new_password2']) ? $_POST['new_password2'] : '');
// 从没设过口令(或仍是出厂占位值)时,跳过"当前口令"校验 —— 否则谁也进不来、又改不了
$firstSet = !fb_admin_password_is_set();
if (!$firstSet && !fb_admin_verify_password($old)) {
$msg = '当前口令不正确。';
$msgType = 'error';
} elseif (fb_len($n1) < 8) {
$msg = '新口令至少 8 个字符。';
$msgType = 'error';
} elseif ($n1 !== $n2) {
$msg = '两次输入的新口令不一致。';
$msgType = 'error';
} else {
fb_admin_set_password($n1);
fb_mod_log('password', null, $firstSet ? '首次设定登录口令(bcrypt 哈希存库)' : '修改登录口令');
$msg = $firstSet ? '口令已设定,下次登录请使用它。' : '口令已修改,下次登录请使用新口令。';
}
}
}
}
/**
* 保存站点设置(把表单值写进 settings 表;config.php 仍是出厂默认值)
* @return string 提示语
*/
function fb_admin_save_settings() {
// PHP 会把表单字段名里的「.」替换成「_」,所以两种写法都读一遍
$post = function ($k, $def = null) {
if (isset($_POST[$k])) { return $_POST[$k]; }
$alt = str_replace('.', '_', $k);
if (isset($_POST[$alt])) { return $_POST[$alt]; }
return $def;
};
/**
* 这个字段本次请求到底有没有被提交?
*
* 🔴 为什么必须有这个:后台有多个 tab(站点设置 / 邮件通知),
* 但它们**共用同一个保存处理**(都提交 act=settings)。
* 如果在站点设置页点保存,该表单里根本没有 smtp_* 字段,
* `$post('smtp.host', '')` 会取到空串,然后被写进数据库 ——
* **把用户之前填好的邮件配置清空**。
*
* 同类事故还会发生在 checkbox 上:没提交 = 视为取消勾选,
* 于是另一个 tab 保存一次就把通知开关全关掉。
*
* 所以写库前一律先问 has():没提交就跳过,绝不写空值。
*/
$has = function ($k) {
if (array_key_exists($k, $_POST)) { return true; }
return array_key_exists(str_replace('.', '_', $k), $_POST);
};
/**
* 🔴🔴 受守卫的写库:**字段没提交就一个字都不写**。
*
* 这是本文件唯一允许写设置的入口(新增设置项也必须走它)。
* 为什么要把这件事做成强制:
* 这个 bug 已经犯过三次 —— 每次都是「某个 tab 保存时,
* 另一个 tab 的字段没提交,被当成空值写回去」。
* 根因不是某个字段忘了判断,而是**写入入口本身没有约束**:
* 32 处 fb_set_setting 只有 8 处手写了 has(),漏一处就是一次事故。
*
* 用法:
* $save('oss.bucket', trim((string)$post('oss.bucket', '')));
* $save('oss.enabled', $storage === 'oss' ? 1 : 0);
* 字段没提交 → 直接 return false,数据库不动。
*
* ⚠️ 少数场景确实要在「字段没提交」时也写(例如用户主动清空某项),
* 那时用 $saveForce(),但必须在注释里写清楚为什么。
*/
$save = function ($k, $v) use ($has) {
if (!$has($k)) { return false; } // ← 铁律:没提交就不动
return fb_set_setting($k, $v);
};
/** 显式强制写入(字段没提交也写),仅在「清空某项」这类确有必要时用 */
$saveForce = function ($k, $v) {
return fb_set_setting($k, $v);
};
// ============ 站点设置组(含站点信息/页脚/附件/OSS)============
// 🔴 整组包在 has() 判断里:邮件页只提交邮件字段,没有 site_*/oss_*/upload_*,
// 无条件写入会把它们全清空。反过来也一样。
$isSiteForm = $has('oss_endpoint') || $has('site_name')
|| $has('attachment_storage') || $has('site_desc');
if ($isSiteForm) {
// 站点信息(对外展示,用于说明「本系统为哪个软件提供反馈服务」)
// 留空 = 删除该设置项,回落到 config.php 的出厂值,便于恢复默认。
foreach (['site_name', 'site_desc'] as $k) {
if (!$has($k)) { continue; }
$v = trim((string)$post($k, ''));
if ($v === '') { fb_unset_setting($k); }
else { $save($k, $v); }
}
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址
$save('footer.show_credit', $post('footer_show_credit') ? 1 : 0);
$curl = trim((string)$post('footer_credit_url', ''));
// 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来
if ($curl !== '' && !preg_match('~^https?://~i', $curl)) {
return '页脚链接地址需以 http:// 或 https:// 开头。';
}
if ($curl === '' || $curl === fb_credit_url_default()) { fb_unset_setting('footer.credit_url'); }
else { $save('footer.credit_url', $curl); }
$ints = [
// 区间下限统一改 0:0 = 不限制(见 #4)。
// 只有 image_max_side 例外 —— 边长 0 没有意义,它是压缩目标不是限制。
'upload.image_max_per_post' => [0, 1000],
'upload.video_max_per_post' => [0, 1000],
'upload.retention_days' => [0, 36500],
'upload.upload_per_hour' => [0, 100000],
'upload.image_max_side' => [480, 4096],
];
foreach ($ints as $k => $range) {
if (!$has($k)) { continue; }
$v = (int)$post($k, 0);
$v = max($range[0], min($range[1], $v));
$save($k, $v);
}
// 图片/视频上传总开关(0=不限个数后,开关必须独立存在)
$save('upload.image_on', (int)(bool)$post('upload.image_on'));
$save('upload.video_on', (int)(bool)$post('upload.video_on'));
// MB → 字节
// 下限改 0 = 不限制(见 #4)
$mbs = [
'upload.image_max_bytes' => [0, 1024],
'upload.video_max_bytes' => [0, 10240],
'upload.total_cap_bytes' => [0, 1048576],
];
foreach ($mbs as $k => $range) {
if (!$has($k)) { continue; } // 没提交就不动(防跨 tab 清空)
$mb = (float)$post($k, 0);
$mb = max($range[0], min($range[1], $mb));
$save($k, (int)round($mb * 1048576));
}
// 附件存储方式:只有表单真的带了这个字段才处理。
// 没有它(比如只提交了 site_name 的局部表单)就整段跳过 ——
// 否则下面 $post(..., 'off') 的默认值会把存储方式误改成「关闭」。
if ($has('attachment.storage')) {
$storage = (string)$post('attachment.storage', 'off');
if (!in_array($storage, ['off', 'local', 'oss'], true)) { $storage = 'off'; }
$save('attachment.storage', $storage);
// OSS 模式下 oss.enabled 必须跟着开;其它模式关掉,避免误判。
// 🔴 这里必须用 $saveForce,不能用 $save:
// oss.enabled 是【派生字段】—— 表单里根本没有名为 oss_enabled 的 input,
// $save 的 $has() 守卫会判定"没提交"而直接 return,于是 storage 存成了 oss、
// enabled 却永远停在旧值 0。media.php 因此报「对象存储未配置」,全站图片 503。
// (2026-11-03 图片全挂的直接原因:守卫修好了跨 tab 清空,却把派生写入也挡掉了。)
// 又因为它处在 $has('attachment.storage') 分支内,不会出现「没提交却强制写 0」的反向问题。
$saveForce('oss.enabled', $storage === 'oss' ? 1 : 0);
}
$texts = ['oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.domain', 'oss.prefix', 'moderation.close_mail_subject'];
foreach ($texts as $k) {
if (!$has($k)) { continue; } // 没提交就不动
$v = trim((string)$post($k, ''));
// 这两个留空都有"出厂默认"兜底,必须删键而不是存空串:
// - oss.prefix 留空会让对象键变成 "/2026/...",默认是 'fb'
// - close_mail_subject 留空会让邮件标题变成空字符串,
// sprintf('') 出来的主题是空的,必须回落到默认模板
if (($k === 'oss.prefix' || $k === 'moderation.close_mail_subject') && $v === '') {
fb_unset_setting($k);
continue;
}
// RAM 角色留空 = 让程序自己去元数据服务探测,不写死一个空值
if ($k === 'oss.instance_role' && $v === '') {
list($rOk, $rVal) = fb_oss_detect_instance_role();
if ($rOk) { $save($k, (string)$rVal); }
continue; // 探测不到就保持原值不动
}
$save($k, $v);
}
$src = (string)$post('oss.credential_source', 'instance_role');
$save('oss.credential_source', $src === 'static' ? 'static' : 'instance_role');
$save('oss.access_key', trim((string)$post('oss.access_key', '')));
$sk = trim((string)$post('oss.secret_key', ''));
if ($sk !== '') { $save('oss.secret_key', $sk); }
$save('oss.visibility', ((string)$post('oss.visibility', 'public')) === 'private' ? 'private' : 'public');
$save('oss.url_scheme', ((string)$post('oss.url_scheme', 'https')) === 'http' ? 'http' : 'https');
$save('oss.verify', $post('oss.verify') ? 1 : 0);
$save('oss.image_process', $post('oss.image_process') ? 1 : 0);
$save('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
} // ← end if ($isSiteForm)
/* ---------------- 邮件通知(SMTP) ----------------
*
* 🔴🔴 必须整段包在「本页到底有没有提交邮件表单」的判断里。
*
* 事故:站点设置页和邮件通知页共用 act=settings。在站点设置页点保存时,
* 该表单里没有 smtp_* 字段,$post() 取到空串,被无条件写进数据库 ——
* **把用户已经填好的 SMTP 服务器/账号/发件地址全清空了**。
* 用户看到的现象就是「我明明配过邮件,怎么全没了、信也发不出」。
*
* 判据用 smtp_enabled:它是邮件表单里的总开关,一定存在。
* 站点设置页没有这个字段 ⇒ 整段跳过,一个键都不动。
*/
$isMailForm = $has('smtp_enabled') || $has('smtp.host') || $has('smtp_host');
if ($isMailForm) {
$smtpText = ['smtp.host', 'smtp.user', 'smtp.from', 'smtp.from_name'];
foreach ($smtpText as $k) {
if (!$has($k)) { continue; } // 没提交就不动它
$save($k, trim((string)$post($k, '')));
}
// 口令留空 = 不修改(避免每次保存都得重填)
$sp = (string)(isset($_POST['smtp_pass']) ? $_POST['smtp_pass'] : '');
if (trim($sp) !== '') { $save('smtp.pass', $sp); }
// 端口留空 = 用默认 465。删键回落,不要存成 0(0 端口根本连不上)
$portRaw = trim((string)$post('smtp.port', ''));
if ($portRaw === '') {
fb_unset_setting('smtp.port');
} else {
$smtpPort = (int)$portRaw;
if ($smtpPort <= 0 || $smtpPort > 65535) { $smtpPort = 465; }
$save('smtp.port', $smtpPort);
}
$sec = strtolower(trim((string)$post('smtp.secure', 'ssl')));
if (!in_array($sec, ['ssl', 'tls', ''], true)) { $sec = 'ssl'; }
$save('smtp.secure', $sec);
// 超时留空 = 用默认 15
$tmoRaw = trim((string)$post('smtp.timeout', ''));
if ($tmoRaw === '') {
fb_unset_setting('smtp.timeout');
} else {
$save('smtp.timeout', max(5, min(60, (int)$tmoRaw)));
}
$save('smtp.verify_ssl', (int)(bool)$post('smtp.verify_ssl'));
// 没填 from 时用 user 兜底(绝大多数 SMTP 要求两者一致)
if ($has('smtp.from') && trim((string)$post('smtp.from', '')) === '') {
$save('smtp.from', trim((string)fb_cfg('smtp.user', '')));
}
// 只有 host 填了才允许开启,否则开着一个空配置只会每次发信都失败
$wantOn = (bool)$post('smtp.enabled');
$hostNow = trim((string)fb_cfg('smtp.host', ''));
$save('smtp.enabled', ($wantOn && $hostNow !== '') ? 1 : 0);
if ($wantOn && $hostNow === '') {
$msg = '邮件通知未开启:请先填写 SMTP 服务器地址。';
$msgType = 'error';
}
/* ---------------- 通知开关 ----------------
* 全是 checkbox:未提交会被当成"取消勾选"。
* 所以只有本页确实提交了邮件表单时才写,否则在站点设置页保存一次
* 就会把「有人回复时通知」这些开关全部关掉。
*/
$save('notify.enabled', (int)(bool)$post('notify.enabled'));
$save('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
$save('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
$save('notify.receipt_enabled', (int)(bool)$post('notify.receipt_enabled'));
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
if ($has('notify_cooldown_seconds')) {
$cdRaw = trim((string)$post('notify.cooldown_seconds', ''));
if ($cdRaw === '') {
fb_unset_setting('notify.cooldown_seconds');
} else {
$save('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw)));
}
}
} // ← end if ($isMailForm)
// 审核开关属于「站点设置」,只在站点设置页提交时才写
if ($has('moderation_enabled')) {
$save('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
}
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
return '设置已保存。';
}
/* ==================================================================
* 页面
* ================================================================== */
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
if (!in_array($tab, ['pending', 'all', 'thread', 'taxonomy', 'statuses', 'settings', 'mail', 'log'], true)) { $tab = 'pending'; }
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow', 'admin' => true]);
?>
<div class="page-head">
<div>
<h1>管理后台</h1>
<p class="muted">登录状态 <?php echo (int)floor(fb_admin_idle_ttl() / 3600); ?> 小时内有效,超时自动退出。</p>
</div>
<a class="btn" href="admin.php?logout=1">退出登录</a>
</div>
<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 === 'mail' ? ' on' : ''; ?>" href="admin.php?tab=mail">邮件通知</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>
<?php fb_flash_box($msg, $msgType === 'ok' ? 'ok' : 'error'); ?>
<?php if ($tab === 'pending'): ?>
<?php
/* 「待审核」= 还没公开过的条目。
* 判据用 status='hidden'(未公开),不再用 visible=0 这种隐式条件 ——
* 已关闭的条目 visible 也是 0,但它是处理完了,不该出现在待审核里。
* 顺便兼容老数据:visible=0 且状态仍是 'open' 的也一并算待审核。 */
$pending = $pdo->query(
"SELECT * FROM threads
WHERE status = 'hidden' OR (visible = 0 AND status = 'open')
ORDER BY created_at DESC"
)->fetchAll();
?>
<h2 style="margin:22px 0 12px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2>
<?php if (!$pending): ?>
<div class="empty"><p>当前没有待审核的反馈。</p></div>
<?php else: ?>
<div class="plist">
<?php foreach ($pending as $t) { echo fb_admin_pending_row($t); } ?>
</div>
<?php endif; ?>
<?php elseif ($tab === 'all'): ?>
<?php
$where = [];
$args = [];
$stF = (string)(isset($_GET['status']) ? $_GET['status'] : '');
$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 ($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)) : '';
$cntSt = $pdo->prepare('SELECT COUNT(*) FROM threads ' . $wSql);
$cntSt->execute($args);
$total = (int)$cntSt->fetchColumn();
$pageSize = max(5, (int)fb_cfg('page_size', 20));
$pages = max(1, (int)ceil($total / $pageSize));
$page = min(max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1)), $pages);
$offset = ($page - 1) * $pageSize;
$lst = $pdo->prepare('SELECT * FROM threads ' . $wSql . ' ORDER BY updated_at DESC LIMIT ' . (int)$pageSize . ' OFFSET ' . (int)$offset);
$lst->execute($args);
$rows = $lst->fetchAll();
?>
<form class="filters filters-row" 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_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="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>
<option value="1"<?php echo $visF === '1' ? ' selected' : ''; ?>>已公开</option>
<option value="0"<?php echo $visF === '0' ? ' selected' : ''; ?>>未公开</option>
</select>
<button class="btn" type="submit">筛选</button>
<a class="btn" href="admin.php?tab=all">重置</a>
</form>
<h2 style="margin:16px 0 10px">全部问题 <span class="muted">(<?php echo $total; ?> 条,第 <?php echo $page; ?>/<?php echo $pages; ?> 页)</span></h2>
<?php if (!$rows): ?>
<div class="empty"><p>没有符合条件的问题。</p></div>
<?php else: ?>
<div class="list">
<?php foreach ($rows as $t): ?>
<div class="item" style="cursor:default">
<?php
/* 标题顶头独占一行,标签(软件 / 分类 / 状态)另起一行。
* 后台是跨软件视图,所以这里必须带软件名 —— 前台某个软件内部
* 不需要显示软件名(满页都是同一个),后台需要。 */
$aSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
?>
<div class="item-head">
<a class="item-title" href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>"><?php echo e($t['title']); ?></a>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="item-tags">
<?php if ($aSw): ?><?php echo fb_software_chip($aSw); ?><?php endif; ?>
<?php
/* 🔴 这里必须用 category_id(数字),不能用 $t['category'](老 slug 字段)。
* fb_category_label() 内部会把参数 (int) 化,传 'bug' 会变成 0 → 返回空串,
* 结果就是渲染出一个**空的** <span class="badge cat"></span>。
* 用户 2026-11-03 看到的就是「反馈类型没了」。
* 优先 category_id,没有才回退 slug(老数据兼容)。 */
$aCat = !empty($t['category_id'])
? fb_category_label((int)$t['category_id'])
: fb_category_label_by_slug((int)$t['software_id'], (string)$t['category']);
?>
<?php if ($aCat !== ''): ?><span class="badge cat"><?php echo e($aCat); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<?php if ((int)$t['visible'] !== 1): ?><span class="badge st st-closed">未公开</span><?php endif; ?>
</div>
<p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo (int)$t['reply_count']; ?> 条回复</span>
<span class="dot">·</span>
<a href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>">管理</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($pages > 1): ?>
<div class="pager">
<?php if ($page > 1): ?><a class="btn" href="admin.php?<?php echo e(http_build_query(array_merge($_GET, ['tab' => 'all', 'page' => $page - 1]))); ?>">上一页</a><?php endif; ?>
<?php if ($page < $pages): ?><a class="btn" href="admin.php?<?php echo e(http_build_query(array_merge($_GET, ['tab' => 'all', 'page' => $page + 1]))); ?>">下一页</a><?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php elseif ($tab === 'thread'): ?>
<?php
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
$t = fb_admin_thread($pdo, $id);
if (!$t): ?>
<div class="empty"><p>问题不存在。<a href="admin.php?tab=all">返回列表</a></p></div>
<?php else:
$rs = $pdo->prepare('SELECT * FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
$rs->execute([$id]);
$replies = $rs->fetchAll();
$atts = fb_attach_for_thread($pdo, $id);
?>
<div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div>
<div class="card-form" style="margin:0 0 16px">
<?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 /* 标题顶头独占一行,标签另起一行 —— 与列表页、前台详情页统一。
用户 2026-11-03:「点到一个具体的反馈里面也是一样的,标题顶头,
然后把同步日历、bug、待处理往下一行,标签写到下面去。」 */ ?>
<div class="item-head">
<span class="item-title" style="font-size:19px"><?php echo e($t['title']); ?></span>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="item-tags">
<?php if ($dSw): ?><?php echo fb_software_chip($dSw); ?><?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>
</div>
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
<?php if (!empty($atts['main'])) { echo fb_attach_render($atts['main']); } ?>
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e($t['author_email']); ?></span>
<?php if (!empty($t['author_phone'])): ?><span class="dot">·</span><span><?php echo e($t['author_phone']); ?></span><?php endif; ?>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo ((int)$t['visible'] === 1) ? '已公开' : '未公开'; ?></span>
</div>
</div>
<?php
/* ---------- 处理动作:一排按钮,点一下立刻生效 ----------
* 用户原话:「第一个就是通过并显示…另外一个再加一个按钮,就是说现在正在处理中。
* 还有比方说不处理,都是在那个问题后边列几个按钮不就行了吗?」
*
* 设计取舍:
* - 「通过并公开」单独成一类(它改的是可见性,不只是状态)
* - 「不予考虑」必须填说明(会随邮件发出,不能发一封没理由的信)
* - 其余状态点一下就走,不弹框 —— 高频操作不该被对话框拖慢
*/
$curStatus = (string)$t['status'];
$isVisible = ((int)$t['visible'] === 1);
// 按钮表:key=状态, label=按钮文字, cls=样式
$quickBtns = [
['key' => 'doing', 'label' => '处理中', 'cls' => ''],
['key' => 'resolved', 'label' => '已解决', 'cls' => 'ok'],
['key' => 'wontfix', 'label' => '不予考虑', 'cls' => 'warn'],
];
?>
<div class="act-bar">
<div class="act-bar-title">处理</div>
<div class="act-bar-body">
<?php if (!$isVisible): ?>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-inline">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<?php /* 通过并公开 = 可见 + 状态转「待处理」。
不能沿用原状态(未公开时原状态是 hidden,通过后还是未公开,
状态和可见性就对不上了)。 */ ?>
<input type="hidden" name="status" value="open">
<input type="hidden" name="also_show" value="1">
<button class="btn primary act-btn" type="submit" name="act" value="setstatus">通过并公开</button>
</form>
<?php else: ?>
<span class="act-state on">已公开显示</span>
<?php endif; ?>
<?php foreach ($quickBtns as $qb):
$isCur = ($curStatus === $qb['key']);
if ($qb['key'] === 'wontfix'): ?>
<button type="button" class="btn act-btn <?php echo e($qb['cls']); ?><?php echo $isCur ? ' is-cur' : ''; ?>"
data-wontfix-open <?php echo $isCur ? 'disabled' : ''; ?>><?php echo e($qb['label']); ?></button>
<?php else: ?>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-inline">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="status" value="<?php echo e($qb['key']); ?>">
<button class="btn act-btn <?php echo e($qb['cls']); ?><?php echo $isCur ? ' is-cur' : ''; ?>"
type="submit" name="act" value="setstatus"<?php echo $isCur ? ' disabled' : ''; ?>><?php echo e($qb['label']); ?></button>
</form>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($isVisible): ?>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-inline"
onsubmit="return confirm('确定把这条件反馈从公开列表中撤下?');">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<button class="btn act-btn" type="submit" name="act" value="hide">撤下</button>
</form>
<?php endif; ?>
<span class="act-hint">点一下立刻生效并自动发邮件通知提交人</span>
</div>
<?php /* 不予考虑:需要填说明,展开一个小面板而不是弹 window.prompt */ ?>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-note" data-wontfix-panel hidden>
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="status" value="wontfix">
<label class="field">
<span>为什么不处理 <b class="req">*</b>
<i class="hint">会作为邮件正文发给 <?php echo e((string)$t['author_email']); ?>,请写清楚原因</i></span>
<textarea name="note" rows="3" maxlength="2000" required
placeholder="例如:这个现象来自服务器端的 CalDAV 限制,并非应用本身的问题;建议在客户端调整同步间隔。"></textarea>
</label>
<div class="form-actions">
<button class="btn warn" type="submit" name="act" value="setstatus">确认不予考虑并邮件告知</button>
<button class="btn" type="button" data-wontfix-close>取消</button>
</div>
</form>
</div>
<?php foreach ($replies as $r): ?>
<div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>">
<div class="reply-head">
<span class="who"><?php echo e($r['author_name']); ?></span>
<?php if ((int)$r['is_admin'] === 1): ?><span class="badge official">官方</span><?php endif; ?>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($r['created_at'])); ?></span>
</div>
<div class="reply-body"><?php echo nl2br(e($r['body'])); ?></div>
<?php if (isset($atts['replies'][(int)$r['id']])) { echo fb_attach_render($atts['replies'][(int)$r['id']], ['compact' => true]); } ?>
</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()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<label class="field">
<span>回复内容</span>
<textarea name="body" rows="5" maxlength="5000" placeholder="写给提问人的答复;提交后会给对方发一封通知邮件"></textarea>
</label>
<label class="field">
<span>同时把状态改为</span>
<select name="status">
<option value="">(不改)</option>
<?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="reply">发布官方回复</button>
</div>
</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()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<div class="row2">
<label class="field">
<span>处理状态</span>
<select name="status">
<?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>
</label>
<label class="field">
<span>公开显示</span>
<label class="check"><input type="checkbox" name="visible" value="1"<?php echo ((int)$t['visible'] === 1) ? ' checked' : ''; ?>> 在公开列表中显示</label>
</label>
</div>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="status">保存</button>
<?php if ((int)$t['visible'] !== 1): ?>
<button class="btn" type="submit" name="act" value="approve">通过并公开</button>
<?php else: ?>
<button class="btn" type="submit" name="act" value="hide">撤下</button>
<?php endif; ?>
</div>
</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()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<label class="field">
<span>关闭说明 <b class="req">*</b> <i class="hint">会作为邮件正文发给 <?php echo e($t['author_email']); ?></i></span>
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象来自服务器端的 CalDAV 限制,并非应用问题;建议…"></textarea>
</label>
<div class="form-actions">
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
</div>
</form>
<form method="post" action="admin.php?tab=all" class="card-form" style="margin-top:24px"
onsubmit="return confirm('确定删除 #<?php echo (int)$id; ?>?该问题的回复与附件会一并删除,且不可恢复。');">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<div class="form-actions">
<button class="btn danger" type="submit" name="act" value="delete">删除这条问题</button>
</div>
</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();
$ossStat = fb_oss_cred_status();
$mb = function ($bytes) { return round(((int)$bytes) / 1048576, 1); };
?>
<form method="post" action="admin.php?tab=settings" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<h2 style="margin:8px 0 10px">站点信息</h2>
<label class="field"><span>站点标题</span>
<input type="text" name="site_name" maxlength="80" placeholder="例如:我的软件 · 问题反馈"
value="<?php echo e(fb_form_val(fb_cfg('site_name', ''), '轻反馈', 'site_name')); ?>">
<i class="hint">显示在浏览器标签、页面顶栏与页脚。用它说明「本系统为哪个软件提供反馈服务」,如「XX 软件 · 问题反馈」。</i></label>
<label class="field"><span>站点描述</span>
<textarea name="site_desc" rows="3" maxlength="300"
placeholder="欢迎在这里反馈使用问题、提出功能建议。"><?php echo e((string)fb_cfg('site_desc', '')); ?></textarea>
<i class="hint">显示在首页标题下方,一句话说明这里可以反馈什么。两项都留空则恢复为出厂默认。</i></label>
<h2 style="margin:24px 0 10px">页脚</h2>
<label class="check"><input type="checkbox" name="footer_show_credit" value="1"<?php echo fb_cfg('footer.show_credit', true) ? ' checked' : ''; ?>> 在页脚显示「由 FeedLite 驱动」
<span class="hint" style="display:block;margin:2px 0 0 24px">取消勾选后,这句话连同链接都不再出现在页脚,只保留「管理后台」入口。</span></label>
<label class="field"><span>FeedLite 链接地址</span>
<input type="text" name="footer_credit_url" maxlength="300"
value="<?php echo e((string)fb_cfg('footer.credit_url', '') !== '' ? (string)fb_cfg('footer.credit_url') : fb_credit_url_default()); ?>">
<i class="hint">页脚「FeedLite」二字指向的地址,默认是本项目开源仓库。只接受 http:// 或 https:// 开头的网址;填回默认值即恢复。</i></label>
<h2 style="margin:24px 0 10px">附件存储</h2>
<label class="field">
<span>附件保存在哪</span>
<select name="attachment_storage">
<option value="off"<?php echo $mode === 'off' ? ' selected' : ''; ?>>关闭(不开放上传)</option>
<option value="local"<?php echo $mode === 'local' ? ' selected' : ''; ?>>本机硬盘(站点 uploads/ 目录)</option>
<option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(私有桶 · 服务器代理)</option>
</select>
<i class="hint">本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。<b>前台展示一律经 media.php 代理</b>——无论存哪,页面上的图片/视频地址都是 <code>media.php?k=…</code>,不暴露 uploads/ 真实路径,也不出现任何 OSS 桶信息。</i>
</label>
<?php
// 所有「限制类」输入框:min=0,填 0 = 不限制(见 #4)。
// 只有 image_max_side 例外 —— 它是压缩目标边长,0 没有意义。
$zero = '<i class="hint">填 0 表示不限制。</i>';
?>
<label class="check"><input type="checkbox" name="upload_image_on" value="1"<?php echo fb_upload_cfg('image_on', true) ? ' checked' : ''; ?>> 允许上传图片
<span class="hint" style="display:block;margin-left:24px">关掉后前台不再显示图片上传入口。与下面「填 0 不限个数」互不干扰。</span></label>
<label class="check"><input type="checkbox" name="upload_video_on" value="1"<?php echo fb_upload_cfg('video_on', true) ? ' checked' : ''; ?>> 允许上传视频</label>
<div class="grid2" style="margin-top:10px">
<label class="field"><span>每个问题最多几张图片</span>
<input type="number" name="upload_image_max_per_post" min="0" max="1000"
value="<?php echo (int)fb_upload_cfg('image_max_per_post', 4); ?>">
<?php echo $zero; ?></label>
<label class="field"><span>单张图片上限(MB)</span>
<input type="number" step="any" min="0" max="1024" name="upload_image_max_bytes"
value="<?php echo $mb(fb_upload_cfg('image_max_bytes', 5242880)); ?>">
<?php echo $zero; ?><i class="hint">填 0 时注意:服务器磁盘可能被大图写满。</i></label>
<label class="field"><span>每个问题最多几个视频</span>
<input type="number" name="upload_video_max_per_post" min="0" max="1000"
value="<?php echo (int)fb_upload_cfg('video_max_per_post', 1); ?>">
<?php echo $zero; ?></label>
<label class="field"><span>单个视频上限(MB)</span>
<input type="number" step="any" min="0" max="10240" name="upload_video_max_bytes"
value="<?php echo $mb(fb_upload_cfg('video_max_bytes', 52428800)); ?>">
<?php echo $zero; ?><i class="hint">填 0 时注意:服务器磁盘可能被大视频写满。</i></label>
<label class="field"><span>全站附件总量上限(MB)</span>
<input type="number" step="any" min="0" max="1048576" name="upload_total_cap_bytes"
value="<?php echo $mb(fb_upload_cfg('total_cap_bytes', 1073741824)); ?>">
<?php echo $zero; ?><i class="hint">填 0 表示不限总量,磁盘写满后上传会失败。</i></label>
<label class="field"><span>附件保留天数</span>
<input type="number" name="upload_retention_days" min="0" max="36500"
value="<?php echo (int)fb_upload_cfg('retention_days', 365); ?>">
<?php echo $zero; ?><i class="hint">填 0 = 永不自动清理。</i></label>
<label class="field"><span>每 IP 每小时最多上传几次</span>
<input type="number" name="upload_upload_per_hour" min="0" max="100000"
value="<?php echo (int)fb_upload_cfg('upload_per_hour', 60); ?>">
<?php echo $zero; ?></label>
<label class="field"><span>图片压缩后的最长边(像素)</span>
<input type="number" name="upload_image_max_side" min="480" max="4096"
value="<?php echo (int)fb_upload_cfg('image_max_side', 1600); ?>">
<i class="hint">这是压缩目标边长,不是限制项,最小 480。</i></label>
</div>
<h2 style="margin:24px 0 10px">阿里云 OSS(选「OSS」时才需要填)</h2>
<p class="muted small" style="margin:-4px 0 12px">私有桶 + 服务器代理:浏览器不直连桶,附件读写都经本站 upload.php / media.php 中转。Endpoint 填公网或内网形式都可以,程序按下方开关自动转换。</p>
<div class="grid2">
<label class="field"><span>Endpoint</span>
<input type="text" name="oss_endpoint" placeholder="oss-cn-qingdao.aliyuncs.com" value="<?php echo e((string)fb_cfg('oss.endpoint', '')); ?>"></label>
<label class="field"><span>Bucket 名</span>
<input type="text" name="oss_bucket" value="<?php echo e((string)fb_cfg('oss.bucket', '')); ?>"></label>
<label class="field"><span>鉴权方式</span>
<select name="oss_credential_source">
<option value="instance_role"<?php echo fb_oss_credential_source() === 'instance_role' ? ' selected' : ''; ?>>ECS 实例 RAM 角色(推荐)</option>
<option value="static"<?php echo fb_oss_credential_source() === 'static' ? ' selected' : ''; ?>>静态 AccessKey</option>
</select></label>
<?php
// RAM 角色名不该让用户去控制台查 —— 直接问 ECS 元数据服务。
// 已配置的显示配置值;没配置就现场探测一次作为占位提示。
$curRole = trim((string)fb_cfg('oss.instance_role', ''));
$detect = null;
if ($curRole === '') {
list($dOk, $dVal) = fb_oss_detect_instance_role();
$detect = [$dOk, $dVal];
}
?>
<div class="field">
<span>ECS 实例 RAM 角色</span>
<?php if ($curRole !== ''): ?>
<div class="role-ok">已绑定:<b><?php echo e($curRole); ?></b>
<i class="hint" style="display:inline;margin-left:6px">(自动从实例元数据读取,无需手填)</i>
</div>
<?php elseif ($detect && $detect[0]): ?>
<div class="role-ok">自动探测到:<b><?php echo e((string)$detect[1]); ?></b>
<i class="hint" style="display:inline;margin-left:6px">保存时若留空就自动用这个</i>
</div>
<?php else: ?>
<div class="role-warn">
<?php echo e($detect ? (string)$detect[1] : '未能自动探测'); ?>
</div>
<?php endif; ?>
<details style="margin-top:6px">
<summary style="cursor:pointer;font-size:13px;color:var(--muted)">手动指定角色名(一般不需要)</summary>
<input type="text" name="oss_instance_role" style="margin-top:6px"
placeholder="留空即自动探测"
value="<?php echo e($curRole); ?>">
</details>
</div>
<label class="field"><span>AccessKeyId(静态鉴权用)</span>
<input type="text" name="oss_access_key" value="<?php echo e((string)fb_cfg('oss.access_key', '')); ?>"></label>
<label class="field"><span>AccessKeySecret(留空=不修改)</span>
<input type="password" name="oss_secret_key" autocomplete="new-password"
placeholder="<?php echo (string)fb_cfg('oss.secret_key', '') !== '' ? '已设置,留空保持不变' : '未设置'; ?>"></label>
<label class="field"><span>对象键前缀</span>
<input type="text" name="oss_prefix" placeholder="留空即用 fb"
value="<?php echo e(fb_form_val(fb_cfg('oss.prefix', 'fb'), 'fb', 'oss.prefix')); ?>"></label>
<label class="field"><span>公网访问协议(走公网 Endpoint 时)</span>
<select name="oss_url_scheme">
<option value="https"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'https' ? ' selected' : ''; ?>>https</option>
<option value="http"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'http' ? ' selected' : ''; ?>>http</option>
</select></label>
</div>
<label class="check"><input type="checkbox" name="oss_use_internal_endpoint" value="1"<?php echo fb_oss_use_internal() ? ' checked' : ''; ?>> 使用内网 Endpoint(<code>*-internal.aliyuncs.com</code>,免公网流量费、延迟低)
<span class="hint" style="display:block;margin:2px 0 0 24px">⚠️ 仅当【桶与本机 ECS 同地域】时可用;跨地域内网不可达,请取消勾选回落公网,或改用同地域的桶。保存后可在下方「存储体检」查看连通性。</span></label>
<label class="check"><input type="checkbox" name="oss_verify" value="1"<?php echo fb_cfg('oss.verify', true) ? ' checked' : ''; ?>> 提交时回源复核附件(防伪造)</label>
<label class="check"><input type="checkbox" name="oss_image_process" value="1"<?php echo fb_cfg('oss.image_process', true) ? ' checked' : ''; ?>> 缩略图使用 OSS 图片处理(服务端缩放后再转发,省下行流量)</label>
<input type="hidden" name="oss_visibility" value="<?php echo e((string)fb_cfg('oss.visibility', 'private')); ?>">
<h2 style="margin:24px 0 10px">审核</h2>
<label class="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需您通过后才显示)</label>
<label class="field"><span>关闭通知邮件标题</span>
<input type="text" name="moderation_close_mail_subject"
placeholder="留空即用:关于您反馈的问题《%s》"
value="<?php echo e(fb_form_val(fb_cfg('moderation.close_mail_subject', '关于您反馈的问题《%s》'), '关于您反馈的问题《%s》', 'moderation.close_mail_subject')); ?>">
<i class="hint">%s 会替换成问题标题。留空则用默认模板。</i></label>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="settings">保存设置</button>
</div>
</form>
<h2 style="margin:26px 0 10px">存储体检</h2>
<div class="card-form">
<div style="display:flex;flex-direction:column;gap:6px">
<span>当前存储方式:<b><?php echo $mode === 'oss' ? '阿里云 OSS' : ($mode === 'local' ? '本机硬盘' : '已关闭'); ?></b></span>
<?php if ($mode === 'local'): ?>
<?php $ld = fb_local_diag(); ?>
<span>上传目录:<code><?php echo e($ld['dir']); ?></code> — <?php echo $ld['ok'] ? '可写 ✅' : '不可写 ❌'; ?></span>
<?php if (!$ld['ok']): ?>
<span class="muted small"><?php echo e($ld['tip']); ?></span>
<?php endif; ?>
<?php elseif ($mode === 'oss'): ?>
<span>鉴权方式:<b><?php echo e($ossStat['label']); ?></b></span>
<span>凭证状态:<?php echo !empty($ossStat['ok']) ? ('已取得 AccessKey(尾号 ' . e($ossStat['ak_tail']) . ')✅') : ('未取得 ❌ ' . e((string)$ossStat['error'])); ?></span>
<span>Bucket:<?php echo e((string)fb_cfg('oss.bucket', '')); ?></span>
<span>Endpoint(配置):<?php echo e((string)fb_cfg('oss.endpoint', '')); ?></span>
<span>Endpoint(实际连接):<code><?php echo e(fb_oss_host()); ?></code> — <?php echo fb_oss_use_internal() ? '内网' : '公网'; ?></span>
<?php if (isset($_GET['probe']) && $_GET['probe'] === '1'): ?>
<?php $pb = fb_oss_probe(); ?>
<span>连通性:<?php echo $pb['ok'] ? '✅' : '❌'; ?> <?php echo e($pb['detail']); ?><?php echo $pb['status'] ? ('(HTTP ' . (int)$pb['status'] . ')') : ''; ?></span>
<?php if (!$pb['ok'] && $pb['internal'] && $pb['status'] === 0): ?>
<span class="muted small">↳ 内网 Endpoint 跨地域不可达:请取消勾选上方「使用内网 Endpoint」后保存,或把桶换到与本机同地域。</span>
<?php endif; ?>
<?php else: ?>
<span><a class="btn" style="padding:4px 10px" href="admin.php?tab=settings&amp;probe=1">测一下桶连通性</a>
<span class="muted small">(会真实访问一次 OSS,跨地域不通时可能等待数秒)</span></span>
<?php endif; ?>
<?php endif; ?>
<span>已用附件:<?php echo e(fb_size_h(fb_attach_total_bytes($pdo))); ?> / 上限 <?php echo e(fb_size_h(fb_upload_cfg('total_cap_bytes', 0))); ?></span>
</div>
</div>
<h2 style="margin:26px 0 10px">登录口令</h2>
<div class="card-form" style="margin:0 0 16px">
<?php $pwSrc = fb_admin_password_source(); ?>
<div style="display:flex;flex-direction:column;gap:6px">
<span>存储位置:
<?php if ($pwSrc === 'db'): ?>
数据库 <code>settings</code> 表(键名 <code>admin.password_hash</code>)
<?php elseif ($pwSrc === 'file'): ?>
<code>config.php</code> 的 <code>admin_password</code>
<?php else: ?>
<b style="color:#b45309">尚未设置</b>
<?php endif; ?>
</span>
<span>是否加密:
<?php if ($pwSrc === 'db'): ?>
<b>是</b> —— 存的是 <code>password_hash()</code> 生成的 bcrypt 单向哈希(<code>$2y$…</code>),
不可逆、无法还原明文;校验用 <code>password_verify()</code>。
<?php elseif ($pwSrc === 'file'): ?>
<b>否</b> —— 目前是明文写在 <code>config.php</code> 里。
<a href="#pwform">在下面改一次口令</a>后即会转存为数据库中的 bcrypt 哈希。
<?php else: ?>
—— 还没有可用口令,当前后台<strong>无法登录</strong>。请用下方表单设定一个。
<?php endif; ?>
</span>
<?php if ($pwSrc === 'db'): ?>
<span class="muted small">数据库文件在 <code>data/feedback.db</code>(已由 .htaccess 拒绝外部访问),且只存哈希,明文不落盘。</span>
<?php endif; ?>
</div>
</div>
<div id="pwform">
<form method="post" action="admin.php?tab=settings" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<?php if ($pwSrc !== 'none'): ?>
<div class="grid2">
<label class="field"><span>当前口令</span><input type="password" name="old_password" autocomplete="current-password"></label>
<label class="field"><span>新口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label>
<label class="field"><span>再输入一次新口令</span><input type="password" name="new_password2" autocomplete="new-password"></label>
</div>
<?php else: ?>
<div class="grid2">
<label class="field"><span>设定登录口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label>
<label class="field"><span>再输入一次</span><input type="password" name="new_password2" autocomplete="new-password"></label>
</div>
<?php endif; ?>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="password"><?php echo $pwSrc === 'none' ? '设定口令' : '修改口令'; ?></button>
</div>
<p class="muted small">新口令以 bcrypt 哈希写入数据库,优先级高于 <code>config.php</code> 的 <code>admin_password</code>;改完立即生效,明文不会留在任何文件里。</p>
</form>
</div>
<?php elseif ($tab === 'mail'): ?>
<div class="page-head">
<div>
<h1>邮件通知</h1>
<p class="muted">配置 SMTP 后,反馈有变动会自动发信通知提交人。</p>
</div>
</div>
<?php fb_flash_box($msg, $msgType); ?>
<form method="post" action="admin.php?tab=mail" class="card-form" data-validate>
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="act" value="settings">
<h2 style="margin:24px 0 10px">邮件通知</h2>
<?php
// ⚠️ config.example.php 的 smtp 段有全套出厂示例值
// (smtp.example.com / noreply@example.com / 轻反馈 …),
// fb_cfg() 合并后表单会把这些当成"已填内容"显示出来,
// 用户点进去得先删一遍。所以回显一律过 fb_form_val():
// 数据库里没设过 = 没自定义 = 显示空,示例只留在 placeholder。
$smOn = (bool)fb_cfg('smtp.enabled', false);
$smHost = fb_form_val(fb_cfg('smtp.host', ''), 'smtp.example.com', 'smtp.host');
$smUser = fb_form_val(fb_cfg('smtp.user', ''), 'noreply@example.com', 'smtp.user');
$smFrom = fb_form_val(fb_cfg('smtp.from', ''), 'noreply@example.com', 'smtp.from');
$smName = fb_form_val(fb_cfg('smtp.from_name', ''), '轻反馈', 'smtp.from_name');
// 端口出厂 465,没设过就留空让 placeholder 提示,别预填数字
$smPortRaw = fb_form_val((string)(int)fb_cfg('smtp.port', 465), '465', 'smtp.port');
$smPort = $smPortRaw === '' ? '' : (string)(int)$smPortRaw;
$smSec = (string)fb_cfg('smtp.secure', 'ssl');
$smHasPw = (string)fb_cfg('smtp.pass', '') !== '';
$smTmo = (int)fb_cfg('smtp.timeout', 15);
$smVerify= (bool)fb_cfg('smtp.verify_ssl', true);
?>
<label class="check"><input type="checkbox" name="smtp_enabled" value="1"<?php echo $smOn ? ' checked' : ''; ?>> 开启邮件通知
<span class="hint" style="display:block;margin:2px 0 0 24px">开启后,有人回复您反馈的问题会发信通知提交人。填好下方参数并用「发送测试邮件」验证通了再勾。</span></label>
<div class="grid2">
<label class="field"><span>SMTP 服务器</span>
<input type="text" name="smtp_host" maxlength="120" placeholder="smtp.qiye.aliyun.com"
value="<?php echo e($smHost); ?>">
<i class="hint">阿里云企业邮 smtp.qiye.aliyun.com;腾讯企业邮 smtp.exmail.qq.com;QQ 邮箱 smtp.qq.com。</i></label>
<label class="field"><span>端口与加密</span>
<div style="display:flex;gap:8px">
<input type="number" name="smtp_port" min="1" max="65535" style="flex:0 0 110px"
placeholder="465"
value="<?php echo e($smPort); ?>">
<select name="smtp_secure" style="flex:1">
<option value="ssl"<?php echo $smSec === 'ssl' ? ' selected' : ''; ?>>SSL(通常 465)</option>
<option value="tls"<?php echo $smSec === 'tls' ? ' selected' : ''; ?>>STARTTLS(通常 587)</option>
<option value=""<?php echo $smSec === '' ? ' selected' : ''; ?>>不加密(通常 25,不推荐)</option>
</select>
</div></label>
</div>
<div class="grid2">
<label class="field"><span>登录账号</span>
<input type="text" name="smtp_user" maxlength="120" placeholder="noreply@example.com"
value="<?php echo e($smUser); ?>"></label>
<label class="field"><span>密码 / 授权码 <i class="hint" style="display:inline"><?php echo $smHasPw ? '已设置,留空不修改' : '未设置'; ?></i></span>
<input type="password" name="smtp_pass" autocomplete="new-password"
placeholder="<?php echo $smHasPw ? '留空保持不变' : '邮箱密码或授权码'; ?>"></label>
</div>
<div class="grid2">
<label class="field"><span>发件地址</span>
<input type="email" name="smtp_from" maxlength="120" placeholder="留空则同登录账号"
value="<?php echo e($smFrom); ?>"></label>
<label class="field"><span>发件人显示名称</span>
<input type="text" name="smtp_from_name" maxlength="60"
placeholder="<?php echo e((string)fb_cfg('site_name', '轻反馈')); ?>"
value="<?php echo e($smName); ?>"></label>
</div>
<details style="margin:6px 0 12px">
<summary style="cursor:pointer;font-size:13px;color:var(--muted)">高级选项</summary>
<div class="grid2" style="margin-top:8px">
<label class="field"><span>超时(秒)</span>
<input type="number" name="smtp_timeout" min="5" max="60" placeholder="15"
value="<?php echo e(fb_form_val((string)(int)$smTmo, '15', 'smtp.timeout')); ?>"></label>
<label class="check" style="align-self:end"><input type="checkbox" name="smtp_verify_ssl" value="1"<?php echo $smVerify ? ' checked' : ''; ?>> 校验服务器 SSL 证书
<span class="hint" style="display:block;margin-left:24px">服务器缺 CA 证书时可临时取消,但不建议。</span></label>
</div>
</details>
<?php if ($smHost !== ''): ?>
<div class="mail-test">
<span class="muted" style="font-size:13.5px">配好后先发一封测试邮件确认能通:</span>
<input type="email" id="mailTestTo" placeholder="收件邮箱,默认发给自己"
value="<?php echo e((string)fb_cfg('admin_email', '') !== '' ? (string)fb_cfg('admin_email', '') : (string)fb_cfg('smtp.user', '')); ?>">
<select id="mailTestSw" title="按哪个软件的样子发送">
<?php foreach (fb_softwares(true) as $swOne): ?>
<option value="<?php echo (int)$swOne['id']; ?>"><?php echo e((string)$swOne['name']); ?></option>
<?php endforeach; ?>
</select>
<button type="button" class="btn" id="mailTestBtn" data-csrf="<?php echo e(fb_csrf_token()); ?>">发送测试邮件</button>
<span id="mailTestOut" class="mail-test-out"></span>
</div>
<p class="hint" style="margin:6px 0 0">测试邮件用的是「提交回执」模板,会带上所选软件的图标与配色 —— 可以直接看出不同软件的邮件长什么样。</p>
<?php endif; ?>
<h3 style="margin:20px 0 8px;font-size:15px">通知规则</h3>
<label class="check"><input type="checkbox" name="notify_receipt_enabled" value="1"<?php echo fb_cfg('notify.receipt_enabled', true) ? ' checked' : ''; ?>> 提交后立即给提交人发「已收到」回执
<span class="hint" style="display:block;margin-left:24px">告诉对方反馈已送达、正在等审核,请耐心等待。提交完就让用户干等着是最容易丢信任的环节。</span></label>
<label class="check"><input type="checkbox" name="notify_enabled" value="1"<?php echo fb_cfg('notify.enabled', true) ? ' checked' : ''; ?>> 有人回复时通知提交人</label>
<label class="check"><input type="checkbox" name="notify_on_every_reply" value="1"<?php echo fb_cfg('notify.on_every_reply', false) ? ' checked' : ''; ?>> 每次回复都通知(默认只在官方回复时通知,避免刷屏)</label>
<?php
// 勾了「有新反馈时通知管理员」却没填官方邮箱 —— 这条通知会发不出去。
// 之前这个组合完全是静默失效的,用户在后台看不出任何异常。
$adminMailNow = trim((string)fb_cfg('admin_email', ''));
$smtpUserNow = trim((string)fb_cfg('smtp.user', ''));
$newNotifyOn = (bool)fb_cfg('notify.admin_new_thread', false);
$mailMissing = ($adminMailNow === '' && $smtpUserNow === '');
?>
<label class="check"><input type="checkbox" name="notify_admin_new_thread" value="1"<?php echo $newNotifyOn ? ' checked' : ''; ?>> 有新反馈时通知管理员
<span class="hint" style="display:block;margin-left:24px">发到「站点信息」里填的官方邮箱;没填官方邮箱时退而发到 SMTP 发件地址。</span>
<?php if ($newNotifyOn && $mailMissing): ?>
<span class="hint" style="display:block;margin-left:24px;color:#b4553c;font-weight:600">⚠️ 官方邮箱和 SMTP 发件地址都是空的,这条通知目前发不出去。请在上方「官方邮箱」处填写。</span>
<?php elseif ($newNotifyOn && $adminMailNow === ''): ?>
<span class="hint" style="display:block;margin-left:24px;color:#8a6a15">当前会发到 SMTP 发件地址 <?php echo e($smtpUserNow); ?>。若想收到别的信箱,请在「站点信息」填写官方邮箱。</span>
<?php endif; ?></label>
<label class="field" style="max-width:280px"><span>同一帖同一收件人的最短发信间隔(秒)</span>
<?php /* 出厂默认是 300(见 config.example.php 的 notify.cooldown_seconds) */ ?>
<input type="number" name="notify_cooldown_seconds" min="0" max="86400" placeholder="300"
value="<?php echo e(fb_form_val((string)(int)fb_cfg('notify.cooldown_seconds', 300), '300', 'notify.cooldown_seconds')); ?>">
<i class="hint">0 = 不限制。</i></label>
<div class="form-actions">
<button class="btn primary" type="submit">保存邮件设置</button>
<a class="btn" href="admin.php?tab=settings">← 回站点设置</a>
</div>
</form>
<?php else: ?>
<?php
$logs = $pdo->query('SELECT * FROM mod_log ORDER BY created_at DESC, id DESC LIMIT 100')->fetchAll();
?>
<h2 style="margin:8px 0 10px">最近操作 <span class="muted">(<?php echo count($logs); ?> 条)</span></h2>
<?php if (!$logs): ?>
<div class="empty"><p>还没有操作记录。</p></div>
<?php else: ?>
<div class="list">
<?php foreach ($logs as $l): ?>
<div class="item" style="cursor:default">
<div class="item-top">
<span class="badge cat"><?php echo e((string)$l['action']); ?></span>
<?php if (!empty($l['thread_id'])): ?>
<a class="item-title" href="admin.php?tab=thread&amp;id=<?php echo (int)$l['thread_id']; ?>">#<?php echo (int)$l['thread_id']; ?></a>
<?php endif; ?>
</div>
<p class="item-ex"><?php echo e((string)$l['detail']); ?></p>
<div class="item-meta"><span><?php echo e(fb_fmt_time($l['created_at'])); ?></span></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php
fb_layout_foot();
/**
* 待审核条目(列表式)
*
* 用户 2026-11-03 明确要求:
* ① 列表形式显示,不要每张卡片塞满表单
* ② 去掉「关闭说明」整块 —— 想关闭就点标题进管理页再填说明,
* 列表页不承担这个职责
* ③ 后面只留一个「通过并公开」按钮,点一下就通过
* ④ 标题自己就是进管理页的链接,别再额外挂「进入管理」
*/
function fb_admin_pending_row(array $t) {
$url = 'admin.php?tab=thread&id=' . (int)$t['id'];
ob_start(); ?>
<div class="prow">
<div class="prow-main">
<?php $cSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null; ?>
<?php /* 标题顶头独占一行,标签(软件/分类/状态)另起一行放下面。
用户 2026-11-03:后台待审核、全部问题、详情页全都按这个方式处理 ——
标签放在标题前面会把标题挤到右边。 */ ?>
<div class="prow-head">
<a class="prow-title" href="<?php echo e($url); ?>"><?php echo e($t['title']); ?></a>
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="prow-tags">
<?php if ($cSw): ?><?php echo fb_software_chip($cSw); ?><?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>
</div>
<p class="prow-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
<div class="prow-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e($t['author_email']); ?></span>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
</div>
</div>
<div class="prow-act">
<form method="post" action="admin.php?tab=pending">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
<button class="btn primary" type="submit" name="act" value="approve">通过并公开</button>
</form>
</div>
</div>
<?php
return ob_get_clean();
}