Files
FeedLite/admin.php
T
laoyang 605640aa90 根治:配置被跨页面保存清空(第三次复发,改为强制守卫) (#2)
## 用户的原话

「为什么我的配置,你总是改掉?到底在干什么?我配的 OSS,好几次都让你给清空了。」

## 先给结论:没有重置数据库

installed_at 仍是 11-01 15:14,oss.* / site_name / admin.username 全都在。
但 **OSS 配置确实又被清空了**,而且这是我的问题,第三次。

## 时间线(这次是旧代码干的)

  22:53:36  用户点「保存设置」→ 日志记录:存储=off
  22:57:10  我的修复才部署上线

晚了 3 分 34 秒。所以那次仍是旧代码。
但这不能当借口 —— 同一类 bug 已经复发三次:

  11-01 23:20  storage=off
  11-02 09:01  之后被清
  11-02 22:53  storage=off

## 为什么反复修不好:一直在治标

前两次我的做法是「给用户点出来的那个字段补 has() 判断」。
但保存函数里有 **32 处 fb_set_setting,只有 8 处手写了 has()**。
漏掉任何一处,就是一次事故。这是结构性缺陷,不是个别疏忽。

## 这次的做法:从写入入口上锁

新增 $save() 强制守卫,**本文件唯一允许写设置的入口**:

    $save = function ($k, $v) use ($has) {
        if (!$has($k)) { return false; }   // 铁律:没提交就不动
        return fb_set_setting($k, $v);
    };

把保存函数体里全部 32 处写入统一改为 $save()。
现在除了守卫自身内部,**没有任何地方能绕过「字段没提交就不写」**。

另外补上两处最关键的漏网:
- smtp.enabled 原先无 has(),站点设置页保存会把它写成 0
- smtp.from 兜底逻辑会在邮件页之外触发,改为仅在 from 被提交时生效

## 验证:写了一个专门抓这类问题的测试

`_tooling/feedlite/fb_config_guard_test.py`,6 项:

  A 站点设置页保存 → 邮件配置必须全部不变
  B 邮件页保存 → OSS/附件配置必须全部不变
  C 反复交叉保存 3 轮 → 全部配置始终不变
  D 提交残缺表单 → 绝不能把其它配置写空

正常代码 6/6 通过。

## 反向验证(关键)

把 $save 退化成直接写库、并去掉两个分组判断后重跑:

  A1 FAIL  smtp.enabled:1→0; notify.enabled:1→0; ...      ← 邮件没了
  B1 FAIL  attachment.storage:oss→off; oss.enabled:1→0; ... ← OSS 没了
  C1 FAIL  3 轮后同样被清
  D1 FAIL  残缺表单把 storage 写成了 off

现象与用户遇到的一模一样,证明测试确实能抓到,不是假通过。

## 回归

全新安装 33/33、邮件专项 73/73;前台各页状态码正常、无 PHP 错误。
(老基线 fb_e2e_wsl.py / fb_attach_wsl.py 两个脚本已不在仓库,
 属历史遗留,本次以全新安装 + 邮件专项 + 配置守护三套覆盖。)
2026-11-02 23:36:26 +08:00

1413 lines
78 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']);
?>
<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') {
$newStatus = ((string)$t['status'] === 'closed') ? 'open' : (string)$t['status'];
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
->execute([$newStatus, time(), $id]);
fb_mod_log('approve', $id, '通过审核并公开');
// 通知提交人「已发布」(工单 #2)
$errA = '';
try {
list($okA, $errA) = fb_notify_event($pdo, 'approve', $t);
} catch (Throwable $e) { $errA = '发信异常'; }
$msg = '#' . $id . ' 已通过审核,现已公开显示。'
. ($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 通不通,不改任何配置
$to = trim((string)(isset($_POST['to']) ? $_POST['to'] : ''));
if ($to === '') { $to = trim((string)fb_cfg('admin_email', '')); }
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$msg = '请填写有效的收件邮箱。';
$msgType = 'error';
} else {
$sc = (array)fb_cfg('smtp', []);
$site = (string)fb_cfg('site_name', '轻反馈');
$body = '<p>这是一封来自 <b>' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8')
. '</b> 的测试邮件。</p>'
. '<p>如果你收到了它,说明 SMTP 配置正确,回复通知可以正常发出。</p>'
. '<p style="color:#888;font-size:12px">发送时间:'
. date('Y-m-d H:i:s') . '</p>';
list($ok, $err) = fb_smtp_send($sc, $to, '【' . $site . '】邮件测试', $body);
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));
}
$storage = (string)$post('attachment.storage', 'off');
if (!in_array($storage, ['off', 'local', 'oss'], true)) { $storage = 'off'; }
$save('attachment.storage', $storage);
// OSS 模式下 oss.enabled 必须跟着开;其它模式关掉,避免误判
$save('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'));
// 冷却秒数留空 = 用默认 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']);
?>
<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
$pending = $pdo->query(
"SELECT id, title, body, category, status, author_name, author_email, created_at
FROM threads WHERE visible = 0 AND status <> 'closed' ORDER BY created_at DESC"
)->fetchAll();
?>
<h2 style="margin:22px 0 10px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2>
<?php if (!$pending): ?>
<div class="empty"><p>当前没有待审核的反馈。</p></div>
<?php else: ?>
<?php foreach ($pending as $t) { echo fb_admin_thread_card($t); } ?>
<?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" 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">
<div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<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>
<?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">
<div class="item-top">
<?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>
</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; ?>">
<input type="hidden" name="status" value="<?php echo e($curStatus === 'closed' ? 'open' : $curStatus); ?>">
<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', '')); ?>">
<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>
<?php endif; ?>
<h3 style="margin:20px 0 8px;font-size:15px">通知规则</h3>
<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>
<label class="check"><input type="checkbox" name="notify_admin_new_thread" value="1"<?php echo fb_cfg('notify.admin_new_thread', false) ? ' checked' : ''; ?>> 有新反馈时通知管理员
<span class="hint" style="display:block;margin-left:24px">发到「站点信息」里填的官方邮箱。</span></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();
/**
* 待审核卡片(含「通过」与「关闭并邮件告知」两个操作)
*/
function fb_admin_thread_card(array $t) {
ob_start(); ?>
<div class="card-form" style="margin:0 0 16px">
<div class="item-top">
<?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>
</div>
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
<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 e($t['author_email']); ?></span>
<span class="dot">·</span>
<a href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>">进入管理</a>
</div>
<div class="row2" style="margin-top:14px">
<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']; ?>">
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="approve">通过并公开</button>
</div>
</form>
<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']; ?>">
<label class="field">
<span>关闭说明 <b class="req">*</b> <i class="hint">判定"不是问题"时填写,会随邮件发给提问人</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>
</div>
</div>
<?php
return ob_get_clean();
}