2026-09-28 20:34:37 +08:00
|
|
|
|
<?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 = '尝试次数过多,请一小时后再试。';
|
2026-09-28 22:53:55 +08:00
|
|
|
|
} elseif (!fb_admin_password_is_set()) {
|
|
|
|
|
|
$loginErr = '本系统还没有设置登录口令,请先运行安装向导(install.php)完成设置。';
|
2026-09-28 20:34:37 +08:00
|
|
|
|
} 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>
|
2026-09-28 22:53:55 +08:00
|
|
|
|
<p class="muted">请输入管理员口令<?php if (fb_admin_password_source() === 'file'): ?>(当前仍是 <code>config.php</code> 里的 <code>admin_password</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; ?>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<?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'] : '');
|
2026-09-28 22:53:55 +08:00
|
|
|
|
// 从没设过口令(或仍是出厂占位值)时,跳过"当前口令"校验 —— 否则谁也进不来、又改不了
|
|
|
|
|
|
$firstSet = !fb_admin_password_is_set();
|
|
|
|
|
|
if (!$firstSet && !fb_admin_verify_password($old)) {
|
2026-09-28 20:34:37 +08:00
|
|
|
|
$msg = '当前口令不正确。';
|
|
|
|
|
|
$msgType = 'error';
|
|
|
|
|
|
} elseif (fb_len($n1) < 8) {
|
|
|
|
|
|
$msg = '新口令至少 8 个字符。';
|
|
|
|
|
|
$msgType = 'error';
|
|
|
|
|
|
} elseif ($n1 !== $n2) {
|
|
|
|
|
|
$msg = '两次输入的新口令不一致。';
|
|
|
|
|
|
$msgType = 'error';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
fb_admin_set_password($n1);
|
2026-09-28 22:53:55 +08:00
|
|
|
|
fb_mod_log('password', null, $firstSet ? '首次设定登录口令(bcrypt 哈希存库)' : '修改登录口令');
|
|
|
|
|
|
$msg = $firstSet ? '口令已设定,下次登录请使用它。' : '口令已修改,下次登录请使用新口令。';
|
2026-09-28 20:34:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 保存站点设置(把表单值写进 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); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 22:53:55 +08:00
|
|
|
|
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址
|
|
|
|
|
|
fb_set_setting('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 { fb_set_setting('footer.credit_url', $curl); }
|
|
|
|
|
|
|
2026-09-28 20:34:37 +08:00
|
|
|
|
$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);
|
2026-09-28 22:12:28 +08:00
|
|
|
|
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
|
2026-09-28 20:34:37 +08:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-09-28 22:53:55 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<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>
|
2026-09-28 22:12:28 +08:00
|
|
|
|
<option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(私有桶 · 服务器代理)</option>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
</select>
|
2026-09-28 22:12:28 +08:00
|
|
|
|
<i class="hint">本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。</i>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
</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>
|
2026-09-28 22:12:28 +08:00
|
|
|
|
<p class="muted small" style="margin:-4px 0 12px">私有桶 + 服务器代理:浏览器不直连桶,附件读写都经本站 upload.php / media.php 中转。Endpoint 填公网或内网形式都可以,程序按下方开关自动转换。</p>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<div class="grid2">
|
|
|
|
|
|
<label class="field"><span>Endpoint</span>
|
2026-09-28 22:12:28 +08:00
|
|
|
|
<input type="text" name="oss_endpoint" placeholder="oss-cn-qingdao.aliyuncs.com" value="<?php echo e((string)fb_cfg('oss.endpoint', '')); ?>"></label>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<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_prefix" value="<?php echo e((string)fb_cfg('oss.prefix', 'fb')); ?>"></label>
|
2026-09-28 22:12:28 +08:00
|
|
|
|
<label class="field"><span>公网访问协议(走公网 Endpoint 时)</span>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<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>
|
2026-09-28 22:12:28 +08:00
|
|
|
|
<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>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<label class="check"><input type="checkbox" name="oss_verify" value="1"<?php echo fb_cfg('oss.verify', true) ? ' checked' : ''; ?>> 提交时回源复核附件(防伪造)</label>
|
2026-09-28 22:12:28 +08:00
|
|
|
|
<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')); ?>">
|
2026-09-28 20:34:37 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-09-28 22:12:28 +08:00
|
|
|
|
<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&probe=1">测一下桶连通性</a>
|
|
|
|
|
|
<span class="muted small">(会真实访问一次 OSS,跨地域不通时可能等待数秒)</span></span>
|
|
|
|
|
|
<?php endif; ?>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<?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>
|
|
|
|
|
|
|
2026-09-28 22:53:55 +08:00
|
|
|
|
<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">
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<form method="post" action="admin.php?tab=settings" class="card-form">
|
|
|
|
|
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
2026-09-28 22:53:55 +08:00
|
|
|
|
<?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; ?>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
<div class="form-actions">
|
2026-09-28 22:53:55 +08:00
|
|
|
|
<button class="btn primary" type="submit" name="act" value="password"><?php echo $pwSrc === 'none' ? '设定口令' : '修改口令'; ?></button>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
</div>
|
2026-09-28 22:53:55 +08:00
|
|
|
|
<p class="muted small">新口令以 bcrypt 哈希写入数据库,优先级高于 <code>config.php</code> 的 <code>admin_password</code>;改完立即生效,明文不会留在任何文件里。</p>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
</form>
|
2026-09-28 22:53:55 +08:00
|
|
|
|
</div>
|
2026-09-28 20:34:37 +08:00
|
|
|
|
|
|
|
|
|
|
<?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();
|
|
|
|
|
|
}
|