Files
FeedLite/admin.php
T
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00

734 lines
38 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';
$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'])) {
$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 = '尝试次数过多,请一小时后再试。';
} elseif ($pw !== '' && fb_admin_verify_password($pw)) {
fb_admin_login();
fb_mod_log('login');
fb_redirect('admin.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">请输入管理员口令(初始口令在 <code>config.php</code> 的 <code>admin_password</code>,登录后在「站点设置」里可以改掉它)。</p>
<?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="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);
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('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']);
?>
<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 === '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'] : ''));
if ($stF !== '') { $where[] = 'status = ?'; $args[] = $stF; }
if ($visF === '1' || $visF === '0') { $where[] = 'visible = ?'; $args[] = (int)$visF; }
if ($catF !== '') { $where[] = 'category = ?'; $args[] = $catF; }
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="status">
<option value="">全部状态</option>
<?php foreach (fb_statuses() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo $stF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
<select name="cat">
<option value="">全部分类</option>
<?php foreach (fb_categories() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo $catF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php 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">
<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>
<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 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; ?>">
<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_statuses() 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_statuses() 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 === '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((string)fb_cfg('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="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">本机:文件落在站点目录,占用服务器磁盘与带宽,但零配置、立刻可用;OSS:文件不经过服务器,需先建桶并授权。</i>
</label>
<div class="grid2">
<label class="field"><span>每个问题最多几张图片</span>
<input type="number" name="upload_image_max_per_post" min="0" max="20" value="<?php echo (int)fb_upload_cfg('image_max_per_post', 4); ?>"></label>
<label class="field"><span>单张图片上限(MB)</span>
<input type="number" step="0.1" min="0.2" max="50" name="upload_image_max_bytes" value="<?php echo $mb(fb_upload_cfg('image_max_bytes', 5242880)); ?>"></label>
<label class="field"><span>每个问题最多几个视频</span>
<input type="number" name="upload_video_max_per_post" min="0" max="10" value="<?php echo (int)fb_upload_cfg('video_max_per_post', 1); ?>"></label>
<label class="field"><span>单个视频上限(MB)</span>
<input type="number" step="1" min="1" max="500" name="upload_video_max_bytes" value="<?php echo $mb(fb_upload_cfg('video_max_bytes', 52428800)); ?>"></label>
<label class="field"><span>全站附件总量上限(MB)</span>
<input type="number" step="16" min="10" name="upload_total_cap_bytes" value="<?php echo $mb(fb_upload_cfg('total_cap_bytes', 1073741824)); ?>"></label>
<label class="field"><span>附件保留天数</span>
<input type="number" name="upload_retention_days" min="1" max="3650" value="<?php echo (int)fb_upload_cfg('retention_days', 365); ?>"></label>
<label class="field"><span>每 IP 每小时最多上传几次</span>
<input type="number" name="upload_upload_per_hour" min="1" max="2000" value="<?php echo (int)fb_upload_cfg('upload_per_hour', 60); ?>"></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); ?>"></label>
</div>
<h2 style="margin:24px 0 10px">阿里云 OSS(选「OSS」时才需要填)</h2>
<div class="grid2">
<label class="field"><span>Endpoint</span>
<input type="text" name="oss_endpoint" placeholder="oss-cn-beijing.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>
<label class="field"><span>ECS 实例 RAM 角色名</span>
<input type="text" name="oss_instance_role" placeholder="FeedLiteOssRole" value="<?php echo e((string)fb_cfg('oss.instance_role', '')); ?>"></label>
<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_domain" placeholder="https://fb-img.example.com" value="<?php echo e((string)fb_cfg('oss.domain', '')); ?>"></label>
<label class="field"><span>对象键前缀</span>
<input type="text" name="oss_prefix" value="<?php echo e((string)fb_cfg('oss.prefix', 'fb')); ?>"></label>
<label class="field"><span>桶读写权限</span>
<select name="oss_visibility">
<option value="public"<?php echo ((string)fb_cfg('oss.visibility', 'public')) === 'public' ? ' selected' : ''; ?>>公共读</option>
<option value="private"<?php echo ((string)fb_cfg('oss.visibility', 'public')) === 'private' ? ' selected' : ''; ?>>私有(展示时签名)</option>
</select></label>
<label class="field"><span>访问协议</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_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>
<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" value="<?php echo e((string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》')); ?>">
<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', '')); ?> / Endpoint:<?php echo e((string)fb_cfg('oss.endpoint', '')); ?></span>
<?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>
<form method="post" action="admin.php?tab=settings" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<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>
<div class="form-actions">
<button class="btn" type="submit" name="act" value="password">修改口令</button>
</div>
<p class="muted small">新口令保存在数据库里,优先级高于 <code>config.php</code> 的 <code>admin_password</code>;改完立即生效。</p>
</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">
<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>
<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();
}