Files
FeedLite/admin.php
T
laoyang e408dc78b8 表单不再预填示例数据,示例只留 placeholder (#3)
用户反馈:输入框里预填的「示例值」点进去得先删干净才能填自己的,不合理。

必须分清两类,否则会误删真实配置:
- A 类 真实配置(从数据库读出来的)→ 保留
- B 类 出厂示例(代码/配置文件里写死的)→ 清空,示例移 placeholder

本次处理的 B 类:

1. 安装页「网站名称」
   原先回显 config.example.php 里的出厂名「轻反馈」,用户以为要保留。
   现在首次进入为空,仅在表单校验失败重来时回填用户刚才填的内容。

2. 安装页「管理员用户名」
   原先预填 admin,现在为空,placeholder 提示「例如 admin」。

3. 后台「对象键前缀」
   原先显示 fb(来自 config 默认值),现在为空,placeholder「留空即用 fb」。

4. 后台「关闭通知邮件标题」
   原先显示模板「关于你反馈的问题《%s》」,现在为空,placeholder 给出模板。

## 新增 fb_form_val()
config.example.php 里给的出厂默认值和「用户填过的值」在 fb_cfg() 里合并后
分不出来 —— 表单永远显示得出内容。这个函数按「数据库里有没有这条设置」判定:
设过就回显(哪怕值恰好等于默认),没设过就当未自定义、显示空。

## 顺带修复一个真 bug
close_mail_subject 原先留空会存成空字符串,fb_cfg() 返回空串后
sprintf() 得到空邮件标题 —— 用户清空模板就发不出有主题的邮件。
改成留空即删除该设置,回落到默认模板。

## 验证
#3 专项 20/20(含:真实配置保存后必须回显、清空后必须回落默认且不存空串);
回归全绿:全新安装 33/33、安装向导 60/60、老基线 25/25、约束检查 20/20。
2026-11-01 16:53:52 +08:00

1090 lines
60 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 反馈管理后台
* ------------------------------------------------------------------
* 管理员用口令登录后可以:
* · 审核:通过(公开)/ 关闭(判定不是问题,邮件告知提问人)/ 撤下
* · 管理:以官方身份回复、改处理状态与可见性、删除问题
* · 设置:附件数量与大小上限、附件存储方式(关闭 / 本机硬盘 / 阿里云 OSS)、
* OSS 桶信息与鉴权方式(ECS 实例 RAM 角色 或 静态 AccessKey)
* · 修改自己的登录口令
*
* 入口:/feedback/admin.php
*/
require __DIR__ . '/lib/bootstrap.php';
require __DIR__ . '/lib/admin_taxonomy.php';
$pdo = fb_db();
/* ---------------- 退出 ---------------- */
if (isset($_GET['logout'])) {
unset($_SESSION['fb_admin'], $_SESSION['fb_admin_at']);
fb_redirect('admin.php');
}
/* ---------------- 登录 ---------------- */
$loginErr = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
$user = isset($_POST['admin_user']) ? (string)$_POST['admin_user'] : '';
$pw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
$cs = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
if (!fb_csrf_ok($cs)) {
$loginErr = '会话已过期,请重试。';
} elseif (!fb_rate_check('admin_login', 10)) {
$loginErr = '尝试次数过多,请一小时后再试。';
} else {
$rc = fb_admin_verify_login($user, $pw);
if ($rc === 0) {
fb_admin_login();
fb_mod_log('login');
fb_redirect('admin.php');
} elseif ($rc === 2) {
$loginErr = '本系统还没有配置管理员账号,请先运行安装向导(install.php)完成设置。';
} else {
fb_rate_hit('admin_login');
// 不区分「用户名错」还是「口令错」,避免被用来探测账号
$loginErr = '用户名或口令不正确。';
}
}
}
if (!fb_admin_logged_in()) {
fb_layout_head('管理登录', ['robots' => 'noindex,nofollow']);
?>
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
<section class="narrow">
<h1>管理登录</h1>
<p class="muted">请输入管理员用户名与口令<?php if (fb_admin_password_source() === 'file'): ?>(当前口令仍是 <code>config.php</code> 里的明文,登录后请到「站点设置」改成加密存储)<?php endif; ?>。</p>
<?php if (fb_admin_password_source() === 'none'): ?>
<div class="flash error">尚未配置管理员账号,且出厂占位口令已被禁用。请运行 <a href="install.php">install.php</a> 完成初始化。</div>
<?php endif; ?>
<?php if (fb_admin_username() === '' && fb_admin_password_is_set()): ?>
<div class="flash error">本站只设了口令、没有用户名(旧版安装)。请运行 <a href="install.php">install.php</a> 补一个用户名;在那之前用户名可留空、只填口令。</div>
<?php endif; ?>
<?php fb_flash_box($loginErr, 'error'); ?>
<form method="post" action="admin.php" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<label class="field">
<span>管理员用户名</span>
<input type="text" name="admin_user" autocomplete="username" required
value="<?php echo e(isset($_POST['admin_user']) ? (string)$_POST['admin_user'] : ''); ?>">
</label>
<label class="field">
<span>管理员口令</span>
<input type="password" name="admin_password" autocomplete="current-password" required>
</label>
<div class="form-actions">
<button class="btn primary" type="submit" name="login" value="1">登录</button>
</div>
</form>
</section>
<?php
fb_layout_foot();
exit;
}
/* ==================================================================
* 已登录:处理各种操作
* ================================================================== */
$msg = '';
$msgType = 'ok';
/** 取一条主题 */
function fb_admin_thread(PDO $pdo, $id) {
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$st->execute([(int)$id]);
$t = $st->fetch();
return is_array($t) ? $t : null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$cs = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
if (!fb_csrf_ok($cs)) {
$msg = '会话已过期,请重试。';
$msgType = 'error';
} else {
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
$act = (string)$_POST['act'];
$t = fb_admin_thread($pdo, $id);
// 这些动作不针对某一条反馈(不依赖 $t),单独放行
$noThread = in_array($act, ['settings', 'password', 'mail_test'], true)
|| fb_admin_is_taxonomy_act($act);
if (!$t && !$noThread) {
$msg = '问题不存在。';
$msgType = 'error';
} elseif ($act === 'approve') {
$newStatus = ((string)$t['status'] === 'closed') ? 'open' : (string)$t['status'];
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
->execute([$newStatus, time(), $id]);
fb_mod_log('approve', $id, '通过审核并公开');
$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 === 'mail_test') {
// 发送测试邮件:只验证 SMTP 通不通,不改任何配置
$to = trim((string)(isset($_POST['to']) ? $_POST['to'] : ''));
if ($to === '') { $to = trim((string)fb_cfg('admin_email', '')); }
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$msg = '请填写有效的收件邮箱。';
$msgType = 'error';
} else {
$sc = (array)fb_cfg('smtp', []);
$site = (string)fb_cfg('site_name', '轻反馈');
$body = '<p>这是一封来自 <b>' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8')
. '</b> 的测试邮件。</p>'
. '<p>如果你收到了它,说明 SMTP 配置正确,回复通知可以正常发出。</p>'
. '<p style="color:#888;font-size:12px">发送时间:'
. date('Y-m-d H:i:s') . '</p>';
list($ok, $err) = fb_smtp_send($sc, $to, '【' . $site . '】邮件测试', $body);
if ($ok) {
$msg = '测试邮件已发出,请查收 ' . $to . '(也看一下垃圾箱)。';
$msgType = 'ok';
} else {
$msg = '发送失败:' . $err;
$msgType = 'error';
}
}
} elseif (fb_admin_is_taxonomy_act($act)) {
list($msg, $msgType) = fb_admin_taxonomy_action($pdo, $act, $_POST);
} elseif ($act === 'settings') {
$msg = fb_admin_save_settings();
} elseif ($act === 'password') {
$old = (string)(isset($_POST['old_password']) ? $_POST['old_password'] : '');
$n1 = (string)(isset($_POST['new_password']) ? $_POST['new_password'] : '');
$n2 = (string)(isset($_POST['new_password2']) ? $_POST['new_password2'] : '');
// 从没设过口令(或仍是出厂占位值)时,跳过"当前口令"校验 —— 否则谁也进不来、又改不了
$firstSet = !fb_admin_password_is_set();
if (!$firstSet && !fb_admin_verify_password($old)) {
$msg = '当前口令不正确。';
$msgType = 'error';
} elseif (fb_len($n1) < 8) {
$msg = '新口令至少 8 个字符。';
$msgType = 'error';
} elseif ($n1 !== $n2) {
$msg = '两次输入的新口令不一致。';
$msgType = 'error';
} else {
fb_admin_set_password($n1);
fb_mod_log('password', null, $firstSet ? '首次设定登录口令(bcrypt 哈希存库)' : '修改登录口令');
$msg = $firstSet ? '口令已设定,下次登录请使用它。' : '口令已修改,下次登录请使用新口令。';
}
}
}
}
/**
* 保存站点设置(把表单值写进 settings 表;config.php 仍是出厂默认值)
* @return string 提示语
*/
function fb_admin_save_settings() {
// PHP 会把表单字段名里的「.」替换成「_」,所以两种写法都读一遍
$post = function ($k, $def = null) {
if (isset($_POST[$k])) { return $_POST[$k]; }
$alt = str_replace('.', '_', $k);
if (isset($_POST[$alt])) { return $_POST[$alt]; }
return $def;
};
// 站点信息(对外展示,用于说明「本系统为哪个软件提供反馈服务」)
// 留空 = 删除该设置项,回落到 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); }
}
// 页脚:是否显示「由 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); }
$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, ''));
// 这两个留空都有"出厂默认"兜底,必须删键而不是存空串:
// - oss.prefix 留空会让对象键变成 "/2026/...",默认是 'fb'
// - close_mail_subject 留空会让邮件标题变成空字符串,
// sprintf('') 出来的主题是空的,必须回落到默认模板
if (($k === 'oss.prefix' || $k === 'moderation.close_mail_subject') && $v === '') {
fb_unset_setting($k);
continue;
}
// RAM 角色留空 = 让程序自己去元数据服务探测,不写死一个空值
if ($k === 'oss.instance_role' && $v === '') {
list($rOk, $rVal) = fb_oss_detect_instance_role();
if ($rOk) { fb_set_setting($k, (string)$rVal); }
continue; // 探测不到就保持原值不动
}
fb_set_setting($k, $v);
}
$src = (string)$post('oss.credential_source', 'instance_role');
fb_set_setting('oss.credential_source', $src === 'static' ? 'static' : 'instance_role');
fb_set_setting('oss.access_key', trim((string)$post('oss.access_key', '')));
$sk = trim((string)$post('oss.secret_key', ''));
if ($sk !== '') { fb_set_setting('oss.secret_key', $sk); }
fb_set_setting('oss.visibility', ((string)$post('oss.visibility', 'public')) === 'private' ? 'private' : 'public');
fb_set_setting('oss.url_scheme', ((string)$post('oss.url_scheme', 'https')) === 'http' ? 'http' : 'https');
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
/* ---------------- 邮件通知(SMTP) ---------------- */
$smtpText = ['smtp.host', 'smtp.user', 'smtp.from', 'smtp.from_name'];
foreach ($smtpText as $k) {
fb_set_setting($k, trim((string)$post($k, '')));
}
// 口令留空 = 不修改(避免每次保存都得重填)
$sp = (string)(isset($_POST['smtp_pass']) ? $_POST['smtp_pass'] : '');
if (trim($sp) !== '') { fb_set_setting('smtp.pass', $sp); }
$smtpPort = (int)$post('smtp.port', 465);
if ($smtpPort <= 0 || $smtpPort > 65535) { $smtpPort = 465; }
fb_set_setting('smtp.port', $smtpPort);
$sec = strtolower(trim((string)$post('smtp.secure', 'ssl')));
if (!in_array($sec, ['ssl', 'tls', ''], true)) { $sec = 'ssl'; }
fb_set_setting('smtp.secure', $sec);
$smtpTo = (int)$post('smtp.timeout', 15);
fb_set_setting('smtp.timeout', max(5, min(60, $smtpTo)));
fb_set_setting('smtp.verify_ssl', (int)(bool)$post('smtp.verify_ssl'));
// 没填 from 时用 user 兜底(绝大多数 SMTP 要求两者一致)
if (trim((string)fb_cfg('smtp.from', '')) === '') {
fb_set_setting('smtp.from', trim((string)fb_cfg('smtp.user', '')));
}
// 只有 host 填了才允许开启,否则开着一个空配置只会每次发信都失败
$wantOn = (bool)$post('smtp.enabled');
$hostNow = trim((string)fb_cfg('smtp.host', ''));
fb_set_setting('smtp.enabled', ($wantOn && $hostNow !== '') ? 1 : 0);
if ($wantOn && $hostNow === '') {
$msg = '邮件通知未开启:请先填写 SMTP 服务器地址。';
$msgType = 'error';
}
/* ---------------- 通知开关 ---------------- */
fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled'));
fb_set_setting('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
fb_set_setting('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
$cd = (int)$post('notify.cooldown_seconds', 60);
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, $cd)));
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', 'taxonomy', 'statuses', '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 === 'taxonomy' ? ' on' : ''; ?>" href="admin.php?tab=taxonomy">软件与分类</a>
<a class="tab<?php echo $tab === 'statuses' ? ' on' : ''; ?>" href="admin.php?tab=statuses">处理状态</a>
<a class="tab<?php echo $tab === 'settings' ? ' on' : ''; ?>" href="admin.php?tab=settings">站点设置</a>
<a class="tab<?php echo $tab === 'log' ? ' on' : ''; ?>" href="admin.php?tab=log">操作日志</a>
</nav>
<?php fb_flash_box($msg, $msgType === 'ok' ? 'ok' : 'error'); ?>
<?php if ($tab === 'pending'): ?>
<?php
$pending = $pdo->query(
"SELECT id, title, body, category, status, author_name, author_email, created_at
FROM threads WHERE visible = 0 AND status <> 'closed' ORDER BY created_at DESC"
)->fetchAll();
?>
<h2 style="margin:22px 0 10px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2>
<?php if (!$pending): ?>
<div class="empty"><p>当前没有待审核的反馈。</p></div>
<?php else: ?>
<?php foreach ($pending as $t) { echo fb_admin_thread_card($t); } ?>
<?php endif; ?>
<?php elseif ($tab === 'all'): ?>
<?php
$where = [];
$args = [];
$stF = (string)(isset($_GET['status']) ? $_GET['status'] : '');
$visF = (string)(isset($_GET['vis']) ? $_GET['vis'] : '');
$catF = (string)(isset($_GET['cat']) ? $_GET['cat'] : '');
$qF = trim((string)(isset($_GET['q']) ? $_GET['q'] : ''));
$swF = isset($_GET['sw']) ? (int)$_GET['sw'] : 0;
$cidF = isset($_GET['cid']) ? (int)$_GET['cid'] : 0;
if ($stF !== '') { $where[] = 'status = ?'; $args[] = $stF; }
if ($visF === '1' || $visF === '0') { $where[] = 'visible = ?'; $args[] = (int)$visF; }
if ($swF > 0) { $where[] = 'software_id = ?'; $args[] = $swF; }
if ($cidF > 0) { $where[] = 'category_id = ?'; $args[] = $cidF; }
if ($qF !== '') { $where[] = '(title LIKE ? OR body LIKE ?)'; $args[] = '%' . $qF . '%'; $args[] = '%' . $qF . '%'; }
$wSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$cntSt = $pdo->prepare('SELECT COUNT(*) FROM threads ' . $wSql);
$cntSt->execute($args);
$total = (int)$cntSt->fetchColumn();
$pageSize = max(5, (int)fb_cfg('page_size', 20));
$pages = max(1, (int)ceil($total / $pageSize));
$page = min(max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1)), $pages);
$offset = ($page - 1) * $pageSize;
$lst = $pdo->prepare('SELECT * FROM threads ' . $wSql . ' ORDER BY updated_at DESC LIMIT ' . (int)$pageSize . ' OFFSET ' . (int)$offset);
$lst->execute($args);
$rows = $lst->fetchAll();
?>
<form class="filters" method="get" action="admin.php">
<input type="hidden" name="tab" value="all">
<input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
<select name="sw" onchange="this.form.submit()">
<option value="0">全部软件</option>
<?php foreach (fb_softwares(false) as $sw1): ?>
<option value="<?php echo (int)$sw1['id']; ?>"<?php echo $swF === (int)$sw1['id'] ? ' selected' : ''; ?>><?php echo e((string)$sw1['name']); ?></option>
<?php endforeach; ?>
</select>
<select name="status">
<option value="">全部状态</option>
<?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo $stF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
<select name="cid">
<option value="0">全部分类</option>
<?php
// 选了某个软件就只列它的分类;否则把所有软件的分类按软件分组列出
$catSwList = $swF > 0 ? [fb_software($swF)] : fb_softwares(false);
foreach ($catSwList as $csw1):
if (!$csw1) { continue; }
foreach (fb_categories((int)$csw1['id'], false) as $c1):
?>
<option value="<?php echo (int)$c1['id']; ?>"<?php echo $cidF === (int)$c1['id'] ? ' selected' : ''; ?>><?php echo e(($swF > 0 ? '' : (string)$csw1['name'] . ' · ') . (string)$c1['name']); ?></option>
<?php endforeach; endforeach; ?>
</select>
<select name="vis">
<option value="">公开与否不限</option>
<option value="1"<?php echo $visF === '1' ? ' selected' : ''; ?>>已公开</option>
<option value="0"<?php echo $visF === '0' ? ' selected' : ''; ?>>未公开</option>
</select>
<button class="btn" type="submit">筛选</button>
<a class="btn" href="admin.php?tab=all">重置</a>
</form>
<h2 style="margin:16px 0 10px">全部问题 <span class="muted">(<?php echo $total; ?> 条,第 <?php echo $page; ?>/<?php echo $pages; ?> 页)</span></h2>
<?php if (!$rows): ?>
<div class="empty"><p>没有符合条件的问题。</p></div>
<?php else: ?>
<div class="list">
<?php foreach ($rows as $t): ?>
<div class="item" style="cursor:default">
<div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<a class="item-title" href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>"><?php echo e($t['title']); ?></a>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
<?php if ((int)$t['visible'] !== 1): ?><span class="badge st st-closed">未公开</span><?php endif; ?>
</div>
<p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo (int)$t['reply_count']; ?> 条回复</span>
<span class="dot">·</span>
<a href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>">管理</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($pages > 1): ?>
<div class="pager">
<?php if ($page > 1): ?><a class="btn" href="admin.php?<?php echo e(http_build_query(array_merge($_GET, ['tab' => 'all', 'page' => $page - 1]))); ?>">上一页</a><?php endif; ?>
<?php if ($page < $pages): ?><a class="btn" href="admin.php?<?php echo e(http_build_query(array_merge($_GET, ['tab' => 'all', 'page' => $page + 1]))); ?>">下一页</a><?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php elseif ($tab === 'thread'): ?>
<?php
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
$t = fb_admin_thread($pdo, $id);
if (!$t): ?>
<div class="empty"><p>问题不存在。<a href="admin.php?tab=all">返回列表</a></p></div>
<?php else:
$rs = $pdo->prepare('SELECT * FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
$rs->execute([$id]);
$replies = $rs->fetchAll();
$atts = fb_attach_for_thread($pdo, $id);
?>
<div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div>
<div class="card-form" style="margin:0 0 16px">
<div class="item-top">
<?php
$dSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
$dCat = !empty($t['category_id']) ? fb_category((int)$t['category_id']) : null;
?>
<?php if ($dSw): ?><span class="badge sw"><?php echo e((string)$dSw['name']); ?></span><?php endif; ?>
<?php if ($dCat): ?><span class="badge cat"><?php echo e((string)$dCat['name']); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
<?php if (!empty($atts['main'])) { echo fb_attach_render($atts['main']); } ?>
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e($t['author_email']); ?></span>
<?php if (!empty($t['author_phone'])): ?><span class="dot">·</span><span><?php echo e($t['author_phone']); ?></span><?php endif; ?>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo ((int)$t['visible'] === 1) ? '已公开' : '未公开'; ?></span>
</div>
</div>
<?php foreach ($replies as $r): ?>
<div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>">
<div class="reply-head">
<span class="who"><?php echo e($r['author_name']); ?></span>
<?php if ((int)$r['is_admin'] === 1): ?><span class="badge official">官方</span><?php endif; ?>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($r['created_at'])); ?></span>
</div>
<div class="reply-body"><?php echo nl2br(e($r['body'])); ?></div>
<?php if (isset($atts['replies'][(int)$r['id']])) { echo fb_attach_render($atts['replies'][(int)$r['id']], ['compact' => true]); } ?>
</div>
<?php endforeach; ?>
<h2 style="margin:24px 0 10px">归属</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<p class="muted" style="margin:0 0 10px;font-size:13.5px">
挂错软件或分类时在这里改正。换软件后分类选项会跟着变,需重新选择。
</p>
<div class="row2">
<label class="field">
<span>所属软件</span>
<select name="software_id" onchange="location.href='admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>&amp;sw='+this.value">
<?php foreach (fb_softwares(false) as $m1): ?>
<option value="<?php echo (int)$m1['id']; ?>"<?php echo (int)(isset($t['software_id']) ? $t['software_id'] : 0) === (int)$m1['id'] ? ' selected' : ''; ?>><?php echo e((string)$m1['name']); ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>分类</span>
<select name="category_id">
<option value="0">(不指定)</option>
<?php
$mvSw = isset($_GET['sw']) ? (int)$_GET['sw']
: (int)(isset($t['software_id']) ? $t['software_id'] : 0);
if ($mvSw <= 0) { $d0 = fb_default_software(); $mvSw = $d0 ? (int)$d0['id'] : 0; }
foreach (fb_categories($mvSw, false) as $m2):
?>
<option value="<?php echo (int)$m2['id']; ?>"<?php echo (int)(isset($t['category_id']) ? $t['category_id'] : 0) === (int)$m2['id'] ? ' selected' : ''; ?>><?php echo e((string)$m2['name']); ?></option>
<?php endforeach; ?>
</select>
</label>
</div>
<button class="btn" name="act" value="th_move" type="submit">保存归属</button>
</form>
<h2 style="margin:24px 0 10px">以官方身份回复</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<label class="field">
<span>回复内容</span>
<textarea name="body" rows="5" maxlength="5000" placeholder="写给提问人的答复;提交后会给对方发一封通知邮件"></textarea>
</label>
<label class="field">
<span>同时把状态改为</span>
<select name="status">
<option value="">(不改)</option>
<?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="reply">发布官方回复</button>
</div>
</form>
<h2 style="margin:24px 0 10px">状态与可见性</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<div class="row2">
<label class="field">
<span>处理状态</span>
<select name="status">
<?php foreach (fb_status_keys() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo ((string)$t['status'] === $k) ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>公开显示</span>
<label class="check"><input type="checkbox" name="visible" value="1"<?php echo ((int)$t['visible'] === 1) ? ' checked' : ''; ?>> 在公开列表中显示</label>
</label>
</div>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="status">保存</button>
<?php if ((int)$t['visible'] !== 1): ?>
<button class="btn" type="submit" name="act" value="approve">通过并公开</button>
<?php else: ?>
<button class="btn" type="submit" name="act" value="hide">撤下</button>
<?php endif; ?>
</div>
</form>
<h2 style="margin:24px 0 10px">关闭(判定不是应用问题,会发邮件给提问人)</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<label class="field">
<span>关闭说明 <b class="req">*</b> <i class="hint">会作为邮件正文发给 <?php echo e($t['author_email']); ?></i></span>
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象来自服务器端的 CalDAV 限制,并非应用问题;建议…"></textarea>
</label>
<div class="form-actions">
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
</div>
</form>
<form method="post" action="admin.php?tab=all" class="card-form" style="margin-top:24px"
onsubmit="return confirm('确定删除 #<?php echo (int)$id; ?>?该问题的回复与附件会一并删除,且不可恢复。');">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<div class="form-actions">
<button class="btn danger" type="submit" name="act" value="delete">删除这条问题</button>
</div>
</form>
<?php endif; ?>
<?php elseif ($tab === 'taxonomy'): ?>
<?php fb_admin_taxonomy_page($pdo,
isset($_GET['edit_sw']) ? (int)$_GET['edit_sw'] : 0,
isset($_GET['edit_cat']) ? (int)$_GET['edit_cat'] : 0); ?>
<?php elseif ($tab === 'statuses'): ?>
<?php fb_admin_status_page($pdo, isset($_GET['edit_st']) ? (int)$_GET['edit_st'] : 0); ?>
<?php elseif ($tab === 'settings'): ?>
<?php
$mode = fb_store_mode();
$ossStat = fb_oss_cred_status();
$mb = function ($bytes) { return round(((int)$bytes) / 1048576, 1); };
?>
<form method="post" action="admin.php?tab=settings" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<h2 style="margin:8px 0 10px">站点信息</h2>
<label class="field"><span>站点标题</span>
<input type="text" name="site_name" maxlength="80" placeholder="例如:我的软件 · 问题反馈"
value="<?php echo e(fb_form_val(fb_cfg('site_name', ''), '轻反馈', 'site_name')); ?>">
<i class="hint">显示在浏览器标签、页面顶栏与页脚。用它说明「本系统为哪个软件提供反馈服务」,如「XX 软件 · 问题反馈」。</i></label>
<label class="field"><span>站点描述</span>
<textarea name="site_desc" rows="3" maxlength="300"
placeholder="欢迎在这里反馈使用问题、提出功能建议。"><?php echo e((string)fb_cfg('site_desc', '')); ?></textarea>
<i class="hint">显示在首页标题下方,一句话说明这里可以反馈什么。两项都留空则恢复为出厂默认。</i></label>
<h2 style="margin:24px 0 10px">页脚</h2>
<label class="check"><input type="checkbox" name="footer_show_credit" value="1"<?php echo fb_cfg('footer.show_credit', true) ? ' checked' : ''; ?>> 在页脚显示「由 FeedLite 驱动」
<span class="hint" style="display:block;margin:2px 0 0 24px">取消勾选后,这句话连同链接都不再出现在页脚,只保留「管理后台」入口。</span></label>
<label class="field"><span>FeedLite 链接地址</span>
<input type="text" name="footer_credit_url" maxlength="300"
value="<?php echo e((string)fb_cfg('footer.credit_url', '') !== '' ? (string)fb_cfg('footer.credit_url') : fb_credit_url_default()); ?>">
<i class="hint">页脚「FeedLite」二字指向的地址,默认是本项目开源仓库。只接受 http:// 或 https:// 开头的网址;填回默认值即恢复。</i></label>
<h2 style="margin:24px 0 10px">邮件通知</h2>
<?php
$smOn = (bool)fb_cfg('smtp.enabled', false);
$smHost = (string)fb_cfg('smtp.host', '');
$smUser = (string)fb_cfg('smtp.user', '');
$smFrom = (string)fb_cfg('smtp.from', '');
$smName = (string)fb_cfg('smtp.from_name', '');
$smPort = (int)fb_cfg('smtp.port', 465);
$smSec = (string)fb_cfg('smtp.secure', 'ssl');
$smHasPw = (string)fb_cfg('smtp.pass', '') !== '';
$smTmo = (int)fb_cfg('smtp.timeout', 15);
$smVerify= (bool)fb_cfg('smtp.verify_ssl', true);
?>
<label class="check"><input type="checkbox" name="smtp_enabled" value="1"<?php echo $smOn ? ' checked' : ''; ?>> 开启邮件通知
<span class="hint" style="display:block;margin:2px 0 0 24px">开启后,有人回复你反馈的问题会发信通知提交人。填好下方参数并用「发送测试邮件」验证通了再勾。</span></label>
<div class="grid2">
<label class="field"><span>SMTP 服务器</span>
<input type="text" name="smtp_host" maxlength="120" placeholder="smtp.qiye.aliyun.com"
value="<?php echo e($smHost); ?>">
<i class="hint">阿里云企业邮 smtp.qiye.aliyun.com;腾讯企业邮 smtp.exmail.qq.com;QQ 邮箱 smtp.qq.com。</i></label>
<label class="field"><span>端口与加密</span>
<div style="display:flex;gap:8px">
<input type="number" name="smtp_port" min="1" max="65535" style="flex:0 0 110px"
value="<?php echo (int)$smPort; ?>">
<select name="smtp_secure" style="flex:1">
<option value="ssl"<?php echo $smSec === 'ssl' ? ' selected' : ''; ?>>SSL(通常 465)</option>
<option value="tls"<?php echo $smSec === 'tls' ? ' selected' : ''; ?>>STARTTLS(通常 587)</option>
<option value=""<?php echo $smSec === '' ? ' selected' : ''; ?>>不加密(通常 25,不推荐)</option>
</select>
</div></label>
</div>
<div class="grid2">
<label class="field"><span>登录账号</span>
<input type="text" name="smtp_user" maxlength="120" placeholder="noreply@example.com"
value="<?php echo e($smUser); ?>"></label>
<label class="field"><span>密码 / 授权码 <i class="hint" style="display:inline"><?php echo $smHasPw ? '已设置,留空不修改' : '未设置'; ?></i></span>
<input type="password" name="smtp_pass" autocomplete="new-password"
placeholder="<?php echo $smHasPw ? '留空保持不变' : '邮箱密码或授权码'; ?>"></label>
</div>
<div class="grid2">
<label class="field"><span>发件地址</span>
<input type="email" name="smtp_from" maxlength="120" placeholder="留空则同登录账号"
value="<?php echo e($smFrom); ?>"></label>
<label class="field"><span>发件人显示名称</span>
<input type="text" name="smtp_from_name" maxlength="60"
placeholder="<?php echo e((string)fb_cfg('site_name', '轻反馈')); ?>"
value="<?php echo e($smName); ?>"></label>
</div>
<details style="margin:6px 0 12px">
<summary style="cursor:pointer;font-size:13px;color:var(--muted)">高级选项</summary>
<div class="grid2" style="margin-top:8px">
<label class="field"><span>超时(秒)</span>
<input type="number" name="smtp_timeout" min="5" max="60" value="<?php echo (int)$smTmo; ?>"></label>
<label class="check" style="align-self:end"><input type="checkbox" name="smtp_verify_ssl" value="1"<?php echo $smVerify ? ' checked' : ''; ?>> 校验服务器 SSL 证书
<span class="hint" style="display:block;margin-left:24px">服务器缺 CA 证书时可临时取消,但不建议。</span></label>
</div>
</details>
<?php if ($smHost !== ''): ?>
<div class="mail-test">
<span class="muted" style="font-size:13.5px">配好后先发一封测试邮件确认能通:</span>
<input type="email" id="mailTestTo" placeholder="收件邮箱,默认发给自己"
value="<?php echo e((string)fb_cfg('admin_email', '')); ?>">
<button type="button" class="btn" id="mailTestBtn" data-csrf="<?php echo e(fb_csrf_token()); ?>">发送测试邮件</button>
<span id="mailTestOut" class="mail-test-out"></span>
</div>
<?php endif; ?>
<h3 style="margin:20px 0 8px;font-size:15px">通知规则</h3>
<label class="check"><input type="checkbox" name="notify_enabled" value="1"<?php echo fb_cfg('notify.enabled', true) ? ' checked' : ''; ?>> 有人回复时通知提交人</label>
<label class="check"><input type="checkbox" name="notify_on_every_reply" value="1"<?php echo fb_cfg('notify.on_every_reply', false) ? ' checked' : ''; ?>> 每次回复都通知(默认只在官方回复时通知,避免刷屏)</label>
<label class="check"><input type="checkbox" name="notify_admin_new_thread" value="1"<?php echo fb_cfg('notify.admin_new_thread', false) ? ' checked' : ''; ?>> 有新反馈时通知管理员
<span class="hint" style="display:block;margin-left:24px">发到「站点信息」里填的官方邮箱。</span></label>
<label class="field" style="max-width:280px"><span>同一帖同一收件人的最短发信间隔(秒)</span>
<input type="number" name="notify_cooldown_seconds" min="0" max="86400"
value="<?php echo (int)fb_cfg('notify.cooldown_seconds', 60); ?>">
<i class="hint">0 = 不限制。</i></label>
<h2 style="margin:24px 0 10px">附件存储</h2>
<label class="field">
<span>附件保存在哪</span>
<select name="attachment_storage">
<option value="off"<?php echo $mode === 'off' ? ' selected' : ''; ?>>关闭(不开放上传)</option>
<option value="local"<?php echo $mode === 'local' ? ' selected' : ''; ?>>本机硬盘(站点 uploads/ 目录)</option>
<option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(私有桶 · 服务器代理)</option>
</select>
<i class="hint">本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。<b>前台展示一律经 media.php 代理</b>——无论存哪,页面上的图片/视频地址都是 <code>media.php?k=…</code>,不暴露 uploads/ 真实路径,也不出现任何 OSS 桶信息。</i>
</label>
<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="any" min="10" max="100000" 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>
<p class="muted small" style="margin:-4px 0 12px">私有桶 + 服务器代理:浏览器不直连桶,附件读写都经本站 upload.php / media.php 中转。Endpoint 填公网或内网形式都可以,程序按下方开关自动转换。</p>
<div class="grid2">
<label class="field"><span>Endpoint</span>
<input type="text" name="oss_endpoint" placeholder="oss-cn-qingdao.aliyuncs.com" value="<?php echo e((string)fb_cfg('oss.endpoint', '')); ?>"></label>
<label class="field"><span>Bucket 名</span>
<input type="text" name="oss_bucket" value="<?php echo e((string)fb_cfg('oss.bucket', '')); ?>"></label>
<label class="field"><span>鉴权方式</span>
<select name="oss_credential_source">
<option value="instance_role"<?php echo fb_oss_credential_source() === 'instance_role' ? ' selected' : ''; ?>>ECS 实例 RAM 角色(推荐)</option>
<option value="static"<?php echo fb_oss_credential_source() === 'static' ? ' selected' : ''; ?>>静态 AccessKey</option>
</select></label>
<?php
// RAM 角色名不该让用户去控制台查 —— 直接问 ECS 元数据服务。
// 已配置的显示配置值;没配置就现场探测一次作为占位提示。
$curRole = trim((string)fb_cfg('oss.instance_role', ''));
$detect = null;
if ($curRole === '') {
list($dOk, $dVal) = fb_oss_detect_instance_role();
$detect = [$dOk, $dVal];
}
?>
<div class="field">
<span>ECS 实例 RAM 角色</span>
<?php if ($curRole !== ''): ?>
<div class="role-ok">已绑定:<b><?php echo e($curRole); ?></b>
<i class="hint" style="display:inline;margin-left:6px">(自动从实例元数据读取,无需手填)</i>
</div>
<?php elseif ($detect && $detect[0]): ?>
<div class="role-ok">自动探测到:<b><?php echo e((string)$detect[1]); ?></b>
<i class="hint" style="display:inline;margin-left:6px">保存时若留空就自动用这个</i>
</div>
<?php else: ?>
<div class="role-warn">
<?php echo e($detect ? (string)$detect[1] : '未能自动探测'); ?>
</div>
<?php endif; ?>
<details style="margin-top:6px">
<summary style="cursor:pointer;font-size:13px;color:var(--muted)">手动指定角色名(一般不需要)</summary>
<input type="text" name="oss_instance_role" style="margin-top:6px"
placeholder="留空即自动探测"
value="<?php echo e($curRole); ?>">
</details>
</div>
<label class="field"><span>AccessKeyId(静态鉴权用)</span>
<input type="text" name="oss_access_key" value="<?php echo e((string)fb_cfg('oss.access_key', '')); ?>"></label>
<label class="field"><span>AccessKeySecret(留空=不修改)</span>
<input type="password" name="oss_secret_key" autocomplete="new-password"
placeholder="<?php echo (string)fb_cfg('oss.secret_key', '') !== '' ? '已设置,留空保持不变' : '未设置'; ?>"></label>
<label class="field"><span>对象键前缀</span>
<input type="text" name="oss_prefix" placeholder="留空即用 fb"
value="<?php echo e(fb_form_val(fb_cfg('oss.prefix', 'fb'), 'fb', 'oss.prefix')); ?>"></label>
<label class="field"><span>公网访问协议(走公网 Endpoint 时)</span>
<select name="oss_url_scheme">
<option value="https"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'https' ? ' selected' : ''; ?>>https</option>
<option value="http"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'http' ? ' selected' : ''; ?>>http</option>
</select></label>
</div>
<label class="check"><input type="checkbox" name="oss_use_internal_endpoint" value="1"<?php echo fb_oss_use_internal() ? ' checked' : ''; ?>> 使用内网 Endpoint(<code>*-internal.aliyuncs.com</code>,免公网流量费、延迟低)
<span class="hint" style="display:block;margin:2px 0 0 24px">⚠️ 仅当【桶与本机 ECS 同地域】时可用;跨地域内网不可达,请取消勾选回落公网,或改用同地域的桶。保存后可在下方「存储体检」查看连通性。</span></label>
<label class="check"><input type="checkbox" name="oss_verify" value="1"<?php echo fb_cfg('oss.verify', true) ? ' checked' : ''; ?>> 提交时回源复核附件(防伪造)</label>
<label class="check"><input type="checkbox" name="oss_image_process" value="1"<?php echo fb_cfg('oss.image_process', true) ? ' checked' : ''; ?>> 缩略图使用 OSS 图片处理(服务端缩放后再转发,省下行流量)</label>
<input type="hidden" name="oss_visibility" value="<?php echo e((string)fb_cfg('oss.visibility', 'private')); ?>">
<h2 style="margin:24px 0 10px">审核</h2>
<label class="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需你通过后才显示)</label>
<label class="field"><span>关闭通知邮件标题</span>
<input type="text" name="moderation_close_mail_subject"
placeholder="留空即用:关于你反馈的问题《%s》"
value="<?php echo e(fb_form_val(fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》'), '关于你反馈的问题《%s》', 'moderation.close_mail_subject')); ?>">
<i class="hint">%s 会替换成问题标题。留空则用默认模板。</i></label>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="settings">保存设置</button>
</div>
</form>
<h2 style="margin:26px 0 10px">存储体检</h2>
<div class="card-form">
<div style="display:flex;flex-direction:column;gap:6px">
<span>当前存储方式:<b><?php echo $mode === 'oss' ? '阿里云 OSS' : ($mode === 'local' ? '本机硬盘' : '已关闭'); ?></b></span>
<?php if ($mode === 'local'): ?>
<?php $ld = fb_local_diag(); ?>
<span>上传目录:<code><?php echo e($ld['dir']); ?></code> — <?php echo $ld['ok'] ? '可写 ✅' : '不可写 ❌'; ?></span>
<?php if (!$ld['ok']): ?>
<span class="muted small"><?php echo e($ld['tip']); ?></span>
<?php endif; ?>
<?php elseif ($mode === 'oss'): ?>
<span>鉴权方式:<b><?php echo e($ossStat['label']); ?></b></span>
<span>凭证状态:<?php echo !empty($ossStat['ok']) ? ('已取得 AccessKey(尾号 ' . e($ossStat['ak_tail']) . ')✅') : ('未取得 ❌ ' . e((string)$ossStat['error'])); ?></span>
<span>Bucket:<?php echo e((string)fb_cfg('oss.bucket', '')); ?></span>
<span>Endpoint(配置):<?php echo e((string)fb_cfg('oss.endpoint', '')); ?></span>
<span>Endpoint(实际连接):<code><?php echo e(fb_oss_host()); ?></code> — <?php echo fb_oss_use_internal() ? '内网' : '公网'; ?></span>
<?php if (isset($_GET['probe']) && $_GET['probe'] === '1'): ?>
<?php $pb = fb_oss_probe(); ?>
<span>连通性:<?php echo $pb['ok'] ? '✅' : '❌'; ?> <?php echo e($pb['detail']); ?><?php echo $pb['status'] ? ('(HTTP ' . (int)$pb['status'] . ')') : ''; ?></span>
<?php if (!$pb['ok'] && $pb['internal'] && $pb['status'] === 0): ?>
<span class="muted small">↳ 内网 Endpoint 跨地域不可达:请取消勾选上方「使用内网 Endpoint」后保存,或把桶换到与本机同地域。</span>
<?php endif; ?>
<?php else: ?>
<span><a class="btn" style="padding:4px 10px" href="admin.php?tab=settings&amp;probe=1">测一下桶连通性</a>
<span class="muted small">(会真实访问一次 OSS,跨地域不通时可能等待数秒)</span></span>
<?php endif; ?>
<?php endif; ?>
<span>已用附件:<?php echo e(fb_size_h(fb_attach_total_bytes($pdo))); ?> / 上限 <?php echo e(fb_size_h(fb_upload_cfg('total_cap_bytes', 0))); ?></span>
</div>
</div>
<h2 style="margin:26px 0 10px">登录口令</h2>
<div class="card-form" style="margin:0 0 16px">
<?php $pwSrc = fb_admin_password_source(); ?>
<div style="display:flex;flex-direction:column;gap:6px">
<span>存储位置:
<?php if ($pwSrc === 'db'): ?>
数据库 <code>settings</code> 表(键名 <code>admin.password_hash</code>)
<?php elseif ($pwSrc === 'file'): ?>
<code>config.php</code> 的 <code>admin_password</code>
<?php else: ?>
<b style="color:#b45309">尚未设置</b>
<?php endif; ?>
</span>
<span>是否加密:
<?php if ($pwSrc === 'db'): ?>
<b>是</b> —— 存的是 <code>password_hash()</code> 生成的 bcrypt 单向哈希(<code>$2y$…</code>),
不可逆、无法还原明文;校验用 <code>password_verify()</code>。
<?php elseif ($pwSrc === 'file'): ?>
<b>否</b> —— 目前是明文写在 <code>config.php</code> 里。
<a href="#pwform">在下面改一次口令</a>后即会转存为数据库中的 bcrypt 哈希。
<?php else: ?>
—— 还没有可用口令,当前后台<strong>无法登录</strong>。请用下方表单设定一个。
<?php endif; ?>
</span>
<?php if ($pwSrc === 'db'): ?>
<span class="muted small">数据库文件在 <code>data/feedback.db</code>(已由 .htaccess 拒绝外部访问),且只存哈希,明文不落盘。</span>
<?php endif; ?>
</div>
</div>
<div id="pwform">
<form method="post" action="admin.php?tab=settings" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<?php if ($pwSrc !== 'none'): ?>
<div class="grid2">
<label class="field"><span>当前口令</span><input type="password" name="old_password" autocomplete="current-password"></label>
<label class="field"><span>新口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label>
<label class="field"><span>再输入一次新口令</span><input type="password" name="new_password2" autocomplete="new-password"></label>
</div>
<?php else: ?>
<div class="grid2">
<label class="field"><span>设定登录口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label>
<label class="field"><span>再输入一次</span><input type="password" name="new_password2" autocomplete="new-password"></label>
</div>
<?php endif; ?>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="password"><?php echo $pwSrc === 'none' ? '设定口令' : '修改口令'; ?></button>
</div>
<p class="muted small">新口令以 bcrypt 哈希写入数据库,优先级高于 <code>config.php</code> 的 <code>admin_password</code>;改完立即生效,明文不会留在任何文件里。</p>
</form>
</div>
<?php else: ?>
<?php
$logs = $pdo->query('SELECT * FROM mod_log ORDER BY created_at DESC, id DESC LIMIT 100')->fetchAll();
?>
<h2 style="margin:8px 0 10px">最近操作 <span class="muted">(<?php echo count($logs); ?> 条)</span></h2>
<?php if (!$logs): ?>
<div class="empty"><p>还没有操作记录。</p></div>
<?php else: ?>
<div class="list">
<?php foreach ($logs as $l): ?>
<div class="item" style="cursor:default">
<div class="item-top">
<span class="badge cat"><?php echo e((string)$l['action']); ?></span>
<?php if (!empty($l['thread_id'])): ?>
<a class="item-title" href="admin.php?tab=thread&amp;id=<?php echo (int)$l['thread_id']; ?>">#<?php echo (int)$l['thread_id']; ?></a>
<?php endif; ?>
</div>
<p class="item-ex"><?php echo e((string)$l['detail']); ?></p>
<div class="item-meta"><span><?php echo e(fb_fmt_time($l['created_at'])); ?></span></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php
fb_layout_foot();
/**
* 待审核卡片(含「通过」与「关闭并邮件告知」两个操作)
*/
function fb_admin_thread_card(array $t) {
ob_start(); ?>
<div class="card-form" style="margin:0 0 16px">
<div class="item-top">
<?php $cSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null; ?>
<?php if ($cSw): ?><span class="badge sw"><?php echo e((string)$cSw['name']); ?></span><?php endif; ?>
<?php if (!empty($t['category_id'])): ?>
<span class="badge cat"><?php echo e(fb_category_label((int)$t['category_id'])); ?></span>
<?php endif; ?>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo e($t['author_email']); ?></span>
<span class="dot">·</span>
<a href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>">进入管理</a>
</div>
<div class="row2" style="margin-top:14px">
<form method="post" action="admin.php?tab=pending">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="approve">通过并公开</button>
</div>
</form>
<form method="post" action="admin.php?tab=pending">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
<label class="field">
<span>关闭说明 <b class="req">*</b> <i class="hint">判定"不是问题"时填写,会随邮件发给提问人</i></span>
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象是服务器端的 CalDAV 限制,并非应用问题;建议改用…"></textarea>
</label>
<div class="form-actions">
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
</div>
</form>
</div>
</div>
<?php
return ob_get_clean();
}