Compare commits
30
Commits
8a850ca0e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
832f112843 | ||
|
|
8db38a834d | ||
|
|
9b971a5b92 | ||
|
|
27614d8ca8 | ||
|
|
a2a3306169 | ||
|
|
338a35cacd | ||
|
|
ad1b0eedf4 | ||
|
|
a00e2221c4 | ||
|
|
26d00ea981 | ||
|
|
974dd52367 | ||
|
|
dcf510ef28 | ||
|
|
82f4fa3c49 | ||
|
|
0ebf250c76 | ||
|
|
c09a9875a3 | ||
|
|
bec5b09bd6 | ||
|
|
0dc8033c6b | ||
|
|
8b3e49e091 | ||
|
|
8763e8e17f | ||
|
|
8bcee46b8a | ||
|
|
2f28b1e3a9 | ||
|
|
28d28ad51c | ||
|
|
314a1cf2a5 | ||
|
|
db8c1969af | ||
|
|
2ef119f7cb | ||
|
|
82484298e4 | ||
|
|
669ab9e43e | ||
|
|
872baf4153 | ||
|
|
835c8c05da | ||
|
|
738c6c09ff | ||
|
|
e43f2ce67a |
@@ -49,7 +49,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!fb_admin_logged_in()) {
|
if (!fb_admin_logged_in()) {
|
||||||
fb_layout_head('管理登录', ['robots' => 'noindex,nofollow']);
|
fb_layout_head('管理登录', ['robots' => 'noindex,nofollow', 'admin' => true]);
|
||||||
?>
|
?>
|
||||||
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
||||||
<section class="narrow">
|
<section class="narrow">
|
||||||
@@ -116,17 +116,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
|||||||
$msgType = 'error';
|
$msgType = 'error';
|
||||||
|
|
||||||
} elseif ($act === 'approve') {
|
} elseif ($act === 'approve') {
|
||||||
$newStatus = ((string)$t['status'] === 'closed') ? 'open' : (string)$t['status'];
|
/* 「通过并公开」= 两件事一起做:
|
||||||
|
* ① 可见性:visible=1
|
||||||
|
* ② 状态:『未公开』→『待处理』
|
||||||
|
*
|
||||||
|
* 状态流转是用户在 2026-11-03 明确要求的:提交后先落「未公开」,
|
||||||
|
* 通过了才变「待处理」;如果判定不是问题,直接走 close 变「已关闭」,
|
||||||
|
* 中间不经过「待处理」。
|
||||||
|
*
|
||||||
|
* 边界:如果这条本来就已经是别的状态(比如以前误操作改成了「处理中」),
|
||||||
|
* 通过审核不该把状态倒退回去,所以只在「未公开」时才推进。
|
||||||
|
* 老数据可能还是 'open' + visible=0,也一并视作待推进。 */
|
||||||
|
$oldSt = (string)$t['status'];
|
||||||
|
$newStatus = ($oldSt === 'hidden' || $oldSt === 'open') ? 'open' : $oldSt;
|
||||||
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
|
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
|
||||||
->execute([$newStatus, time(), $id]);
|
->execute([$newStatus, time(), $id]);
|
||||||
fb_mod_log('approve', $id, '通过审核并公开');
|
fb_mod_log('approve', $id, '通过审核并公开(状态:' . fb_status_label($newStatus) . ')');
|
||||||
|
|
||||||
// 通知提交人「已发布」(工单 #2)
|
// 通知提交人「已发布」(工单 #2)
|
||||||
$errA = '';
|
$errA = '';
|
||||||
try {
|
try {
|
||||||
list($okA, $errA) = fb_notify_event($pdo, 'approve', $t);
|
// 用更新后的状态渲染邮件,否则信里还写着「未公开」
|
||||||
|
$fresh = fb_admin_thread($pdo, $id);
|
||||||
|
list($okA, $errA) = fb_notify_event($pdo, 'approve', $fresh ? $fresh : $t);
|
||||||
} catch (Throwable $e) { $errA = '发信异常'; }
|
} catch (Throwable $e) { $errA = '发信异常'; }
|
||||||
$msg = '#' . $id . ' 已通过审核,现已公开显示。'
|
$msg = '#' . $id . ' 已通过审核并公开,状态为「' . fb_status_label($newStatus) . '」。'
|
||||||
. ($errA === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errA . ')');
|
. ($errA === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errA . ')');
|
||||||
|
|
||||||
} elseif ($act === 'close') {
|
} elseif ($act === 'close') {
|
||||||
@@ -280,21 +294,41 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
|||||||
$msg = '#' . $id . ' 已删除。';
|
$msg = '#' . $id . ' 已删除。';
|
||||||
|
|
||||||
} elseif ($act === 'mail_test') {
|
} elseif ($act === 'mail_test') {
|
||||||
// 发送测试邮件:只验证 SMTP 通不通,不改任何配置
|
// 发送测试邮件:只验证 SMTP 通不通,不改任何配置。
|
||||||
|
// 可选 sw=<软件id>:按那个软件的样子渲染(配色 / 图标 / 软件名),
|
||||||
|
// 便于在后台直接预览「不同软件的邮件长什么样」。
|
||||||
$to = trim((string)(isset($_POST['to']) ? $_POST['to'] : ''));
|
$to = trim((string)(isset($_POST['to']) ? $_POST['to'] : ''));
|
||||||
if ($to === '') { $to = trim((string)fb_cfg('admin_email', '')); }
|
if ($to === '') { $to = trim((string)fb_cfg('admin_email', '')); }
|
||||||
|
if ($to === '') { $to = trim((string)fb_cfg('smtp.user', '')); }
|
||||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
$msg = '请填写有效的收件邮箱。';
|
$msg = '请填写有效的收件邮箱。';
|
||||||
$msgType = 'error';
|
$msgType = 'error';
|
||||||
} else {
|
} else {
|
||||||
$sc = (array)fb_cfg('smtp', []);
|
$sc = (array)fb_cfg('smtp', []);
|
||||||
$site = (string)fb_cfg('site_name', '轻反馈');
|
$site = (string)fb_cfg('site_name', '轻反馈');
|
||||||
$body = '<p>这是一封来自 <b>' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8')
|
|
||||||
. '</b> 的测试邮件。</p>'
|
// 选中的软件(没选就用默认软件,保证测试邮件也带软件标识)
|
||||||
. '<p>如果你收到了它,说明 SMTP 配置正确,回复通知可以正常发出。</p>'
|
$swId = (int)(isset($_POST['sw']) ? $_POST['sw'] : 0);
|
||||||
. '<p style="color:#888;font-size:12px">发送时间:'
|
$swRow = $swId > 0 ? fb_software($swId) : fb_default_software();
|
||||||
. date('Y-m-d H:i:s') . '</p>';
|
|
||||||
list($ok, $err) = fb_smtp_send($sc, $to, '【' . $site . '】邮件测试', $body);
|
// 用真实的回执模板发,比一句「测试邮件」更能看出实际效果
|
||||||
|
$fake = [
|
||||||
|
'id' => 0,
|
||||||
|
'software_id' => $swRow ? (int)$swRow['id'] : 0,
|
||||||
|
'title' => '这是一封测试邮件,用来检查排版与配色',
|
||||||
|
'body' => "这是测试用的反馈正文。\n如果您收到这封信,说明 SMTP 配置正确,配色与图标也应当对应「"
|
||||||
|
. ($swRow ? (string)$swRow['name'] : '默认软件') . "」。\n\n发送时间:" . date('Y-m-d H:i:s'),
|
||||||
|
'author_name' => '测试',
|
||||||
|
'author_email' => $to,
|
||||||
|
'author_phone' => '',
|
||||||
|
'status' => 'open',
|
||||||
|
'visible' => 1,
|
||||||
|
];
|
||||||
|
list($subject, $html) = fb_notify_render('receipt', $fake,
|
||||||
|
['software' => $swRow], $to);
|
||||||
|
$subject = '【测试】' . $subject;
|
||||||
|
|
||||||
|
list($ok, $err) = fb_smtp_send($sc, $to, $subject, $html);
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
$msg = '测试邮件已发出,请查收 ' . $to . '(也看一下垃圾箱)。';
|
$msg = '测试邮件已发出,请查收 ' . $to . '(也看一下垃圾箱)。';
|
||||||
$msgType = 'ok';
|
$msgType = 'ok';
|
||||||
@@ -574,6 +608,7 @@ function fb_admin_save_settings() {
|
|||||||
$save('notify.enabled', (int)(bool)$post('notify.enabled'));
|
$save('notify.enabled', (int)(bool)$post('notify.enabled'));
|
||||||
$save('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
|
$save('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
|
||||||
$save('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
|
$save('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
|
||||||
|
$save('notify.receipt_enabled', (int)(bool)$post('notify.receipt_enabled'));
|
||||||
|
|
||||||
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
|
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
|
||||||
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
|
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
|
||||||
@@ -603,7 +638,7 @@ function fb_admin_save_settings() {
|
|||||||
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
|
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
|
||||||
if (!in_array($tab, ['pending', 'all', 'thread', 'taxonomy', 'statuses', 'settings', 'mail', 'log'], true)) { $tab = 'pending'; }
|
if (!in_array($tab, ['pending', 'all', 'thread', 'taxonomy', 'statuses', 'settings', 'mail', 'log'], true)) { $tab = 'pending'; }
|
||||||
|
|
||||||
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow', 'admin' => true]);
|
||||||
?>
|
?>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -627,16 +662,23 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
|
|
||||||
<?php if ($tab === 'pending'): ?>
|
<?php if ($tab === 'pending'): ?>
|
||||||
<?php
|
<?php
|
||||||
|
/* 「待审核」= 还没公开过的条目。
|
||||||
|
* 判据用 status='hidden'(未公开),不再用 visible=0 这种隐式条件 ——
|
||||||
|
* 已关闭的条目 visible 也是 0,但它是处理完了,不该出现在待审核里。
|
||||||
|
* 顺便兼容老数据:visible=0 且状态仍是 'open' 的也一并算待审核。 */
|
||||||
$pending = $pdo->query(
|
$pending = $pdo->query(
|
||||||
"SELECT id, title, body, category, status, author_name, author_email, created_at
|
"SELECT * FROM threads
|
||||||
FROM threads WHERE visible = 0 AND status <> 'closed' ORDER BY created_at DESC"
|
WHERE status = 'hidden' OR (visible = 0 AND status = 'open')
|
||||||
|
ORDER BY created_at DESC"
|
||||||
)->fetchAll();
|
)->fetchAll();
|
||||||
?>
|
?>
|
||||||
<h2 style="margin:22px 0 10px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2>
|
<h2 style="margin:22px 0 12px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2>
|
||||||
<?php if (!$pending): ?>
|
<?php if (!$pending): ?>
|
||||||
<div class="empty"><p>当前没有待审核的反馈。</p></div>
|
<div class="empty"><p>当前没有待审核的反馈。</p></div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($pending as $t) { echo fb_admin_thread_card($t); } ?>
|
<div class="plist">
|
||||||
|
<?php foreach ($pending as $t) { echo fb_admin_pending_row($t); } ?>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php elseif ($tab === 'all'): ?>
|
<?php elseif ($tab === 'all'): ?>
|
||||||
@@ -668,7 +710,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
$lst->execute($args);
|
$lst->execute($args);
|
||||||
$rows = $lst->fetchAll();
|
$rows = $lst->fetchAll();
|
||||||
?>
|
?>
|
||||||
<form class="filters" method="get" action="admin.php">
|
<form class="filters filters-row" method="get" action="admin.php">
|
||||||
<input type="hidden" name="tab" value="all">
|
<input type="hidden" name="tab" value="all">
|
||||||
<input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
|
<input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
|
||||||
<select name="sw" onchange="this.form.submit()">
|
<select name="sw" onchange="this.form.submit()">
|
||||||
@@ -712,11 +754,30 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
<div class="list">
|
<div class="list">
|
||||||
<?php foreach ($rows as $t): ?>
|
<?php foreach ($rows as $t): ?>
|
||||||
<div class="item" style="cursor:default">
|
<div class="item" style="cursor:default">
|
||||||
<div class="item-top">
|
<?php
|
||||||
<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>
|
* 后台是跨软件视图,所以这里必须带软件名 —— 前台某个软件内部
|
||||||
|
* 不需要显示软件名(满页都是同一个),后台需要。 */
|
||||||
|
$aSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
|
||||||
|
?>
|
||||||
|
<div class="item-head">
|
||||||
<a class="item-title" href="admin.php?tab=thread&id=<?php echo (int)$t['id']; ?>"><?php echo e($t['title']); ?></a>
|
<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>
|
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="item-tags">
|
||||||
|
<?php if ($aSw): ?><?php echo fb_software_chip($aSw); ?><?php endif; ?>
|
||||||
|
<?php
|
||||||
|
/* 🔴 这里必须用 category_id(数字),不能用 $t['category'](老 slug 字段)。
|
||||||
|
* fb_category_label() 内部会把参数 (int) 化,传 'bug' 会变成 0 → 返回空串,
|
||||||
|
* 结果就是渲染出一个**空的** <span class="badge cat"></span>。
|
||||||
|
* 用户 2026-11-03 看到的就是「反馈类型没了」。
|
||||||
|
* 优先 category_id,没有才回退 slug(老数据兼容)。 */
|
||||||
|
$aCat = !empty($t['category_id'])
|
||||||
|
? fb_category_label((int)$t['category_id'])
|
||||||
|
: fb_category_label_by_slug((int)$t['software_id'], (string)$t['category']);
|
||||||
|
?>
|
||||||
|
<?php if ($aCat !== ''): ?><span class="badge cat"><?php echo e($aCat); ?></span><?php endif; ?>
|
||||||
|
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||||
<?php if ((int)$t['visible'] !== 1): ?><span class="badge st st-closed">未公开</span><?php endif; ?>
|
<?php if ((int)$t['visible'] !== 1): ?><span class="badge st st-closed">未公开</span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
|
<p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
|
||||||
@@ -755,16 +816,21 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
<div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div>
|
<div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div>
|
||||||
|
|
||||||
<div class="card-form" style="margin:0 0 16px">
|
<div class="card-form" style="margin:0 0 16px">
|
||||||
<div class="item-top">
|
<?php
|
||||||
<?php
|
$dSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
|
||||||
$dSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
|
$dCat = !empty($t['category_id']) ? fb_category((int)$t['category_id']) : null;
|
||||||
$dCat = !empty($t['category_id']) ? fb_category((int)$t['category_id']) : null;
|
?>
|
||||||
?>
|
<?php /* 标题顶头独占一行,标签另起一行 —— 与列表页、前台详情页统一。
|
||||||
<?php if ($dSw): ?><span class="badge sw"><?php echo e((string)$dSw['name']); ?></span><?php endif; ?>
|
用户 2026-11-03:「点到一个具体的反馈里面也是一样的,标题顶头,
|
||||||
|
然后把同步日历、bug、待处理往下一行,标签写到下面去。」 */ ?>
|
||||||
|
<div class="item-head">
|
||||||
|
<span class="item-title" style="font-size:19px"><?php echo e($t['title']); ?></span>
|
||||||
|
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="item-tags">
|
||||||
|
<?php if ($dSw): ?><?php echo fb_software_chip($dSw); ?><?php endif; ?>
|
||||||
<?php if ($dCat): ?><span class="badge cat"><?php echo e((string)$dCat['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="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>
|
||||||
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></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']); } ?>
|
<?php if (!empty($atts['main'])) { echo fb_attach_render($atts['main']); } ?>
|
||||||
@@ -807,7 +873,10 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="act-inline">
|
<form method="post" action="admin.php?tab=thread&id=<?php echo (int)$id; ?>" class="act-inline">
|
||||||
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
<input type="hidden" name="status" value="<?php echo e($curStatus === 'closed' ? 'open' : $curStatus); ?>">
|
<?php /* 通过并公开 = 可见 + 状态转「待处理」。
|
||||||
|
不能沿用原状态(未公开时原状态是 hidden,通过后还是未公开,
|
||||||
|
状态和可见性就对不上了)。 */ ?>
|
||||||
|
<input type="hidden" name="status" value="open">
|
||||||
<input type="hidden" name="also_show" value="1">
|
<input type="hidden" name="also_show" value="1">
|
||||||
<button class="btn primary act-btn" type="submit" name="act" value="setstatus">通过并公开</button>
|
<button class="btn primary act-btn" type="submit" name="act" value="setstatus">通过并公开</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1136,11 +1205,11 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
<input type="hidden" name="oss_visibility" value="<?php echo e((string)fb_cfg('oss.visibility', 'private')); ?>">
|
<input type="hidden" name="oss_visibility" value="<?php echo e((string)fb_cfg('oss.visibility', 'private')); ?>">
|
||||||
|
|
||||||
<h2 style="margin:24px 0 10px">审核</h2>
|
<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="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需您通过后才显示)</label>
|
||||||
<label class="field"><span>关闭通知邮件标题</span>
|
<label class="field"><span>关闭通知邮件标题</span>
|
||||||
<input type="text" name="moderation_close_mail_subject"
|
<input type="text" name="moderation_close_mail_subject"
|
||||||
placeholder="留空即用:关于你反馈的问题《%s》"
|
placeholder="留空即用:关于您反馈的问题《%s》"
|
||||||
value="<?php echo e(fb_form_val(fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》'), '关于你反馈的问题《%s》', 'moderation.close_mail_subject')); ?>">
|
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>
|
<i class="hint">%s 会替换成问题标题。留空则用默认模板。</i></label>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -1263,7 +1332,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
$smVerify= (bool)fb_cfg('smtp.verify_ssl', true);
|
$smVerify= (bool)fb_cfg('smtp.verify_ssl', true);
|
||||||
?>
|
?>
|
||||||
<label class="check"><input type="checkbox" name="smtp_enabled" value="1"<?php echo $smOn ? ' checked' : ''; ?>> 开启邮件通知
|
<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>
|
<span class="hint" style="display:block;margin:2px 0 0 24px">开启后,有人回复您反馈的问题会发信通知提交人。填好下方参数并用「发送测试邮件」验证通了再勾。</span></label>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<label class="field"><span>SMTP 服务器</span>
|
<label class="field"><span>SMTP 服务器</span>
|
||||||
@@ -1317,17 +1386,38 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
<div class="mail-test">
|
<div class="mail-test">
|
||||||
<span class="muted" style="font-size:13.5px">配好后先发一封测试邮件确认能通:</span>
|
<span class="muted" style="font-size:13.5px">配好后先发一封测试邮件确认能通:</span>
|
||||||
<input type="email" id="mailTestTo" placeholder="收件邮箱,默认发给自己"
|
<input type="email" id="mailTestTo" placeholder="收件邮箱,默认发给自己"
|
||||||
value="<?php echo e((string)fb_cfg('admin_email', '')); ?>">
|
value="<?php echo e((string)fb_cfg('admin_email', '') !== '' ? (string)fb_cfg('admin_email', '') : (string)fb_cfg('smtp.user', '')); ?>">
|
||||||
|
<select id="mailTestSw" title="按哪个软件的样子发送">
|
||||||
|
<?php foreach (fb_softwares(true) as $swOne): ?>
|
||||||
|
<option value="<?php echo (int)$swOne['id']; ?>"><?php echo e((string)$swOne['name']); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
<button type="button" class="btn" id="mailTestBtn" data-csrf="<?php echo e(fb_csrf_token()); ?>">发送测试邮件</button>
|
<button type="button" class="btn" id="mailTestBtn" data-csrf="<?php echo e(fb_csrf_token()); ?>">发送测试邮件</button>
|
||||||
<span id="mailTestOut" class="mail-test-out"></span>
|
<span id="mailTestOut" class="mail-test-out"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="hint" style="margin:6px 0 0">测试邮件用的是「提交回执」模板,会带上所选软件的图标与配色 —— 可以直接看出不同软件的邮件长什么样。</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<h3 style="margin:20px 0 8px;font-size:15px">通知规则</h3>
|
<h3 style="margin:20px 0 8px;font-size:15px">通知规则</h3>
|
||||||
|
<label class="check"><input type="checkbox" name="notify_receipt_enabled" value="1"<?php echo fb_cfg('notify.receipt_enabled', true) ? ' checked' : ''; ?>> 提交后立即给提交人发「已收到」回执
|
||||||
|
<span class="hint" style="display:block;margin-left:24px">告诉对方反馈已送达、正在等审核,请耐心等待。提交完就让用户干等着是最容易丢信任的环节。</span></label>
|
||||||
<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_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_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' : ''; ?>> 有新反馈时通知管理员
|
<?php
|
||||||
<span class="hint" style="display:block;margin-left:24px">发到「站点信息」里填的官方邮箱。</span></label>
|
// 勾了「有新反馈时通知管理员」却没填官方邮箱 —— 这条通知会发不出去。
|
||||||
|
// 之前这个组合完全是静默失效的,用户在后台看不出任何异常。
|
||||||
|
$adminMailNow = trim((string)fb_cfg('admin_email', ''));
|
||||||
|
$smtpUserNow = trim((string)fb_cfg('smtp.user', ''));
|
||||||
|
$newNotifyOn = (bool)fb_cfg('notify.admin_new_thread', false);
|
||||||
|
$mailMissing = ($adminMailNow === '' && $smtpUserNow === '');
|
||||||
|
?>
|
||||||
|
<label class="check"><input type="checkbox" name="notify_admin_new_thread" value="1"<?php echo $newNotifyOn ? ' checked' : ''; ?>> 有新反馈时通知管理员
|
||||||
|
<span class="hint" style="display:block;margin-left:24px">发到「站点信息」里填的官方邮箱;没填官方邮箱时退而发到 SMTP 发件地址。</span>
|
||||||
|
<?php if ($newNotifyOn && $mailMissing): ?>
|
||||||
|
<span class="hint" style="display:block;margin-left:24px;color:#b4553c;font-weight:600">⚠️ 官方邮箱和 SMTP 发件地址都是空的,这条通知目前发不出去。请在上方「官方邮箱」处填写。</span>
|
||||||
|
<?php elseif ($newNotifyOn && $adminMailNow === ''): ?>
|
||||||
|
<span class="hint" style="display:block;margin-left:24px;color:#8a6a15">当前会发到 SMTP 发件地址 <?php echo e($smtpUserNow); ?>。若想收到别的信箱,请在「站点信息」填写官方邮箱。</span>
|
||||||
|
<?php endif; ?></label>
|
||||||
<label class="field" style="max-width:280px"><span>同一帖同一收件人的最短发信间隔(秒)</span>
|
<label class="field" style="max-width:280px"><span>同一帖同一收件人的最短发信间隔(秒)</span>
|
||||||
<?php /* 出厂默认是 300(见 config.example.php 的 notify.cooldown_seconds) */ ?>
|
<?php /* 出厂默认是 300(见 config.example.php 的 notify.cooldown_seconds) */ ?>
|
||||||
<input type="number" name="notify_cooldown_seconds" min="0" max="86400" placeholder="300"
|
<input type="number" name="notify_cooldown_seconds" min="0" max="86400" placeholder="300"
|
||||||
@@ -1369,53 +1459,49 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
|||||||
fb_layout_foot();
|
fb_layout_foot();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 待审核卡片(含「通过」与「关闭并邮件告知」两个操作)
|
* 待审核条目(列表式)
|
||||||
|
*
|
||||||
|
* 用户 2026-11-03 明确要求:
|
||||||
|
* ① 列表形式显示,不要每张卡片塞满表单
|
||||||
|
* ② 去掉「关闭说明」整块 —— 想关闭就点标题进管理页再填说明,
|
||||||
|
* 列表页不承担这个职责
|
||||||
|
* ③ 后面只留一个「通过并公开」按钮,点一下就通过
|
||||||
|
* ④ 标题自己就是进管理页的链接,别再额外挂「进入管理」
|
||||||
*/
|
*/
|
||||||
function fb_admin_thread_card(array $t) {
|
function fb_admin_pending_row(array $t) {
|
||||||
|
$url = 'admin.php?tab=thread&id=' . (int)$t['id'];
|
||||||
ob_start(); ?>
|
ob_start(); ?>
|
||||||
<div class="card-form" style="margin:0 0 16px">
|
<div class="prow">
|
||||||
<div class="item-top">
|
<div class="prow-main">
|
||||||
<?php $cSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null; ?>
|
<?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 /* 标题顶头独占一行,标签(软件/分类/状态)另起一行放下面。
|
||||||
<?php if (!empty($t['category_id'])): ?>
|
用户 2026-11-03:后台待审核、全部问题、详情页全都按这个方式处理 ——
|
||||||
<span class="badge cat"><?php echo e(fb_category_label((int)$t['category_id'])); ?></span>
|
标签放在标题前面会把标题挤到右边。 */ ?>
|
||||||
<?php endif; ?>
|
<div class="prow-head">
|
||||||
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
<a class="prow-title" href="<?php echo e($url); ?>"><?php echo e($t['title']); ?></a>
|
||||||
<span class="item-title"><?php echo e($t['title']); ?></span>
|
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
|
||||||
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
|
</div>
|
||||||
|
<div class="prow-tags">
|
||||||
|
<?php if ($cSw): ?><?php echo fb_software_chip($cSw); ?><?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>
|
||||||
|
</div>
|
||||||
|
<p class="prow-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
|
||||||
|
<div class="prow-meta">
|
||||||
|
<span class="who"><?php echo e($t['author_name']); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e($t['author_email']); ?></span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="prow-act">
|
||||||
<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">
|
<form method="post" action="admin.php?tab=pending">
|
||||||
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
|
<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>
|
||||||
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+646
-54
@@ -61,29 +61,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 管理员口令:填写后显示"处理状态",并解除邮箱必填 ---------- */
|
/* ---------- (已删)管理员口令切换 ----------
|
||||||
function bindAdminToggle() {
|
* 回复表单里不再有「管理员口令」和「处理状态」——用户 2026-11-03 要求去掉。
|
||||||
var pw = document.querySelector('input[name=admin_password]');
|
* 管理员身份改由后台登录态判断,状态流转在后台详情页的按钮里做。 */
|
||||||
if (!pw) { return; }
|
|
||||||
var statusBox = document.querySelector('.admin-status');
|
|
||||||
var emailInput = document.querySelector('input[name=email]');
|
|
||||||
var emailField = document.getElementById('emailField');
|
|
||||||
|
|
||||||
function sync() {
|
|
||||||
var isAdmin = pw.value.trim() !== '';
|
|
||||||
if (statusBox) { statusBox.hidden = !isAdmin; }
|
|
||||||
if (emailInput) {
|
|
||||||
emailInput.required = !isAdmin;
|
|
||||||
if (isAdmin) { emailInput.removeAttribute('required'); }
|
|
||||||
}
|
|
||||||
if (emailField) {
|
|
||||||
var req = emailField.querySelector('.req');
|
|
||||||
if (req) { req.style.visibility = isAdmin ? 'hidden' : 'visible'; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pw.addEventListener('input', sync);
|
|
||||||
sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 防止重复提交(上传未完成时阻止提交) ---------- */
|
/* ---------- 防止重复提交(上传未完成时阻止提交) ---------- */
|
||||||
function bindNoDoubleSubmit() {
|
function bindNoDoubleSubmit() {
|
||||||
@@ -729,55 +709,663 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 主题色选择器(后台软件编辑) ---------- */
|
/* ---------- 主题色选择器(后台软件编辑) ----------
|
||||||
|
* 用户 2026-11-04:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」
|
||||||
|
*
|
||||||
|
* 现在的形态是一块**完整色板**(不是小浮窗):
|
||||||
|
* 340×200 的色相/饱和度画布 + 色相条 + 明度条 + 十六进制输入。
|
||||||
|
* 右侧那排预设圆点已删除 —— 既能任意取色,再摆一排样例就是重复。
|
||||||
|
*
|
||||||
|
* 交互约定:
|
||||||
|
* - 点色块 → 色板出现(下方空间不够自动翻到上方)
|
||||||
|
* - 画布内拖动 = 同时调饱和度与明度
|
||||||
|
* - 色相条单独调 H;明度条单独调 V
|
||||||
|
* - 十六进制框可手输,合法立即同步
|
||||||
|
* - 点「完成」/ 点色板外 / Esc → 关闭(值早已写入隐藏字段)
|
||||||
|
*/
|
||||||
function bindColorPicker() {
|
function bindColorPicker() {
|
||||||
var rows = document.querySelectorAll('.color-row');
|
var rows = document.querySelectorAll('[data-colorpicker]');
|
||||||
|
|
||||||
for (var i = 0; i < rows.length; i++) {
|
for (var i = 0; i < rows.length; i++) {
|
||||||
(function (row) {
|
(function (row) {
|
||||||
var input = row.querySelector('input[type=color]');
|
var trigger = row.querySelector('[data-color-trigger]');
|
||||||
var presets = row.querySelectorAll('.color-preset');
|
var valueEl = row.querySelector('[data-color-value]');
|
||||||
var reset = row.querySelector('.color-reset');
|
var reset = row.querySelector('.color-reset');
|
||||||
if (!input) { return; }
|
var panel = row.querySelector('[data-color-panel]');
|
||||||
|
if (!trigger || !valueEl || !panel) { return; }
|
||||||
|
|
||||||
function syncOn(v) {
|
var canvas = panel.querySelector('[data-cp-sv]');
|
||||||
for (var k = 0; k < presets.length; k++) {
|
var cursor = panel.querySelector('[data-cp-cursor]');
|
||||||
var same = (presets[k].getAttribute('data-color') || '').toLowerCase() === (v || '').toLowerCase();
|
var hueEl = panel.querySelector('[data-cp-hue]');
|
||||||
if (same) { presets[k].classList.add('on'); } else { presets[k].classList.remove('on'); }
|
var valEl = panel.querySelector('[data-cp-val]'); // 明度条(可能不存在,做兼容)
|
||||||
|
var prevEl = panel.querySelector('[data-cp-prev]');
|
||||||
|
var hexEl = panel.querySelector('[data-cp-hex]');
|
||||||
|
var okBtn = panel.querySelector('[data-cp-ok]');
|
||||||
|
|
||||||
|
var hsv = { h: 0, s: 0, v: 0 };
|
||||||
|
|
||||||
|
function hexToRgb(h) {
|
||||||
|
h = (h || '').replace('#', '');
|
||||||
|
if (h.length === 3) { h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]; }
|
||||||
|
return [parseInt(h.substr(0,2),16) || 0, parseInt(h.substr(2,2),16) || 0, parseInt(h.substr(4,2),16) || 0];
|
||||||
|
}
|
||||||
|
function rgbToHex(r, g, b) {
|
||||||
|
function p(n) { n = Math.max(0, Math.min(255, Math.round(n))); return (n < 16 ? '0' : '') + n.toString(16); }
|
||||||
|
return '#' + p(r) + p(g) + p(b);
|
||||||
|
}
|
||||||
|
function rgbToHsv(r, g, b) {
|
||||||
|
r /= 255; g /= 255; b /= 255;
|
||||||
|
var mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, h = 0;
|
||||||
|
if (d !== 0) {
|
||||||
|
if (mx === r) { h = ((g - b) / d) % 6; }
|
||||||
|
else if (mx === g) { h = (b - r) / d + 2; }
|
||||||
|
else { h = (r - g) / d + 4; }
|
||||||
|
h *= 60; if (h < 0) { h += 360; }
|
||||||
}
|
}
|
||||||
|
return { h: h, s: mx === 0 ? 0 : d / mx, v: mx };
|
||||||
|
}
|
||||||
|
function hsvToRgb(h, s, v) {
|
||||||
|
var c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c, r = 0, g = 0, b = 0;
|
||||||
|
if (h < 60) { r = c; g = x; }
|
||||||
|
else if (h < 120){ r = x; g = c; }
|
||||||
|
else if (h < 180){ g = c; b = x; }
|
||||||
|
else if (h < 240){ g = x; b = c; }
|
||||||
|
else if (h < 300){ r = x; b = c; }
|
||||||
|
else { r = c; b = x; }
|
||||||
|
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
|
||||||
|
}
|
||||||
|
function curHex() {
|
||||||
|
var c = hsvToRgb(hsv.h, hsv.s, hsv.v);
|
||||||
|
return rgbToHex(c[0], c[1], c[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var j = 0; j < presets.length; j++) {
|
/* 画色相/饱和度板:底色是当前色相的纯色,叠一层
|
||||||
presets[j].addEventListener('click', function () {
|
* 左→右白到透明的横向渐变(饱和度)+ 上→下透明的纵向渐变(明度)。
|
||||||
var c = this.getAttribute('data-color') || '';
|
* 这是取色器通用画法,比逐像素画快得多。 */
|
||||||
input.value = c;
|
function drawSV() {
|
||||||
syncOn(c);
|
var ctx = canvas.getContext('2d');
|
||||||
|
var w = canvas.width, h = canvas.height;
|
||||||
|
var base = hsvToRgb(hsv.h, 1, 1);
|
||||||
|
ctx.fillStyle = rgbToHex(base[0], base[1], base[2]);
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
var gx = ctx.createLinearGradient(0, 0, w, 0);
|
||||||
|
gx.addColorStop(0, 'rgba(255,255,255,1)');
|
||||||
|
gx.addColorStop(1, 'rgba(255,255,255,0)');
|
||||||
|
ctx.fillStyle = gx; ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
var gy = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
|
gy.addColorStop(0, 'rgba(0,0,0,0)');
|
||||||
|
gy.addColorStop(1, 'rgba(0,0,0,1)');
|
||||||
|
ctx.fillStyle = gy; ctx.fillRect(0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursor() {
|
||||||
|
/* cursor 现在是 .cp-sv-wrap 的子元素(和 canvas 同级),
|
||||||
|
* 所以坐标可以直接相对 canvas 自己算 —— 不用再管 .cp 的 padding:
|
||||||
|
* left/top 相对 wrap,wrap 和 canvas 左/上边重合。
|
||||||
|
* 但仍要注意:canvas 显示尺寸(CSS width:100%)≠ 内部像素宽高,
|
||||||
|
* 必须用 getBoundingClientRect() 的宽高。 */
|
||||||
|
var br = canvas.getBoundingClientRect();
|
||||||
|
var w = br.width || canvas.width;
|
||||||
|
var h = br.height || canvas.height;
|
||||||
|
cursor.style.left = (hsv.s * w) + 'px';
|
||||||
|
cursor.style.top = ((1 - hsv.v) * h) + 'px';
|
||||||
|
cursor.style.background = curHex();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAll(skipHex) {
|
||||||
|
var hex = curHex();
|
||||||
|
valueEl.value = hex;
|
||||||
|
trigger.style.setProperty('--cur', hex);
|
||||||
|
var hexTxt = trigger.querySelector('.color-trigger-hex');
|
||||||
|
if (hexTxt) { hexTxt.textContent = hex.toUpperCase(); }
|
||||||
|
if (prevEl) { prevEl.style.background = hex; }
|
||||||
|
if (!skipHex && hexEl) { hexEl.value = hex.toUpperCase(); }
|
||||||
|
hueEl.value = Math.round(hsv.h);
|
||||||
|
if (valEl) { valEl.value = Math.round(hsv.v * 100); }
|
||||||
|
drawSV();
|
||||||
|
drawValTrack();
|
||||||
|
moveCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 明度条的底色要跟着当前色相变 —— 否则永远是灰阶,
|
||||||
|
* 用户看不清"拖到这里会得到什么颜色"。 */
|
||||||
|
function drawValTrack() {
|
||||||
|
if (!valEl) { return; }
|
||||||
|
var pure = hsvToRgb(hsv.h, hsv.s, 1);
|
||||||
|
var top = rgbToHex(pure[0], pure[1], pure[2]);
|
||||||
|
valEl.style.background = 'linear-gradient(to right, #000, ' + top + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHex(hex, skipHex) {
|
||||||
|
var c = hexToRgb(hex);
|
||||||
|
hsv = rgbToHsv(c[0], c[1], c[2]);
|
||||||
|
syncAll(skipHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 打开 / 关闭 ----
|
||||||
|
function openPanel() {
|
||||||
|
panel.hidden = false;
|
||||||
|
drawSV(); moveCursor();
|
||||||
|
positionPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 色板是 position:fixed,位置必须自己算。
|
||||||
|
*
|
||||||
|
* 🔴 为什么不用 absolute 贴按钮:后台这个表单在
|
||||||
|
* <details class="tax-form-box"> 里,那个容器有 overflow:hidden,
|
||||||
|
* absolute 会被裁掉下半部分 —— 色相条正好在底部,连带看不见,
|
||||||
|
* 用户就以为"色板只有蓝色"。fixed 不受任何祖先 overflow 影响。
|
||||||
|
*
|
||||||
|
* 定位规则:
|
||||||
|
* ① 默认贴在触发按钮下方,左边缘对齐按钮
|
||||||
|
* ② 右边超出视口就往左挪
|
||||||
|
* ③ 下方空间不够就翻到按钮上方
|
||||||
|
* ④ 都放不下(屏幕太矮)就贴住视口上下边,保证能看全 */
|
||||||
|
function positionPanel() {
|
||||||
|
if (panel.hidden) { return; }
|
||||||
|
var r = trigger.getBoundingClientRect();
|
||||||
|
var pw = panel.offsetWidth;
|
||||||
|
var ph = panel.offsetHeight;
|
||||||
|
var vw = window.innerWidth;
|
||||||
|
var vh = window.innerHeight;
|
||||||
|
var gap = 8;
|
||||||
|
var margin = 10;
|
||||||
|
|
||||||
|
// 水平:默认左对齐按钮
|
||||||
|
var left = r.left;
|
||||||
|
if (left + pw + margin > vw) { left = vw - pw - margin; }
|
||||||
|
if (left < margin) { left = margin; }
|
||||||
|
|
||||||
|
// 垂直:优先下方
|
||||||
|
var top = r.bottom + gap;
|
||||||
|
if (top + ph + margin > vh) {
|
||||||
|
var above = r.top - gap - ph;
|
||||||
|
if (above >= margin) {
|
||||||
|
top = above; // 放上面
|
||||||
|
} else {
|
||||||
|
top = Math.max(margin, vh - ph - margin); // 都放不下:贴住底部
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.style.left = Math.round(left) + 'px';
|
||||||
|
panel.style.top = Math.round(top) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() { panel.hidden = true; }
|
||||||
|
|
||||||
|
// 窗口尺寸变化 / 滚动时重新定位,避免色板"漂走"
|
||||||
|
window.addEventListener('resize', positionPanel);
|
||||||
|
window.addEventListener('scroll', positionPanel, true);
|
||||||
|
|
||||||
|
trigger.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (panel.hidden) { openPanel(); } else { closePanel(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!panel.hidden && !panel.contains(e.target) && e.target !== trigger && !trigger.contains(e.target)) {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && !panel.hidden) { closePanel(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 渐变板拖动 ----
|
||||||
|
var dragging = false;
|
||||||
|
function pickSV(ev) {
|
||||||
|
var rect = canvas.getBoundingClientRect();
|
||||||
|
var x = (ev.touches ? ev.touches[0].clientX : ev.clientX) - rect.left;
|
||||||
|
var y = (ev.touches ? ev.touches[0].clientY : ev.clientY) - rect.top;
|
||||||
|
hsv.s = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
hsv.v = Math.max(0, Math.min(1, 1 - y / rect.height));
|
||||||
|
syncAll();
|
||||||
|
}
|
||||||
|
canvas.addEventListener('mousedown', function (e) { dragging = true; pickSV(e); e.preventDefault(); });
|
||||||
|
canvas.addEventListener('touchstart', function (e) { dragging = true; pickSV(e); }, { passive: true });
|
||||||
|
document.addEventListener('mousemove', function (e) { if (dragging) { pickSV(e); } });
|
||||||
|
document.addEventListener('touchmove', function (e) { if (dragging) { pickSV(e); } }, { passive: true });
|
||||||
|
document.addEventListener('mouseup', function () { dragging = false; });
|
||||||
|
document.addEventListener('touchend', function () { dragging = false; });
|
||||||
|
|
||||||
|
// ---- 色相滑条 ----
|
||||||
|
hueEl.addEventListener('input', function () {
|
||||||
|
hsv.h = parseFloat(this.value) || 0;
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 明度滑条 ----
|
||||||
|
if (valEl) {
|
||||||
|
valEl.addEventListener('input', function () {
|
||||||
|
hsv.v = (parseFloat(this.value) || 0) / 100;
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 十六进制手输 ----
|
||||||
|
hexEl.addEventListener('input', function () {
|
||||||
|
var v = this.value.trim();
|
||||||
|
if (/^#?[0-9a-fA-F]{6}$/.test(v)) {
|
||||||
|
setHex(v[0] === '#' ? v : '#' + v, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
okBtn.addEventListener('click', function () { closePanel(); });
|
||||||
|
|
||||||
|
// ---- 用默认(清空 = 保存后回落到按 id 取的默认色)----
|
||||||
|
if (reset) {
|
||||||
|
reset.addEventListener('click', function () {
|
||||||
|
valueEl.value = '';
|
||||||
|
valueEl.setAttribute('name', 'color');
|
||||||
|
trigger.style.setProperty('--cur', '#cccccc');
|
||||||
|
var hexTxt = trigger.querySelector('.color-trigger-hex');
|
||||||
|
if (hexTxt) { hexTxt.textContent = '跟随默认'; }
|
||||||
|
closePanel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
if (valueEl.value) { setHex(valueEl.value, false); } else { syncAll(); }
|
||||||
|
})(rows[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 图标上传:选中即预览 + 即时校验 ----------
|
||||||
|
* 用户 2026-11-04:「上传的那个图标没有显示,也没有上传成功、失败之类的提示信息。」
|
||||||
|
*
|
||||||
|
* 原来就是一个裸 <input type="file">,选完文件什么都没发生 ——
|
||||||
|
* 用户不知道选没选中、图片对不对、会不会传不上去。表单整页提交后
|
||||||
|
* 提示条又在页面顶部,很容易错过。
|
||||||
|
*
|
||||||
|
* 这里做三件事(都在前端,最终仍以后端校验为准):
|
||||||
|
* ① 选中文件立刻用 FileReader 本地预览,不等提交
|
||||||
|
* ② 显示文件名 + 尺寸 + 大小,让用户确认选对了
|
||||||
|
* ③ 选之前就拦掉明显不合格的(格式/大小/太小),省一次白提交
|
||||||
|
* ⚠️ 前端校验只是体验优化,**绝不能**当作安全边界 ——
|
||||||
|
* 真正的类型/尺寸/大小校验仍在 php 的 fb_icon_store_upload() 里。
|
||||||
|
*/
|
||||||
|
function bindIconUpload() {
|
||||||
|
var inputs = document.querySelectorAll('input[type=file][name=icon_file]');
|
||||||
|
for (var i = 0; i < inputs.length; i++) {
|
||||||
|
(function (input) {
|
||||||
|
var wrap = input.closest('.icon-up');
|
||||||
|
if (!wrap) { return; }
|
||||||
|
var prev = wrap.querySelector('.icon-prev');
|
||||||
|
// 按钮上的文件名(可选,新样式才有)
|
||||||
|
var nameEl = wrap.querySelector('[data-file-name]');
|
||||||
|
|
||||||
|
/* 状态条插在**整个文件按钮之后**(不是 input 之后)。
|
||||||
|
* input 现在被包在 <label class="file-btn"> 里,如果插到 input.nextSibling
|
||||||
|
* 会跑到 label 内部,跟按钮文字挤在一起。 */
|
||||||
|
var btn = input.closest('.file-btn') || input;
|
||||||
|
var tip = document.createElement('span');
|
||||||
|
tip.className = 'icon-up-tip';
|
||||||
|
tip.setAttribute('role', 'status');
|
||||||
|
if (btn.parentNode) { btn.parentNode.insertBefore(tip, btn.nextSibling); }
|
||||||
|
|
||||||
|
function setTip(msg, kind) {
|
||||||
|
tip.textContent = msg || '';
|
||||||
|
tip.className = 'icon-up-tip' + (kind ? ' is-' + kind : '');
|
||||||
|
}
|
||||||
|
function setName(txt) {
|
||||||
|
if (nameEl) { nameEl.textContent = txt || ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var f = this.files && this.files[0];
|
||||||
|
if (!f) { setTip(''); setName(''); return; }
|
||||||
|
|
||||||
|
var KB = Math.round(f.size / 1024);
|
||||||
|
var sizeTxt = KB >= 1024 ? (KB / 1024).toFixed(1) + ' MB' : KB + ' KB';
|
||||||
|
|
||||||
|
// ① 格式
|
||||||
|
var okType = /^image\/(png|jpeg|webp)$/.test(f.type);
|
||||||
|
if (!okType) {
|
||||||
|
setTip('✕ 只能选 PNG / JPG / WEBP 图片,当前是「' + (f.type || '未知类型') + '」', 'err');
|
||||||
|
setName('');
|
||||||
|
this.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ② 大小(后端限 2MB)
|
||||||
|
if (f.size > 2 * 1024 * 1024) {
|
||||||
|
setTip('✕ 图片 ' + sizeTxt + ',超过 2 MB 上限,请压缩后再传', 'err');
|
||||||
|
setName('');
|
||||||
|
this.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f.size <= 0) {
|
||||||
|
setTip('✕ 这是个空文件', 'err');
|
||||||
|
setName('');
|
||||||
|
this.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 本地预览 + 尺寸检查
|
||||||
|
var fr = new FileReader();
|
||||||
|
fr.onload = function (ev) {
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
if (img.width < 32 || img.height < 32) {
|
||||||
|
setTip('✕ 图片只有 ' + img.width + '×' + img.height + ',至少 32×32', 'err');
|
||||||
|
setName('');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (prev) {
|
||||||
|
prev.innerHTML = '';
|
||||||
|
var el = document.createElement('img');
|
||||||
|
el.src = ev.target.result;
|
||||||
|
el.alt = '';
|
||||||
|
el.width = 48; el.height = 48;
|
||||||
|
prev.appendChild(el);
|
||||||
|
}
|
||||||
|
var warn = (img.width !== img.height) ? ' ⚠ 不是正方形,显示时会被裁切' : '';
|
||||||
|
setName(f.name);
|
||||||
|
setTip('✓ ' + img.width + '×' + img.height + ',' + sizeTxt
|
||||||
|
+ warn + ' 保存后生效', 'ok');
|
||||||
|
};
|
||||||
|
img.onerror = function () {
|
||||||
|
setTip('✕ 这张图读不出来,可能已损坏', 'err');
|
||||||
|
setName('');
|
||||||
|
input.value = '';
|
||||||
|
};
|
||||||
|
img.src = ev.target.result;
|
||||||
|
};
|
||||||
|
fr.onerror = function () { setTip('✕ 读取文件失败', 'err'); setName(''); };
|
||||||
|
fr.readAsDataURL(f);
|
||||||
|
});
|
||||||
|
})(inputs[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 图标快选(后台软件编辑) ---------- */
|
||||||
|
function bindIconPicker() {
|
||||||
|
var boxes = document.querySelectorAll('.icon-presets');
|
||||||
|
for (var i = 0; i < boxes.length; i++) {
|
||||||
|
(function (box) {
|
||||||
|
// 图标输入框是「图标快选」的前一个兄弟里的 input[name=icon]
|
||||||
|
var field = box.parentNode;
|
||||||
|
var input = field ? field.querySelector('input[name=icon]') : null;
|
||||||
|
if (!input) { return; }
|
||||||
|
var btns = box.querySelectorAll('.icon-preset');
|
||||||
|
|
||||||
|
function syncOn(v) {
|
||||||
|
for (var k = 0; k < btns.length; k++) {
|
||||||
|
var same = (btns[k].getAttribute('data-icon') || '') === v;
|
||||||
|
if (same) { btns[k].classList.add('on'); } else { btns[k].classList.remove('on'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var j = 0; j < btns.length; j++) {
|
||||||
|
btns[j].addEventListener('click', function () {
|
||||||
|
input.value = this.getAttribute('data-icon') || '';
|
||||||
|
syncOn(input.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
input.addEventListener('input', function () { syncOn(this.value); });
|
input.addEventListener('input', function () { syncOn(this.value); });
|
||||||
|
syncOn(input.value);
|
||||||
if (reset) {
|
})(boxes[i]);
|
||||||
reset.addEventListener('click', function () {
|
|
||||||
// 清空 color 字段 = 保存后回落到按 id 取的默认色
|
|
||||||
input.value = '#0057b8';
|
|
||||||
syncOn('');
|
|
||||||
var hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.name = 'color';
|
|
||||||
hidden.value = '';
|
|
||||||
row.appendChild(hidden);
|
|
||||||
input.removeAttribute('name'); // 提交时以隐藏字段为准
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})(rows[i]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 自定义下拉:替换原生 <select> 的展开列表 ----------
|
||||||
|
* 用户 2026-11-04:「所有下拉框拉下来之后怎么全是灰色的?不好看,重新设计。」
|
||||||
|
*
|
||||||
|
* 🔴 为什么必须自绘:原生 <select> 的展开列表是**浏览器渲染的原生控件**,
|
||||||
|
* CSS 只能改 <option> 的 background/color,改不了圆角、行高、hover 高亮、
|
||||||
|
* 分组间距 —— 而且各平台样式完全不同(macOS 是圆角浮层、Windows 是灰方框)。
|
||||||
|
* 想让"展开后"好看,只能自己画一个列表。
|
||||||
|
*
|
||||||
|
* 设计取舍:
|
||||||
|
* - **保留原生 select 作为数据源**:不重写 HTML(全站 16 个 select,
|
||||||
|
* 逐个改容易漏、也容易破坏 onchange 内联逻辑)。
|
||||||
|
* 把原生 select 视觉隐藏,在旁边放一个按钮 + 浮层列表。
|
||||||
|
* - 原生 select 仍参与表单提交(值同步),onchange 也照样触发
|
||||||
|
* —— 调用 this.form.submit() 的那些筛选下拉因此不用改动。
|
||||||
|
* - 无 JS 时原生 select 正常可用(只是样式朴素),不会白屏。
|
||||||
|
* - 浮层用 position:fixed 定位,避免被 overflow:hidden 容器裁掉(踩过)。
|
||||||
|
*/
|
||||||
|
function bindSelect() {
|
||||||
|
var sels = document.querySelectorAll('select');
|
||||||
|
for (var i = 0; i < sels.length; i++) {
|
||||||
|
(function (sel) {
|
||||||
|
// 已经增强过、或明确排除的跳过
|
||||||
|
if (sel.getAttribute('data-enhanced') === '1') { return; }
|
||||||
|
if (sel.hasAttribute('data-no-enhance')) { return; }
|
||||||
|
// 隐藏域 / 多选 / 尺寸>1 的列表框不动
|
||||||
|
if (sel.multiple || (parseInt(sel.getAttribute('size'), 10) || 0) > 1) { return; }
|
||||||
|
|
||||||
|
sel.setAttribute('data-enhanced', '1');
|
||||||
|
|
||||||
|
// 原生 select:保留在 DOM 里(提交用),但视觉隐藏
|
||||||
|
sel.classList.add('sel-native');
|
||||||
|
|
||||||
|
// 包一层,方便定位
|
||||||
|
var wrap = document.createElement('span');
|
||||||
|
wrap.className = 'sel';
|
||||||
|
sel.parentNode.insertBefore(wrap, sel);
|
||||||
|
wrap.appendChild(sel);
|
||||||
|
|
||||||
|
// 触发按钮
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'sel-btn';
|
||||||
|
btn.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
|
||||||
|
// 浮层列表(挂到 body,避开一切 overflow 裁剪)
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.className = 'sel-panel';
|
||||||
|
panel.hidden = true;
|
||||||
|
panel.setAttribute('role', 'listbox');
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
var active = -1; // 键盘高亮项
|
||||||
|
|
||||||
|
function curText() {
|
||||||
|
var o = sel.options[sel.selectedIndex];
|
||||||
|
return o ? o.textContent : '';
|
||||||
|
}
|
||||||
|
function syncBtn() {
|
||||||
|
btn.innerHTML = '<span class="sel-txt"></span><span class="sel-caret" aria-hidden="true"></span>';
|
||||||
|
btn.querySelector('.sel-txt').textContent = curText();
|
||||||
|
btn.disabled = sel.disabled;
|
||||||
|
btn.classList.toggle('is-placeholder', sel.selectedIndex <= 0 && sel.options.length > 0
|
||||||
|
&& (sel.options[0].value === ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildList() {
|
||||||
|
panel.innerHTML = '';
|
||||||
|
for (var k = 0; k < sel.options.length; k++) {
|
||||||
|
(function (idx) {
|
||||||
|
var o = sel.options[idx];
|
||||||
|
if (o.hidden) { return; }
|
||||||
|
var item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.className = 'sel-item';
|
||||||
|
item.setAttribute('role', 'option');
|
||||||
|
var on = idx === sel.selectedIndex;
|
||||||
|
if (on) { item.classList.add('on'); item.setAttribute('aria-selected', 'true'); }
|
||||||
|
var label = document.createElement('span');
|
||||||
|
label.className = 'sel-item-txt';
|
||||||
|
label.textContent = o.textContent;
|
||||||
|
item.appendChild(label);
|
||||||
|
if (on) {
|
||||||
|
var ck = document.createElement('span');
|
||||||
|
ck.className = 'sel-item-ck';
|
||||||
|
ck.setAttribute('aria-hidden', 'true');
|
||||||
|
item.appendChild(ck);
|
||||||
|
}
|
||||||
|
item.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
pick(idx);
|
||||||
|
});
|
||||||
|
panel.appendChild(item);
|
||||||
|
})(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(idx) {
|
||||||
|
if (idx < 0 || idx >= sel.options.length) { return; }
|
||||||
|
var changed = sel.selectedIndex !== idx;
|
||||||
|
sel.selectedIndex = idx;
|
||||||
|
syncBtn();
|
||||||
|
close();
|
||||||
|
// 触发原生 change,兼容那些 onchange="this.form.submit()" 的下拉
|
||||||
|
if (changed) {
|
||||||
|
var ev = document.createEvent('HTMLEvents');
|
||||||
|
ev.initEvent('change', true, false);
|
||||||
|
sel.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (btn.disabled) { return; }
|
||||||
|
buildList();
|
||||||
|
panel.hidden = false;
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
btn.classList.add('is-open');
|
||||||
|
position();
|
||||||
|
// 打开后键盘可直接上下选
|
||||||
|
active = sel.selectedIndex;
|
||||||
|
highlight(active);
|
||||||
|
document.addEventListener('mousedown', outside, true);
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
window.addEventListener('scroll', position, true);
|
||||||
|
window.addEventListener('resize', position);
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
if (panel.hidden) { return; }
|
||||||
|
panel.hidden = true;
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.classList.remove('is-open');
|
||||||
|
document.removeEventListener('mousedown', outside, true);
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
window.removeEventListener('scroll', position, true);
|
||||||
|
window.removeEventListener('resize', position);
|
||||||
|
}
|
||||||
|
function outside(e) {
|
||||||
|
if (!panel.contains(e.target) && !btn.contains(e.target)) { close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定位:fixed,挂在 body 上,不服从任何祖先的 overflow。
|
||||||
|
* 优先向下展开;下方空间不足且上方更宽裕时向上翻。 */
|
||||||
|
function position() {
|
||||||
|
if (panel.hidden) { return; }
|
||||||
|
var r = btn.getBoundingClientRect();
|
||||||
|
var vw = window.innerWidth, vh = window.innerHeight;
|
||||||
|
var pw = Math.max(panel.offsetWidth, r.width);
|
||||||
|
var ph = panel.offsetHeight;
|
||||||
|
var margin = 8;
|
||||||
|
|
||||||
|
var left = r.left;
|
||||||
|
if (left + pw + margin > vw) { left = Math.max(margin, vw - pw - margin); }
|
||||||
|
|
||||||
|
var below = vh - r.bottom - margin;
|
||||||
|
var above = r.top - margin;
|
||||||
|
var openUp = (ph > below) && (above > below);
|
||||||
|
var top = openUp ? (r.top - ph - 4) : (r.bottom + 4);
|
||||||
|
if (top < margin) { top = margin; }
|
||||||
|
if (top + ph > vh - margin) { top = Math.max(margin, vh - ph - margin); }
|
||||||
|
|
||||||
|
panel.style.left = Math.round(left) + 'px';
|
||||||
|
panel.style.top = Math.round(top) + 'px';
|
||||||
|
panel.style.minWidth = Math.round(r.width) + 'px';
|
||||||
|
panel.classList.toggle('is-up', openUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight(idx) {
|
||||||
|
var items = panel.querySelectorAll('.sel-item');
|
||||||
|
for (var k = 0; k < items.length; k++) {
|
||||||
|
items[k].classList.toggle('is-active', k === idx);
|
||||||
|
}
|
||||||
|
if (idx >= 0 && items[idx]) {
|
||||||
|
var it = items[idx];
|
||||||
|
var t = it.offsetTop, b = t + it.offsetHeight;
|
||||||
|
if (t < panel.scrollTop) { panel.scrollTop = t; }
|
||||||
|
else if (b > panel.scrollTop + panel.clientHeight) {
|
||||||
|
panel.scrollTop = b - panel.clientHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e) {
|
||||||
|
var items = panel.querySelectorAll('.sel-item');
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
active = Math.min(items.length - 1, active + 1); highlight(active);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
active = Math.max(0, active - 1); highlight(active);
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (active >= 0) { pick(active); }
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault(); active = 0; highlight(active);
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault(); active = items.length - 1; highlight(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
if (panel.hidden) { open(); } else { close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 原生 select 若在别处被程序改值,按钮文字要跟着变
|
||||||
|
sel.addEventListener('change', syncBtn);
|
||||||
|
|
||||||
|
syncBtn();
|
||||||
|
})(sels[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 顶栏软件下拉菜单(右上角三横线) ----------
|
||||||
|
* 极简:点了就展开一个小浮窗,点外面 / Esc 就关。
|
||||||
|
* 不做遮罩、不锁背景滚动 —— 那套是侧边栏的做法,下拉菜单不需要。 */
|
||||||
|
function bindSoftwarePanel() {
|
||||||
|
var btn = document.getElementById('swToggle');
|
||||||
|
var panel = document.getElementById('swPanel');
|
||||||
|
if (!btn || !panel) { return; }
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
panel.hidden = true;
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (panel.hidden) {
|
||||||
|
panel.hidden = false;
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点菜单内部不算点外面
|
||||||
|
panel.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||||
|
|
||||||
|
document.addEventListener('click', function () {
|
||||||
|
if (!panel.hidden) { close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && !panel.hidden) { close(); btn.focus(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 邮件测试发送(后台设置页) ---------- */
|
/* ---------- 邮件测试发送(后台设置页) ---------- */
|
||||||
function bindMailTest() {
|
function bindMailTest() {
|
||||||
var btn = document.getElementById('mailTestBtn');
|
var btn = document.getElementById('mailTestBtn');
|
||||||
if (!btn) { return; }
|
if (!btn) { return; }
|
||||||
var out = document.getElementById('mailTestOut');
|
var out = document.getElementById('mailTestOut');
|
||||||
var to = document.getElementById('mailTestTo');
|
var to = document.getElementById('mailTestTo');
|
||||||
|
var swSel = document.getElementById('mailTestSw');
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
var addr = to ? (to.value || '').trim() : '';
|
var addr = to ? (to.value || '').trim() : '';
|
||||||
if (!addr) {
|
if (!addr) {
|
||||||
@@ -793,6 +1381,7 @@
|
|||||||
fd.append('csrf', btn.getAttribute('data-csrf') || '');
|
fd.append('csrf', btn.getAttribute('data-csrf') || '');
|
||||||
fd.append('act', 'mail_test');
|
fd.append('act', 'mail_test');
|
||||||
fd.append('to', addr);
|
fd.append('to', addr);
|
||||||
|
if (swSel && swSel.value) { fd.append('sw', swSel.value); }
|
||||||
|
|
||||||
fetch('admin.php', { method: 'POST', body: fd, credentials: 'same-origin' })
|
fetch('admin.php', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||||
.then(function (r) { return r.text(); })
|
.then(function (r) { return r.text(); })
|
||||||
@@ -818,9 +1407,12 @@
|
|||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
bindCaptchaRefresh();
|
bindCaptchaRefresh();
|
||||||
bindAdminToggle();
|
|
||||||
bindWontfixPanel();
|
bindWontfixPanel();
|
||||||
bindColorPicker();
|
bindColorPicker();
|
||||||
|
bindIconPicker();
|
||||||
|
bindIconUpload();
|
||||||
|
bindSelect();
|
||||||
|
bindSoftwarePanel();
|
||||||
bindMailTest();
|
bindMailTest();
|
||||||
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
||||||
bindNoDoubleSubmit();
|
bindNoDoubleSubmit();
|
||||||
|
|||||||
+889
-36
File diff suppressed because it is too large
Load Diff
+145
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 启动引导:加载配置、注册自动加载、开启会话、初始化数据库。
|
||||||
|
* 所有页面(index/thread/new/api)都以此为入口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('FB_ROOT')) {
|
||||||
|
define('FB_ROOT', dirname(__DIR__));
|
||||||
|
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
||||||
|
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
||||||
|
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 37); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
* 错误显示策略必须最先设置:
|
||||||
|
* 任何被打印到页面的 Warning/Deprecated 都会让响应头提前发出,
|
||||||
|
* 从而令随后的 session_start() 失败(表现为「会话已过期」、CSRF 全部失效)。
|
||||||
|
* 所以这里在 require 任何库文件之前就把 display_errors 关掉。
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
@ini_set('display_errors', '0');
|
||||||
|
@ini_set('log_errors', '1');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// 兜底:捕获致命错误时也不要向页面输出堆栈(生产环境)
|
||||||
|
if (!defined('FB_BOOTSTRAPPED')) {
|
||||||
|
define('FB_BOOTSTRAPPED', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台改设置后自增,用来让 fb_cfg 的进程内缓存失效
|
||||||
|
if (!isset($GLOBALS['__fb_cfg_stamp'])) {
|
||||||
|
$GLOBALS['__fb_cfg_stamp'] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_cfg')) {
|
||||||
|
/**
|
||||||
|
* 只读【配置文件】的值(config.php → config.example.php),
|
||||||
|
* **不叠加 settings 表、绝不触达数据库**。
|
||||||
|
*
|
||||||
|
* 为什么必须单独有这么一条通道:正常的 fb_cfg() 会去读 settings 表,
|
||||||
|
* 而读表要先 fb_db() —— 那一步会就地建出一个空数据库文件。
|
||||||
|
* 「是否已安装」的判定恰恰以「数据库文件在不在」为依据,
|
||||||
|
* 所以判定必须走这条不碰库的路径,否则第一秒就把安装态自己造出来了。
|
||||||
|
*/
|
||||||
|
function fb_cfg_file() {
|
||||||
|
static $file = null;
|
||||||
|
static $stamp = -1;
|
||||||
|
$now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0;
|
||||||
|
if ($file === null || $stamp !== $now) {
|
||||||
|
$local = FB_ROOT . '/config.php';
|
||||||
|
$example = FB_ROOT . '/config.example.php';
|
||||||
|
if (is_file($local)) { $file = (array)(require $local); }
|
||||||
|
elseif (is_file($example)) { $file = (array)(require $example); }
|
||||||
|
else { $file = []; }
|
||||||
|
$stamp = $now;
|
||||||
|
}
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取配置(点分路径,如 fb_cfg('smtp.host'))
|
||||||
|
* 取值顺序:settings 表(后台改过的)> config.php(本地配置)> config.example.php(出厂默认)> $default
|
||||||
|
*
|
||||||
|
* config.php 不参与版本管理(见 .gitignore),新部署时可从 config.example.php 复制一份;
|
||||||
|
* 若不存在则直接以 config.example.php 为默认值运行,保证 clone 下来即可跑。
|
||||||
|
*/
|
||||||
|
function fb_cfg($path = null, $default = null) {
|
||||||
|
static $merged = null;
|
||||||
|
static $stamp = -1;
|
||||||
|
$now = isset($GLOBALS['__fb_cfg_stamp']) ? (int)$GLOBALS['__fb_cfg_stamp'] : 0;
|
||||||
|
if ($merged === null || $stamp !== $now) {
|
||||||
|
$cfg = fb_cfg_file();
|
||||||
|
// 后台设置以嵌套数组形式叠加在文件配置之上(只覆盖同名键)
|
||||||
|
$tree = function_exists('fb_setting_tree') ? fb_setting_tree() : [];
|
||||||
|
$merged = is_array($tree) && $tree ? array_replace_recursive($cfg, $tree) : $cfg;
|
||||||
|
$stamp = $now;
|
||||||
|
}
|
||||||
|
if ($path === null || $path === '') { return $merged; }
|
||||||
|
$cur = $merged;
|
||||||
|
foreach (explode('.', (string)$path) as $k) {
|
||||||
|
if (is_array($cur) && array_key_exists($k, $cur)) {
|
||||||
|
$cur = $cur[$k];
|
||||||
|
} else {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 让 fb_cfg() 的进程内缓存失效(改了磁盘上的 config.php 后必须调用) */
|
||||||
|
function fb_cfg_flush() {
|
||||||
|
$GLOBALS['__fb_cfg_stamp'] = ((int)$GLOBALS['__fb_cfg_stamp']) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once FB_ROOT . '/lib/util.php';
|
||||||
|
require_once FB_ROOT . '/lib/db.php';
|
||||||
|
require_once FB_ROOT . '/lib/oss.php';
|
||||||
|
require_once FB_ROOT . '/lib/attach.php';
|
||||||
|
require_once FB_ROOT . '/lib/captcha.php';
|
||||||
|
require_once FB_ROOT . '/lib/mailer.php';
|
||||||
|
require_once FB_ROOT . '/lib/layout.php';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
* 安装引导:数据库还没生成(data/ 下没数据)就说明是全新部署,
|
||||||
|
* 一律先送去 install.php 填配置、建库、设口令,装完才进系统。
|
||||||
|
* 判据只看「数据库文件在不在」——绝不能在这里调 fb_db(),
|
||||||
|
* 因为那会就地建出一个空库、把安装态自己"装"出来,之后就再也进不了向导。
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
if (!defined('FB_SKIP_INSTALL_REDIRECT') && !fb_installed()) {
|
||||||
|
if (PHP_SAPI === 'cli') {
|
||||||
|
fwrite(STDERR, "FeedLite 尚未安装:请先在浏览器中打开本目录下的 install.php 完成安装。\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
fb_redirect('install.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话(CLI 下不启用)
|
||||||
|
if (PHP_SAPI !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
$p = session_get_cookie_params();
|
||||||
|
$https = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off');
|
||||||
|
@session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => !empty($p['path']) ? $p['path'] : '/',
|
||||||
|
'domain' => '',
|
||||||
|
'secure' => $https,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
@session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化数据库(建表)。
|
||||||
|
// ★ 只在【已安装】时做:未安装就建库,等于第一秒把这个状态自己抹掉,
|
||||||
|
// 安装向导(install.php)随后就会被判定成「已经装好了」而拒绝工作。
|
||||||
|
// install.php 在真正提交安装时会自己显式调用 fb_db()。
|
||||||
|
if (fb_installed()) {
|
||||||
|
fb_db();
|
||||||
|
// 顺手确保 uploads/ 目录与「禁止直读」规则在位且为最新版(local / oss 都需要)。
|
||||||
|
// ⚠️ 为什么放在这里:老站点上早已存在的旧版 .htaccess(曾允许直读)不会自己更新,
|
||||||
|
// 而部署账号通常对 uploads/ 无写权限 —— 只有 PHP(目录属主)能改。
|
||||||
|
// 函数内部只在「文件缺失或版本标记过期」时才写盘,命中后每次请求几乎零开销。
|
||||||
|
if (function_exists('fb_local_ensure_dir')) {
|
||||||
|
fb_local_ensure_dir();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,44 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 问题列表
|
* 首页 / 当前软件的问题列表
|
||||||
|
*
|
||||||
|
* 两种形态(用户 2026-11-03 定的产品逻辑):
|
||||||
|
* ① **不带 sw**:首页 = 轻反馈系统的介绍页。
|
||||||
|
* 不再列「全部问题」—— 多个软件的反馈混在一起,对访客毫无意义:
|
||||||
|
* 他不是所有软件的使用者,看别人的反馈没价值。
|
||||||
|
* ② **带 sw**:当前软件的问题列表。
|
||||||
|
* 检索/筛选/分类全部只在本软件范围内,弱化其他软件。
|
||||||
*/
|
*/
|
||||||
require __DIR__ . '/lib/bootstrap.php';
|
require __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
$pdo = fb_db();
|
$pdo = fb_db();
|
||||||
|
|
||||||
/* ---- 软件切换:只配一个软件时自动落到它,不暴露软件选择器 ---- */
|
/* ---- 软件切换 ---- */
|
||||||
$swList = fb_softwares(true);
|
$swList = fb_softwares(true);
|
||||||
$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
|
$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
|
||||||
$sw = null;
|
$sw = null;
|
||||||
if ($swKey !== '') {
|
if ($swKey !== '') {
|
||||||
$sw = fb_software($swKey);
|
$sw = fb_software($swKey);
|
||||||
// 指定的软件不存在或已停用 → 退回默认,不报错(老链接要能打开)
|
// 指定的软件不存在或已停用 → 退回首页(不自动落到第一个软件)
|
||||||
if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; }
|
if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; }
|
||||||
}
|
}
|
||||||
if ($sw === null && $swList) { $sw = $swList[0]; }
|
|
||||||
$swId = $sw ? (int)$sw['id'] : 0;
|
/* ==================================================================
|
||||||
|
* ① 没有指定软件 → 首页介绍页
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 用户原话:「首页应该是对我们这个轻量级反馈系统的介绍,
|
||||||
|
* 说明它的特点是什么,然后告诉用户,如果现在想提交反馈,
|
||||||
|
* 可以点右上角的设置按钮,选择不同的系统,进去就可以了。」
|
||||||
|
*
|
||||||
|
* 🔴 不要在这里列问题。以前首页默认落到第一个软件并列出全部问题,
|
||||||
|
* 等于把 A 软件的反馈摊给 B 软件的用户看。
|
||||||
|
* ================================================================== */
|
||||||
|
if ($sw === null) {
|
||||||
|
fb_render_home($swList);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$swId = (int)$sw['id'];
|
||||||
|
|
||||||
$cats = fb_categories($swId);
|
$cats = fb_categories($swId);
|
||||||
$statuses = fb_status_keys();
|
$statuses = fb_status_keys();
|
||||||
@@ -76,32 +98,31 @@ $attImgs = $tids ? fb_attach_map_images($pdo, $tids, 3) : [];
|
|||||||
$attCount = $tids ? fb_attach_count_map($pdo, $tids) : [];
|
$attCount = $tids ? fb_attach_count_map($pdo, $tids) : [];
|
||||||
|
|
||||||
$siteName = (string)fb_cfg('site_name');
|
$siteName = (string)fb_cfg('site_name');
|
||||||
$headTitle = $siteName !== '' ? $siteName . ' · 问题反馈' : '问题反馈';
|
/* 页面标题(浏览器标签 / 页面大标题):
|
||||||
fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]);
|
* 选中某个软件时用「XX 反馈系统」,让用户一眼确认自己在这个软件的反馈区;
|
||||||
|
* 没选软件时退回站点名。 */
|
||||||
|
$headTitle = $sw ? fb_software_site_title($sw)
|
||||||
|
: ($siteName !== '' ? $siteName . ' · 问题反馈' : '问题反馈');
|
||||||
|
fb_layout_head($sw ? fb_software_site_title($sw) : $headTitle, ['software' => $sw]);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-head">
|
<?php /* 这一整行(软件图标+名字 / 简介 / 「我要反馈」按钮)已删除。
|
||||||
<div>
|
*
|
||||||
<h1><?php echo e($sw ? (string)$sw['name'] : '全部问题'); ?></h1>
|
* 用户 2026-11-03:「上面已经有标题和『我要反馈』了,下面又出现一次,
|
||||||
<?php
|
* 完全重复。下面这一行不要了…其他内容开始往前提,往上提,这样更简洁。」
|
||||||
$siteDesc = (string)fb_cfg('site_desc');
|
*
|
||||||
$swNote = ($sw && !empty($sw['note'])) ? (string)$sw['note'] : '';
|
* 现在:
|
||||||
?>
|
* - 「同步日历反馈系统」+ 灰色简介 → 只在顶栏出现一次
|
||||||
<?php if ($siteDesc !== ''): ?><p class="muted"><?php echo e($siteDesc); ?></p><?php endif; ?>
|
* - 「我要反馈」→ 顶栏右侧那个 btn-mini 就够了(列表页不再放第二个)
|
||||||
<?php if ($swNote !== ''): ?><p class="muted small" style="margin:2px 0 0"><?php echo e($swNote); ?></p><?php endif; ?>
|
* - 列表内容直接从下面开始,省掉一整行高度
|
||||||
</div>
|
*/
|
||||||
<a class="btn primary" href="new.php<?php echo $sw ? '?sw=' . e(urlencode((string)$sw['slug'])) : ''; ?>">我要反馈</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="software-bar">
|
if (!$sw):
|
||||||
<span class="sw-label">选择软件:</span>
|
?>
|
||||||
<div class="chips">
|
<div class="page-head">
|
||||||
<?php foreach ($swList as $s): ?>
|
<div><h1>全部问题</h1></div>
|
||||||
<a class="chip <?php echo ($sw && (int)$s['id'] === (int)$sw['id']) ? 'on' : ''; ?>"
|
|
||||||
href="<?php echo e(fb_qs(['sw' => (string)$s['slug'], 'cat' => null, 'page' => null]) ?: ('?sw=' . e(urlencode((string)$s['slug'])))); ?>"><?php echo e((string)$s['name']); ?></a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
@@ -137,12 +158,25 @@ fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]);
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($threads as $t): ?>
|
<?php foreach ($threads as $t): ?>
|
||||||
<a class="item" href="thread.php?id=<?php echo (int)$t['id']; ?>">
|
<a class="item" href="thread.php?id=<?php echo (int)$t['id']; ?>">
|
||||||
<div class="item-top">
|
<?php /* 标题单独占第一行、从最左边顶头开始 ——
|
||||||
<span class="badge cat"><?php echo e(fb_category_label($t['category_id'])); ?></span>
|
用户 2026-11-03:标签放在标题前面会把标题挤到右边,很难看。
|
||||||
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
顺序改为「标题 → 标签」:标题在上,分类/状态另起一行。 */ ?>
|
||||||
|
<div class="item-head">
|
||||||
<span class="item-title"><?php echo e($t['title']); ?></span>
|
<span class="item-title"><?php echo e($t['title']); ?></span>
|
||||||
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
|
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="item-tags">
|
||||||
|
<?php
|
||||||
|
/* 分类标签必须有名字才渲染 —— 否则会输出一个空胶囊(什么都看不见),
|
||||||
|
* 用户会以为「反馈类型丢了」。老数据 category_id 可能为空,
|
||||||
|
* 这时按 slug 兜底;两者都取不到才不显示。 */
|
||||||
|
$lCat = !empty($t['category_id'])
|
||||||
|
? fb_category_label((int)$t['category_id'])
|
||||||
|
: fb_category_label_by_slug((int)$t['software_id'], (string)$t['category']);
|
||||||
|
?>
|
||||||
|
<?php if ($lCat !== ''): ?><span class="badge cat"><?php echo e($lCat); ?></span><?php endif; ?>
|
||||||
|
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||||
|
</div>
|
||||||
<p class="item-ex"><?php echo e(fb_excerpt($t['excerpt'], 120)); ?></p>
|
<p class="item-ex"><?php echo e(fb_excerpt($t['excerpt'], 120)); ?></p>
|
||||||
<?php if (!empty($attImgs[(int)$t['id']])): ?>
|
<?php if (!empty($attImgs[(int)$t['id']])): ?>
|
||||||
<div class="thumbs">
|
<div class="thumbs">
|
||||||
@@ -188,3 +222,94 @@ fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]);
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
fb_layout_foot();
|
fb_layout_foot();
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 首页介绍页
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 用户 2026-11-03 定的产品逻辑:首页不讲"有哪些问题",只讲"这是什么"。
|
||||||
|
*
|
||||||
|
* 场景说明(决定了下面文案的取舍):
|
||||||
|
* 这个系统一般是给某个软件配一个反馈入口,把地址贴进那个软件的
|
||||||
|
* 「意见反馈」里。所以访客**总是从某个软件过来的**,他心里已经有一个
|
||||||
|
* 明确的目标软件;首页要做的是「介绍 + 指路」,不是「陈列别人的问题」。
|
||||||
|
* ================================================================== */
|
||||||
|
function fb_render_home(array $swList) {
|
||||||
|
$siteName = (string)fb_cfg('site_name');
|
||||||
|
$title = $siteName !== '' ? $siteName : '轻反馈';
|
||||||
|
|
||||||
|
fb_layout_head($title . ' · 轻量级反馈系统');
|
||||||
|
|
||||||
|
$n = count($swList);
|
||||||
|
?>
|
||||||
|
<div class="home">
|
||||||
|
<section class="home-hero">
|
||||||
|
<h1>轻量级反馈系统</h1>
|
||||||
|
<p class="home-lead">给每个软件一个独立的反馈入口:用户不用注册,填个昵称和邮箱就能提问题;
|
||||||
|
所有进展都会自动发邮件通知他。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="home-feats">
|
||||||
|
<div class="home-feat">
|
||||||
|
<div class="home-feat-ic">📮</div>
|
||||||
|
<h3>不用注册</h3>
|
||||||
|
<p>填昵称和常用邮箱即可提交。后续每一条进展都会发邮件通知,不用反复回来刷。</p>
|
||||||
|
</div>
|
||||||
|
<div class="home-feat">
|
||||||
|
<div class="home-feat-ic">🔗</div>
|
||||||
|
<h3>私人追踪链接</h3>
|
||||||
|
<p>提交后会收到一条专属链接,凭它随时查看进度——哪怕这条反馈还没审核通过。</p>
|
||||||
|
</div>
|
||||||
|
<div class="home-feat">
|
||||||
|
<div class="home-feat-ic">🗂</div>
|
||||||
|
<h3>按软件分开</h3>
|
||||||
|
<p>每个软件一套独立的问题列表和分类,互不干扰。同一软件的其他人也能看到已解决的问题。</p>
|
||||||
|
</div>
|
||||||
|
<div class="home-feat">
|
||||||
|
<div class="home-feat-ic">💬</div>
|
||||||
|
<h3>公开讨论</h3>
|
||||||
|
<p>已公开的问题任何人都能查看和回复。提交前先搜一下,避免重复提同样的问题。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="home-enter">
|
||||||
|
<h2>进入某个软件的反馈系统</h2>
|
||||||
|
<?php if ($n === 0): ?>
|
||||||
|
<p class="muted">管理员还没有配置软件。</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="home-enter-tip">
|
||||||
|
点击右上角的<span class="home-menu-demo" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
|
<b>设置按钮</b>,从列表里选择您要反馈的软件即可。
|
||||||
|
</p>
|
||||||
|
<div class="home-sw-list">
|
||||||
|
<?php foreach ($swList as $s): ?>
|
||||||
|
<?php
|
||||||
|
list($c) = fb_software_colors($s);
|
||||||
|
if ($c === '') { $c = '#0057B8'; }
|
||||||
|
?>
|
||||||
|
<a class="home-sw" href="?sw=<?php echo e(urlencode((string)$s['slug'])); ?>"
|
||||||
|
style="--swc:<?php echo e($c); ?>">
|
||||||
|
<span class="home-sw-ic">
|
||||||
|
<?php
|
||||||
|
$ik = trim((string)(isset($s['icon_key']) ? $s['icon_key'] : ''));
|
||||||
|
$iu = $ik !== '' ? fb_icon_url($ik, 72) : '';
|
||||||
|
?>
|
||||||
|
<?php if ($iu !== ''): ?>
|
||||||
|
<img src="<?php echo e($iu); ?>" alt="" width="36" height="36">
|
||||||
|
<?php else: ?>
|
||||||
|
<span><?php echo e(fb_software_icon($s)); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<span class="home-sw-nm"><?php echo e((string)$s['name']); ?></span>
|
||||||
|
<span class="home-sw-go">进入 →</span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p class="home-enter-alt muted small">
|
||||||
|
也可以直接点上方的软件卡片进入。
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
fb_layout_foot();
|
||||||
|
}
|
||||||
|
|||||||
+302
-20
@@ -5,6 +5,129 @@
|
|||||||
*/
|
*/
|
||||||
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 软件图标图片:上传 / 删除
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 安全边界(三条,缺一不可):
|
||||||
|
* ① 只有管理员能进(这个函数只从后台登录后的动作分支调用);
|
||||||
|
* ② 落盘前用 finfo 读真实文件头,必须是图片白名单里的类型 ——
|
||||||
|
* 与附件上传同一个教训:把 PHP 改名成 .png 就能写进目录,
|
||||||
|
* .htaccess 是唯一防线,换 nginx 就失守;
|
||||||
|
* ③ 键空间独立为 icons/,且形状受限(无穿越、无自由路径)。
|
||||||
|
*
|
||||||
|
* 存储方式跟附件完全一致:本机 → uploads/;OSS → 服务器代理 PUT 到私有桶。
|
||||||
|
* 对外一律 media.php?k=... 代理,不暴露真实位置。
|
||||||
|
* ================================================================== */
|
||||||
|
|
||||||
|
/** 图标允许的扩展名 —— 定义在 lib/attach.php(fb_icon_allowed_ext),
|
||||||
|
* 因为 media.php 与 CLI 场景也会用到,不能依赖这个后台文件被加载。 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存一个上传的图标文件,返回 [bool ok, string 值(键或错误信息), string mime]。
|
||||||
|
* 成功后值是【对象键】;失败时值是给用户看的错误原因。
|
||||||
|
*/
|
||||||
|
function fb_icon_store_upload(array $file) {
|
||||||
|
if (!isset($file['error']) || (int)$file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$code = isset($file['error']) ? (int)$file['error'] : -1;
|
||||||
|
return [false, $code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE
|
||||||
|
? '图片太大,请换一张小一点的(建议 512×512 以内)。'
|
||||||
|
: '上传失败(错误码 ' . $code . ')。', ''];
|
||||||
|
}
|
||||||
|
$tmp = (string)(isset($file['tmp_name']) ? $file['tmp_name'] : '');
|
||||||
|
if ($tmp === '' || !is_uploaded_file($tmp)) {
|
||||||
|
return [false, '上传失败,请重试。', ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int)@filesize($tmp);
|
||||||
|
if ($size <= 0) { return [false, '图片是空文件。', '']; }
|
||||||
|
if ($size > 2 * 1024 * 1024) { return [false, '图标不能超过 2 MB。', '']; }
|
||||||
|
|
||||||
|
// 真实类型(不信扩展名、不信前端上报的 mime)
|
||||||
|
$mime = '';
|
||||||
|
if (function_exists('finfo_open')) {
|
||||||
|
$fi = @finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
if ($fi) { $mime = strtolower(trim((string)@finfo_file($fi, $tmp))); @finfo_close($fi); }
|
||||||
|
}
|
||||||
|
$mimeMap = [
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
];
|
||||||
|
if (!isset($mimeMap[$mime])) {
|
||||||
|
return [false, '只支持 PNG / JPG / WEBP 格式的图片(检测到 ' . ($mime === '' ? '未知' : $mime) . ')。', ''];
|
||||||
|
}
|
||||||
|
$ext = $mimeMap[$mime];
|
||||||
|
|
||||||
|
// 尺寸下限:太小的图放大会糊,直接拒绝比事后难看更好
|
||||||
|
if (function_exists('getimagesize')) {
|
||||||
|
$wh = @getimagesize($tmp);
|
||||||
|
if ($wh && isset($wh[0], $wh[1])) {
|
||||||
|
if ($wh[0] < 32 || $wh[1] < 32) {
|
||||||
|
return [false, '图片太小(' . (int)$wh[0] . '×' . (int)$wh[1] . '),至少 32×32。', ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = fb_store_mode();
|
||||||
|
if ($mode === 'off') { return [false, '附件存储未启用,无法保存图标。请先在「站点设置」里启用存储。', '']; }
|
||||||
|
|
||||||
|
$key = fb_icon_make_key($ext);
|
||||||
|
|
||||||
|
/* 断言:键的前缀必须与当前存储方式一致。
|
||||||
|
* 这两者一旦脱钩,就会出现「文件写进 uploads/ 但 media.php 去 OSS 找」的 503 ——
|
||||||
|
* 早期把键硬编码成 icons/… 就是这么坏的。宁可当场报错,也不要静默存错位置。 */
|
||||||
|
if ($mode === 'local' && !fb_attach_is_local($key)) {
|
||||||
|
return [false, '内部错误:本地模式下生成了非本机路径的图标键。', ''];
|
||||||
|
}
|
||||||
|
if ($mode === 'oss' && fb_attach_is_local($key)) {
|
||||||
|
return [false, '内部错误:OSS 模式下生成了本机路径的图标键。', ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mode === 'local') {
|
||||||
|
if (!fb_local_writable()) { return [false, '本机附件目录不可写(uploads/),无法保存图标。', '']; }
|
||||||
|
$path = fb_local_path_of($key);
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||||||
|
if (!is_dir($dir) || !@move_uploaded_file($tmp, $path)) {
|
||||||
|
return [false, '保存图片失败,请稍后重试。', ''];
|
||||||
|
}
|
||||||
|
@chmod($path, 0644);
|
||||||
|
} else {
|
||||||
|
if (!fb_oss_enabled()) { return [false, '对象存储未配置完整,无法保存图标。', '']; }
|
||||||
|
list($ok, $err) = fb_oss_put_file($key, $tmp, $mime);
|
||||||
|
if (!$ok) { return [false, '写入对象存储失败:' . $err, '']; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return [true, $key, $mime];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一个图标实体(换成新图 / 清空图标时调用,避免孤儿文件堆积)。
|
||||||
|
*
|
||||||
|
* @return bool 是否已确认删除(文件不存在也算成功)
|
||||||
|
*
|
||||||
|
* 🔴 不要用 @ 把错误吞掉:删失败却返回「已保存」,
|
||||||
|
* 用户会以为旧图清掉了,实际 OSS 上还留着一个可访问的孤儿图。
|
||||||
|
* 这里失败就返回 false,调用方据此提示。
|
||||||
|
*/
|
||||||
|
function fb_icon_delete($key) {
|
||||||
|
$key = trim((string)$key);
|
||||||
|
if ($key === '' || !fb_icon_key_ok($key)) { return true; } // 没东西可删
|
||||||
|
|
||||||
|
if (fb_attach_is_local($key)) {
|
||||||
|
$p = fb_local_path_of($key);
|
||||||
|
if (!is_file($p)) { return true; }
|
||||||
|
return (bool)@unlink($p);
|
||||||
|
}
|
||||||
|
if (!fb_oss_enabled()) { return true; } // OSS 关掉了,对象已不可达,不必纠结
|
||||||
|
if (!function_exists('fb_oss_delete')) { return false; }
|
||||||
|
try {
|
||||||
|
return (bool)fb_oss_delete($key);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================================================================
|
/* ==================================================================
|
||||||
* 统一的表单处理入口(由 admin.php 在 CSRF 校验通过后调用)
|
* 统一的表单处理入口(由 admin.php 在 CSRF 校验通过后调用)
|
||||||
* 返回 [消息, 类型],类型 ok / error
|
* 返回 [消息, 类型],类型 ok / error
|
||||||
@@ -27,10 +150,27 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
|||||||
$slug = fb_sw_slugify($name, 'sw', $next);
|
$slug = fb_sw_slugify($name, 'sw', $next);
|
||||||
}
|
}
|
||||||
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
|
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
|
||||||
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
|
/* 排序:优先用表单里填的值(新建表单现在也有这一项了),
|
||||||
|
* 没填才算「排到最后」。用 isset 判断而不是空判断 ——
|
||||||
|
* 用户明确填 0 也是合法值(排最前面),不能被当成"没填"。 */
|
||||||
|
$sort = isset($post['sort_order']) && $post['sort_order'] !== ''
|
||||||
|
? (int)$post['sort_order']
|
||||||
|
: (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
|
||||||
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
||||||
$pdo->prepare('INSERT INTO software(slug,name,note,color,sort_order,enabled,created_at) VALUES(?,?,?,?,?,1,?)')
|
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
|
||||||
->execute([$slug, $name, $note !== '' ? $note : null, $color, $sort, $now]);
|
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
|
||||||
|
|
||||||
|
// 新建时也可以直接带上图标图片(先入库,再更新 icon_key)
|
||||||
|
$iconKeyNew = '';
|
||||||
|
if (isset($_FILES['icon_file']) && is_array($_FILES['icon_file'])
|
||||||
|
&& (int)(isset($_FILES['icon_file']['error']) ? $_FILES['icon_file']['error'] : UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||||
|
list($okUp, $valUp) = fb_icon_store_upload($_FILES['icon_file']);
|
||||||
|
if (!$okUp) { return ['图标上传失败:' . $valUp, 'error']; }
|
||||||
|
$iconKeyNew = $valUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare('INSERT INTO software(slug,name,note,color,icon,icon_key,download_url,sort_order,enabled,created_at) VALUES(?,?,?,?,?,?,?,?,1,?)')
|
||||||
|
->execute([$slug, $name, $note !== '' ? $note : null, $color, $icon, $iconKeyNew, $dl, $sort, $now]);
|
||||||
$newId = (int)$pdo->lastInsertId();
|
$newId = (int)$pdo->lastInsertId();
|
||||||
// 新软件默认给一组常见分类,省得空着没法发帖
|
// 新软件默认给一组常见分类,省得空着没法发帖
|
||||||
$i = 0;
|
$i = 0;
|
||||||
@@ -54,10 +194,51 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
|||||||
$other = fb_software($slug);
|
$other = fb_software($slug);
|
||||||
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
|
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
|
||||||
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
||||||
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, sort_order=? WHERE id=?')
|
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
|
||||||
->execute([$name, $slug, $note !== '' ? $note : null, $color,
|
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
|
||||||
|
|
||||||
|
/* ---- 图标图片 ----
|
||||||
|
* 三种情况:
|
||||||
|
* ① 上传了新图 → 换掉旧图(旧实体删掉,不留孤儿文件)
|
||||||
|
* ② 勾了「移除图标」→ 删掉图片,退回字符图标
|
||||||
|
* ③ 都没动 → 保持原样(不能因为没传文件就当成清空,
|
||||||
|
* 这与「配置被跨页清空」是同一类错误) */
|
||||||
|
$oldIconKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
|
||||||
|
$iconKey = $oldIconKey;
|
||||||
|
$delFailed = '';
|
||||||
|
|
||||||
|
$hasNewFile = isset($_FILES['icon_file'])
|
||||||
|
&& is_array($_FILES['icon_file'])
|
||||||
|
&& (int)(isset($_FILES['icon_file']['error']) ? $_FILES['icon_file']['error'] : UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE;
|
||||||
|
|
||||||
|
if ($hasNewFile) {
|
||||||
|
list($okUp, $valUp) = fb_icon_store_upload($_FILES['icon_file']);
|
||||||
|
if (!$okUp) { return ['图标上传失败:' . $valUp, 'error']; }
|
||||||
|
$iconKey = $valUp;
|
||||||
|
if ($oldIconKey !== '' && $oldIconKey !== $iconKey) {
|
||||||
|
if (!fb_icon_delete($oldIconKey)) { $delFailed = $oldIconKey; }
|
||||||
|
}
|
||||||
|
} elseif (!empty($post['icon_clear'])) {
|
||||||
|
if ($oldIconKey !== '') {
|
||||||
|
if (!fb_icon_delete($oldIconKey)) { $delFailed = $oldIconKey; }
|
||||||
|
}
|
||||||
|
$iconKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, icon=?, icon_key=?, download_url=?, sort_order=? WHERE id=?')
|
||||||
|
->execute([$name, $slug, $note !== '' ? $note : null, $color, $icon, $iconKey, $dl,
|
||||||
(int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]);
|
(int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]);
|
||||||
return ['软件「' . $name . '」已保存。', 'ok'];
|
|
||||||
|
$extraMsg = '';
|
||||||
|
if ($hasNewFile) { $extraMsg = ',图标已更新'; }
|
||||||
|
elseif (!empty($post['icon_clear']) && $oldIconKey !== '') { $extraMsg = ',图标已移除'; }
|
||||||
|
|
||||||
|
// 旧图删不掉要如实说 —— 悄悄留下一个还能访问的孤儿图是安全隐患
|
||||||
|
if ($delFailed !== '') {
|
||||||
|
return ['软件「' . $name . '」已保存' . $extraMsg . ',但旧图标文件删除失败('
|
||||||
|
. $delFailed . '),它可能仍可被访问,建议手动到存储里删掉。', 'error'];
|
||||||
|
}
|
||||||
|
return ['软件「' . $name . '」已保存' . $extraMsg . '。', 'ok'];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'sw_del': {
|
case 'sw_del': {
|
||||||
@@ -73,6 +254,8 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
|||||||
}
|
}
|
||||||
$pdo->prepare('DELETE FROM categories WHERE software_id = ?')->execute([$id]);
|
$pdo->prepare('DELETE FROM categories WHERE software_id = ?')->execute([$id]);
|
||||||
$pdo->prepare('DELETE FROM software WHERE id = ?')->execute([$id]);
|
$pdo->prepare('DELETE FROM software WHERE id = ?')->execute([$id]);
|
||||||
|
// 顺手删掉它的图标文件,避免 icons/ 下堆孤儿图
|
||||||
|
if (isset($sw['icon_key'])) { fb_icon_delete((string)$sw['icon_key']); }
|
||||||
return ['已删除软件「' . $sw['name'] . '」。', 'ok'];
|
return ['已删除软件「' . $sw['name'] . '」。', 'ok'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +447,8 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
|||||||
echo ' <div class="tax-main">';
|
echo ' <div class="tax-main">';
|
||||||
list($swC, $swCd, $swCs) = fb_software_colors($sw);
|
list($swC, $swCd, $swCs) = fb_software_colors($sw);
|
||||||
echo ' <span class="sw-dot" style="background:' . e($swC) . '" title="' . e($swC) . '"></span>';
|
echo ' <span class="sw-dot" style="background:' . e($swC) . '" title="' . e($swC) . '"></span>';
|
||||||
echo ' <a class="tax-name" href="admin.php?tab=taxonomy&sw=' . $sid . '">' . e((string)$sw['name']) . '</a>';
|
echo ' <a class="tax-name" href="admin.php?tab=taxonomy&sw=' . $sid . '">'
|
||||||
|
. fb_software_badge($sw, 'sm') . '</a>';
|
||||||
echo ' <span class="tax-slug">' . e((string)$sw['slug']) . '</span>';
|
echo ' <span class="tax-slug">' . e((string)$sw['slug']) . '</span>';
|
||||||
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
|
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
|
||||||
echo ' <span class="tax-meta">' . $nCat . ' 个分类 · ' . $nTh . ' 条反馈</span>';
|
echo ' <span class="tax-meta">' . $nCat . ' 个分类 · ' . $nTh . ' 条反馈</span>';
|
||||||
@@ -295,7 +479,7 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
|||||||
if ($editSw > 0) { $esw = fb_software($editSw); }
|
if ($editSw > 0) { $esw = fb_software($editSw); }
|
||||||
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
|
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
|
||||||
echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>';
|
echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>';
|
||||||
echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form" data-validate>';
|
echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form" enctype="multipart/form-data" data-validate>';
|
||||||
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
|
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
|
||||||
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
|
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
|
||||||
echo ' <div class="row2">';
|
echo ' <div class="row2">';
|
||||||
@@ -310,26 +494,124 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
|||||||
. '<input type="text" name="note" maxlength="120" value="'
|
. '<input type="text" name="note" maxlength="120" value="'
|
||||||
. e($esw ? (string)($esw['note'] === null ? '' : $esw['note']) : '') . '"></label>';
|
. e($esw ? (string)($esw['note'] === null ? '' : $esw['note']) : '') . '"></label>';
|
||||||
|
|
||||||
|
/* ---- 图标 ----
|
||||||
|
* 两种方式并存,图片优先:
|
||||||
|
* ① 上传一张图片(存 OSS / 本机,前端经 media.php 代理显示)—— 推荐
|
||||||
|
* ② 不传图片时用下面那个字符(emoji / 汉字 / 字母)兜底
|
||||||
|
* 有了图片,列表、页头、邮件里全都自动换成图片,不用分别设置。 */
|
||||||
|
$curIcon = $esw ? (string)(isset($esw['icon']) ? $esw['icon'] : '') : '';
|
||||||
|
$curIconKey = $esw ? trim((string)(isset($esw['icon_key']) ? $esw['icon_key'] : '')) : '';
|
||||||
|
$curIconUrl = $curIconKey !== '' ? fb_icon_url($curIconKey, 96) : '';
|
||||||
|
|
||||||
|
echo ' <div class="field"><span>图标图片<i class="hint">上传一张图(PNG/JPG/WEBP,≤2MB,方形最佳)。传了图片就优先用图片,不再用下面的字符</i></span>';
|
||||||
|
echo ' <div class="icon-up">';
|
||||||
|
echo ' <div class="icon-prev">';
|
||||||
|
if ($curIconUrl !== '') {
|
||||||
|
echo '<img src="' . e($curIconUrl) . '" alt="" width="48" height="48">';
|
||||||
|
} else {
|
||||||
|
list($pc) = $esw ? fb_software_colors($esw) : ['#0057B8'];
|
||||||
|
echo '<span class="icon-prev-ph" style="background:' . e($pc) . '">'
|
||||||
|
. e(fb_software_icon($esw ? $esw : ['name' => ''])) . '</span>';
|
||||||
|
}
|
||||||
|
echo ' </div>';
|
||||||
|
echo ' <div class="icon-up-main">';
|
||||||
|
/* 文件选择按钮重做成真按钮(用户:原生那个太丑)。
|
||||||
|
* <label class="file-btn"> 包住 input[type=file],点 label 就等于点 input;
|
||||||
|
* input 用 clip 视觉隐藏但仍可聚焦(不能用 display:none,那样点不到)。 */
|
||||||
|
$ficonFileId = 'iconFile' . ($esw ? (int)$esw['id'] : 'new');
|
||||||
|
echo ' <label class="file-btn" for="' . $ficonFileId . '">'
|
||||||
|
. '<span class="file-btn-ic" aria-hidden="true"></span>'
|
||||||
|
. '<span class="file-btn-txt">选择图片</span>'
|
||||||
|
. '<span class="file-btn-name" data-file-name></span>'
|
||||||
|
. '<input type="file" name="icon_file" accept="image/png,image/jpeg,image/webp" id="' . $ficonFileId . '">'
|
||||||
|
. '</label>';
|
||||||
|
echo ' <i class="hint" style="margin-top:6px">建议 256×256 或 512×512 的方形图;带透明背景的 PNG 效果最好。</i>';
|
||||||
|
if ($curIconUrl !== '') {
|
||||||
|
echo ' <label class="check" style="margin-top:8px"><input type="checkbox" name="icon_clear" value="1"> 移除当前图片,改用下面的字符图标</label>';
|
||||||
|
}
|
||||||
|
echo ' </div>';
|
||||||
|
echo ' </div>';
|
||||||
|
echo ' </div>';
|
||||||
|
|
||||||
|
// 字符兜底:没有图片时用它
|
||||||
|
$iconSug = ['📅', '🔐', '🔑', '📝', '🎵', '📷', '🌤', '🧭', '📦', '⚙️', '🗂', '💬'];
|
||||||
|
echo ' <label class="field"><span>字符图标<i class="hint">没上传图片时用它,一个字符:emoji 或汉字/字母</i></span>'
|
||||||
|
. '<input type="text" name="icon" maxlength="8" value="' . e($curIcon) . '" placeholder="例如 📅">'
|
||||||
|
. '<span class="icon-presets">';
|
||||||
|
foreach ($iconSug as $ic) {
|
||||||
|
echo '<button type="button" class="icon-preset" data-icon="' . e($ic) . '">' . e($ic) . '</button>';
|
||||||
|
}
|
||||||
|
echo '</span></label>';
|
||||||
|
|
||||||
|
/* ── 「下载地址」字段已删除 ──
|
||||||
|
* 用户 2026-11-04:「有个什么下载链接,这个东西不要,要它干什么?」
|
||||||
|
*
|
||||||
|
* 它原本的唯一用途:当反馈状态改成「已解决」时,把通知邮件里的按钮
|
||||||
|
* 从「查看详情」换成「下载最新版本」。属于可有可无的小功能,
|
||||||
|
* 却要求管理员为每个软件再维护一条 URL —— 收益远小于维护成本。
|
||||||
|
*
|
||||||
|
* ⚠️ 数据库的 download_url 列**保留不删**:
|
||||||
|
* ① 删列会让历史数据不可逆丢失;
|
||||||
|
* ② 万一以后想恢复,不用再走一次迁移。
|
||||||
|
* 只是前台表单不再暴露,mailer 里也已改为始终用「查看详情」。 */
|
||||||
|
|
||||||
// 主题色:前台按软件换色,用短链接进不同软件一眼就能区分
|
// 主题色:前台按软件换色,用短链接进不同软件一眼就能区分
|
||||||
$curColor = $esw ? fb_color_norm((string)$esw['color']) : '';
|
$curColor = $esw ? fb_color_norm((string)$esw['color']) : '';
|
||||||
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
|
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
|
||||||
$presets = ['#0057B8', '#2E7D5B', '#B4553C', '#6A4C93', '#0E7C86', '#A02C5A', '#8A6D1F', '#3A5A8C'];
|
/* 颜色选择:**点色块弹出一整块色板**。
|
||||||
|
* 用户 2026-11-04:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」
|
||||||
|
* ⇒ ① 右侧那排预设圆点删除,界面更干净,能力靠色板本身(任意取色)覆盖;
|
||||||
|
* ② 色板要"完整":足够大的色相/饱和度画布 + 色相条 + 明度条 +
|
||||||
|
* 十六进制输入。太小会让人觉得是"玩具",拖着不好定位颜色。
|
||||||
|
* ⚠️ 不再需要 $presets:色板本身就能取任意颜色,
|
||||||
|
* 留着那排小圆点反而和色板重复、还占横向空间(用户明确要求去掉)。 */
|
||||||
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
|
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
|
||||||
echo ' <div class="color-row">';
|
echo ' <div class="color-row" data-colorpicker>';
|
||||||
echo ' <input type="color" name="color" value="' . e($sugColor) . '" class="color-input">';
|
echo ' <button type="button" class="color-trigger" data-color-trigger'
|
||||||
echo ' <div class="color-presets">';
|
. ' style="--cur:' . e($sugColor) . '" title="点击选择颜色">'
|
||||||
foreach ($presets as $pc) {
|
. ' <span class="color-trigger-chip"></span>'
|
||||||
$on = strcasecmp($pc, $sugColor) === 0 ? ' on' : '';
|
. ' <span class="color-trigger-hex">' . e(strtoupper($sugColor)) . '</span>'
|
||||||
echo '<button type="button" class="color-preset' . $on . '" data-color="' . e($pc)
|
. ' <span class="color-trigger-caret">▾</span>'
|
||||||
. '" style="background:' . e($pc) . '" title="' . e($pc) . '"></button>';
|
. ' </button>';
|
||||||
}
|
echo ' <input type="hidden" name="color" value="' . e($sugColor) . '" data-color-value>';
|
||||||
echo ' </div>';
|
|
||||||
echo ' <button type="button" class="btn sm color-reset">用默认</button>';
|
echo ' <button type="button" class="btn sm color-reset">用默认</button>';
|
||||||
|
|
||||||
|
/* 色板:默认隐藏,点色块才出现。 */
|
||||||
|
echo ' <div class="cp" data-color-panel hidden>';
|
||||||
|
echo ' <div class="cp-hd">选择颜色</div>';
|
||||||
|
// 画布做大:340×200,拖起来才好定位
|
||||||
|
echo ' <div class="cp-sv-wrap">';
|
||||||
|
echo ' <canvas class="cp-sv" width="340" height="200" data-cp-sv></canvas>';
|
||||||
|
echo ' <div class="cp-cursor" data-cp-cursor></div>';
|
||||||
|
echo ' </div>';
|
||||||
|
// 色相条
|
||||||
|
echo ' <div class="cp-slider cp-hue"><input type="range" min="0" max="360" value="0" data-cp-hue aria-label="色相"></div>';
|
||||||
|
// 明度条:背景由 JS 按当前色相刷新
|
||||||
|
echo ' <div class="cp-slider cp-val"><input type="range" min="0" max="100" value="100" data-cp-val aria-label="明度"></div>';
|
||||||
|
echo ' <div class="cp-foot">';
|
||||||
|
echo ' <span class="cp-preview" data-cp-prev></span>';
|
||||||
|
echo ' <input type="text" class="cp-hex" maxlength="7" value="' . e(strtoupper($sugColor)) . '" data-cp-hex aria-label="十六进制颜色值">';
|
||||||
|
echo ' <button type="button" class="btn sm cp-ok" data-cp-ok>完成</button>';
|
||||||
|
echo ' </div>';
|
||||||
|
echo ' </div>';
|
||||||
echo ' </div>';
|
echo ' </div>';
|
||||||
echo ' </div>';
|
echo ' </div>';
|
||||||
|
/* 排序字段:新建和编辑**都要有**。
|
||||||
|
* 之前只在 if ($esw) 分支里渲染,导致「添加软件」时根本没有这一项,
|
||||||
|
* 用户以为排序被删了。新建时给个默认值 = 当前最大排序+1(排到最后)。
|
||||||
|
*
|
||||||
|
* ⚠️ 与「下载地址」不同:排序是**真实会被用到**的字段
|
||||||
|
* (前台软件列表、顶栏三横线菜单都按它排),不能删。 */
|
||||||
|
$sortVal = 0;
|
||||||
|
if ($esw) {
|
||||||
|
$sortVal = (int)$esw['sort_order'];
|
||||||
|
} else {
|
||||||
|
$sortVal = (int)fb_db()->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
|
||||||
|
}
|
||||||
|
echo ' <label class="field field-narrow"><span>排序<i class="hint">数字小的排前面,同级按名称排</i></span>'
|
||||||
|
. '<input type="number" name="sort_order" min="0" max="999" value="' . $sortVal . '"></label>';
|
||||||
|
|
||||||
if ($esw) {
|
if ($esw) {
|
||||||
echo ' <label class="field"><span>排序<i class="hint">数字小的排前面</i></span>'
|
|
||||||
. '<input type="number" name="sort_order" value="' . (int)$esw['sort_order'] . '"></label>';
|
|
||||||
echo ' <button class="btn primary" name="act" value="sw_edit" type="submit">保存</button> '
|
echo ' <button class="btn primary" name="act" value="sw_edit" type="submit">保存</button> '
|
||||||
. '<a class="btn" href="admin.php?tab=taxonomy">取消</a>';
|
. '<a class="btn" href="admin.php?tab=taxonomy">取消</a>';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+114
@@ -149,6 +149,110 @@ function fb_attach_is_local($key) {
|
|||||||
return strpos((string)$key, 'local/') === 0;
|
return strpos((string)$key, 'local/') === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 软件图标(管理员上传的图片)
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 🔴 存储位置【跟随后台配置】,与附件完全同一套规则:
|
||||||
|
* attachment.storage = local → local/icons/YYYY/MM/<hex>.<ext> 写 uploads/
|
||||||
|
* attachment.storage = oss → <oss.prefix>/icons/YYYY/MM/<hex>.<ext> 写私有桶
|
||||||
|
* 管理员在后台配的哪种,图标就存哪种;前台地址两种模式完全一致,
|
||||||
|
* 都是 media.php?k=... 代理,换存储方式不会让已有图标失效。
|
||||||
|
*
|
||||||
|
* 早期实现把图标键固定写成 `icons/...`(不带 local/ 前缀),
|
||||||
|
* 结果 fb_attach_is_local() 永远返回 false —— 本机模式下文件明明写进了
|
||||||
|
* uploads/icons/,media.php 却当成 OSS 对象去读,直接 503。
|
||||||
|
* 这是「本地存储时图标显示不出来」的根因,键必须带存储前缀。
|
||||||
|
*
|
||||||
|
* 为什么不复用普通附件:
|
||||||
|
* 普通附件必须挂在某条 thread 下才允许被 media.php 读出(否则本站就成了
|
||||||
|
* 「能读整个桶」的开放代理)。软件图标不属于任何帖子,用它那套鉴权会被拒。
|
||||||
|
* 于是键里多一段 `icons/`,media.php 据此识别并按公开资源处理(写入仍受控)。
|
||||||
|
* ================================================================== */
|
||||||
|
function fb_icon_key_prefix() {
|
||||||
|
return 'icons/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标允许的扩展名(比附件白名单窄:图标不需要 gif 动图)。
|
||||||
|
* ⚠️ 刻意不收 svg:SVG 能内嵌 <script>,一旦被当图片直出到浏览器就是 XSS。
|
||||||
|
* 图标是管理员自己传的,但防线不该依赖「管理员不会传坏东西」。
|
||||||
|
*
|
||||||
|
* 定义在这里而不是 admin_taxonomy.php:media.php 与校验函数都会用到,
|
||||||
|
* 不能依赖后台那个文件被加载(它在 bootstrap 里并不 require)。
|
||||||
|
*/
|
||||||
|
function fb_icon_allowed_ext() {
|
||||||
|
return ['png', 'jpg', 'jpeg', 'webp'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个键是不是软件图标。
|
||||||
|
* 同时认正常形态(local/icons/… 、<prefix>/icons/…)与历史形态(icons/…)。
|
||||||
|
* 历史形态必须认,否则用户早先上传、仍存在桶里的图标就成了读不到的孤儿。
|
||||||
|
*/
|
||||||
|
function fb_icon_is_key($key) {
|
||||||
|
return (bool)preg_match('#^(?:(?:local|[A-Za-z0-9][A-Za-z0-9._-]*)/)?icons/#', (string)$key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成一个图标对象键 —— 存储位置由后台配置决定,与附件一致。
|
||||||
|
* local 模式带 `local/` 前缀,OSS 模式带 oss.prefix 前缀;
|
||||||
|
* 两种都能被 fb_attach_is_local() / fb_local_path_of() 正确解析。
|
||||||
|
*/
|
||||||
|
function fb_icon_make_key($ext) {
|
||||||
|
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
|
||||||
|
$leaf = fb_icon_key_prefix() . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||||
|
|
||||||
|
if (fb_store_mode() === 'local') {
|
||||||
|
return 'local/' . $leaf;
|
||||||
|
}
|
||||||
|
$prefix = trim((string)fb_cfg('oss.prefix', 'fb'), '/');
|
||||||
|
if ($prefix === '') { $prefix = 'fb'; }
|
||||||
|
return $prefix . '/' . $leaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标键是否合法。
|
||||||
|
* 接受两种形态:
|
||||||
|
* ① 正常形态:<local|oss.prefix>/icons/YYYY/MM/<16hex>.<ext>
|
||||||
|
* ② 历史形态:icons/YYYY/MM/<16hex>.<ext>(无存储前缀)
|
||||||
|
* —— 2026-11-03 之前的代码把键硬编码成这种样子,用户当时上传的图
|
||||||
|
* 真实存在于桶根下的 icons/ 目录。这些对象是有效资产,
|
||||||
|
* 不能因为键「格式不规范」就让图标显示不出来,所以这里认它。
|
||||||
|
* 新写入一律走正常形态,见 fb_icon_make_key()。
|
||||||
|
*
|
||||||
|
* 🔴 扩展名必须是白名单里的图片类型,不能只写 `[a-z0-9]{2,5}` ——
|
||||||
|
* 那样 `.php`(3 个字母)也会通过,等于给自己留了个写脚本的口子。
|
||||||
|
* 实际验证时就是这条先被漏掉了。
|
||||||
|
*/
|
||||||
|
function fb_icon_key_ok($key) {
|
||||||
|
$key = (string)$key;
|
||||||
|
if (strlen($key) < 16 || strlen($key) > 200) { return false; }
|
||||||
|
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
|
||||||
|
// 带存储前缀,或历史无前缀形态
|
||||||
|
if (!preg_match('#^(?:(?:local|[A-Za-z0-9][A-Za-z0-9._-]*)/)?icons/\d{4}/\d{2}/[a-f0-9]{16}\.([a-z0-9]{2,5})$#', $key, $m)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return in_array(strtolower($m[1]), fb_icon_allowed_ext(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标图片的【对外地址】—— 一律走 media.php 代理。
|
||||||
|
* 与附件完全同一套约定:不暴露 uploads/ 真实路径,也不暴露任何 OSS 痕迹。
|
||||||
|
* 两种存储模式对外都是这一个形态,所以换存储方式不影响已显示的图标。
|
||||||
|
*
|
||||||
|
* @param string $key 图标对象键
|
||||||
|
* @param int $w 期望宽度(0=原图)。OSS 模式下交给服务端缩放;本机模式忽略。
|
||||||
|
* @return string 未经 HTML 转义的 URL(调用方自行 e() 转义)
|
||||||
|
*/
|
||||||
|
function fb_icon_url($key, $w = 0) {
|
||||||
|
$key = trim((string)$key);
|
||||||
|
if ($key === '' || !fb_icon_key_ok($key)) { return ''; }
|
||||||
|
if (!fb_attach_is_local($key) && function_exists('fb_oss_thumb_url')) {
|
||||||
|
return fb_oss_thumb_url($key, $w > 0 ? (int)$w : 96, 'image/png');
|
||||||
|
}
|
||||||
|
return fb_base_url() . '/media.php?k=' . rawurlencode($key);
|
||||||
|
}
|
||||||
|
|
||||||
/** key → 本机绝对路径(已做穿越防护) */
|
/** key → 本机绝对路径(已做穿越防护) */
|
||||||
function fb_local_path_of($key) {
|
function fb_local_path_of($key) {
|
||||||
$rel = ltrim((string)$key, '/');
|
$rel = ltrim((string)$key, '/');
|
||||||
@@ -292,6 +396,16 @@ function fb_upload_key_ok($key) {
|
|||||||
if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) { return false; }
|
if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) { return false; }
|
||||||
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
|
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
|
||||||
|
|
||||||
|
/* 扩展名必须是本站认可的类型。
|
||||||
|
* 🔴 原先本机分支写的是 `[a-z0-9]{2,5}`,`.php`(3 个字母)正好能过 ——
|
||||||
|
* 虽然真上传还有 finfo 读魔数那道关,但「键本身就不该长这样」,
|
||||||
|
* 不该把安全押在下一层校验上(换 nginx、改配置都可能绕过那层)。
|
||||||
|
* OSS 分支同理:键的扩展名也要过白名单,否则 media.php 会照扩展名片面给 Content-Type。 */
|
||||||
|
$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'mp4', 'mov', 'webm'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (fb_attach_is_local($key)) {
|
if (fb_attach_is_local($key)) {
|
||||||
// 本机存储:必须是 local/YYYY/MM/随机名.扩展名
|
// 本机存储:必须是 local/YYYY/MM/随机名.扩展名
|
||||||
return (bool)preg_match('#^local/\d{4}/\d{2}/[a-f0-9]{16}\.[a-z0-9]{2,5}$#', $key);
|
return (bool)preg_match('#^local/\d{4}/\d{2}/[a-f0-9]{16}\.[a-z0-9]{2,5}$#', $key);
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
|
|||||||
define('FB_ROOT', dirname(__DIR__));
|
define('FB_ROOT', dirname(__DIR__));
|
||||||
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
||||||
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
||||||
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 16); }
|
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 53); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
|
|||||||
+65
-8
@@ -94,17 +94,39 @@ CREATE TABLE IF NOT EXISTS threads (
|
|||||||
author_name TEXT NOT NULL,
|
author_name TEXT NOT NULL,
|
||||||
author_email TEXT NOT NULL,
|
author_email TEXT NOT NULL,
|
||||||
author_phone TEXT,
|
author_phone TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
status TEXT NOT NULL DEFAULT 'hidden',
|
||||||
reply_count INTEGER NOT NULL DEFAULT 0,
|
reply_count INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
ip_hash TEXT
|
ip_hash TEXT,
|
||||||
|
track_token TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
SQL
|
SQL
|
||||||
);
|
);
|
||||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC)');
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC)');
|
||||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads(category)');
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads(category)');
|
||||||
|
|
||||||
|
/* ---- threads.track_token:提交人的「私人追踪钥匙」 ----
|
||||||
|
* 因为本站不做用户登录,没办法认人;但每个人都必须能跟踪自己提交的反馈,
|
||||||
|
* 哪怕是还没审核通过的。做法:提交时生成一串随机 token 只发到他的邮箱,
|
||||||
|
* 链接带 ?t=<token> 就能看 / 能回复,别人不知道这串东西就看不到。
|
||||||
|
*
|
||||||
|
* 🔴 不要用 id 代替:id 是自增的,任何人改个数字就能翻别人的私有反馈。
|
||||||
|
* token 必须是不可猜的随机值(这里用 32 位 hex = 128 位熵)。 */
|
||||||
|
$thCols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
|
||||||
|
$thNames = [];
|
||||||
|
foreach ($thCols as $c4) { if (isset($c4['name'])) { $thNames[(string)$c4['name']] = true; } }
|
||||||
|
if (!isset($thNames['track_token'])) {
|
||||||
|
$pdo->exec("ALTER TABLE threads ADD COLUMN track_token TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
// 存量回填:给还没有 token 的帖子补一个,保证老数据的邮件链接也能用
|
||||||
|
$need = $pdo->query("SELECT id FROM threads WHERE track_token IS NULL OR track_token = ''")->fetchAll();
|
||||||
|
if ($need) {
|
||||||
|
$upd = $pdo->prepare('UPDATE threads SET track_token = ? WHERE id = ?');
|
||||||
|
foreach ($need as $r) { $upd->execute([bin2hex(random_bytes(16)), (int)$r['id']]); }
|
||||||
|
}
|
||||||
|
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_token ON threads(track_token)');
|
||||||
|
|
||||||
$pdo->exec(<<<'SQL'
|
$pdo->exec(<<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS replies (
|
CREATE TABLE IF NOT EXISTS replies (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -324,6 +346,23 @@ SQL
|
|||||||
if (!isset($swNames['color'])) {
|
if (!isset($swNames['color'])) {
|
||||||
$pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''");
|
$pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''");
|
||||||
}
|
}
|
||||||
|
/* ---- 软件增加图标(多软件并存时靠「图标 + 名字 + 颜色」三重区分)----
|
||||||
|
* icon 存一个字符(emoji / 汉字 / 字母)—— 轻量兜底,没有图片时用它;
|
||||||
|
* icon_key 存【上传图片的对象键】—— 有它就优先用图片。
|
||||||
|
* 图片走与附件相同的存储与代理(local/... 或 <prefix>/...),
|
||||||
|
* 前端只输出 media.php?k=... 代理地址,不暴露真实位置。
|
||||||
|
* 两者并存:先有字符兜底,用户上传图片后自动切到图片,删掉图片又退回字符,
|
||||||
|
* 任何时刻都不会出现「没有图标」。 */
|
||||||
|
if (!isset($swNames['icon'])) {
|
||||||
|
$pdo->exec("ALTER TABLE software ADD COLUMN icon TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
if (!isset($swNames['icon_key'])) {
|
||||||
|
$pdo->exec("ALTER TABLE software ADD COLUMN icon_key TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
/* ---- 软件增加下载地址(状态「已解决」的邮件里给用户一个去下新版的入口)---- */
|
||||||
|
if (!isset($swNames['download_url'])) {
|
||||||
|
$pdo->exec("ALTER TABLE software ADD COLUMN download_url TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- threads 增加两列(老 category 文本列保留,不删)---- */
|
/* ---- threads 增加两列(老 category 文本列保留,不删)---- */
|
||||||
$cols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
|
$cols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
|
||||||
@@ -382,18 +421,36 @@ SQL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) 状态:空表时灌 5 个(4 个原有 + 新增「不予考虑」)
|
// 3) 状态:空表时灌全部状态
|
||||||
if ((int)$pdo->query('SELECT COUNT(*) FROM statuses')->fetchColumn() === 0) {
|
if ((int)$pdo->query('SELECT COUNT(*) FROM statuses')->fetchColumn() === 0) {
|
||||||
$rows = [
|
$rows = [
|
||||||
['open', '待处理', '您提交的反馈已收到,我们正在处理中。', 0],
|
// key_name 名称 给用户看的说明(进邮件) 排序
|
||||||
['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 1],
|
['hidden', '未公开', '您的反馈已提交,正在等待管理员查看。', 0],
|
||||||
['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 2],
|
['open', '待处理', '您的反馈已公开,我们已收到,会尽快安排处理。', 1],
|
||||||
['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 3],
|
['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 2],
|
||||||
['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢你的建议。', 4],
|
['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 3],
|
||||||
|
['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 4],
|
||||||
|
['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢您的建议。', 5],
|
||||||
];
|
];
|
||||||
$st = $pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled)
|
$st = $pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled)
|
||||||
VALUES(?,?,?,?,1)');
|
VALUES(?,?,?,?,1)');
|
||||||
foreach ($rows as $r) { $st->execute($r); }
|
foreach ($rows as $r) { $st->execute($r); }
|
||||||
|
} else {
|
||||||
|
/* 老库补「未公开」状态。
|
||||||
|
* 2026-11-03 新增:用户提交后先落「未公开」,审核通过才转「待处理」——
|
||||||
|
* 「未公开」本身就是一种状态,不该只靠 threads.visible=0 这个附加字段隐式表达。 */
|
||||||
|
$hasHidden = (int)$pdo->query("SELECT COUNT(*) FROM statuses WHERE key_name = 'hidden'")->fetchColumn();
|
||||||
|
if ($hasHidden === 0) {
|
||||||
|
// 先把现有状态整体后移一位,给「未公开」腾出 0 号位
|
||||||
|
$pdo->exec('UPDATE statuses SET sort_order = sort_order + 1');
|
||||||
|
$pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled)
|
||||||
|
VALUES(?,?,?,?,1)')
|
||||||
|
->execute(['hidden', '未公开', '您的反馈已提交,正在等待管理员查看。', 0]);
|
||||||
|
|
||||||
|
/* 存量数据:visible=0 且状态还是「待处理」的,语义上就是「没公开」,
|
||||||
|
* 归到「未公开」;visible=1 的保持「待处理」不动。 */
|
||||||
|
$pdo->exec("UPDATE threads SET status = 'hidden' WHERE visible = 0 AND status = 'open'");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) 回填老帖子:software_id / category_id(只填 NULL 的,重复执行无副作用)
|
// 4) 回填老帖子:software_id / category_id(只填 NULL 的,重复执行无副作用)
|
||||||
|
|||||||
+152
-9
@@ -40,25 +40,168 @@ function fb_layout_head($title, array $opt = []) {
|
|||||||
<link rel="manifest" href="assets/site.webmanifest">
|
<link rel="manifest" href="assets/site.webmanifest">
|
||||||
<link rel="stylesheet" href="assets/style.css?v=<?php echo (int)FB_ASSET_V; ?>">
|
<link rel="stylesheet" href="assets/style.css?v=<?php echo (int)FB_ASSET_V; ?>">
|
||||||
<?php if ($swColors): ?>
|
<?php if ($swColors): ?>
|
||||||
<?php /* 按软件覆写主题色:只改这三个变量,其它样式自动跟着变 */ ?>
|
<?php
|
||||||
|
/* 按软件覆写主题色。
|
||||||
|
* --brand-tint-1 / --brand-tint-2 是给下拉项用的两档浅色:
|
||||||
|
* tint-1(与白混 91%)→ hover
|
||||||
|
* tint-2(与白混 80%)→ 选中项,比 hover 更深一档,一眼能分开
|
||||||
|
* 在 PHP 里算好再输出,而不是 CSS 的 color-mix():
|
||||||
|
* 实测 color-mix 配 var(--brand) 在 Chrome 里解析成透明,规则匹配上却不生效。 */
|
||||||
|
$tint1 = fb_color_tint($swColors[0], 0.91);
|
||||||
|
$tint2 = fb_color_tint($swColors[0], 0.80);
|
||||||
|
$tint3 = fb_color_tint($swColors[0], 0.72); // 选中项 hover 时再深一点
|
||||||
|
?>
|
||||||
<style>:root{
|
<style>:root{
|
||||||
--brand:<?php echo e($swColors[0]); ?>;
|
--brand:<?php echo e($swColors[0]); ?>;
|
||||||
--brand-600:<?php echo e($swColors[1]); ?>;
|
--brand-600:<?php echo e($swColors[1]); ?>;
|
||||||
--brand-soft:<?php echo e($swColors[2]); ?>;
|
--brand-soft:<?php echo e($swColors[2]); ?>;
|
||||||
|
--brand-tint-1:<?php echo e($tint1); ?>;
|
||||||
|
--brand-tint-2:<?php echo e($tint2); ?>;
|
||||||
|
--brand-tint-3:<?php echo e($tint3); ?>;
|
||||||
}</style>
|
}</style>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<a class="brand-mini" href="./" title="<?php echo e($site); ?>">
|
<?php
|
||||||
<span class="logo-mark" role="img" aria-label="轻反馈图标"></span>
|
/* 顶栏品牌位:当前在哪个软件下,就显示「图标 + XX 反馈系统」。
|
||||||
<span class="brand-text"><?php echo e($site); ?></span>
|
* 这是页面唯一的主标题 —— 多软件共用一套反馈系统,
|
||||||
</a>
|
* 用户要一眼看清「我现在是在给哪个软件的反馈系统提问题」。
|
||||||
|
* 内容区不再重复一遍同样的文字,只保留「图标 + 软件名」。
|
||||||
|
*
|
||||||
|
* 字号刻意比「选择软件」那个标签大一号左右(17px),
|
||||||
|
* 不再压过内容区标题 —— 之前给到 26px 反而比下面的字还小,
|
||||||
|
* 视觉上主次颠倒。 */
|
||||||
|
$curSw = isset($opt['software']) && is_array($opt['software']) ? $opt['software'] : null;
|
||||||
|
$swList = fb_softwares(true);
|
||||||
|
/* ---- 后台模式 ----
|
||||||
|
* 用户 2026-11-03 问:「管理后台还需要『我要反馈』吗?还需要右上角三横线吗?
|
||||||
|
* 还需要下拉选软件吗?这些是不是都不需要了?」
|
||||||
|
* ⇒ 都不需要,全去掉。理由:
|
||||||
|
* ① 「我要反馈」是给访客用的入口,管理员不该从后台跳去前台表单;
|
||||||
|
* ② 后台是**跨软件**视图(待审核/全部问题本身就带软件筛选器),
|
||||||
|
* 再放一个切软件的菜单是重复的,而且切了也不会改变后台视图;
|
||||||
|
* ③ 「问题列表」依赖 ?sw= 当前软件上下文,后台没有这个上下文,
|
||||||
|
* 点了只会跳到前台某个软件,属于误导。
|
||||||
|
* 后台保留的只有:站点标识(回首页)+ 管理后台 + 退出登录。 */
|
||||||
|
$isAdminBar = !empty($opt['admin']);
|
||||||
|
?>
|
||||||
|
<?php if ($curSw): ?>
|
||||||
|
<?php
|
||||||
|
/* 顶栏标题用该软件的主题色(用户 2026-11-03 要求):
|
||||||
|
* 每个软件都配了颜色,标题仍用统一黑色等于白配。
|
||||||
|
* --swc 传给 CSS,让 .sw-badge-brand 的文字取这个颜色。 */
|
||||||
|
list($curSwColor) = fb_software_colors($curSw);
|
||||||
|
if ($curSwColor === '') { $curSwColor = '#0057B8'; }
|
||||||
|
?>
|
||||||
|
<?php /* 🔴 点标题回到**当前软件**的列表,不是回首页。
|
||||||
|
用户 2026-11-03:「点完之后应该还是进入这个反馈系统,而不是进到首页,
|
||||||
|
首页其实没有太大作用 —— 它只是演示/说明用的。
|
||||||
|
正常人用的时候都是每个软件独立使用,要让用户觉得这套反馈系统
|
||||||
|
就是专门为这个软件做的。」
|
||||||
|
⇒ 在某个软件的反馈系统里,标题 / 「问题列表」/「我要反馈」
|
||||||
|
全都只跟这个软件有关,跟其他软件无关。
|
||||||
|
想切别的软件,只有右上角三横线那一个入口。 */ ?>
|
||||||
|
<?php
|
||||||
|
/* 软件的一句话简介(software.note)放在标题正下方,灰色小字。
|
||||||
|
* 用户 2026-11-03:「把「优秀的鸿蒙 CalDAV 同步客户端」放在最上面
|
||||||
|
* 那个「同步日历反馈系统」下面那个位置,用个灰色的。」
|
||||||
|
* 这样内容区就不用再重复一遍标题和简介了。 */
|
||||||
|
$swNoteTop = !empty($curSw['note']) ? trim((string)$curSw['note']) : '';
|
||||||
|
/* 图标要单独拿出来放在左边,才能与「标题 + 简介」两行**整体**垂直居中。
|
||||||
|
* 🔴 用户 2026-11-03:「图标应该在标题和简介的正中间…现在图标和上面的标题
|
||||||
|
* 是对齐的、在图标的正中间…把图标往下放一放,让标题和它下面的简介合起来
|
||||||
|
* 和图标高度一样,两个对齐上下。」
|
||||||
|
* ⇒ 结构必须是「图标 | 文字列」两栏,所以这里不用 fb_software_badge()
|
||||||
|
* (那个函数把图标和名字包在同一个 .sw-badge 里,天然是单行)。
|
||||||
|
* 改用 fb_software_icon()/icon_key 自己拼图标,名字单独放右侧。 */
|
||||||
|
$bIcon = fb_software_icon($curSw);
|
||||||
|
$bKey = trim((string)(isset($curSw['icon_key']) ? $curSw['icon_key'] : ''));
|
||||||
|
$bImg = $bKey !== '' ? fb_icon_url($bKey, 64) : '';
|
||||||
|
$bTitle = fb_software_site_title($curSw);
|
||||||
|
?>
|
||||||
|
<a class="brand-mini" href="<?php echo e(fb_software_list_url($curSw)); ?>"
|
||||||
|
style="--swc:<?php echo e($curSwColor); ?>"
|
||||||
|
title="<?php echo e($bTitle); ?>">
|
||||||
|
<span class="brand-ic" aria-hidden="true">
|
||||||
|
<?php if ($bImg !== ''): ?>
|
||||||
|
<img src="<?php echo e($bImg); ?>" alt="" width="30" height="30" loading="lazy">
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="brand-ic-ch"><?php echo e($bIcon); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<span class="brand-txt">
|
||||||
|
<span class="brand-title"><?php echo e($bTitle); ?></span>
|
||||||
|
<?php if ($swNoteTop !== ''): ?>
|
||||||
|
<span class="brand-mini-note"><?php echo e($swNoteTop); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="brand-mini" href="./" title="<?php echo e($site); ?>">
|
||||||
|
<span class="logo-mark" role="img" aria-label="轻反馈图标"></span>
|
||||||
|
<span class="brand-text"><?php echo e($site); ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<a class="navlink hide-sm" href="./">全部问题</a>
|
<?php
|
||||||
<?php if (fb_admin_logged_in()): ?><a class="navlink" href="admin.php">管理后台</a><?php endif; ?>
|
/* 🔴 后台模式:这三样全都不渲染。
|
||||||
<a class="btn-mini" href="new.php">我要反馈</a>
|
* 用户 2026-11-03 问「后台还需要我要反馈吗?还需要三横线吗?还需要选软件吗?」
|
||||||
|
* ⇒ 都不需要,理由写在上面的 $isAdminBar 注释里。
|
||||||
|
*
|
||||||
|
* 后台顶栏只留「回前台看看」一个小按钮 —— 管理员有时需要确认
|
||||||
|
* 前台实际显示效果,这是唯一有意义的出口。
|
||||||
|
* 「管理后台」本身不标(已经在后台了,且下面的页头有大标题+退出登录),
|
||||||
|
* 避免一排没用的标记占位置。 */
|
||||||
|
if ($isAdminBar):
|
||||||
|
?>
|
||||||
|
<?php if ($curSw): ?>
|
||||||
|
<a class="navlink" href="<?php echo e(fb_software_list_url($curSw)); ?>">去前台看看</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="navlink" href="index.php">去前台看看</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php
|
||||||
|
/* 「问题列表」只在进入某个软件之后才有意义 ——
|
||||||
|
* 首页现在是介绍页,没有"全部问题"可回。 */
|
||||||
|
if ($curSw):
|
||||||
|
?>
|
||||||
|
<a class="navlink hide-sm" href="?sw=<?php echo e(urlencode((string)$curSw['slug'])); ?>">问题列表</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (fb_admin_logged_in()): ?><a class="navlink hide-sm" href="admin.php">管理后台</a><?php endif; ?>
|
||||||
|
<?php /* ⚠️ 这里**不能**加 hide-sm:列表页那个「我要反馈」大按钮已删除,
|
||||||
|
「我要反馈」现在全站只剩顶栏这一个入口。
|
||||||
|
如果手机上把它隐藏掉,手机端就没有提交入口了。 */ ?>
|
||||||
|
<a class="btn-mini" href="new.php<?php echo $curSw ? '?sw=' . e(urlencode((string)$curSw['slug'])) : ''; ?>">我要反馈</a>
|
||||||
|
<?php endif; /* isAdminBar */ ?>
|
||||||
|
|
||||||
|
<?php if (!$isAdminBar && count($swList) > 1): ?>
|
||||||
|
<?php /* 右上角三横线:点开就是一个小下拉菜单。
|
||||||
|
不用整页平铺「选择软件:」,也不必做成侧边栏。 */ ?>
|
||||||
|
<button type="button" class="sw-toggle" id="swToggle"
|
||||||
|
aria-label="切换软件" aria-expanded="false" aria-controls="swPanel">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<?php /* 极简下拉:贴在按钮下方、右对齐,内容多高就多高。
|
||||||
|
参考家书 FamilyBook 顶栏那个小浮窗的做法。 */ ?>
|
||||||
|
<div class="sw-panel" id="swPanel" hidden>
|
||||||
|
<div class="sw-panel-list">
|
||||||
|
<?php foreach ($swList as $s): ?>
|
||||||
|
<?php
|
||||||
|
$on = $curSw && (int)$s['id'] === (int)$curSw['id'];
|
||||||
|
$href = fb_qs(['sw' => (string)$s['slug'], 'cat' => null, 'page' => null]);
|
||||||
|
if ($href === '') { $href = '?sw=' . urlencode((string)$s['slug']); }
|
||||||
|
?>
|
||||||
|
<a class="sw-item<?php echo $on ? ' on' : ''; ?>" href="<?php echo e($href); ?>">
|
||||||
|
<?php echo fb_software_badge($s, 'sm'); ?>
|
||||||
|
<?php if ($on): ?><span class="sw-item-cur">当前</span><?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
@@ -77,7 +220,7 @@ function fb_layout_foot() {
|
|||||||
?>
|
?>
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<p class="muted">本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
|
<p class="muted">本站仅保存您提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
|
||||||
<p class="muted foot-line">
|
<p class="muted foot-line">
|
||||||
<?php if ($showCredit): ?><span><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 <a href="<?php echo e($creditUrl); ?>" target="_blank" rel="noopener">FeedLite</a> 驱动</span><?php endif; ?>
|
<?php if ($showCredit): ?><span><?php echo e((string)fb_cfg('site_name', '轻反馈')); ?> · 由 <a href="<?php echo e($creditUrl); ?>" target="_blank" rel="noopener">FeedLite</a> 驱动</span><?php endif; ?>
|
||||||
<?php if ($showCredit): ?><span class="dot">·</span><?php endif; ?>
|
<?php if ($showCredit): ?><span class="dot">·</span><?php endif; ?>
|
||||||
|
|||||||
+352
-84
@@ -128,8 +128,8 @@ function fb_smtp_send(array $s, $to, $subject, $html) {
|
|||||||
* echo fb_mail_layout([
|
* echo fb_mail_layout([
|
||||||
* 'preheader' => '预览摘要(收件箱列表里显示的那行灰字)',
|
* 'preheader' => '预览摘要(收件箱列表里显示的那行灰字)',
|
||||||
* 'badge' => ['text' => '处理中', 'color' => '#0a6cf5'],
|
* 'badge' => ['text' => '处理中', 'color' => '#0a6cf5'],
|
||||||
* 'title' => '你的反馈已被受理',
|
* 'title' => '您的反馈已被受理',
|
||||||
* 'intro' => '老杨,你好:',
|
* 'intro' => '老杨,您好:',
|
||||||
* 'blocks' => [ ... ], // 内容块,见下
|
* 'blocks' => [ ... ], // 内容块,见下
|
||||||
* 'button' => ['text' => '查看详情', 'url' => 'https://...'],
|
* 'button' => ['text' => '查看详情', 'url' => 'https://...'],
|
||||||
* 'footer' => '此邮件由系统自动发送。',
|
* 'footer' => '此邮件由系统自动发送。',
|
||||||
@@ -144,14 +144,81 @@ if (!function_exists('fb_mail_esc')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('fb_mail_brand')) {
|
if (!function_exists('fb_mail_brand')) {
|
||||||
/** 取站点主色,用于邮件头与按钮。跟随「软件主题色」体系,但邮件里只用主色与浅底 */
|
/**
|
||||||
|
* 邮件主色。
|
||||||
|
*
|
||||||
|
* 🔴 多软件站点的核心要求:邮件颜色必须跟「这条反馈属于哪个软件」一致,
|
||||||
|
* 否则用户在收件箱里根本分不清是哪个软件的来信。
|
||||||
|
* 取值顺序:
|
||||||
|
* ① 调用方显式传入的软件行(fb_mail_layout(['software' => ...]))
|
||||||
|
* ② 当前全局软件($GLOBALS['__fb_mail_sw'],由 fb_notify_render 设置)
|
||||||
|
* ③ 配置里的 mail_brand_color(单软件站点的老配置,保持兼容)
|
||||||
|
* ④ 兜底蓝
|
||||||
|
*/
|
||||||
function fb_mail_brand() {
|
function fb_mail_brand() {
|
||||||
|
$sw = null;
|
||||||
|
if (isset($GLOBALS['__fb_mail_sw']) && is_array($GLOBALS['__fb_mail_sw'])) {
|
||||||
|
$sw = $GLOBALS['__fb_mail_sw'];
|
||||||
|
}
|
||||||
|
if ($sw) {
|
||||||
|
list($c) = fb_software_colors($sw);
|
||||||
|
if ($c !== '') { return $c; }
|
||||||
|
}
|
||||||
$c = fb_color_norm((string)fb_cfg('mail_brand_color', ''));
|
$c = fb_color_norm((string)fb_cfg('mail_brand_color', ''));
|
||||||
if ($c === '') { $c = '#0057B8'; }
|
if ($c === '') { $c = '#0057B8'; }
|
||||||
return $c;
|
return $c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_mail_sw_ctx')) {
|
||||||
|
/** 设置/读取「当前邮件所属软件」,返回之前的上下文(便于嵌套调用后还原) */
|
||||||
|
function fb_mail_sw_ctx($sw = null, $set = false) {
|
||||||
|
$prev = isset($GLOBALS['__fb_mail_sw']) ? $GLOBALS['__fb_mail_sw'] : null;
|
||||||
|
if ($set) { $GLOBALS['__fb_mail_sw'] = $sw; }
|
||||||
|
return $prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('fb_mail_sw_header')) {
|
||||||
|
/**
|
||||||
|
* 邮件头部的「软件标识」:色块图标 + 软件名。
|
||||||
|
* 用 table + 内联样式写,兼容 Outlook 等不认 flex 的客户端。
|
||||||
|
*/
|
||||||
|
function fb_mail_sw_header($sw) {
|
||||||
|
if (!is_array($sw) || empty($sw['name'])) { return ''; }
|
||||||
|
list($c) = fb_software_colors($sw);
|
||||||
|
if ($c === '') { $c = '#0057B8'; }
|
||||||
|
$icon = fb_software_icon($sw);
|
||||||
|
$name = (string)$sw['name'];
|
||||||
|
|
||||||
|
/* 有上传的图标图片就用图片(管理员自己的 logo),没有才用字符。
|
||||||
|
* 一律不加背景色块 —— 图标本身已经能说明是哪个软件,
|
||||||
|
* 再垫个色块反而糊在一起(与前端 fb_software_badge 保持同一观感)。
|
||||||
|
* 邮件客户端对 flex 支持参差,这里一律用 table + 内联样式;
|
||||||
|
* 图片必须给死宽高,否则 Outlook 里会撑变形。 */
|
||||||
|
$imgKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
|
||||||
|
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, 60) : '';
|
||||||
|
|
||||||
|
$cell = '';
|
||||||
|
if ($imgUrl !== '') {
|
||||||
|
$cell = '<img src="' . fb_mail_esc($imgUrl) . '" alt="' . fb_mail_esc($name) . '"'
|
||||||
|
. ' width="30" height="30"'
|
||||||
|
. ' style="width:30px;height:30px;display:block;border:0;outline:none;text-decoration:none">';
|
||||||
|
} else {
|
||||||
|
$cell = '<div style="font-size:22px;line-height:30px;text-align:center;color:#1b2430">'
|
||||||
|
. '<span style="display:inline-block;line-height:30px">' . fb_mail_esc($icon) . '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
|
||||||
|
. ' style="border-collapse:collapse"><tr>'
|
||||||
|
. '<td width="30" style="width:30px;vertical-align:middle">' . $cell . '</td>'
|
||||||
|
. '<td style="padding-left:9px;vertical-align:middle;font-size:15px;'
|
||||||
|
. 'font-weight:700;color:#1b2430;letter-spacing:.2px">'
|
||||||
|
. fb_mail_esc($name) . '</td>'
|
||||||
|
. '</tr></table>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!function_exists('fb_mail_block')) {
|
if (!function_exists('fb_mail_block')) {
|
||||||
/**
|
/**
|
||||||
* 渲染一个内容块。
|
* 渲染一个内容块。
|
||||||
@@ -242,6 +309,10 @@ if (!function_exists('fb_mail_layout')) {
|
|||||||
* @return string 完整 HTML(可直接作为 text/html 正文)
|
* @return string 完整 HTML(可直接作为 text/html 正文)
|
||||||
*/
|
*/
|
||||||
function fb_mail_layout(array $o) {
|
function fb_mail_layout(array $o) {
|
||||||
|
// 软件上下文:显式传入优先,否则用全局上下文(fb_notify_render 会设好)
|
||||||
|
$sw = isset($o['software']) && is_array($o['software']) ? $o['software'] : null;
|
||||||
|
if ($sw) { fb_mail_sw_ctx($sw, true); } // 让本函数内 fb_mail_brand() 取到软件色
|
||||||
|
|
||||||
$brand = fb_mail_brand();
|
$brand = fb_mail_brand();
|
||||||
$site = (string)fb_cfg('site_name', '轻反馈');
|
$site = (string)fb_cfg('site_name', '轻反馈');
|
||||||
$base = fb_base_url();
|
$base = fb_base_url();
|
||||||
@@ -296,6 +367,36 @@ if (!function_exists('fb_mail_layout')) {
|
|||||||
. fb_mail_esc($link) . '</span></p>';
|
. fb_mail_esc($link) . '</span></p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 私人链接安全提醒 ----
|
||||||
|
*
|
||||||
|
* 用户 2026-11-03:「每次给他发这种链接的时候,都要在链接下面或上面
|
||||||
|
* 给他一个提示:这个链接是你独有的,不要转给别人。」
|
||||||
|
*
|
||||||
|
* 为什么必须提醒:这个链接里带 track_token,它就是**唯一凭证**——
|
||||||
|
* 谁拿到这串东西,谁就能看(甚至回复)这条反馈。而本站不做登录,
|
||||||
|
* 我们没法事后判断"是本人还是捡到链接的人"。
|
||||||
|
* 只要邮件里带了私人链接(personal=true),这条提醒就必须出现。
|
||||||
|
*
|
||||||
|
* 措辞取舍:
|
||||||
|
* - 说清"是什么"(只有你能看的入口),而不只是"别外传"——只讲禁止,
|
||||||
|
* 用户会觉得是免责声明;讲清缘由他才会真的照做。
|
||||||
|
* - 不让用户恐慌:补一句"不需要的话可以删掉这封邮件",给出退路。
|
||||||
|
* - 管理员后台通知里的链接不是私人凭证,不加这段(personal=false)。 */
|
||||||
|
$privNoteHtml = '';
|
||||||
|
if (!empty($o['personal'])) {
|
||||||
|
$privNoteHtml = '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
|
||||||
|
. ' width="100%" style="margin:0 0 18px">'
|
||||||
|
. '<tr><td style="padding:11px 14px;background:#fff8e6;'
|
||||||
|
. 'border-left:3px solid #d99b1f;border-radius:6px">'
|
||||||
|
. '<p style="margin:0 0 5px;font-size:13px;font-weight:600;color:#6b5313">'
|
||||||
|
. '这个链接只有您能打开,请不要转发</p>'
|
||||||
|
. '<p style="margin:0;font-size:12.5px;line-height:1.7;color:#7a6420">'
|
||||||
|
. '它等同于您在这条反馈上的身份凭证 —— 谁拿到这个链接,谁就能查看和处理这条反馈。'
|
||||||
|
. '请只自己使用,不要发给别人,也不要发到公开的地方。'
|
||||||
|
. '如果不需要继续跟踪,删掉这封邮件即可。</p>'
|
||||||
|
. '</td></tr></table>';
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 收件人问候 ----
|
// ---- 收件人问候 ----
|
||||||
$introHtml = '';
|
$introHtml = '';
|
||||||
if ($intro !== '') {
|
if ($intro !== '') {
|
||||||
@@ -330,11 +431,18 @@ if (!function_exists('fb_mail_layout')) {
|
|||||||
. '<tr><td style="height:4px;background:' . fb_mail_esc($brand) . ';font-size:0;'
|
. '<tr><td style="height:4px;background:' . fb_mail_esc($brand) . ';font-size:0;'
|
||||||
. 'line-height:0"> </td></tr>'
|
. 'line-height:0"> </td></tr>'
|
||||||
|
|
||||||
// 站名
|
// 站名 / 软件标识
|
||||||
|
// 有软件上下文时:上排是「图标 + 软件名」(这是用户最需要一眼认出的信息),
|
||||||
|
// 下排再补一行小字站点名。没有软件时退回原来的纯站名写法。
|
||||||
. '<tr><td style="padding:22px 32px 0">'
|
. '<tr><td style="padding:22px 32px 0">'
|
||||||
. '<div style="font-size:13px;font-weight:700;letter-spacing:.6px;'
|
. (isset($o['software']) && is_array($o['software'])
|
||||||
. 'color:' . fb_mail_esc($brand) . ';text-transform:uppercase">'
|
? fb_mail_sw_header($o['software'])
|
||||||
. fb_mail_esc($site) . '</div></td></tr>'
|
. '<div style="margin-top:7px;font-size:11.5px;letter-spacing:.4px;color:#9aa2ad">'
|
||||||
|
. fb_mail_esc($site) . '</div>'
|
||||||
|
: '<div style="font-size:13px;font-weight:700;letter-spacing:.6px;'
|
||||||
|
. 'color:' . fb_mail_esc($brand) . ';text-transform:uppercase">'
|
||||||
|
. fb_mail_esc($site) . '</div>')
|
||||||
|
. '</td></tr>'
|
||||||
|
|
||||||
// 标题
|
// 标题
|
||||||
. '<tr><td style="padding:12px 32px 4px">' . $badgeHtml
|
. '<tr><td style="padding:12px 32px 4px">' . $badgeHtml
|
||||||
@@ -342,8 +450,10 @@ if (!function_exists('fb_mail_layout')) {
|
|||||||
. 'color:#1b2430">' . fb_mail_esc($title) . '</h1></td></tr>'
|
. 'color:#1b2430">' . fb_mail_esc($title) . '</h1></td></tr>'
|
||||||
|
|
||||||
// 正文
|
// 正文
|
||||||
|
// 顺序:按钮 → 私人链接提醒 → 纯文本兜底链接。
|
||||||
|
// 提醒要贴在按钮旁边(同一屏可见),不能埋到邮件最底部。
|
||||||
. '<tr><td style="padding:16px 32px 8px">'
|
. '<tr><td style="padding:16px 32px 8px">'
|
||||||
. $introHtml . $body . $btnHtml . $linkHtml
|
. $introHtml . $body . $btnHtml . $privNoteHtml . $linkHtml
|
||||||
. '</td></tr>'
|
. '</td></tr>'
|
||||||
|
|
||||||
// 页脚
|
// 页脚
|
||||||
@@ -353,7 +463,7 @@ if (!function_exists('fb_mail_layout')) {
|
|||||||
. ($recipient !== ''
|
. ($recipient !== ''
|
||||||
? '<p style="margin:0;font-size:11.5px;line-height:1.6;color:#b3b9c2">'
|
? '<p style="margin:0;font-size:11.5px;line-height:1.6;color:#b3b9c2">'
|
||||||
. '这封邮件发给了 ' . fb_mail_esc($recipient) . '。'
|
. '这封邮件发给了 ' . fb_mail_esc($recipient) . '。'
|
||||||
. '如果这不是你,忽略即可。</p>' : '')
|
. '如果这不是您,忽略即可。</p>' : '')
|
||||||
. '<p style="margin:8px 0 0;font-size:11.5px;color:#b3b9c2">'
|
. '<p style="margin:8px 0 0;font-size:11.5px;color:#b3b9c2">'
|
||||||
. '<a href="' . fb_mail_esc($base) . '" target="_blank"'
|
. '<a href="' . fb_mail_esc($base) . '" target="_blank"'
|
||||||
. ' style="color:#9aa2ad;text-decoration:none">'
|
. ' style="color:#9aa2ad;text-decoration:none">'
|
||||||
@@ -367,7 +477,8 @@ if (!function_exists('fb_mail_layout')) {
|
|||||||
}
|
}
|
||||||
function fb_notify_html($thread, $reply, $isAdmin) {
|
function fb_notify_html($thread, $reply, $isAdmin) {
|
||||||
$base = fb_base_url();
|
$base = fb_base_url();
|
||||||
$url = $base . '/thread.php?id=' . (int)$thread['id'];
|
// 发给提问人的信,链接必须带 token,否则他看不了自己那条未公开的反馈
|
||||||
|
$url = fb_thread_link($thread, true);
|
||||||
$site = (string)fb_cfg('site_name');
|
$site = (string)fb_cfg('site_name');
|
||||||
$name = (string)$thread['author_name'];
|
$name = (string)$thread['author_name'];
|
||||||
$title = (string)$thread['title'];
|
$title = (string)$thread['title'];
|
||||||
@@ -378,8 +489,8 @@ function fb_notify_html($thread, $reply, $isAdmin) {
|
|||||||
$safeRName = htmlspecialchars($rname, ENT_QUOTES, 'UTF-8');
|
$safeRName = htmlspecialchars($rname, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
||||||
. '<p>' . $safeName . ',你好:</p>'
|
. '<p>' . $safeName . ',您好:</p>'
|
||||||
. '<p>你在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>'
|
. '<p>您在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>'
|
||||||
. '<blockquote style="margin:12px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
. '<blockquote style="margin:12px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
||||||
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>'
|
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>'
|
||||||
. $rbody
|
. $rbody
|
||||||
@@ -426,19 +537,19 @@ function fb_notify_closed_html(array $thread, string $note) {
|
|||||||
$mail = htmlspecialchars((string)fb_cfg('admin_email'), ENT_QUOTES, 'UTF-8');
|
$mail = htmlspecialchars((string)fb_cfg('admin_email'), ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
||||||
. '<p>' . $name . ',你好:</p>'
|
. '<p>' . $name . ',您好:</p>'
|
||||||
. '<p>感谢你反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
|
. '<p>感谢您反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
|
||||||
. '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>'
|
. '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>'
|
||||||
. '<div style="margin:14px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
. '<div style="margin:14px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
||||||
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">官方说明:</div>'
|
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">官方说明:</div>'
|
||||||
. $noteH
|
. $noteH
|
||||||
. '</div>'
|
. '</div>'
|
||||||
. '<p style="color:#6b7280;font-size:13px">你当时反馈的内容:</p>'
|
. '<p style="color:#6b7280;font-size:13px">您当时反馈的内容:</p>'
|
||||||
. '<div style="margin:8px 0 16px;padding:10px 14px;background:#f7f8fa;border-radius:6px;color:#4b5563;font-size:14px">'
|
. '<div style="margin:8px 0 16px;padding:10px 14px;background:#f7f8fa;border-radius:6px;color:#4b5563;font-size:14px">'
|
||||||
. $body
|
. $body
|
||||||
. '</div>'
|
. '</div>'
|
||||||
. ($mail !== '' ? '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进;'
|
. ($mail !== '' ? '<p>如果上面的说明没能解决您的疑问,可以直接回复本邮件,我们会继续跟进;'
|
||||||
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
|
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决您的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
|
||||||
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
|
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
|
||||||
. '<p style="color:#9aa2ad;font-size:12px">此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。</p>'
|
. '<p style="color:#9aa2ad;font-size:12px">此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。</p>'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
@@ -491,7 +602,7 @@ function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
|
|||||||
* 邮件通知事件(工单 #2)
|
* 邮件通知事件(工单 #2)
|
||||||
*
|
*
|
||||||
* 全部事件:
|
* 全部事件:
|
||||||
* reply 有人回复了你的反馈 → 发帖人
|
* reply 有人回复了您的反馈 → 发帖人
|
||||||
* status 处理状态变了 → 发帖人
|
* status 处理状态变了 → 发帖人
|
||||||
* approve 审核通过、已公开 → 发帖人
|
* approve 审核通过、已公开 → 发帖人
|
||||||
* hide 转为不公开 → 发帖人
|
* hide 转为不公开 → 发帖人
|
||||||
@@ -512,6 +623,7 @@ if (!function_exists('fb_notify_reason_label')) {
|
|||||||
/** 事件的中文名,用于日志与模板徽标 */
|
/** 事件的中文名,用于日志与模板徽标 */
|
||||||
function fb_notify_reason_label($reason) {
|
function fb_notify_reason_label($reason) {
|
||||||
$map = [
|
$map = [
|
||||||
|
'receipt' => '已收到反馈',
|
||||||
'reply' => '有新回复',
|
'reply' => '有新回复',
|
||||||
'status' => '状态更新',
|
'status' => '状态更新',
|
||||||
'approve' => '已发布',
|
'approve' => '已发布',
|
||||||
@@ -533,9 +645,15 @@ if (!function_exists('fb_notify_enabled_for')) {
|
|||||||
* 它独立于前者 —— 就算关了给用户的信,管理员也可以想收通知
|
* 它独立于前者 —— 就算关了给用户的信,管理员也可以想收通知
|
||||||
*/
|
*/
|
||||||
function fb_notify_enabled_for($reason) {
|
function fb_notify_enabled_for($reason) {
|
||||||
|
// 「新反馈」按 admin_new_thread 独立开关;「提交回执」按 receipt_enabled 独立开关
|
||||||
|
// (回执是发给提问人的,跟「有人回复时通知提交人」是两件不同的事,
|
||||||
|
// 有人可能只想要回执不想要回复通知,所以不能共用一个开关)
|
||||||
if ($reason === 'new') {
|
if ($reason === 'new') {
|
||||||
return (bool)fb_cfg('notify.admin_new_thread', false);
|
return (bool)fb_cfg('notify.admin_new_thread', false);
|
||||||
}
|
}
|
||||||
|
if ($reason === 'receipt') {
|
||||||
|
return (bool)fb_cfg('notify.receipt_enabled', true);
|
||||||
|
}
|
||||||
return (bool)fb_cfg('notify.enabled', true);
|
return (bool)fb_cfg('notify.enabled', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,8 +699,20 @@ if (!function_exists('fb_notify_event')) {
|
|||||||
function fb_notify_event(PDO $pdo, $reason, array $thread, array $extra = []) {
|
function fb_notify_event(PDO $pdo, $reason, array $thread, array $extra = []) {
|
||||||
$reason = (string)$reason;
|
$reason = (string)$reason;
|
||||||
|
|
||||||
|
/* 🔴 铁律:任何一条退出路径都必须写 notify_log。
|
||||||
|
*
|
||||||
|
* 原先前 4 个判断(开关 / 收件人 / 自己回自己 / 冷却)直接 return 不记日志,
|
||||||
|
* 结果就是「用户说没收到信」时后台查不到任何记录 —— 因为压根就没进发信那一步。
|
||||||
|
* 2026-11-03 排查时 notify_log 全表为空,一度被误判成「发信链路坏了」,
|
||||||
|
* 实际是这些静默 return 造成的黑箱。日志必须回答「为什么没发」。
|
||||||
|
*/
|
||||||
|
$log = function ($to, $ok, $err) use ($pdo, $thread, $reason) {
|
||||||
|
fb_notify_log2($pdo, $thread, $to, $reason, $ok, $err);
|
||||||
|
};
|
||||||
|
|
||||||
// ---- 1. 开关 ----
|
// ---- 1. 开关 ----
|
||||||
if (!fb_notify_enabled_for($reason)) {
|
if (!fb_notify_enabled_for($reason)) {
|
||||||
|
$log('', 0, '通知未启用(该事件的开关是关的)');
|
||||||
return [false, '通知未启用'];
|
return [false, '通知未启用'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,30 +721,56 @@ if (!function_exists('fb_notify_event')) {
|
|||||||
if ($isAdminNotice) {
|
if ($isAdminNotice) {
|
||||||
$to = trim((string)fb_cfg('admin_email', ''));
|
$to = trim((string)fb_cfg('admin_email', ''));
|
||||||
if ($to === '') { $to = trim((string)fb_cfg('admin.email', '')); }
|
if ($to === '') { $to = trim((string)fb_cfg('admin.email', '')); }
|
||||||
|
// 还没填官方邮箱时回落到 SMTP 发件地址 —— 至少信能到管理员手上,
|
||||||
|
// 而不是整个「新反馈通知」功能静默失效(实际就发生过:管理员从不收信,
|
||||||
|
// 后台也没有任何提示,直到来测才发现)。
|
||||||
|
if ($to === '') { $to = trim((string)fb_cfg('smtp.user', '')); }
|
||||||
|
if ($to === '') { $to = trim((string)fb_cfg('smtp.from', '')); }
|
||||||
} else {
|
} else {
|
||||||
$to = trim((string)(isset($thread['author_email']) ? $thread['author_email'] : ''));
|
$to = trim((string)(isset($thread['author_email']) ? $thread['author_email'] : ''));
|
||||||
}
|
}
|
||||||
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
return [false, '收件邮箱无效'];
|
$why = $isAdminNotice
|
||||||
|
? '收件邮箱无效:官方邮箱与 SMTP 发件地址都没填,新反馈通知发不出去'
|
||||||
|
: '收件邮箱无效:提交人没留邮箱或格式不对';
|
||||||
|
$log($to, 0, $why);
|
||||||
|
return [false, $why];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 3. 自己通知自己就别发了 ----
|
/* ---- 3. 自己通知自己就别发了 ----
|
||||||
if (!$isAdminNotice && !empty($extra['is_admin']) === false
|
*
|
||||||
&& !empty($extra['reply']['author_email'])) {
|
* 用户 2026-11-03:「他自己回复他自己的反馈,不要再给他发邮件了 ——
|
||||||
if (strcasecmp(trim((string)$extra['reply']['author_email']), $to) === 0) {
|
* 他自己说的,他发给他自己,不是很奇怪吗?但如果是别人回复他的,
|
||||||
return [false, '自己回复自己,不发'];
|
* 就可以给他发邮件。」
|
||||||
|
*
|
||||||
|
* 覆盖三种「其实是同一个人」的情形:
|
||||||
|
* ① 提交人自己回复(带 token 进来,服务端会把邮箱强制设为原作者);
|
||||||
|
* ② 回复者填的邮箱与提交人相同(普通访客碰巧同邮箱);
|
||||||
|
* ③ 邮箱大小写 / 前后空格不同(DAO@x.com vs dao@x.com)。
|
||||||
|
*
|
||||||
|
* ⚠️ 旧写法是 `!empty($extra['is_admin']) === false` —— 双重否定绕,
|
||||||
|
* 读起来要停一下才知道在判什么。这里改成直接判 is_admin。
|
||||||
|
* ⚠️ 官方(管理员)回复不在豁免之列:管理员邮箱通常与提交人不同,
|
||||||
|
* 但万一相同(管理员给自己提的反馈回复),也不该给自己发信。 */
|
||||||
|
if (!$isAdminNotice) {
|
||||||
|
$replyMail = isset($extra['reply']['author_email'])
|
||||||
|
? trim((string)$extra['reply']['author_email']) : '';
|
||||||
|
if ($replyMail !== '' && strcasecmp($replyMail, $to) === 0) {
|
||||||
|
$log($to, 0, '本人回复自己的反馈,不发信');
|
||||||
|
return [false, '本人回复自己的反馈,不发信'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 4. 节流 ----
|
// ---- 4. 节流 ----
|
||||||
if (fb_notify_throttled($pdo, (int)$thread['id'], $to, $reason)) {
|
if (fb_notify_throttled($pdo, (int)$thread['id'], $to, $reason)) {
|
||||||
|
$log($to, 0, '冷却中跳过(notify.cooldown_seconds=' . (int)fb_cfg('notify.cooldown_seconds', 300) . ')');
|
||||||
return [false, '冷却中,跳过'];
|
return [false, '冷却中,跳过'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 5. SMTP 就绪吗 ----
|
// ---- 5. SMTP 就绪吗 ----
|
||||||
$smtp = (array)fb_cfg('smtp', []);
|
$smtp = (array)fb_cfg('smtp', []);
|
||||||
if (empty($smtp['enabled']) || trim((string)$smtp['host']) === '') {
|
if (empty($smtp['enabled']) || trim((string)$smtp['host']) === '') {
|
||||||
fb_notify_log2($pdo, $thread, $to, $reason, 0, 'SMTP 未启用或未配置服务器');
|
$log($to, 0, 'SMTP 未启用或未配置服务器');
|
||||||
return [false, 'SMTP 未启用或未配置服务器'];
|
return [false, 'SMTP 未启用或未配置服务器'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +779,7 @@ if (!function_exists('fb_notify_event')) {
|
|||||||
|
|
||||||
// ---- 7. 发送 ----
|
// ---- 7. 发送 ----
|
||||||
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
|
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
|
||||||
fb_notify_log2($pdo, $thread, $to, $reason, $ok ? 1 : 0, $ok ? null : $err);
|
$log($to, $ok ? 1 : 0, $ok ? null : $err);
|
||||||
return [$ok, $ok ? '' : (string)$err];
|
return [$ok, $ok ? '' : (string)$err];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -662,8 +818,16 @@ if (!function_exists('fb_notify_log2')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!function_exists('fb_notify_thread_url')) {
|
if (!function_exists('fb_notify_thread_url')) {
|
||||||
|
/**
|
||||||
|
* 邮件里给提问人的链接 —— **一律带 token**,不带 id 裸链。
|
||||||
|
*
|
||||||
|
* 为什么:邮件只发给提问人自己,而 token 只有他和我们(库里)知道。
|
||||||
|
* 带上它,即使这条反馈还没审核通过、甚至最终被关闭,他点进去也能看到
|
||||||
|
* 完整详情与进度。早期给的是 thread.php?id=N,未公开时点进去只看到
|
||||||
|
* 「不存在」或「等待审核」,等于邮件里的链接是废的。
|
||||||
|
*/
|
||||||
function fb_notify_thread_url(array $thread) {
|
function fb_notify_thread_url(array $thread) {
|
||||||
return fb_base_url() . '/thread.php?id=' . (int)$thread['id'];
|
return fb_thread_link($thread, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,35 +842,44 @@ if (!function_exists('fb_notify_status_meta')) {
|
|||||||
*/
|
*/
|
||||||
function fb_notify_status_meta($key) {
|
function fb_notify_status_meta($key) {
|
||||||
$map = [
|
$map = [
|
||||||
|
/* 「未公开」:用户刚提交时就是这个状态。
|
||||||
|
* 一般不单独发信(提交回执已经说了「等待审核」),
|
||||||
|
* 但万一从别的状态退回来,也要有话可说,不能落到兜底文案。 */
|
||||||
|
'hidden' => [
|
||||||
|
'badge' => '未公开', 'color' => '#6b7684', 'tone' => 'muted',
|
||||||
|
'title' => '您的反馈已提交,正在等待审核',
|
||||||
|
'intro' => '管理员会尽快查看您的反馈,确认后才会公开显示。',
|
||||||
|
'tip' => '审核结果我们会通过邮件通知您,无需反复回来查看。',
|
||||||
|
],
|
||||||
'open' => [
|
'open' => [
|
||||||
'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted',
|
'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted',
|
||||||
'title' => '你的反馈已收到,正在等待处理',
|
'title' => '您的反馈已收到,正在等待处理',
|
||||||
'intro' => '我们已经收到你的反馈,会尽快安排处理。',
|
'intro' => '我们已经收到您的反馈,会尽快安排处理。',
|
||||||
'tip' => null,
|
'tip' => null,
|
||||||
],
|
],
|
||||||
'doing' => [
|
'doing' => [
|
||||||
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
|
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
|
||||||
'title' => '你反馈的问题正在处理中',
|
'title' => '您反馈的问题正在处理中',
|
||||||
'intro' => '我们已经开始跟进你反馈的问题,有进展会第一时间告诉你。',
|
'intro' => '我们已经开始跟进您反馈的问题,有进展会第一时间告诉您。',
|
||||||
'tip' => '感谢你的耐心等待,暂时不需要你做任何操作。',
|
'tip' => '感谢您的耐心等待,暂时不需要您做任何操作。',
|
||||||
],
|
],
|
||||||
'resolved' => [
|
'resolved' => [
|
||||||
'badge' => '已解决', 'color' => '#1e7b3c', 'tone' => 'ok',
|
'badge' => '已完成', 'color' => '#1e7b3c', 'tone' => 'ok',
|
||||||
'title' => '你反馈的问题已解决',
|
'title' => '您反馈的问题已完成并上线',
|
||||||
'intro' => '好消息,你反馈的问题我们已经处理完成。',
|
'intro' => '好消息:您反馈的内容我们已经处理完成,并已发布上线。',
|
||||||
'tip' => '如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
|
'tip' => '建议更新到最新版本后试一下。如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
|
||||||
],
|
],
|
||||||
'closed' => [
|
'closed' => [
|
||||||
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
|
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
|
||||||
'title' => '你反馈的问题已关闭',
|
'title' => '您反馈的问题已关闭',
|
||||||
'intro' => '你反馈的问题已经关闭。',
|
'intro' => '您反馈的问题已经关闭。',
|
||||||
'tip' => null,
|
'tip' => null,
|
||||||
],
|
],
|
||||||
'wontfix' => [
|
'wontfix' => [
|
||||||
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
|
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
|
||||||
'title' => '关于你反馈的问题,我们的处理意见',
|
'title' => '关于您反馈的问题,我们的处理意见',
|
||||||
'intro' => '感谢你的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
|
'intro' => '感谢您的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
|
||||||
'tip' => '如果你认为这个判断有误,或者有补充信息,直接在本页回复即可。',
|
'tip' => '如果您认为这个判断有误,或者有补充信息,直接在本页回复即可。',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
return isset($map[$key]) ? $map[$key] : $map['open'];
|
return isset($map[$key]) ? $map[$key] : $map['open'];
|
||||||
@@ -731,6 +904,24 @@ if (!function_exists('fb_notify_render')) {
|
|||||||
$short = fb_excerpt($title, 42);
|
$short = fb_excerpt($title, 42);
|
||||||
$isAdmin = !empty($extra['is_admin']);
|
$isAdmin = !empty($extra['is_admin']);
|
||||||
|
|
||||||
|
/* ---- 软件上下文:整封邮件的配色、头部标识、主题前缀都靠它 ----
|
||||||
|
* 每条反馈都属于某个软件。邮件必须让用户一眼看出「这是哪个软件的来信」,
|
||||||
|
* 所以这里解析一次,后面所有分支共用:
|
||||||
|
* $swName 软件名(进主题行前缀)
|
||||||
|
* $sw 软件行(进邮件头标识与配色)
|
||||||
|
*
|
||||||
|
* 🔴 允许 $extra['software'] 显式指定软件行:
|
||||||
|
* ① 后台「发送测试邮件」可以指定「按某个软件的样子发」,便于预览配色;
|
||||||
|
* ② 测试脚本能在不往库里插数据的前提下验证配色切换;
|
||||||
|
* ③ 万一 threads.software_id 指向已被删除的软件,调用方仍能给出正确上下文。
|
||||||
|
* 没有显式指定时,才按 software_id 回查、再回落默认软件。 */
|
||||||
|
$sw = (isset($extra['software']) && is_array($extra['software'])) ? $extra['software'] : null;
|
||||||
|
if (!$sw) { $sw = fb_software_of_thread($thread); }
|
||||||
|
$swName = $sw ? (string)$sw['name'] : '';
|
||||||
|
fb_mail_sw_ctx($sw, true);
|
||||||
|
// 主题统一加软件名前缀,收件箱列表里不点开就能分辨
|
||||||
|
$pfx = ($swName !== '') ? '【' . $swName . '】' : '';
|
||||||
|
|
||||||
$subject = '';
|
$subject = '';
|
||||||
$layout = [];
|
$layout = [];
|
||||||
|
|
||||||
@@ -741,26 +932,36 @@ if (!function_exists('fb_notify_render')) {
|
|||||||
$rbody = (string)(isset($reply['body']) ? $reply['body'] : '');
|
$rbody = (string)(isset($reply['body']) ? $reply['body'] : '');
|
||||||
$isOff = $isAdmin ? '官方回复' : '新回复';
|
$isOff = $isAdmin ? '官方回复' : '新回复';
|
||||||
|
|
||||||
$subject = '【' . $site . '】' . $isOff . ':' . $short;
|
$subject = $pfx . $isOff . ':' . $short;
|
||||||
|
|
||||||
|
/* 像博客评论通知那样带上上下文:
|
||||||
|
* 用户可能同时提过好几条反馈,只给「回复内容」根本想不起来是哪一条。
|
||||||
|
* 所以固定三段:① 您反馈了什么 ② 谁回复了什么 ③ 去哪里继续。 */
|
||||||
|
$origBody = (string)(isset($thread['body']) ? $thread['body'] : '');
|
||||||
$blocks = [
|
$blocks = [
|
||||||
['type' => 'text', 'text' => '你在「' . $site . '」提交的反馈有新的回复。'],
|
['type' => 'text', 'text' => ($swName !== '' ? '您在「' . $swName . '」提交的反馈' : '您提交的反馈')
|
||||||
|
. '有新的回复。'],
|
||||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody],
|
|
||||||
];
|
];
|
||||||
|
if (trim($origBody) !== '') {
|
||||||
|
$blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => fb_excerpt($origBody, 220)];
|
||||||
|
}
|
||||||
|
$blocks[] = ['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody];
|
||||||
if ($isAdmin) {
|
if ($isAdmin) {
|
||||||
$blocks[] = ['type' => 'callout', 'tone' => 'info',
|
$blocks[] = ['type' => 'callout', 'tone' => 'info',
|
||||||
'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。'];
|
'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$layout = [
|
$layout = [
|
||||||
|
'software' => $sw,
|
||||||
'preheader' => $rname . ':' . fb_excerpt($rbody, 60),
|
'preheader' => $rname . ':' . fb_excerpt($rbody, 60),
|
||||||
'badge' => ['text' => $isOff, 'color' => $isAdmin ? '#0057B8' : '#5c6572'],
|
'badge' => ['text' => $isOff, 'color' => $isAdmin ? fb_mail_brand() : '#5c6572'],
|
||||||
'title' => $isAdmin ? '官方回复了你的反馈' : '有人回复了你的反馈',
|
'title' => $isAdmin ? '官方回复了您的反馈' : '有人回复了您的反馈',
|
||||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||||
'blocks' => $blocks,
|
'blocks' => $blocks,
|
||||||
'button' => ['text' => '查看并回复', 'url' => $url],
|
'button' => ['text' => '查看并回复', 'url' => $url],
|
||||||
'link' => $url,
|
'link' => $url,
|
||||||
|
'personal' => true, // 发给提交人:带 token 的私人链接,需提醒勿外传
|
||||||
'recipient' => $to,
|
'recipient' => $to,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -775,9 +976,10 @@ if (!function_exists('fb_notify_render')) {
|
|||||||
$label = fb_status_label($newKey);
|
$label = fb_status_label($newKey);
|
||||||
if ($label === '') { $label = $meta['badge']; }
|
if ($label === '') { $label = $meta['badge']; }
|
||||||
|
|
||||||
$subject = '【' . $site . '】' . $short . ' —— ' . $label;
|
$subject = $pfx . $short . ' —— ' . $label;
|
||||||
|
|
||||||
$rows = ['反馈标题' => $title];
|
$rows = ['反馈标题' => $title];
|
||||||
|
if ($swName !== '') { $rows['所属软件'] = $swName; }
|
||||||
if ($oldKey !== '' && $oldKey !== $newKey) {
|
if ($oldKey !== '' && $oldKey !== $newKey) {
|
||||||
$oldLabel = fb_status_label($oldKey);
|
$oldLabel = fb_status_label($oldKey);
|
||||||
if ($oldLabel !== '') { $rows['原状态'] = $oldLabel; }
|
if ($oldLabel !== '') { $rows['原状态'] = $oldLabel; }
|
||||||
@@ -795,34 +997,50 @@ if (!function_exists('fb_notify_render')) {
|
|||||||
$blocks[] = ['type' => 'callout', 'tone' => $meta['tone'], 'text' => $meta['tip']];
|
$blocks[] = ['type' => 'callout', 'tone' => $meta['tone'], 'text' => $meta['tip']];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 「已解决」时给用户一个按钮去验证。
|
||||||
|
*
|
||||||
|
* ⚠️ 2026-11-04:后台的「下载地址」字段已删除,
|
||||||
|
* 所以这里不再判断 $sw['download_url'] —— 那个值只可能是历史遗留。
|
||||||
|
* 按钮统一为「查看详情」,不再有「下载最新版本」分支。
|
||||||
|
* 想要恢复的话:后台加回字段 + 这里加回判断即可。 */
|
||||||
|
$btnText = '查看详情';
|
||||||
|
$btnUrl = $url;
|
||||||
|
|
||||||
$layout = [
|
$layout = [
|
||||||
|
'software' => $sw,
|
||||||
'preheader' => '状态更新为「' . $label . '」',
|
'preheader' => '状态更新为「' . $label . '」',
|
||||||
'badge' => ['text' => $meta['badge'], 'color' => $meta['color']],
|
'badge' => ['text' => $meta['badge'], 'color' => $meta['color']],
|
||||||
'title' => $meta['title'],
|
'title' => $meta['title'],
|
||||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||||
'blocks' => $blocks,
|
'blocks' => $blocks,
|
||||||
'button' => ['text' => '查看详情', 'url' => $url],
|
'button' => ['text' => $btnText, 'url' => $btnUrl],
|
||||||
'link' => $url,
|
// 下载按钮之外,始终保留「查看这条反馈」的原文链接
|
||||||
|
'link' => ($btnUrl === $url) ? $url
|
||||||
|
: ('下载地址:' . $dlUrl . "\n" . '查看反馈:' . $url),
|
||||||
|
'personal' => true, // 发给提交人
|
||||||
'recipient' => $to,
|
'recipient' => $to,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 3. 审核通过 ---------------- */
|
/* ---------------- 3. 审核通过 ---------------- */
|
||||||
elseif ($reason === 'approve') {
|
elseif ($reason === 'approve') {
|
||||||
$subject = '【' . $site . '】你的反馈已发布:' . $short;
|
$subject = $pfx . '您的反馈已发布:' . $short;
|
||||||
$layout = [
|
$layout = [
|
||||||
'preheader' => '你的反馈已经公开可见',
|
'software' => $sw,
|
||||||
|
'preheader' => '您的反馈已经公开可见',
|
||||||
'badge' => ['text' => '已发布', 'color' => '#1e7b3c'],
|
'badge' => ['text' => '已发布', 'color' => '#1e7b3c'],
|
||||||
'title' => '你的反馈已通过审核并公开',
|
'title' => '您的反馈已通过审核并公开',
|
||||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||||
'blocks' => [
|
'blocks' => [
|
||||||
['type' => 'text', 'text' => '你提交的反馈已通过审核,现在可以在公开列表中看到了。'],
|
['type' => 'text', 'text' => '您提交的反馈已通过审核,现在可以在'
|
||||||
|
. ($swName !== '' ? '「' . $swName . '」的' : '') . '公开列表中看到了。'],
|
||||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
['type' => 'callout', 'tone' => 'info',
|
['type' => 'callout', 'tone' => 'info',
|
||||||
'text' => '后续的处理进展与回复,我们会通过邮件通知你。'],
|
'text' => '后续的处理进展与回复,我们会通过邮件通知您。'],
|
||||||
],
|
],
|
||||||
'button' => ['text' => '查看这条反馈', 'url' => $url],
|
'button' => ['text' => '查看并跟踪这条反馈', 'url' => $url],
|
||||||
'link' => $url,
|
'link' => $url,
|
||||||
|
'personal' => true, // 发给提交人
|
||||||
'recipient' => $to,
|
'recipient' => $to,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -830,26 +1048,28 @@ if (!function_exists('fb_notify_render')) {
|
|||||||
/* ---------------- 4. 转为不公开 ---------------- */
|
/* ---------------- 4. 转为不公开 ---------------- */
|
||||||
elseif ($reason === 'hide') {
|
elseif ($reason === 'hide') {
|
||||||
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
|
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
|
||||||
$subject = '【' . $site . '】你的反馈已转为不公开:' . $short;
|
$subject = $pfx . '您的反馈已转为不公开:' . $short;
|
||||||
|
|
||||||
$blocks = [
|
$blocks = [
|
||||||
['type' => 'text', 'text' => '你提交的反馈已从公开列表中撤下,不再对其他人可见。'],
|
['type' => 'text', 'text' => '您提交的反馈已从公开列表中撤下,不再对其他人可见。'],
|
||||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
];
|
];
|
||||||
if ($note !== '') {
|
if ($note !== '') {
|
||||||
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
||||||
}
|
}
|
||||||
$blocks[] = ['type' => 'callout', 'tone' => 'muted',
|
$blocks[] = ['type' => 'callout', 'tone' => 'muted',
|
||||||
'text' => '这条反馈本身仍然保留,你依然可以通过原链接访问和处理进展。'];
|
'text' => '这条反馈本身仍然保留,您依然可以通过原链接访问和处理进展。'];
|
||||||
|
|
||||||
$layout = [
|
$layout = [
|
||||||
|
'software' => $sw,
|
||||||
'preheader' => '这条反馈已不对其他人可见',
|
'preheader' => '这条反馈已不对其他人可见',
|
||||||
'badge' => ['text' => '不公开', 'color' => '#5c6572'],
|
'badge' => ['text' => '不公开', 'color' => '#5c6572'],
|
||||||
'title' => '你的反馈已转为不公开',
|
'title' => '您的反馈已转为不公开',
|
||||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||||
'blocks' => $blocks,
|
'blocks' => $blocks,
|
||||||
'button' => ['text' => '查看这条反馈', 'url' => $url],
|
'button' => ['text' => '查看这条反馈', 'url' => $url],
|
||||||
'link' => $url,
|
'link' => $url,
|
||||||
|
'personal' => true, // 发给提交人(转为不公开)
|
||||||
'recipient' => $to,
|
'recipient' => $to,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -860,30 +1080,38 @@ if (!function_exists('fb_notify_render')) {
|
|||||||
$body = (string)$thread['body'];
|
$body = (string)$thread['body'];
|
||||||
$mail = trim((string)fb_cfg('admin_email', ''));
|
$mail = trim((string)fb_cfg('admin_email', ''));
|
||||||
|
|
||||||
$subject = '【' . $site . '】关于你反馈的问题《' . $short . '》';
|
$subject = $pfx . '关于您反馈的问题《' . $short . '》';
|
||||||
|
|
||||||
$blocks = [
|
$blocks = [
|
||||||
['type' => 'text',
|
['type' => 'text',
|
||||||
'text' => '感谢你的反馈。我们已经核实过,这个情况并不是应用本身的问题,因此这条反馈已关闭(不会公开显示)。'],
|
'text' => '感谢您的反馈。我们已经核实过,这个情况并不是'
|
||||||
|
. ($swName !== '' ? '「' . $swName . '」' : '应用') . '本身的问题,因此这条反馈已关闭(不会公开显示)。'],
|
||||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
];
|
];
|
||||||
if ($note !== '') {
|
if ($note !== '') {
|
||||||
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
||||||
}
|
}
|
||||||
$blocks[] = ['type' => 'quote', 'who' => '你当时反馈的内容:', 'text' => $body];
|
$blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => $body];
|
||||||
$blocks[] = [
|
$blocks[] = [
|
||||||
'type' => 'callout', 'tone' => 'info',
|
'type' => 'callout', 'tone' => 'info',
|
||||||
'text' => ($mail !== ''
|
'text' => ($mail !== ''
|
||||||
? '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
|
? '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
|
||||||
: '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进。'),
|
: '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进。'),
|
||||||
];
|
];
|
||||||
|
/* 这条已经「已关闭 + 不公开」,不带 token 的链接打不开 ——
|
||||||
|
* 必须给带 token 的私人链接,用户才能回来看上下文、继续补充。
|
||||||
|
* 注意:button 是 layout 层属性,不是 block 类型。 */
|
||||||
|
$closeBtnUrl = fb_thread_link($thread, true);
|
||||||
|
|
||||||
$layout = [
|
$layout = [
|
||||||
'preheader' => '关于你反馈的问题,我们的说明',
|
'software' => $sw,
|
||||||
|
'preheader' => '关于您反馈的问题,我们的说明',
|
||||||
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
|
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
|
||||||
'title' => '关于你反馈的问题,我们的说明',
|
'title' => '关于您反馈的问题,我们的说明',
|
||||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||||
'blocks' => $blocks,
|
'blocks' => $blocks,
|
||||||
|
'button' => ['text' => '查看这条反馈', 'url' => $closeBtnUrl],
|
||||||
|
'personal' => true, // 发给提交人(关闭说明)
|
||||||
'recipient' => $to,
|
'recipient' => $to,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -893,18 +1121,24 @@ if (!function_exists('fb_notify_render')) {
|
|||||||
$body = (string)$thread['body'];
|
$body = (string)$thread['body'];
|
||||||
$mail = (string)(isset($thread['author_email']) ? $thread['author_email'] : '');
|
$mail = (string)(isset($thread['author_email']) ? $thread['author_email'] : '');
|
||||||
$phone = trim((string)(isset($thread['author_phone']) ? $thread['author_phone'] : ''));
|
$phone = trim((string)(isset($thread['author_phone']) ? $thread['author_phone'] : ''));
|
||||||
$swName = (string)(isset($extra['software_name']) ? $extra['software_name'] : '');
|
// 软件名优先取实际软件行(比 extra 里传进来的更可靠)
|
||||||
|
$swName = $swName !== '' ? $swName
|
||||||
|
: (string)(isset($extra['software_name']) ? $extra['software_name'] : '');
|
||||||
|
|
||||||
$subject = '【' . $site . '】新反馈:' . $short;
|
$subject = $pfx . '新反馈:' . $short;
|
||||||
|
|
||||||
$rows = ['标题' => $title];
|
$rows = [];
|
||||||
if ($swName !== '') { $rows['所属软件'] = $swName; }
|
if ($swName !== '') { $rows['所属软件'] = $swName; }
|
||||||
|
$rows['标题'] = $title;
|
||||||
$rows['提交人'] = $name !== '' ? $name : '(未留名)';
|
$rows['提交人'] = $name !== '' ? $name : '(未留名)';
|
||||||
if ($mail !== '') { $rows['邮箱'] = $mail; }
|
if ($mail !== '') { $rows['邮箱'] = $mail; }
|
||||||
if ($phone !== '') { $rows['电话'] = $phone; }
|
if ($phone !== '') { $rows['电话'] = $phone; }
|
||||||
|
|
||||||
|
$adminUrl = fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id'];
|
||||||
|
|
||||||
$blocks = [
|
$blocks = [
|
||||||
['type' => 'text', 'text' => '有新的反馈提交,请及时处理。'],
|
['type' => 'text', 'text' => ($swName !== '' ? '「' . $swName . '」' : '')
|
||||||
|
. '有一条新的反馈提交,请到管理后台处理。'],
|
||||||
['type' => 'field', 'rows' => $rows],
|
['type' => 'field', 'rows' => $rows],
|
||||||
['type' => 'quote', 'who' => '反馈内容:', 'text' => $body],
|
['type' => 'quote', 'who' => '反馈内容:', 'text' => $body],
|
||||||
['type' => 'callout', 'tone' => 'warn',
|
['type' => 'callout', 'tone' => 'warn',
|
||||||
@@ -912,28 +1146,62 @@ if (!function_exists('fb_notify_render')) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
$layout = [
|
$layout = [
|
||||||
|
'software' => $sw,
|
||||||
'preheader' => $name . ':' . fb_excerpt($body, 60),
|
'preheader' => $name . ':' . fb_excerpt($body, 60),
|
||||||
'badge' => ['text' => '新反馈', 'color' => '#b4553c'],
|
'badge' => ['text' => '新反馈', 'color' => '#b4553c'],
|
||||||
'title' => '收到一条新反馈',
|
'title' => ($swName !== '' ? $swName . ' 收到一条新反馈' : '收到一条新反馈'),
|
||||||
'intro' => '你好:',
|
'intro' => '您好:',
|
||||||
'blocks' => $blocks,
|
'blocks' => $blocks,
|
||||||
'button' => ['text' => '去后台处理', 'url' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id']],
|
'button' => ['text' => '去后台处理这条反馈', 'url' => $adminUrl],
|
||||||
'link' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id'],
|
'link' => $adminUrl,
|
||||||
'recipient' => $to,
|
'recipient' => $to,
|
||||||
'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。',
|
'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 7. 提交回执(发给提问人) ---------------- */
|
||||||
|
elseif ($reason === 'receipt') {
|
||||||
|
$body = (string)$thread['body'];
|
||||||
|
|
||||||
|
$subject = $pfx . '已收到您的反馈:' . $short;
|
||||||
|
|
||||||
|
$blocks = [
|
||||||
|
['type' => 'text', 'text' => '我们已收到您在'
|
||||||
|
. ($swName !== '' ? '「' . $swName . '」' : '本站')
|
||||||
|
. '提交的反馈,感谢您的反馈!'],
|
||||||
|
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||||
|
['type' => 'quote', 'who' => '您提交的内容:', 'text' => fb_excerpt($body, 400)],
|
||||||
|
['type' => 'callout', 'tone' => 'info',
|
||||||
|
'text' => '这条反馈正在等待管理员审核,请耐心等待。审核通过后会在列表中公开显示。'],
|
||||||
|
['type' => 'text', 'text' => '审核结果与后续处理进展,我们都会通过邮件通知您,无需反复回来查看。'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$layout = [
|
||||||
|
'software' => $sw,
|
||||||
|
'preheader' => '已收到您的反馈,正在等待审核',
|
||||||
|
'badge' => ['text' => '已收到', 'color' => fb_mail_brand()],
|
||||||
|
'title' => '已收到您的反馈',
|
||||||
|
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||||
|
'blocks' => $blocks,
|
||||||
|
'button' => ['text' => '查看这条反馈', 'url' => $url],
|
||||||
|
'link' => $url,
|
||||||
|
'personal' => true, // 发给提交人(回执)
|
||||||
|
'recipient' => $to,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 兜底 ---------------- */
|
/* ---------------- 兜底 ---------------- */
|
||||||
else {
|
else {
|
||||||
$subject = '【' . $site . '】' . $short;
|
$subject = $pfx . $short;
|
||||||
$layout = [
|
$layout = [
|
||||||
'preheader' => '你有一条新的通知',
|
'software' => $sw,
|
||||||
'title' => '你有一条新的通知',
|
'preheader' => '您有一条新的通知',
|
||||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
'title' => '您有一条新的通知',
|
||||||
'blocks' => [['type' => 'text', 'text' => '你关注的反馈有更新。']],
|
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||||
|
'blocks' => [['type' => 'text', 'text' => '您关注的反馈有更新。']],
|
||||||
'button' => ['text' => '查看详情', 'url' => $url],
|
'button' => ['text' => '查看详情', 'url' => $url],
|
||||||
'link' => $url,
|
'link' => $url,
|
||||||
|
'personal' => true, // 兜底同样是发给提交人的
|
||||||
'recipient' => $to,
|
'recipient' => $to,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+349
-4
@@ -171,6 +171,20 @@ function fb_default_software() {
|
|||||||
return $list ? $list[0] : null;
|
return $list ? $list[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由帖子行取它所属的软件行(邮件/页面要显示「这是对哪个软件提交的」都用它)。
|
||||||
|
* threads 只存 software_id,这里统一解析,避免各处自己写 SQL 漏掉。
|
||||||
|
* 取不到时回落到默认软件,保证邮件里永远有软件名可显示。
|
||||||
|
*/
|
||||||
|
function fb_software_of_thread(array $thread) {
|
||||||
|
$id = isset($thread['software_id']) ? (int)$thread['software_id'] : 0;
|
||||||
|
if ($id > 0) {
|
||||||
|
$sw = fb_software($id);
|
||||||
|
if ($sw) { return $sw; }
|
||||||
|
}
|
||||||
|
return fb_default_software();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 表单回显用:如果当前值等于「出厂默认」,就返回空串。
|
* 表单回显用:如果当前值等于「出厂默认」,就返回空串。
|
||||||
*
|
*
|
||||||
@@ -240,11 +254,68 @@ function fb_color_variants($hex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 给某个软件挑一个默认主题色(未配置时用)。
|
* 按「与白色混合的比例」生成主题色的浅色档位。
|
||||||
* 按软件的排序位次从调色板里取,保证同一站点里不同软件颜色不撞。
|
*
|
||||||
|
* 🔴 为什么在 PHP 里算,而不用 CSS 的 color-mix():
|
||||||
|
* 实测 Chrome 里 `color-mix(in srgb, var(--brand) 9%, #fff)` 配合
|
||||||
|
* 自定义属性会解析成透明(oklab(0 0 0 / 0))—— 规则匹配上了却不生效。
|
||||||
|
* 直接在 PHP 把值算好、以 CSS 变量输出最稳,也不依赖浏览器版本。
|
||||||
|
*
|
||||||
|
* @param string $hex 主题色
|
||||||
|
* @param float $ratio 与白色混合的比例(0=原色 1=纯白)
|
||||||
|
* @return string
|
||||||
*/
|
*/
|
||||||
|
function fb_color_tint($hex, $ratio) {
|
||||||
|
$hex = fb_color_norm($hex);
|
||||||
|
if ($hex === '') { return ''; }
|
||||||
|
$r = hexdec(substr($hex, 1, 2));
|
||||||
|
$g = hexdec(substr($hex, 3, 2));
|
||||||
|
$b = hexdec(substr($hex, 5, 2));
|
||||||
|
$ratio = max(0.0, min(1.0, (float)$ratio));
|
||||||
|
return sprintf('#%02x%02x%02x',
|
||||||
|
(int)round($r + (255 - $r) * $ratio),
|
||||||
|
(int)round($g + (255 - $g) * $ratio),
|
||||||
|
(int)round($b + (255 - $b) * $ratio));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给某个软件挑一个默认主题色(未配置时用)。
|
||||||
|
* 按软件的 id 从调色板里取,保证同一站点里不同软件颜色不撞。
|
||||||
|
*
|
||||||
|
* 🔴 2026-11-04 按色相环**整体重排**为 12 色(用户:颜色快不够分了)。
|
||||||
|
*
|
||||||
|
* 选色规则,改之前先读:
|
||||||
|
* ① **色相均匀分布**:360° 切成 12 段,相邻至少差 22°(实测最小 22°)。
|
||||||
|
* 旧调色板有 3 个蓝挤在 185°/212°/217°,并排看几乎同色 —— 这是重排的原因;
|
||||||
|
* ② **对比度落在 4.76~6.19 的窄带**:白底上文字都够看清(WCAG AA 要 ≥4.5),
|
||||||
|
* 又不会某个颜色特别深、显得比其他刺眼;
|
||||||
|
* ③ **避开纯红/纯绿**:会与「待处理/已解决」状态色打架,所以红取 2°、绿取 122°;
|
||||||
|
* ④ 黄/黄绿饱和度压到 50%(再高就荧光),蓝/靛蓝也压到 62%/50%。
|
||||||
|
*
|
||||||
|
* ⚠️ 这个数组和 lib/admin_taxonomy.php 里的 $presets **必须保持一致**。
|
||||||
|
* 以前是两份独立硬编码,早就不同步了 —— 后台只给 8 个可选色,
|
||||||
|
* 实际调色板却是另一批,用户在后台根本选不到新增颜色。
|
||||||
|
* 改这里记得同步改那边(见 fb_color_palette() 统一出口)。
|
||||||
|
*/
|
||||||
|
function fb_color_palette() {
|
||||||
|
return [
|
||||||
|
'#B53530', // 1 红 2°
|
||||||
|
'#9D6525', // 2 橙 32°
|
||||||
|
'#747728', // 3 黄 62°
|
||||||
|
'#517E2A', // 4 黄绿 92°
|
||||||
|
'#2B822E', // 5 绿 122°
|
||||||
|
'#2D8059', // 6 青绿 152°
|
||||||
|
'#267E7B', // 7 青 178°
|
||||||
|
'#2678A1', // 8 天蓝 200°
|
||||||
|
'#4154D2', // 9 蓝 232°
|
||||||
|
'#7A44C1', // 10 靛蓝 266°
|
||||||
|
'#943BA0', // 11 紫 293°
|
||||||
|
'#AD3470', // 12 玫红 330°
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function fb_color_default_for($softwareId) {
|
function fb_color_default_for($softwareId) {
|
||||||
$palette = ['#0057B8', '#2E7D5B', '#B4553C', '#6A4C93', '#0E7C86', '#A02C5A', '#8A6D1F', '#3A5A8C'];
|
$palette = fb_color_palette();
|
||||||
$id = max(0, (int)$softwareId);
|
$id = max(0, (int)$softwareId);
|
||||||
return $palette[$id % count($palette)];
|
return $palette[$id % count($palette)];
|
||||||
}
|
}
|
||||||
@@ -263,6 +334,185 @@ function fb_software_colors($software) {
|
|||||||
return fb_color_variants($hex);
|
return fb_color_variants($hex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化软件图标字符。
|
||||||
|
* 只留前几个字符,去掉换行与 HTML 尖括号 —— 这个值会直接输出到页面和邮件里,
|
||||||
|
* 放进 <span> 之间。虽然后面统一走 e() 转义,但源头收窄一层更稳。
|
||||||
|
*/
|
||||||
|
function fb_sw_icon_norm($s) {
|
||||||
|
$s = trim(strip_tags((string)$s));
|
||||||
|
$s = preg_replace('/[\x00-\x1F\x7F]+/u', '', $s);
|
||||||
|
if ($s === null || $s === '') { return ''; }
|
||||||
|
return mb_substr($s, 0, 4, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化软件的下载地址:只接受 http/https,其它一律丢空 */
|
||||||
|
function fb_sw_url_norm($s) {
|
||||||
|
$s = trim((string)$s);
|
||||||
|
if ($s === '') { return ''; }
|
||||||
|
if (!preg_match('~^https?://~i', $s)) { return ''; }
|
||||||
|
return mb_substr($s, 0, 300, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某软件的图标字符(单字符:emoji / 汉字 / 字母)。
|
||||||
|
* 没配就按软件名首字兜底 —— 多软件并存时,任何地方都不该出现「无图标」的空档。
|
||||||
|
*/
|
||||||
|
function fb_software_icon($software) {
|
||||||
|
$ic = '';
|
||||||
|
if (is_array($software)) {
|
||||||
|
$ic = trim((string)(isset($software['icon']) ? $software['icon'] : ''));
|
||||||
|
if ($ic === '') {
|
||||||
|
// 兜底:取名称首字(中文取第一个字,英文取首字母大写)
|
||||||
|
$nm = trim((string)(isset($software['name']) ? $software['name'] : ''));
|
||||||
|
if ($nm !== '') {
|
||||||
|
if (preg_match('/^[A-Za-z]/', $nm)) { $ic = strtoupper($nm[0]); }
|
||||||
|
else { $ic = mb_substr($nm, 0, 1, 'UTF-8'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($ic === '') { $ic = '·'; }
|
||||||
|
// 只允许极短内容,防止把整段 HTML 塞进图标位
|
||||||
|
return mb_substr($ic, 0, 4, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标 + 名字的组合标签(HTML)。
|
||||||
|
* 前端所有「这是哪个软件」的地方都用它,保证视觉一致。
|
||||||
|
*
|
||||||
|
* 🔴 不要背景色:图标本身就是一张图/一个 emoji,再垫一个色块反而糊在一起。
|
||||||
|
* 直接「图标 + 文字」并排,识别度最高(用户 2026-11-03 明确要求)。
|
||||||
|
*
|
||||||
|
* @param array $sw 软件行
|
||||||
|
* @param string $size 'sm' | 'md' | 'lg' | 'brand'(brand = 顶栏主标题)
|
||||||
|
* @param string $label 覆盖显示文字(默认=软件名)。顶栏用它传「XX 反馈系统」。
|
||||||
|
*/
|
||||||
|
function fb_software_badge($software, $size = 'md', $label = '') {
|
||||||
|
if (!is_array($software) || empty($software['name'])) { return ''; }
|
||||||
|
$icon = fb_software_icon($software);
|
||||||
|
$name = $label !== '' ? (string)$label : (string)$software['name'];
|
||||||
|
|
||||||
|
/* 尺寸档位(以「选择软件」标签的 13.5px 为基准):
|
||||||
|
* sm 18px 图标 / 14px 字 小标签
|
||||||
|
* md 24px 图标 / 16px 字 抽屉里的条目
|
||||||
|
* lg 24px 图标 / 15px 字 内容区「图标 + 软件名」
|
||||||
|
* brand 30px 图标 / 17px 字 顶栏主标题
|
||||||
|
*
|
||||||
|
* 🔴 两条硬约束(都踩过):
|
||||||
|
* 1. 顶栏标题 = 比「选择软件」的 13.5px 大两号 ≈ 17px,不要再放大到 26px;
|
||||||
|
* 2. **顶栏必须比内容区大**。之前顶栏 26px、内容区 30px,
|
||||||
|
* 主次直接颠倒,看着就是「上面的字比下面的小」。
|
||||||
|
* 现在 17 > 15,层级正确;内容区只是标识,不该抢标题的戏。 */
|
||||||
|
$px = ['sm' => 18, 'md' => 24, 'lg' => 24, 'brand' => 30];
|
||||||
|
$fs = ['sm' => 14, 'md' => 16, 'lg' => 15, 'brand' => 17];
|
||||||
|
$box = isset($px[$size]) ? $px[$size] : 24;
|
||||||
|
$fnt = isset($fs[$size]) ? $fs[$size] : 16;
|
||||||
|
|
||||||
|
/* 有上传的图标图片就用图片(管理员自己传的 logo),没有才用字符。
|
||||||
|
* 两者外框尺寸一致,换用图片不会让布局跳动。 */
|
||||||
|
$imgKey = isset($software['icon_key']) ? trim((string)$software['icon_key']) : '';
|
||||||
|
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, $box * 2) : '';
|
||||||
|
if ($imgUrl !== '') {
|
||||||
|
return '<span class="sw-badge sw-badge-' . e($size) . '">'
|
||||||
|
. '<img class="sw-badge-img" src="' . e($imgUrl) . '" alt=""'
|
||||||
|
. ' width="' . $box . '" height="' . $box . '" loading="lazy"'
|
||||||
|
. ' style="width:' . $box . 'px;height:' . $box . 'px">'
|
||||||
|
. '<span class="sw-badge-nm">' . e($name) . '</span>'
|
||||||
|
. '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<span class="sw-badge sw-badge-' . e($size) . '">'
|
||||||
|
. '<span class="sw-badge-ic" aria-hidden="true" style="font-size:' . $fnt . 'px">'
|
||||||
|
. e($icon) . '</span>'
|
||||||
|
. '<span class="sw-badge-nm">' . e($name) . '</span>'
|
||||||
|
. '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「XX 反馈系统」—— 当前软件下的站点标题。
|
||||||
|
* 多软件共用一个反馈站时,标题必须点明是哪个软件的反馈系统,
|
||||||
|
* 用户才知道自己没走错地方。没传软件时退回站点名。
|
||||||
|
*
|
||||||
|
* ⚠️ 软件名本身已含「反馈」二字时不再叠加,避免出现「轻反馈反馈系统」。
|
||||||
|
*/
|
||||||
|
function fb_software_site_title($software) {
|
||||||
|
if (!is_array($software) || empty($software['name'])) {
|
||||||
|
return (string)fb_cfg('site_name', '轻反馈');
|
||||||
|
}
|
||||||
|
$name = trim((string)$software['name']);
|
||||||
|
if ($name === '') { return (string)fb_cfg('site_name', '轻反馈'); }
|
||||||
|
if (mb_strpos($name, '反馈', 0, 'UTF-8') !== false) { return $name; }
|
||||||
|
return $name . '反馈系统';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 软件徽标(后台/前台的小标签)—— 带图标,且用该软件自己的主题色。
|
||||||
|
*
|
||||||
|
* 为什么不复用 .badge 的固定蓝色:每个软件都配了主题色,
|
||||||
|
* 徽标却一律蓝色,等于把「颜色区分软件」这条设计浪费掉了。
|
||||||
|
*
|
||||||
|
* @param array $sw 软件行
|
||||||
|
* @param bool $icon 是否显示图标(默认显示)
|
||||||
|
*/
|
||||||
|
function fb_software_chip($sw, $withIcon = true) {
|
||||||
|
if (!is_array($sw) || empty($sw['name'])) { return ''; }
|
||||||
|
$name = (string)$sw['name'];
|
||||||
|
|
||||||
|
list($c) = fb_software_colors($sw);
|
||||||
|
if ($c === '') { $c = '#0057B8'; }
|
||||||
|
$soft = fb_color_soft($c); // 同色系浅底
|
||||||
|
$line = fb_color_line($c); // 同色系描边
|
||||||
|
|
||||||
|
$icHtml = '';
|
||||||
|
if ($withIcon) {
|
||||||
|
$imgKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
|
||||||
|
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, 30) : '';
|
||||||
|
if ($imgUrl !== '') {
|
||||||
|
$icHtml = '<span class="badge-sw-ic"><img src="' . e($imgUrl) . '" alt=""></span>';
|
||||||
|
} else {
|
||||||
|
$icHtml = '<span class="badge-sw-ic" aria-hidden="true">' . e(fb_software_icon($sw)) . '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<span class="badge sw"'
|
||||||
|
. ' style="--swc:' . e($c) . ';--swc-soft:' . e($soft) . ';--swc-line:' . e($line) . '">'
|
||||||
|
. $icHtml
|
||||||
|
. '<span class="badge-sw-nm">' . e($name) . '</span>'
|
||||||
|
. '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把主色调淡成浅底(与主色同色系,避免灰化) */
|
||||||
|
function fb_color_soft($hex) {
|
||||||
|
$rgb = fb_hex_to_rgb($hex);
|
||||||
|
if (!$rgb) { return '#eef2f8'; }
|
||||||
|
return sprintf('#%02x%02x%02x',
|
||||||
|
(int)round($rgb[0] + (255 - $rgb[0]) * 0.90),
|
||||||
|
(int)round($rgb[1] + (255 - $rgb[1]) * 0.90),
|
||||||
|
(int)round($rgb[2] + (255 - $rgb[2]) * 0.90));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把主色调成描边色(略深于浅底) */
|
||||||
|
function fb_color_line($hex) {
|
||||||
|
$rgb = fb_hex_to_rgb($hex);
|
||||||
|
if (!$rgb) { return '#cfdcee'; }
|
||||||
|
return sprintf('#%02x%02x%02x',
|
||||||
|
(int)round($rgb[0] + (255 - $rgb[0]) * 0.72),
|
||||||
|
(int)round($rgb[1] + (255 - $rgb[1]) * 0.72),
|
||||||
|
(int)round($rgb[2] + (255 - $rgb[2]) * 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #rrggbb -> [r,g,b],非法返回 null */
|
||||||
|
function fb_hex_to_rgb($hex) {
|
||||||
|
$h = ltrim((string)$hex, '#');
|
||||||
|
// 三位简写只接受纯十六进制字符(#abc -> #aabbcc);
|
||||||
|
// 先做字符校验再展开,否则 "bad" 这种非色值会被当成 #bbaadd
|
||||||
|
if (strlen($h) === 3 && preg_match('/^[0-9a-fA-F]{3}$/', $h)) {
|
||||||
|
$h = $h[0].$h[0].$h[1].$h[1].$h[2].$h[2];
|
||||||
|
}
|
||||||
|
if (!preg_match('/^[0-9a-fA-F]{6}$/', $h)) { return null; }
|
||||||
|
return [hexdec(substr($h, 0, 2)), hexdec(substr($h, 2, 2)), hexdec(substr($h, 4, 2))];
|
||||||
|
}
|
||||||
|
|
||||||
/** 某软件下的分类;不传软件则返回空数组 */
|
/** 某软件下的分类;不传软件则返回空数组 */
|
||||||
function fb_categories($softwareId = null, $onlyEnabled = true) {
|
function fb_categories($softwareId = null, $onlyEnabled = true) {
|
||||||
if ($softwareId === null) { return []; }
|
if ($softwareId === null) { return []; }
|
||||||
@@ -293,7 +543,11 @@ function fb_category_by_slug($softwareId, $slug) {
|
|||||||
return $r ? $r : null;
|
return $r ? $r : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分类显示名:只给 id 就能拿到名字(列表渲染用) */
|
/** 分类显示名:只给 id 就能拿到名字(列表渲染用)
|
||||||
|
*
|
||||||
|
* ⚠️ 参数必须是**数字 id**。传字符串 slug(如 'bug')会被 (int) 成 0 而返回空串,
|
||||||
|
* 调用方如果不判空就会渲染出一个空标签 —— 这个坑踩过(见 admin.php 全部问题列表)。
|
||||||
|
* 需要按 slug 取名字请用 fb_category_label_by_slug()。 */
|
||||||
function fb_category_label($id) {
|
function fb_category_label($id) {
|
||||||
if (!$id) { return ''; }
|
if (!$id) { return ''; }
|
||||||
static $cache = [];
|
static $cache = [];
|
||||||
@@ -305,6 +559,24 @@ function fb_category_label($id) {
|
|||||||
return $cache[$id];
|
return $cache[$id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按「软件 + slug」取分类显示名 —— 给只存了老 slug 字段的历史数据兜底。
|
||||||
|
* slug 在不同软件下可能重名,所以必须带上 software_id 才能定位。
|
||||||
|
*/
|
||||||
|
function fb_category_label_by_slug($softwareId, $slug) {
|
||||||
|
$slug = trim((string)$slug);
|
||||||
|
if ($slug === '') { return ''; }
|
||||||
|
if ((int)$softwareId > 0) {
|
||||||
|
$c = fb_category_by_slug((int)$softwareId, $slug);
|
||||||
|
if ($c) { return (string)$c['name']; }
|
||||||
|
}
|
||||||
|
// 实在定不到软件(老数据 software_id 为空)时,按 slug 全局找一个
|
||||||
|
$st = fb_db()->prepare('SELECT name FROM categories WHERE slug = ? LIMIT 1');
|
||||||
|
$st->execute([$slug]);
|
||||||
|
$nm = $st->fetchColumn();
|
||||||
|
return $nm !== false ? (string)$nm : '';
|
||||||
|
}
|
||||||
|
|
||||||
/** 全部状态(按排序) */
|
/** 全部状态(按排序) */
|
||||||
function fb_statuses($onlyEnabled = true) {
|
function fb_statuses($onlyEnabled = true) {
|
||||||
$sql = 'SELECT * FROM statuses';
|
$sql = 'SELECT * FROM statuses';
|
||||||
@@ -439,6 +711,78 @@ function fb_thread_public(array $thread) {
|
|||||||
return fb_is_admin();
|
return fb_is_admin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成一个新的追踪 token(提交反馈时调用)。
|
||||||
|
* 32 位 hex = 128 位熵,猜不出来;这个值只存在自己库里、只发给提问人邮箱。
|
||||||
|
*/
|
||||||
|
function fb_track_new_token() {
|
||||||
|
return bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个请求带来的 token 是否与帖子的匹配(常数时间比较,防时序探测) */
|
||||||
|
function fb_track_token_ok(array $thread, $given) {
|
||||||
|
$given = trim((string)$given);
|
||||||
|
if ($given === '') { return false; }
|
||||||
|
$real = isset($thread['track_token']) ? trim((string)$thread['track_token']) : '';
|
||||||
|
if ($real === '') { return false; }
|
||||||
|
return hash_equals($real, $given);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前访问者是否「有权跟踪」这条帖子 —— 即带对了 token。
|
||||||
|
* 审核后台的管理员也算有权(不然审核时看不了)。
|
||||||
|
*/
|
||||||
|
function fb_thread_trackable(array $thread, $given = null) {
|
||||||
|
if ($given === null) { $given = isset($_GET['t']) ? (string)$_GET['t'] : ''; }
|
||||||
|
if (fb_track_token_ok($thread, $given)) { return true; }
|
||||||
|
return fb_is_admin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前访问者能否【查看】这条帖子。
|
||||||
|
*
|
||||||
|
* 公开的(visible=1)所有人可看;
|
||||||
|
* 未公开的只有两种人能看:审核后台管理员、拿着正确 token 的提交人。
|
||||||
|
* 这就是「不登录也能跟踪自己那条」的实现方式,不靠任何登录态。
|
||||||
|
*/
|
||||||
|
function fb_thread_viewable(array $thread, $given = null) {
|
||||||
|
if (fb_thread_public($thread)) { return true; }
|
||||||
|
return fb_thread_trackable($thread, $given);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回「某个软件的问题列表」的链接。
|
||||||
|
*
|
||||||
|
* 首页现在是介绍页,不带 sw 的回首页等于把用户扔回介绍页 ——
|
||||||
|
* 从详情/提交页点「返回问题列表」应该回到**当前软件**的列表。
|
||||||
|
*/
|
||||||
|
function fb_software_list_url($sw) {
|
||||||
|
if (!is_array($sw) || empty($sw['slug'])) { return 'index.php'; }
|
||||||
|
return 'index.php?sw=' . urlencode((string)$sw['slug']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这条帖子的对外链接。
|
||||||
|
*
|
||||||
|
* 🔴 用 token 不用 id:
|
||||||
|
* - 邮件里发给提问人的链接一律带 ?t=<token>,他才能看未公开的那条;
|
||||||
|
* - 公开列表里给所有人看的链接照旧用 id(公开内容本来就谁都能看)。
|
||||||
|
* 早期所有链接都是 thread.php?id=N,导致「未审核就点不进去」——
|
||||||
|
* id 是自增的,用它当权限等于没有权限。
|
||||||
|
*
|
||||||
|
* @param array $thread 帖子行
|
||||||
|
* @param bool $personal true = 带 token 的私人链接;false = 公开链接
|
||||||
|
*/
|
||||||
|
function fb_thread_link(array $thread, $personal = false) {
|
||||||
|
$id = (int)(isset($thread['id']) ? $thread['id'] : 0);
|
||||||
|
$url = fb_base_url() . '/thread.php?id=' . $id;
|
||||||
|
if ($personal) {
|
||||||
|
$tok = isset($thread['track_token']) ? trim((string)$thread['track_token']) : '';
|
||||||
|
if ($tok !== '') { $url .= '&t=' . rawurlencode($tok); }
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 安装状态 ---------------- */
|
/* ---------------- 安装状态 ---------------- */
|
||||||
|
|
||||||
/** FeedLite 开源仓库地址(页脚版权链接的默认值,可在后台改) */
|
/** FeedLite 开源仓库地址(页脚版权链接的默认值,可在后台改) */
|
||||||
@@ -490,6 +834,7 @@ function fb_setting_keys() {
|
|||||||
'smtp.timeout', 'smtp.verify_ssl',
|
'smtp.timeout', 'smtp.verify_ssl',
|
||||||
'notify.enabled', 'notify.on_every_reply', 'notify.cooldown_seconds',
|
'notify.enabled', 'notify.on_every_reply', 'notify.cooldown_seconds',
|
||||||
'notify.admin_new_thread',
|
'notify.admin_new_thread',
|
||||||
|
'notify.receipt_enabled',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ function fb_media_fail($code, $msg) {
|
|||||||
|
|
||||||
$key = isset($_GET['k']) ? trim((string)$_GET['k']) : '';
|
$key = isset($_GET['k']) ? trim((string)$_GET['k']) : '';
|
||||||
if ($key === '') { fb_media_fail(400, '缺少附件标识'); }
|
if ($key === '') { fb_media_fail(400, '缺少附件标识'); }
|
||||||
if (!fb_upload_key_ok($key)) { fb_media_fail(400, '附件标识不合法'); }
|
/* 合法的键有两种:普通附件(local/… 或 <oss.prefix>/…)与软件图标(icons/…)。
|
||||||
|
* 图标有自己的键空间与校验规则,不能塞进 fb_upload_key_ok 里判断(它只认附件前缀)。*/
|
||||||
|
if (!fb_upload_key_ok($key) && !fb_icon_key_ok($key)) {
|
||||||
|
fb_media_fail(400, '附件标识不合法');
|
||||||
|
}
|
||||||
|
|
||||||
$process = isset($_GET['p']) ? trim((string)$_GET['p']) : '';
|
$process = isset($_GET['p']) ? trim((string)$_GET['p']) : '';
|
||||||
// 图片处理串只允许本站自己生成的形态:image/resize,w_NNN/quality,q_NN
|
// 图片处理串只允许本站自己生成的形态:image/resize,w_NNN/quality,q_NN
|
||||||
@@ -41,6 +45,31 @@ if ($process !== '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 软件图标:独立的公开读取通道 ----------------
|
||||||
|
* 图标不属于任何帖子,用它那套「帖子可见才给看」的鉴权会被一律拒绝。
|
||||||
|
* 图标本身是公开信息(每个访客都要看到),所以这里只校验:
|
||||||
|
* ① 键的形状合法(icons/YYYY/MM/<hex>.<ext>,无法穿越);
|
||||||
|
* ② 文件确实存在。
|
||||||
|
* 写入侧仍然是严格的:只有登录管理员、且经过真实 MIME 校验才能落到 icons/ 下。
|
||||||
|
* 也就是说「读公开、写受控」,不会让本站变成能读整个桶的开放代理。 */
|
||||||
|
if (fb_icon_is_key($key)) {
|
||||||
|
if (!fb_icon_key_ok($key)) { fb_media_fail(400, '图标标识不合法'); }
|
||||||
|
|
||||||
|
if (fb_attach_is_local($key)) {
|
||||||
|
$path = fb_local_path_of($key);
|
||||||
|
if (!is_file($path)) { fb_media_fail(404, '图标不存在'); }
|
||||||
|
$size = (int)@filesize($path);
|
||||||
|
$mime = fb_media_local_mime($path, $key);
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . $size);
|
||||||
|
header('Cache-Control: public, max-age=604800'); // 图标变动很少,缓存一周
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
fb_media_send_local($path, $size);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 本机存储:直接读磁盘并流式输出 ----------------
|
/* ---------------- 本机存储:直接读磁盘并流式输出 ----------------
|
||||||
* 与前台的统一代理地址配套:local 附件也走这里,uploads/ 目录本身禁止直连。 */
|
* 与前台的统一代理地址配套:local 附件也走这里,uploads/ 目录本身禁止直连。 */
|
||||||
if (fb_attach_is_local($key)) {
|
if (fb_attach_is_local($key)) {
|
||||||
@@ -60,8 +89,10 @@ if (fb_attach_is_local($key)) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- OSS 存储 ---------------- */
|
/* ---------------- OSS 存储 ----------------
|
||||||
if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
|
* 图标已在上面单独鉴权过(读公开、写受控),到这里就不再走帖子可见性判断;
|
||||||
|
* 普通附件仍然必须过 fb_media_authorized —— 否则本站会变成能读整个桶的开放代理。 */
|
||||||
|
if (!fb_icon_is_key($key) && !fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
|
||||||
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
|
if (!fb_oss_enabled()) { fb_media_fail(503, '对象存储未配置'); }
|
||||||
|
|
||||||
$range = isset($_SERVER['HTTP_RANGE']) ? (string)$_SERVER['HTTP_RANGE'] : '';
|
$range = isset($_SERVER['HTTP_RANGE']) ? (string)$_SERVER['HTTP_RANGE'] : '';
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ $pdo = fb_db();
|
|||||||
$err = '';
|
$err = '';
|
||||||
$atts = []; // 校验失败时保留已上传的附件
|
$atts = []; // 校验失败时保留已上传的附件
|
||||||
|
|
||||||
/* ---- 软件:URL 带 sw 就用它,否则落到默认软件 ---- */
|
/* ---- 软件:URL 带 sw 就用它 ----
|
||||||
|
* 🔴 不再「没带 sw 就默认落到第一个软件」:
|
||||||
|
* 首页现在是介绍页,直接访问 new.php 没有软件上下文,
|
||||||
|
* 默默替他选一个软件会让反馈进错地方、发错通知邮件。
|
||||||
|
* 没有软件时给一句引导,让用户先选软件。 */
|
||||||
$swList = fb_softwares(true);
|
$swList = fb_softwares(true);
|
||||||
$sw = null;
|
$sw = null;
|
||||||
$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
|
$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
|
||||||
@@ -16,8 +20,32 @@ if ($swKey !== '') {
|
|||||||
$sw = fb_software($swKey);
|
$sw = fb_software($swKey);
|
||||||
if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; }
|
if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; }
|
||||||
}
|
}
|
||||||
if ($sw === null && $swList) { $sw = $swList[0]; }
|
if ($sw === null) {
|
||||||
$swId = $sw ? (int)$sw['id'] : 0;
|
fb_layout_head('请先选择软件', ['robots' => 'noindex,follow']);
|
||||||
|
?>
|
||||||
|
<div class="empty">
|
||||||
|
<h2 style="margin:0 0 12px">请先选择要给哪个软件提反馈</h2>
|
||||||
|
<p class="muted" style="margin:0 0 16px">不同软件的问题列表是分开的,请先选一个。</p>
|
||||||
|
<div class="home-sw-list" style="max-width:420px;margin:0 auto 18px">
|
||||||
|
<?php foreach ($swList as $s): ?>
|
||||||
|
<?php
|
||||||
|
list($c) = fb_software_colors($s);
|
||||||
|
if ($c === '') { $c = '#0057B8'; }
|
||||||
|
?>
|
||||||
|
<a class="home-sw" href="new.php?sw=<?php echo e(urlencode((string)$s['slug'])); ?>"
|
||||||
|
style="--swc:<?php echo e($c); ?>">
|
||||||
|
<span class="home-sw-nm"><?php echo e((string)$s['name']); ?></span>
|
||||||
|
<span class="home-sw-go">去反馈 →</span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p><a class="btn" href="index.php">返回首页</a></p>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
fb_layout_foot();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$swId = (int)$sw['id'];
|
||||||
|
|
||||||
$cats = fb_categories($swId);
|
$cats = fb_categories($swId);
|
||||||
$catKeys = [];
|
$catKeys = [];
|
||||||
@@ -89,9 +117,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$atts = $vList;
|
$atts = $vList;
|
||||||
} else {
|
} else {
|
||||||
$now = time();
|
$now = time();
|
||||||
// 审核:开启后新提交默认不公开(visible=0),管理员通过后才会出现在列表
|
/* 审核:开启后新提交默认不公开(visible=0),管理员通过后才出现在列表。
|
||||||
|
*
|
||||||
|
* 🔴 状态与可见性必须同时设,两者是同一件事的两个侧面:
|
||||||
|
* 未公开 → status='hidden'(未公开),公开 → status='open'(待处理)。
|
||||||
|
* 以前一律写 'open',导致后台「待审核」里全是「待处理」,
|
||||||
|
* 状态根本反映不出"还没公开"这个事实。 */
|
||||||
$visible = fb_moderation_on() ? 0 : 1;
|
$visible = fb_moderation_on() ? 0 : 1;
|
||||||
$ins = $pdo->prepare('INSERT INTO threads(title, body, category, category_id, software_id, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?)');
|
$initStatus = $visible === 1 ? 'open' : 'hidden';
|
||||||
|
/* track_token:提交人的私人追踪钥匙,只发到他的邮箱。
|
||||||
|
* 有了它,即使这条还没审核通过,他也能凭链接看到进度。 */
|
||||||
|
$trackToken = fb_track_new_token();
|
||||||
|
$ins = $pdo->prepare('INSERT INTO threads(title, body, category, category_id, software_id, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash, track_token) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?,?)');
|
||||||
$ins->execute([
|
$ins->execute([
|
||||||
$form['title'], $form['body'],
|
$form['title'], $form['body'],
|
||||||
$curCat ? (string)$curCat['slug'] : 'other', // 老列留个值,便于回退与检索
|
$curCat ? (string)$curCat['slug'] : 'other', // 老列留个值,便于回退与检索
|
||||||
@@ -99,7 +136,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$swId > 0 ? $swId : null,
|
$swId > 0 ? $swId : null,
|
||||||
$form['name'], $form['email'],
|
$form['name'], $form['email'],
|
||||||
$form['phone'] !== '' ? $form['phone'] : null,
|
$form['phone'] !== '' ? $form['phone'] : null,
|
||||||
'open', $visible, $now, $now, fb_ip_hash(),
|
$initStatus, $visible, $now, $now, fb_ip_hash(), $trackToken,
|
||||||
]);
|
]);
|
||||||
$newId = (int)$pdo->lastInsertId();
|
$newId = (int)$pdo->lastInsertId();
|
||||||
fb_attach_save($pdo, $newId, null, $vList);
|
fb_attach_save($pdo, $newId, null, $vList);
|
||||||
@@ -119,13 +156,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$swRow = fb_software((int)$swId);
|
$swRow = fb_software((int)$swId);
|
||||||
if ($swRow) { $swName = (string)$swRow['name']; }
|
if ($swRow) { $swName = (string)$swRow['name']; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ① 先给提交人发「已收到」回执 —— 让他知道东西送达了、正在等审核。
|
||||||
|
* 这一步是用户最在意的:提交完页面跳走了,没有回执就等于石沉大海。
|
||||||
|
* 放在管理员通知之前,是因为回执对人的价值更高,且失败互不影响。 */
|
||||||
|
fb_notify_event($pdo, 'receipt', $newThread, ['software_name' => $swName]);
|
||||||
|
|
||||||
|
/* ② 再通知管理员「有新反馈」 */
|
||||||
fb_notify_event($pdo, 'new', $newThread, ['software_name' => $swName]);
|
fb_notify_event($pdo, 'new', $newThread, ['software_name' => $swName]);
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// 发信失败绝不能让用户提交失败 —— 反馈已经存库了
|
// 发信失败绝不能让用户提交失败 —— 反馈已经存库了
|
||||||
}
|
}
|
||||||
|
|
||||||
fb_redirect('thread.php?id=' . $newId . '&submitted=1');
|
/* 提交完成 → 原地渲染「已收到」页面,不跳转。
|
||||||
|
*
|
||||||
|
* 用户 2026-11-03:「提交完怎么直接跳转到首页去了?应该给它一个提示,
|
||||||
|
* 说你反馈我们已经收到…里面有一个标志码,是你以后跟踪它的唯一凭据…
|
||||||
|
* 然后下面再列出来其他功能(返回首页、继续提交、其他软件链接、开源链接),
|
||||||
|
* 不能直接跳转,这给人感觉很突兀。」
|
||||||
|
*
|
||||||
|
* 🔴 关键取舍:**不跳转、不用 302**。跳转会让用户失去"我刚提交成功"这一帧,
|
||||||
|
* 而且详情页此刻还是「未公开」状态,跳过去反而像出错了。
|
||||||
|
* 这里直接把成功页渲染出来,把 token 明明白白摆在眼前。 */
|
||||||
|
fb_render_submitted($sw, $newId, $trackToken, $form);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,11 +188,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]);
|
fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
<div class="crumbs"><a href="<?php echo e(fb_software_list_url($sw)); ?>">← 返回问题列表</a></div>
|
||||||
|
|
||||||
<section class="narrow">
|
<section class="narrow">
|
||||||
<h1>我要反馈</h1>
|
<h1>我要反馈</h1>
|
||||||
<p class="muted">请描述你遇到的问题或想要的功能。提交后会先进入审核,确认为有效问题后才会公开显示;<b>若经确认不是应用的问题,我们会把结论与说明通过邮件发送给你</b>(请填写常用邮箱)。</p>
|
<?php if ($sw): ?>
|
||||||
|
<div class="sw-context"><?php echo fb_software_badge($sw, 'md'); ?>
|
||||||
|
<span class="muted small">您正在为「<?php echo e(fb_software_site_title($sw)); ?>」提交反馈</span></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="muted">请描述您遇到的问题或想要的功能。提交后会先进入审核,确认为有效问题后才会公开显示;<b>若经确认不是应用的问题,我们会把结论与说明通过邮件发送给您</b>(请填写常用邮箱)。</p>
|
||||||
|
|
||||||
<?php fb_flash_box($err, 'error'); ?>
|
<?php fb_flash_box($err, 'error'); ?>
|
||||||
|
|
||||||
@@ -150,17 +209,8 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
|
|||||||
|
|
||||||
<?php if ($sw): ?>
|
<?php if ($sw): ?>
|
||||||
<input type="hidden" name="software_id" value="<?php echo (int)$sw['id']; ?>">
|
<input type="hidden" name="software_id" value="<?php echo (int)$sw['id']; ?>">
|
||||||
<div class="field">
|
<?php /* 软件切换统一收到顶栏的汉堡菜单里,表单内不再平铺一排 chip ——
|
||||||
<span>所属软件 <b class="req">*</b><i class="hint">换软件会刷新下面的分类</i></span>
|
免得同一个选择在两个地方出现,用户不知道该点哪个。 */ ?>
|
||||||
<div class="chips">
|
|
||||||
<?php foreach ($swList as $one): ?>
|
|
||||||
<a class="chip <?php echo ((int)$one['id'] === $swId) ? 'on' : ''; ?>"
|
|
||||||
href="new.php?sw=<?php echo e(urlencode((string)$one['slug'])); ?>"><?php echo e((string)$one['name']); ?></a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php elseif ($sw): ?>
|
|
||||||
<input type="hidden" name="software_id" value="<?php echo (int)$sw['id']; ?>">
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($cats): ?>
|
<?php if ($cats): ?>
|
||||||
@@ -176,18 +226,18 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
|
|||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>标题 <b class="req">*</b></span>
|
<span>标题 <b class="req">*</b></span>
|
||||||
<input type="text" name="title" maxlength="80" value="<?php echo e($form['title']); ?>" placeholder="一句话概括你的问题" required>
|
<input type="text" name="title" maxlength="80" value="<?php echo e($form['title']); ?>" placeholder="一句话概括您的问题" required>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>详细描述 <b class="req">*</b></span>
|
<span>详细描述 <b class="req">*</b></span>
|
||||||
<textarea name="body" rows="9" maxlength="5000" placeholder="请尽量说明:你做了什么、期望的结果、实际的结果;如是同步问题,可说明使用的邮箱 / 服务器类型。" required><?php echo e($form['body']); ?></textarea>
|
<textarea name="body" rows="9" maxlength="5000" placeholder="请尽量说明:您做了什么、期望的结果、实际的结果;如是同步问题,可说明使用的邮箱 / 服务器类型。" required><?php echo e($form['body']); ?></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="row2">
|
<div class="row2">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>昵称 <b class="req">*</b></span>
|
<span>昵称 <b class="req">*</b></span>
|
||||||
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼你" required>
|
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼您" required>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
|
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
|
||||||
@@ -218,8 +268,94 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="muted small">提示:本页面不会公开展示你的邮箱与手机号。请勿在内容中填写密码等敏感信息。可上传截图或录屏帮助说明问题;文件会直接传到对象存储,不占用本站服务器空间。</p>
|
<p class="muted small">提示:本页面不会公开展示您的邮箱与手机号。请勿在内容中填写密码等敏感信息。可上传截图或录屏帮助说明问题;文件会直接传到对象存储,不占用本站服务器空间。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
fb_layout_foot();
|
fb_layout_foot();
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* 提交成功页
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 用户 2026-11-03 要求:提交完不要直接跳走,要给一个明确回执。
|
||||||
|
* 页面结构(按他的话组织):
|
||||||
|
* ① 你的反馈我们已经收到
|
||||||
|
* ② 详细信息已发到你邮箱
|
||||||
|
* ③ 下面这个标志码是**你以后跟踪它的唯一凭据**,请保存好
|
||||||
|
* ④ 我们会尽快处理,你可以凭它持续关注进展
|
||||||
|
* ⑤ 再往下给出路:查看这条反馈 / 继续提交 / 返回列表 / 其他软件 / 开源仓库
|
||||||
|
*
|
||||||
|
* 为什么要显式展示 token(而不只是说"已发到邮箱"):
|
||||||
|
* 邮件可能进垃圾箱、也可能被误删。把码当场亮出来,用户至少能复制存档,
|
||||||
|
* 不至于彻底失去跟踪能力。这是"唯一凭据",丢了就找不回来(我们不认人)。
|
||||||
|
* ================================================================== */
|
||||||
|
function fb_render_submitted($sw, $newId, $token, array $form) {
|
||||||
|
$swName = $sw ? (string)$sw['name'] : '';
|
||||||
|
fb_layout_head('反馈已提交', ['robots' => 'noindex,nofollow', 'software' => $sw]);
|
||||||
|
?>
|
||||||
|
<section class="narrow">
|
||||||
|
<div class="submitted">
|
||||||
|
<div class="submitted-ok" aria-hidden="true">✓</div>
|
||||||
|
<h1>您的反馈我们已经收到</h1>
|
||||||
|
|
||||||
|
<p class="submitted-lead">
|
||||||
|
我们已经把这条反馈存下来了,会尽快安排查看与处理。
|
||||||
|
<?php if (trim((string)$form['email']) !== ''): ?>
|
||||||
|
这条反馈的详细信息也已发送到 <b><?php echo e((string)$form['email']); ?></b>,
|
||||||
|
请留意收件箱(如果没看到,可以看一下垃圾邮件)。
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="token-box">
|
||||||
|
<div class="token-box-hd">您跟踪这条反馈的唯一凭据</div>
|
||||||
|
<div class="token-code" id="trackToken"><?php echo e((string)$token); ?></div>
|
||||||
|
<p class="token-box-tip">
|
||||||
|
请把这段标志码保存好。今后凭它就能查看这条反馈的处理进度 ——
|
||||||
|
即使它还没有通过审核、甚至最终被关闭,也依然能看。
|
||||||
|
我们也会在每封通知邮件里带上它,所以务必留意邮箱。
|
||||||
|
</p>
|
||||||
|
<p class="token-box-warn">
|
||||||
|
这段码等同于您的身份凭证:谁拿到它,谁就能查看这条反馈。
|
||||||
|
请不要发给别人,也不要发到公开的地方。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="submitted-next">接下来您可以:</p>
|
||||||
|
<div class="submitted-actions">
|
||||||
|
<a class="btn primary" href="thread.php?id=<?php echo (int)$newId; ?>&t=<?php echo e(rawurlencode((string)$token)); ?>">
|
||||||
|
查看这条反馈
|
||||||
|
</a>
|
||||||
|
<a class="btn" href="new.php<?php echo $sw ? '?sw=' . e(urlencode((string)$sw['slug'])) : ''; ?>">继续提交一条</a>
|
||||||
|
<?php /* 不再放「返回首页」—— 在某个软件的反馈系统里,首页(全局介绍页)
|
||||||
|
对用户没有意义。要回就回这个软件的问题列表。
|
||||||
|
想看别的软件,只有右上角三横线一个入口。 */ ?>
|
||||||
|
<a class="btn" href="<?php echo e(fb_software_list_url($sw)); ?>">
|
||||||
|
<?php echo $swName !== '' ? '看看「' . e($swName) . '」的其他反馈' : '返回问题列表'; ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
/* ⚠️ 这里原来有一个「也可以去其他软件反馈」的列表,已删除。
|
||||||
|
*
|
||||||
|
* 用户 2026-11-03:「在某个软件的反馈系统里面,什么我要反馈、问题列表、
|
||||||
|
* 还有左上角的那个链接,全部都是这个软件相关的内容,跟其他软件没关系 ——
|
||||||
|
* 相对来讲它是独立的。他如果想进别的软件,只有右上角三个横线点开。」
|
||||||
|
*
|
||||||
|
* 所以在软件内部(列表 / 详情 / 提交页 / 成功页)都不引导用户去别的软件。
|
||||||
|
* 唯一的跨软件入口就是顶栏那个三横线菜单。
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$creditUrl = (string)fb_cfg('footer.credit_url', fb_credit_url_default());
|
||||||
|
if ($creditUrl !== ''):
|
||||||
|
?>
|
||||||
|
<p class="submitted-credit">
|
||||||
|
这套反馈系统是开源的:<a href="<?php echo e($creditUrl); ?>" target="_blank" rel="noopener"><?php echo e($creditUrl); ?></a>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php
|
||||||
|
fb_layout_foot();
|
||||||
|
}
|
||||||
|
|||||||
+115
-60
@@ -11,49 +11,69 @@ if ($id <= 0) { fb_redirect('index.php'); }
|
|||||||
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||||||
$st->execute([$id]);
|
$st->execute([$id]);
|
||||||
$thread = $st->fetch();
|
$thread = $st->fetch();
|
||||||
|
|
||||||
|
/* 这条帖子所属的软件 —— 「返回问题列表」要回到它的列表,
|
||||||
|
* 而不是回到首页(首页现在是介绍页)。 */
|
||||||
|
$backUrl = fb_software_list_url(
|
||||||
|
$thread && !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null
|
||||||
|
);
|
||||||
if (!$thread) {
|
if (!$thread) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
fb_layout_head('问题不存在');
|
fb_layout_head('问题不存在');
|
||||||
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
|
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
|
||||||
fb_layout_foot();
|
fb_layout_foot();
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 审核(先审后显):未通过审核的问题对访客不可见
|
/* 审核(先审后显)+ 提交人追踪:
|
||||||
if (!fb_thread_public($thread)) {
|
*
|
||||||
|
* 可见性三档 ——
|
||||||
|
* ① 已公开:所有人可看
|
||||||
|
* ② 未公开 + 带对 token:提交人本人可看(这是他的私人进度页)
|
||||||
|
* ③ 未公开 + 没 token:对外一律装作「不存在」,不泄露标题也不泄露状态
|
||||||
|
*
|
||||||
|
* 🔴 关键:带对 token 时**绝不能**再走「提交成功,等待审核」那个提示页。
|
||||||
|
* 用户之前就卡在这里 —— 邮件里给了链接让他看进度,点进来却只看到
|
||||||
|
* 「等待审核」,等于链接白给。带 token 进来就应该看到完整详情。 */
|
||||||
|
$hasToken = isset($_GET['t']) ? trim((string)$_GET['t']) : '';
|
||||||
|
$isOwnerView = $hasToken !== '' && fb_track_token_ok($thread, $hasToken);
|
||||||
|
|
||||||
|
if (!fb_thread_viewable($thread)) {
|
||||||
$justSubmitted = (isset($_GET['submitted']) && (string)$_GET['submitted'] === '1');
|
$justSubmitted = (isset($_GET['submitted']) && (string)$_GET['submitted'] === '1');
|
||||||
if ($justSubmitted) {
|
if ($justSubmitted) {
|
||||||
fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']);
|
fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']);
|
||||||
echo '<div class="empty">'
|
echo '<div class="empty">'
|
||||||
. '<h2 style="margin:0 0 10px">提交成功,等待审核</h2>'
|
. '<h2 style="margin:0 0 10px">提交成功,等待审核</h2>'
|
||||||
. '<p>你的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>'
|
. '<p>您的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>'
|
||||||
. '<p>· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;<br>'
|
. '<p>· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;<br>'
|
||||||
. '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到你留下的邮箱。</p>'
|
. '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到您留下的邮箱。</p>'
|
||||||
. '<p class="muted">审核通常在 1~2 个工作日内完成,请留意邮箱。</p>'
|
. '<p class="muted">审核通常在 1~2 个工作日内完成。'
|
||||||
. '<p><a class="btn primary" href="index.php">返回问题列表</a></p>'
|
. '我们会发一封邮件给您,里面的链接可以随时查看这条反馈的处理进度。</p>'
|
||||||
|
. '<p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p>'
|
||||||
. '</div>';
|
. '</div>';
|
||||||
fb_layout_foot();
|
fb_layout_foot();
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
fb_layout_head('问题不存在');
|
fb_layout_head('问题不存在');
|
||||||
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
|
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
|
||||||
fb_layout_foot();
|
fb_layout_foot();
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statuses = fb_status_keys();
|
|
||||||
$err = '';
|
$err = '';
|
||||||
$atts = [];
|
$atts = [];
|
||||||
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
|
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
|
||||||
|
|
||||||
/* ---------------- 处理回复提交 ---------------- */
|
/* ---------------- 处理回复提交 ---------------- */
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// 审核中(未公开)的问题不允许回复
|
/* 未公开的帖子,只有带对 token 的提交人(或管理员)能回复。
|
||||||
if (!fb_thread_public($thread)) {
|
* 别人连看都看不到,自然也不该能回。 */
|
||||||
|
if (!fb_thread_public($thread) && !$isOwnerView) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
fb_layout_head('暂不可回复');
|
fb_layout_head('暂不可回复');
|
||||||
echo '<div class="empty"><p>该问题还在审核中,暂时不能回复。</p><p><a class="btn primary" href="index.php">返回问题列表</a></p></div>';
|
echo '<div class="empty"><p>该问题还在审核中,暂时不能回复。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
|
||||||
fb_layout_foot();
|
fb_layout_foot();
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -61,14 +81,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$form['name'] = trim(isset($_POST['name']) ? (string)$_POST['name'] : '');
|
$form['name'] = trim(isset($_POST['name']) ? (string)$_POST['name'] : '');
|
||||||
$form['email'] = trim(isset($_POST['email']) ? (string)$_POST['email'] : '');
|
$form['email'] = trim(isset($_POST['email']) ? (string)$_POST['email'] : '');
|
||||||
$form['body'] = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
|
$form['body'] = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
|
||||||
|
|
||||||
|
/* 🔴 带 token(= 提交人本人)时,身份以库里那条帖子为准,**不采信 POST**。
|
||||||
|
* 前端已经把这两个字段设成 hidden 了,但 hidden 是可以被改的:
|
||||||
|
* 谁拿到链接、改个 email 提交,这条反馈的联系人就变成他的了。
|
||||||
|
* 身份校验必须在服务端做,不能只靠前端不给输入框。 */
|
||||||
|
if ($isOwnerView) {
|
||||||
|
$form['name'] = (string)$thread['author_name'];
|
||||||
|
$form['email'] = (string)$thread['author_email'];
|
||||||
|
}
|
||||||
|
|
||||||
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
|
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
|
||||||
$adminPw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
|
|
||||||
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
|
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
|
||||||
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
|
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
|
||||||
|
|
||||||
list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : '');
|
list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : '');
|
||||||
|
|
||||||
$isAdmin = ($adminPw !== '' && fb_admin_verify_password($adminPw));
|
/* 管理员身份只认「后台登录态」,不再从回复表单收口令。
|
||||||
|
* 用户 2026-11-03:「管理员口令、处理状态都不要填了,
|
||||||
|
* 用户进来之后就没有这些东西,只保留回复内容 + 人机验证」。
|
||||||
|
* 状态流转在后台详情页那排按钮里做,不该混在公开的回复框里。 */
|
||||||
|
$isAdmin = fb_is_admin();
|
||||||
|
|
||||||
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
|
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
|
||||||
$err = '会话已过期,请重新提交。';
|
$err = '会话已过期,请重新提交。';
|
||||||
@@ -107,18 +140,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$replyId = (int)$pdo->lastInsertId();
|
$replyId = (int)$pdo->lastInsertId();
|
||||||
fb_attach_save($pdo, $id, $replyId, $vList);
|
fb_attach_save($pdo, $id, $replyId, $vList);
|
||||||
|
|
||||||
// 更新主题:回复数 +1、活跃时间;管理员可同时调整状态
|
/* 更新主题:回复数 +1、活跃时间。
|
||||||
$newStatus = null;
|
* 回复框不再承担改状态的职责 —— 状态流转在后台详情页那排按钮里做。 */
|
||||||
if ($isAdmin && isset($_POST['status']) && isset($statuses[(string)$_POST['status']])) {
|
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?');
|
||||||
$newStatus = (string)$_POST['status'];
|
$upd->execute([$now, $id]);
|
||||||
}
|
|
||||||
if ($newStatus !== null) {
|
|
||||||
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ?, status = ? WHERE id = ?');
|
|
||||||
$upd->execute([$now, $newStatus, $id]);
|
|
||||||
} else {
|
|
||||||
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?');
|
|
||||||
$upd->execute([$now, $id]);
|
|
||||||
}
|
|
||||||
fb_rate_hit('reply');
|
fb_rate_hit('reply');
|
||||||
|
|
||||||
// 发送通知邮件给发帖人
|
// 发送通知邮件给发帖人
|
||||||
@@ -131,7 +156,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
];
|
];
|
||||||
fb_notify_reply($pdo, $thread, $replyRow, $isAdmin);
|
fb_notify_reply($pdo, $thread, $replyRow, $isAdmin);
|
||||||
|
|
||||||
fb_redirect('thread.php?id=' . $id . '#r' . $replyId);
|
/* 回复后跳回去时要带上 token,否则提交人对未公开反馈的访问权会当场丢掉 */
|
||||||
|
fb_redirect('thread.php?id=' . $id
|
||||||
|
. ($isOwnerView ? '&t=' . rawurlencode($hasToken) : '')
|
||||||
|
. '#r' . $replyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,20 +181,36 @@ $thSwForColor = !empty($thread['software_id']) ? fb_software((int)$thread['softw
|
|||||||
fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
<div class="crumbs"><a href="<?php echo e($backUrl); ?>">← 返回问题列表</a></div>
|
||||||
|
|
||||||
|
<?php if ($isOwnerView && !fb_thread_public($thread)): ?>
|
||||||
|
<?php /* 提交人凭 token 进来看到自己的未公开反馈时,要明确告诉他:
|
||||||
|
这条还没公开、别人看不到,但他可以一直用这个链接跟踪。
|
||||||
|
否则他会以为「既然能打开,是不是已经公开了」。 */ ?>
|
||||||
|
<div class="owner-note">
|
||||||
|
<b>这是您的私人跟踪链接</b>
|
||||||
|
<span>这条反馈还没通过审核,其他人看不到。请保留本页链接(或邮件里的链接),
|
||||||
|
审核结果与后续进展都能在这里查看。</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<article class="subject">
|
<article class="subject">
|
||||||
<div class="item-top">
|
<?php
|
||||||
<?php
|
$thSw = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
|
||||||
$thSw = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
|
$thCat = !empty($thread['category_id']) ? fb_category((int)$thread['category_id']) : null;
|
||||||
$thCat = !empty($thread['category_id']) ? fb_category((int)$thread['category_id']) : null;
|
?>
|
||||||
?>
|
<?php /* 标题在最上面、顶头开始;软件名/分类/状态另起一行放下面。
|
||||||
<?php if ($thSw): ?><span class="badge sw"><?php echo e((string)$thSw['name']); ?></span><?php endif; ?>
|
用户 2026-11-03:这三个标签挤在标题前面,把标题推到右边很难看。
|
||||||
<?php if ($thCat): ?><span class="badge cat"><?php echo e((string)$thCat['name']); ?></span><?php endif; ?>
|
⚠️ 顺序是「行1 标题 → 行2 标签 → 行3 作者信息」,别把标签挪回前面。 */ ?>
|
||||||
<span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span>
|
<div class="subject-head">
|
||||||
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
|
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
|
||||||
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>
|
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="subject-tags">
|
||||||
|
<?php if ($thSw): ?><?php echo fb_software_chip($thSw); ?><?php endif; ?>
|
||||||
|
<?php if ($thCat): ?><span class="badge cat"><?php echo e((string)$thCat['name']); ?></span><?php endif; ?>
|
||||||
|
<span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span>
|
||||||
|
</div>
|
||||||
<div class="item-meta">
|
<div class="item-meta">
|
||||||
<span class="who"><?php echo e($thread['author_name']); ?></span>
|
<span class="who"><?php echo e($thread['author_name']); ?></span>
|
||||||
<span class="dot">·</span>
|
<span class="dot">·</span>
|
||||||
@@ -206,8 +250,11 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
|||||||
<section class="reply-form">
|
<section class="reply-form">
|
||||||
<h2>我要回复</h2>
|
<h2>我要回复</h2>
|
||||||
<?php fb_flash_box($err, 'error'); ?>
|
<?php fb_flash_box($err, 'error'); ?>
|
||||||
<form method="post" action="thread.php?id=<?php echo (int)$id; ?>">
|
<?php /* 表单要带上 token:否则提交后跳转回不带 t 的地址,
|
||||||
|
提交人会立刻失去对自己未公开反馈的访问权,体验上就是「回复完就看不见了」。 */ ?>
|
||||||
|
<form method="post" action="thread.php?id=<?php echo (int)$id; ?><?php echo $isOwnerView ? '&t=' . e(rawurlencode($hasToken)) : ''; ?>">
|
||||||
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
|
||||||
|
<input type="hidden" name="t" value="<?php echo e($isOwnerView ? $hasToken : ''); ?>">
|
||||||
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||||
<input type="hidden" name="t0" value="<?php echo time(); ?>">
|
<input type="hidden" name="t0" value="<?php echo time(); ?>">
|
||||||
<div class="hp-field" aria-hidden="true">
|
<div class="hp-field" aria-hidden="true">
|
||||||
@@ -215,19 +262,41 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row2">
|
<div class="row2">
|
||||||
<label class="field">
|
<?php if ($isOwnerView): ?>
|
||||||
<span>昵称 <b class="req">*</b></span>
|
<?php /* 带 token 进来 = 已经证明是提交人本人。
|
||||||
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼你" required>
|
*
|
||||||
</label>
|
* 🔴 昵称/邮箱在这里**只读**,理由:
|
||||||
<label class="field" id="emailField">
|
* 1. token 就是身份凭证,再让他在表单里声明一次身份,自相矛盾;
|
||||||
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
|
* 2. 邮箱是唯一能联系到提交人的途径。如果能随手改,
|
||||||
<input type="email" name="email" maxlength="120" value="<?php echo e($form['email']); ?>" placeholder="you@example.com" required>
|
* 谁拿到这条链接,填个自己的邮箱,后续所有通知就归他了 ——
|
||||||
</label>
|
* 等于把这条反馈「过户」给别人,我们再也找不到原作者。
|
||||||
|
* 要改联系方式,是另一件事(见下方说明),不该藏在回复框里顺手改掉。 */ ?>
|
||||||
|
<div class="field">
|
||||||
|
<span>回复身份</span>
|
||||||
|
<div class="reply-identity">
|
||||||
|
<span class="reply-identity-nm"><?php echo e((string)$thread['author_name']); ?></span>
|
||||||
|
<span class="reply-identity-mail"><?php echo e((string)$thread['author_email']); ?></span>
|
||||||
|
</div>
|
||||||
|
<i class="hint">这是您提交时用的身份。回复会以它显示,通知也会发到这个邮箱。</i>
|
||||||
|
</div>
|
||||||
|
<?php /* 表单仍要带这两个值,服务端才能落库;但不给用户改的机会 */ ?>
|
||||||
|
<input type="hidden" name="name" value="<?php echo e((string)$thread['author_name']); ?>">
|
||||||
|
<input type="hidden" name="email" value="<?php echo e((string)$thread['author_email']); ?>">
|
||||||
|
<?php else: ?>
|
||||||
|
<label class="field">
|
||||||
|
<span>昵称 <b class="req">*</b></span>
|
||||||
|
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼您" required>
|
||||||
|
</label>
|
||||||
|
<label class="field" id="emailField">
|
||||||
|
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
|
||||||
|
<input type="email" name="email" maxlength="120" value="<?php echo e($form['email']); ?>" placeholder="you@example.com" required>
|
||||||
|
</label>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>回复内容 <b class="req">*</b></span>
|
<span>回复内容 <b class="req">*</b></span>
|
||||||
<textarea name="body" rows="6" maxlength="5000" placeholder="说说你的看法或补充…" required><?php echo e($form['body']); ?></textarea>
|
<textarea name="body" rows="6" maxlength="5000" placeholder="说说您的看法或补充…" required><?php echo e($form['body']); ?></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<?php fb_uploaders($atts); ?>
|
<?php fb_uploaders($atts); ?>
|
||||||
@@ -243,20 +312,6 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="field admin-pw">
|
|
||||||
<span>管理员口令 <i class="hint">选填;填写后将以「官方」身份回复</i></span>
|
|
||||||
<input type="password" name="admin_password" value="" autocomplete="off" placeholder="管理员专用">
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="field admin-status" hidden>
|
|
||||||
<span>处理状态 <i class="hint">仅管理员可见</i></span>
|
|
||||||
<select name="status">
|
|
||||||
<?php foreach ($statuses as $k => $v): ?>
|
|
||||||
<option value="<?php echo e($k); ?>" <?php echo $thread['status'] === $k ? 'selected' : ''; ?>><?php echo e($v); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn primary" type="submit">提交回复</button>
|
<button class="btn primary" type="submit">提交回复</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user