- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流) - 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志 - 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式 - 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest) - 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希 - 配置:config.example.php 为模板,config.php 不入库
734 lines
38 KiB
PHP
734 lines
38 KiB
PHP
<?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&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&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&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&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&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&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&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();
|
||
}
|