用户要求:附件存储里所有限制项(几张图、几个视频、大小、总量、保留天数、 每小时次数)都能填 0,0 表示没有任何限制。 ## 最危险的地方:填 0 原本是「全禁」不是「不限」 两处逻辑把 0 判成了禁止,语义正好相反: 1. `fb_rate_check()` —— `count < limit`,limit=0 时 `0 < 0` 为假 ⇒ 每小时上传次数填 0 = 一次都不许传 2. 每帖数量校验 —— `count > max`,max=0 时传一个就超限 ⇒ 每帖图片填 0 = 一张都不许传 都改成「limit <= 0 直接放行」。 ## 校验区间与 input 下限全部放开到 0 | 字段 | 原下限 | 现下限 | |---|---|---| | image_max_per_post | 0(但界面上限 20) | 0,上限 1000 | | video_max_per_post | 0(上限 10) | 0,上限 1000 | | image_max_bytes | 0.2 MB | 0,上限 1024 MB | | video_max_bytes | 1 MB | 0,上限 10240 MB | | total_cap_bytes | 10 MB | 0,上限 1048576 MB | | retention_days | 1 天 | 0,上限 36500 | | upload_per_hour | 1 次 | 0,上限 100000 | | image_max_side | 480 | **保持 480** —— 这是压缩目标边长,不是限制项 | 服务端 `max($range[0], ...)` 不再把 0 拉回下限;input 的 min 一并改 0, 否则浏览器直接拦住连提交都到不了后端。 ## 语义冲突:0=不限与「0=关闭该类上传」冲突 原先 `fb_upload_kind_on()` 靠「数量上限是否为 0」判断开关, 0 现在表示不限个数,两者冲突。 按用户选择:**新增独立总开关** `upload.image_on` / `upload.video_on`(默认开), `fb_upload_kind_on()` 改看开关,不再看数量。 两个新键已加入 `fb_setting_keys()` 白名单(这个坑踩过多次)。 ## 前端同步 - `maxCount > 0` 才拦,0 不拦 - 提示文案:数量 0 显示「数量不限」、大小 0 显示「大小不限」, 不再出现「最多 0 张」这种荒唐文案 ## 风险提示(按用户选择用文字告知,不阻止保存) 单张图片/单个视频/全站总量填 0 时,字段下方提示「填 0 时注意:磁盘可能被写满」。 ## 验证 0 专项 31/31,关键几条: - 七项输入框 min 均为 0,image_max_side 保持 480 - 填 0 后数据库里就是 0,没被拉回下限 - **填 0 后行为是放行**:每小时放行、数量不限、总量放行、不清理 - 数量填 0 时上传仍是开的(靠独立开关) - 取消勾选开关后确实关闭,且与数量填 0 互不干扰 - 非 0 值全部正确(4 张 / 2 个 / 5MB / 50MB / 1024MB / 30 天 / 60 次) 回归全绿:全新安装 33/33、附件 22/22、老基线 25/25、安装向导 60/60、约束检查 20/20。
1166 lines
65 KiB
PHP
1166 lines
65 KiB
PHP
<?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 = [
|
||
// 区间下限统一改 0:0 = 不限制(见 #4)。
|
||
// 只有 image_max_side 例外 —— 边长 0 没有意义,它是压缩目标不是限制。
|
||
'upload.image_max_per_post' => [0, 1000],
|
||
'upload.video_max_per_post' => [0, 1000],
|
||
'upload.retention_days' => [0, 36500],
|
||
'upload.upload_per_hour' => [0, 100000],
|
||
'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);
|
||
}
|
||
// 图片/视频上传总开关(0=不限个数后,开关必须独立存在)
|
||
fb_set_setting('upload.image_on', (int)(bool)$post('upload.image_on'));
|
||
fb_set_setting('upload.video_on', (int)(bool)$post('upload.video_on'));
|
||
|
||
// MB → 字节
|
||
// 下限改 0 = 不限制(见 #4)
|
||
$mbs = [
|
||
'upload.image_max_bytes' => [0, 1024],
|
||
'upload.video_max_bytes' => [0, 10240],
|
||
'upload.total_cap_bytes' => [0, 1048576],
|
||
];
|
||
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); }
|
||
|
||
// 端口留空 = 用默认 465。删键回落,不要存成 0(0 端口根本连不上)
|
||
$portRaw = trim((string)$post('smtp.port', ''));
|
||
if ($portRaw === '') {
|
||
fb_unset_setting('smtp.port');
|
||
} else {
|
||
$smtpPort = (int)$portRaw;
|
||
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);
|
||
|
||
// 超时留空 = 用默认 15
|
||
$tmoRaw = trim((string)$post('smtp.timeout', ''));
|
||
if ($tmoRaw === '') {
|
||
fb_unset_setting('smtp.timeout');
|
||
} else {
|
||
fb_set_setting('smtp.timeout', max(5, min(60, (int)$tmoRaw)));
|
||
}
|
||
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'));
|
||
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
|
||
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
|
||
$cdRaw = trim((string)$post('notify.cooldown_seconds', ''));
|
||
if ($cdRaw === '') {
|
||
fb_unset_setting('notify.cooldown_seconds');
|
||
} else {
|
||
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw)));
|
||
}
|
||
|
||
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', 'mail', '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 === 'mail' ? ' on' : ''; ?>" href="admin.php?tab=mail">邮件通知</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&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">
|
||
<?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&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&id=<?php echo (int)$id; ?>&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&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&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&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>
|
||
<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>
|
||
|
||
<?php
|
||
// 所有「限制类」输入框:min=0,填 0 = 不限制(见 #4)。
|
||
// 只有 image_max_side 例外 —— 它是压缩目标边长,0 没有意义。
|
||
$zero = '<i class="hint">填 0 表示不限制。</i>';
|
||
?>
|
||
<label class="check"><input type="checkbox" name="upload_image_on" value="1"<?php echo fb_upload_cfg('image_on', true) ? ' checked' : ''; ?>> 允许上传图片
|
||
<span class="hint" style="display:block;margin-left:24px">关掉后前台不再显示图片上传入口。与下面「填 0 不限个数」互不干扰。</span></label>
|
||
<label class="check"><input type="checkbox" name="upload_video_on" value="1"<?php echo fb_upload_cfg('video_on', true) ? ' checked' : ''; ?>> 允许上传视频</label>
|
||
|
||
<div class="grid2" style="margin-top:10px">
|
||
<label class="field"><span>每个问题最多几张图片</span>
|
||
<input type="number" name="upload_image_max_per_post" min="0" max="1000"
|
||
value="<?php echo (int)fb_upload_cfg('image_max_per_post', 4); ?>">
|
||
<?php echo $zero; ?></label>
|
||
<label class="field"><span>单张图片上限(MB)</span>
|
||
<input type="number" step="any" min="0" max="1024" name="upload_image_max_bytes"
|
||
value="<?php echo $mb(fb_upload_cfg('image_max_bytes', 5242880)); ?>">
|
||
<?php echo $zero; ?><i class="hint">填 0 时注意:服务器磁盘可能被大图写满。</i></label>
|
||
<label class="field"><span>每个问题最多几个视频</span>
|
||
<input type="number" name="upload_video_max_per_post" min="0" max="1000"
|
||
value="<?php echo (int)fb_upload_cfg('video_max_per_post', 1); ?>">
|
||
<?php echo $zero; ?></label>
|
||
<label class="field"><span>单个视频上限(MB)</span>
|
||
<input type="number" step="any" min="0" max="10240" name="upload_video_max_bytes"
|
||
value="<?php echo $mb(fb_upload_cfg('video_max_bytes', 52428800)); ?>">
|
||
<?php echo $zero; ?><i class="hint">填 0 时注意:服务器磁盘可能被大视频写满。</i></label>
|
||
<label class="field"><span>全站附件总量上限(MB)</span>
|
||
<input type="number" step="any" min="0" max="1048576" name="upload_total_cap_bytes"
|
||
value="<?php echo $mb(fb_upload_cfg('total_cap_bytes', 1073741824)); ?>">
|
||
<?php echo $zero; ?><i class="hint">填 0 表示不限总量,磁盘写满后上传会失败。</i></label>
|
||
<label class="field"><span>附件保留天数</span>
|
||
<input type="number" name="upload_retention_days" min="0" max="36500"
|
||
value="<?php echo (int)fb_upload_cfg('retention_days', 365); ?>">
|
||
<?php echo $zero; ?><i class="hint">填 0 = 永不自动清理。</i></label>
|
||
<label class="field"><span>每 IP 每小时最多上传几次</span>
|
||
<input type="number" name="upload_upload_per_hour" min="0" max="100000"
|
||
value="<?php echo (int)fb_upload_cfg('upload_per_hour', 60); ?>">
|
||
<?php echo $zero; ?></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); ?>">
|
||
<i class="hint">这是压缩目标边长,不是限制项,最小 480。</i></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&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 elseif ($tab === 'mail'): ?>
|
||
<div class="page-head">
|
||
<div>
|
||
<h1>邮件通知</h1>
|
||
<p class="muted">配置 SMTP 后,反馈有变动会自动发信通知提交人。</p>
|
||
</div>
|
||
</div>
|
||
<?php fb_flash_box($msg, $msgType); ?>
|
||
<form method="post" action="admin.php?tab=mail" class="card-form" data-validate>
|
||
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||
<input type="hidden" name="act" value="settings">
|
||
<h2 style="margin:24px 0 10px">邮件通知</h2>
|
||
<?php
|
||
// ⚠️ config.example.php 的 smtp 段有全套出厂示例值
|
||
// (smtp.example.com / noreply@example.com / 轻反馈 …),
|
||
// fb_cfg() 合并后表单会把这些当成"已填内容"显示出来,
|
||
// 用户点进去得先删一遍。所以回显一律过 fb_form_val():
|
||
// 数据库里没设过 = 没自定义 = 显示空,示例只留在 placeholder。
|
||
$smOn = (bool)fb_cfg('smtp.enabled', false);
|
||
$smHost = fb_form_val(fb_cfg('smtp.host', ''), 'smtp.example.com', 'smtp.host');
|
||
$smUser = fb_form_val(fb_cfg('smtp.user', ''), 'noreply@example.com', 'smtp.user');
|
||
$smFrom = fb_form_val(fb_cfg('smtp.from', ''), 'noreply@example.com', 'smtp.from');
|
||
$smName = fb_form_val(fb_cfg('smtp.from_name', ''), '轻反馈', 'smtp.from_name');
|
||
// 端口出厂 465,没设过就留空让 placeholder 提示,别预填数字
|
||
$smPortRaw = fb_form_val((string)(int)fb_cfg('smtp.port', 465), '465', 'smtp.port');
|
||
$smPort = $smPortRaw === '' ? '' : (string)(int)$smPortRaw;
|
||
$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"
|
||
placeholder="465"
|
||
value="<?php echo e($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" placeholder="15"
|
||
value="<?php echo e(fb_form_val((string)(int)$smTmo, '15', 'smtp.timeout')); ?>"></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>
|
||
<?php /* 出厂默认是 300(见 config.example.php 的 notify.cooldown_seconds) */ ?>
|
||
<input type="number" name="notify_cooldown_seconds" min="0" max="86400" placeholder="300"
|
||
value="<?php echo e(fb_form_val((string)(int)fb_cfg('notify.cooldown_seconds', 300), '300', 'notify.cooldown_seconds')); ?>">
|
||
<i class="hint">0 = 不限制。</i></label>
|
||
<div class="form-actions">
|
||
<button class="btn primary" type="submit">保存邮件设置</button>
|
||
<a class="btn" href="admin.php?tab=settings">← 回站点设置</a>
|
||
</div>
|
||
</form>
|
||
|
||
|
||
<?php else: ?>
|
||
<?php
|
||
$logs = $pdo->query('SELECT * FROM mod_log ORDER BY created_at DESC, id DESC LIMIT 100')->fetchAll();
|
||
?>
|
||
<h2 style="margin:8px 0 10px">最近操作 <span class="muted">(<?php echo count($logs); ?> 条)</span></h2>
|
||
<?php if (!$logs): ?>
|
||
<div class="empty"><p>还没有操作记录。</p></div>
|
||
<?php else: ?>
|
||
<div class="list">
|
||
<?php foreach ($logs as $l): ?>
|
||
<div class="item" style="cursor:default">
|
||
<div class="item-top">
|
||
<span class="badge cat"><?php echo e((string)$l['action']); ?></span>
|
||
<?php if (!empty($l['thread_id'])): ?>
|
||
<a class="item-title" href="admin.php?tab=thread&id=<?php echo (int)$l['thread_id']; ?>">#<?php echo (int)$l['thread_id']; ?></a>
|
||
<?php endif; ?>
|
||
</div>
|
||
<p class="item-ex"><?php echo e((string)$l['detail']); ?></p>
|
||
<div class="item-meta"><span><?php echo e(fb_fmt_time($l['created_at'])); ?></span></div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
|
||
<?php
|
||
fb_layout_foot();
|
||
|
||
/**
|
||
* 待审核卡片(含「通过」与「关闭并邮件告知」两个操作)
|
||
*/
|
||
function fb_admin_thread_card(array $t) {
|
||
ob_start(); ?>
|
||
<div class="card-form" style="margin:0 0 16px">
|
||
<div class="item-top">
|
||
<?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&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();
|
||
}
|