'noindex,nofollow']);
?>
管理登录
请输入管理员口令(初始口令在 config.php 的 admin_password,登录后在「站点设置」里可以改掉它)。
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);
if (!$t && $act !== 'settings' && $act !== 'password') {
$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, '通过审核并公开');
$msg = '#' . $id . ' 已通过审核,现已公开显示。';
} 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, '撤下(转为不公开)');
$msg = '#' . $id . ' 已撤下(转为不公开)。';
} 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;
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
->execute([$new, $vis, time(), $id]);
fb_mod_log('status', $id, '状态改为 ' . $statuses[$new] . ',公开=' . $vis);
$msg = '#' . $id . ' 已更新:状态「' . $statuses[$new] . '」,' . ($vis ? '公开' : '不公开') . '。';
}
} 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 === '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'] : '');
if (!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');
$msg = '口令已修改,下次登录请使用新口令。';
}
}
}
}
/**
* 保存站点设置(把表单值写进 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;
};
// 站点信息(对外展示,用于说明「本系统为哪个软件提供反馈服务」)
// 留空 = 删除该设置项,回落到 config.php 的出厂值,便于恢复默认。
foreach (['site_name', 'site_desc'] as $k) {
$v = trim((string)$post($k, ''));
if ($v === '') { fb_unset_setting($k); }
else { fb_set_setting($k, $v); }
}
$ints = [
'upload.image_max_per_post' => [0, 20],
'upload.video_max_per_post' => [0, 10],
'upload.retention_days' => [1, 3650],
'upload.upload_per_hour' => [1, 2000],
'upload.image_max_side' => [480, 4096],
];
foreach ($ints as $k => $range) {
$v = (int)$post($k, 0);
$v = max($range[0], min($range[1], $v));
fb_set_setting($k, $v);
}
// MB → 字节
$mbs = [
'upload.image_max_bytes' => [0.2, 50],
'upload.video_max_bytes' => [1, 500],
'upload.total_cap_bytes' => [10, 100000],
];
foreach ($mbs as $k => $range) {
$mb = (float)$post($k, 0);
$mb = max($range[0], min($range[1], $mb));
fb_set_setting($k, (int)round($mb * 1048576));
}
$storage = (string)$post('attachment.storage', 'off');
if (!in_array($storage, ['off', 'local', 'oss'], true)) { $storage = 'off'; }
fb_set_setting('attachment.storage', $storage);
// OSS 模式下 oss.enabled 必须跟着开;其它模式关掉,避免误判
fb_set_setting('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) {
$v = trim((string)$post($k, ''));
// 对象键前缀留空会让对象键变成 "/2026/...",回落到出厂默认 'fb'
if ($k === 'oss.prefix' && $v === '') { fb_unset_setting($k); continue; }
fb_set_setting($k, $v);
}
$src = (string)$post('oss.credential_source', 'instance_role');
fb_set_setting('oss.credential_source', $src === 'static' ? 'static' : 'instance_role');
fb_set_setting('oss.access_key', trim((string)$post('oss.access_key', '')));
$sk = trim((string)$post('oss.secret_key', ''));
if ($sk !== '') { fb_set_setting('oss.secret_key', $sk); }
fb_set_setting('oss.visibility', ((string)$post('oss.visibility', 'public')) === 'private' ? 'private' : 'public');
fb_set_setting('oss.url_scheme', ((string)$post('oss.url_scheme', 'https')) === 'http' ? 'http' : 'https');
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
fb_set_setting('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', 'settings', 'log'], true)) { $tab = 'pending'; }
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
?>
待审核
全部问题
站点设置
操作日志
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();
?>
待审核 ()
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();
?>
全部问题 ( 条,第 / 页)
1): ?>
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);
?>
以官方身份回复
状态与可见性
处理状态
$label): ?>
>
公开显示
> 在公开列表中显示
保存
通过并公开
撤下
关闭(判定不是应用问题,会发邮件给提问人)
关闭说明 * 会作为邮件正文发给
关闭并邮件告知
删除这条问题
站点信息
站点标题
显示在浏览器标签、页面顶栏与页脚。用它说明「本系统为哪个软件提供反馈服务」,如「XX 软件 · 问题反馈」。
站点描述
显示在首页标题下方,一句话说明这里可以反馈什么。两项都留空则恢复为出厂默认。
附件存储
附件保存在哪
>关闭(不开放上传)
>本机硬盘(站点 uploads/ 目录)
>阿里云 OSS(私有桶 · 服务器代理)
本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。
每个问题最多几张图片
单张图片上限(MB)
每个问题最多几个视频
单个视频上限(MB)
全站附件总量上限(MB)
附件保留天数
每 IP 每小时最多上传几次
图片压缩后的最长边(像素)
阿里云 OSS(选「OSS」时才需要填)
私有桶 + 服务器代理:浏览器不直连桶,附件读写都经本站 upload.php / media.php 中转。Endpoint 填公网或内网形式都可以,程序按下方开关自动转换。
Endpoint
Bucket 名
鉴权方式
>ECS 实例 RAM 角色(推荐)
>静态 AccessKey
ECS 实例 RAM 角色名
AccessKeyId(静态鉴权用)
AccessKeySecret(留空=不修改)
对象键前缀
公网访问协议(走公网 Endpoint 时)
>https
>http
> 使用内网 Endpoint(*-internal.aliyuncs.com,免公网流量费、延迟低)
⚠️ 仅当【桶与本机 ECS 同地域】时可用;跨地域内网不可达,请取消勾选回落公网,或改用同地域的桶。保存后可在下方「存储体检」查看连通性。
> 提交时回源复核附件(防伪造)
> 缩略图使用 OSS 图片处理(服务端缩放后再转发,省下行流量)
审核
> 先审后显(新提交的问题默认不公开,需你通过后才显示)
关闭通知邮件标题
%s 会替换成问题标题。
保存设置
存储体检
修改登录口令
当前口令
新口令(至少 8 位)
再输入一次新口令
修改口令
新口令保存在数据库里,优先级高于 config.php 的 admin_password;改完立即生效。
query('SELECT * FROM mod_log ORDER BY created_at DESC, id DESC LIMIT 100')->fetchAll();
?>
最近操作 ( 条)