Compare commits
28
Commits
738c6c09ff
..
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 |
@@ -49,7 +49,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
|
||||
}
|
||||
|
||||
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>
|
||||
<section class="narrow">
|
||||
@@ -116,17 +116,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
||||
$msgType = 'error';
|
||||
|
||||
} 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 = ?')
|
||||
->execute([$newStatus, time(), $id]);
|
||||
fb_mod_log('approve', $id, '通过审核并公开');
|
||||
fb_mod_log('approve', $id, '通过审核并公开(状态:' . fb_status_label($newStatus) . ')');
|
||||
|
||||
// 通知提交人「已发布」(工单 #2)
|
||||
$errA = '';
|
||||
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 = '发信异常'; }
|
||||
$msg = '#' . $id . ' 已通过审核,现已公开显示。'
|
||||
$msg = '#' . $id . ' 已通过审核并公开,状态为「' . fb_status_label($newStatus) . '」。'
|
||||
. ($errA === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errA . ')');
|
||||
|
||||
} elseif ($act === 'close') {
|
||||
@@ -302,7 +316,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
||||
'id' => 0,
|
||||
'software_id' => $swRow ? (int)$swRow['id'] : 0,
|
||||
'title' => '这是一封测试邮件,用来检查排版与配色',
|
||||
'body' => "这是测试用的反馈正文。\n如果你收到这封信,说明 SMTP 配置正确,配色与图标也应当对应「"
|
||||
'body' => "这是测试用的反馈正文。\n如果您收到这封信,说明 SMTP 配置正确,配色与图标也应当对应「"
|
||||
. ($swRow ? (string)$swRow['name'] : '默认软件') . "」。\n\n发送时间:" . date('Y-m-d H:i:s'),
|
||||
'author_name' => '测试',
|
||||
'author_email' => $to,
|
||||
@@ -624,7 +638,7 @@ function fb_admin_save_settings() {
|
||||
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
|
||||
if (!in_array($tab, ['pending', 'all', 'thread', 'taxonomy', 'statuses', 'settings', 'mail', 'log'], true)) { $tab = 'pending'; }
|
||||
|
||||
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow', 'admin' => true]);
|
||||
?>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
@@ -648,16 +662,23 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
|
||||
<?php if ($tab === 'pending'): ?>
|
||||
<?php
|
||||
/* 「待审核」= 还没公开过的条目。
|
||||
* 判据用 status='hidden'(未公开),不再用 visible=0 这种隐式条件 ——
|
||||
* 已关闭的条目 visible 也是 0,但它是处理完了,不该出现在待审核里。
|
||||
* 顺便兼容老数据:visible=0 且状态仍是 'open' 的也一并算待审核。 */
|
||||
$pending = $pdo->query(
|
||||
"SELECT id, title, body, category, status, author_name, author_email, created_at
|
||||
FROM threads WHERE visible = 0 AND status <> 'closed' ORDER BY created_at DESC"
|
||||
"SELECT * FROM threads
|
||||
WHERE status = 'hidden' OR (visible = 0 AND status = 'open')
|
||||
ORDER BY created_at DESC"
|
||||
)->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): ?>
|
||||
<div class="empty"><p>当前没有待审核的反馈。</p></div>
|
||||
<?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 elseif ($tab === 'all'): ?>
|
||||
@@ -689,7 +710,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
$lst->execute($args);
|
||||
$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="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
|
||||
<select name="sw" onchange="this.form.submit()">
|
||||
@@ -733,11 +754,30 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
<div class="list">
|
||||
<?php foreach ($rows as $t): ?>
|
||||
<div class="item" style="cursor:default">
|
||||
<div class="item-top">
|
||||
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
|
||||
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||
<?php
|
||||
/* 标题顶头独占一行,标签(软件 / 分类 / 状态)另起一行。
|
||||
* 后台是跨软件视图,所以这里必须带软件名 —— 前台某个软件内部
|
||||
* 不需要显示软件名(满页都是同一个),后台需要。 */
|
||||
$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>
|
||||
<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; ?>
|
||||
</div>
|
||||
<p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
|
||||
@@ -776,16 +816,21 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
<div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div>
|
||||
|
||||
<div class="card-form" style="margin:0 0 16px">
|
||||
<div class="item-top">
|
||||
<?php
|
||||
$dSw = !empty($t['software_id']) ? fb_software((int)$t['software_id']) : null;
|
||||
$dCat = !empty($t['category_id']) ? fb_category((int)$t['category_id']) : null;
|
||||
?>
|
||||
<?php if ($dSw): ?><span class="badge sw"><?php echo e((string)$dSw['name']); ?></span><?php endif; ?>
|
||||
<?php /* 标题顶头独占一行,标签另起一行 —— 与列表页、前台详情页统一。
|
||||
用户 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; ?>
|
||||
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||
<span class="item-title"><?php echo e($t['title']); ?></span>
|
||||
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
|
||||
</div>
|
||||
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
|
||||
<?php if (!empty($atts['main'])) { echo fb_attach_render($atts['main']); } ?>
|
||||
@@ -828,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">
|
||||
<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="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">
|
||||
<button class="btn primary act-btn" type="submit" name="act" value="setstatus">通过并公开</button>
|
||||
</form>
|
||||
@@ -1157,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')); ?>">
|
||||
|
||||
<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>
|
||||
<input type="text" name="moderation_close_mail_subject"
|
||||
placeholder="留空即用:关于你反馈的问题《%s》"
|
||||
value="<?php echo e(fb_form_val(fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》'), '关于你反馈的问题《%s》', 'moderation.close_mail_subject')); ?>">
|
||||
placeholder="留空即用:关于您反馈的问题《%s》"
|
||||
value="<?php echo e(fb_form_val(fb_cfg('moderation.close_mail_subject', '关于您反馈的问题《%s》'), '关于您反馈的问题《%s》', 'moderation.close_mail_subject')); ?>">
|
||||
<i class="hint">%s 会替换成问题标题。留空则用默认模板。</i></label>
|
||||
|
||||
<div class="form-actions">
|
||||
@@ -1284,7 +1332,7 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
$smVerify= (bool)fb_cfg('smtp.verify_ssl', true);
|
||||
?>
|
||||
<label class="check"><input type="checkbox" name="smtp_enabled" value="1"<?php echo $smOn ? ' checked' : ''; ?>> 开启邮件通知
|
||||
<span class="hint" style="display:block;margin:2px 0 0 24px">开启后,有人回复你反馈的问题会发信通知提交人。填好下方参数并用「发送测试邮件」验证通了再勾。</span></label>
|
||||
<span class="hint" style="display:block;margin:2px 0 0 24px">开启后,有人回复您反馈的问题会发信通知提交人。填好下方参数并用「发送测试邮件」验证通了再勾。</span></label>
|
||||
|
||||
<div class="grid2">
|
||||
<label class="field"><span>SMTP 服务器</span>
|
||||
@@ -1411,53 +1459,49 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
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(); ?>
|
||||
<div class="card-form" style="margin:0 0 16px">
|
||||
<div class="item-top">
|
||||
<div class="prow">
|
||||
<div class="prow-main">
|
||||
<?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 /* 标题顶头独占一行,标签(软件/分类/状态)另起一行放下面。
|
||||
用户 2026-11-03:后台待审核、全部问题、详情页全都按这个方式处理 ——
|
||||
标签放在标题前面会把标题挤到右边。 */ ?>
|
||||
<div class="prow-head">
|
||||
<a class="prow-title" href="<?php echo e($url); ?>"><?php echo e($t['title']); ?></a>
|
||||
<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>
|
||||
<span class="item-title"><?php echo e($t['title']); ?></span>
|
||||
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
|
||||
</div>
|
||||
|
||||
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
|
||||
|
||||
<div class="item-meta">
|
||||
<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(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>
|
||||
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
|
||||
</div>
|
||||
|
||||
<div class="row2" style="margin-top:14px">
|
||||
</div>
|
||||
<div class="prow-act">
|
||||
<form method="post" action="admin.php?tab=pending">
|
||||
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
|
||||
<div class="form-actions">
|
||||
<button class="btn primary" type="submit" name="act" value="approve">通过并公开</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="admin.php?tab=pending">
|
||||
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
|
||||
<label class="field">
|
||||
<span>关闭说明 <b class="req">*</b> <i class="hint">判定"不是问题"时填写,会随邮件发给提问人</i></span>
|
||||
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象是服务器端的 CalDAV 限制,并非应用问题;建议改用…"></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+608
-48
@@ -61,29 +61,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 管理员口令:填写后显示"处理状态",并解除邮箱必填 ---------- */
|
||||
function bindAdminToggle() {
|
||||
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();
|
||||
}
|
||||
/* ---------- (已删)管理员口令切换 ----------
|
||||
* 回复表单里不再有「管理员口令」和「处理状态」——用户 2026-11-03 要求去掉。
|
||||
* 管理员身份改由后台登录态判断,状态流转在后台详情页的按钮里做。 */
|
||||
|
||||
/* ---------- 防止重复提交(上传未完成时阻止提交) ---------- */
|
||||
function bindNoDoubleSubmit() {
|
||||
@@ -729,49 +709,377 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 主题色选择器(后台软件编辑) ---------- */
|
||||
/* ---------- 主题色选择器(后台软件编辑) ----------
|
||||
* 用户 2026-11-04:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」
|
||||
*
|
||||
* 现在的形态是一块**完整色板**(不是小浮窗):
|
||||
* 340×200 的色相/饱和度画布 + 色相条 + 明度条 + 十六进制输入。
|
||||
* 右侧那排预设圆点已删除 —— 既能任意取色,再摆一排样例就是重复。
|
||||
*
|
||||
* 交互约定:
|
||||
* - 点色块 → 色板出现(下方空间不够自动翻到上方)
|
||||
* - 画布内拖动 = 同时调饱和度与明度
|
||||
* - 色相条单独调 H;明度条单独调 V
|
||||
* - 十六进制框可手输,合法立即同步
|
||||
* - 点「完成」/ 点色板外 / Esc → 关闭(值早已写入隐藏字段)
|
||||
*/
|
||||
function bindColorPicker() {
|
||||
var rows = document.querySelectorAll('.color-row');
|
||||
var rows = document.querySelectorAll('[data-colorpicker]');
|
||||
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
(function (row) {
|
||||
var input = row.querySelector('input[type=color]');
|
||||
var presets = row.querySelectorAll('.color-preset');
|
||||
var trigger = row.querySelector('[data-color-trigger]');
|
||||
var valueEl = row.querySelector('[data-color-value]');
|
||||
var reset = row.querySelector('.color-reset');
|
||||
if (!input) { return; }
|
||||
var panel = row.querySelector('[data-color-panel]');
|
||||
if (!trigger || !valueEl || !panel) { return; }
|
||||
|
||||
function syncOn(v) {
|
||||
for (var k = 0; k < presets.length; k++) {
|
||||
var same = (presets[k].getAttribute('data-color') || '').toLowerCase() === (v || '').toLowerCase();
|
||||
if (same) { presets[k].classList.add('on'); } else { presets[k].classList.remove('on'); }
|
||||
var canvas = panel.querySelector('[data-cp-sv]');
|
||||
var cursor = panel.querySelector('[data-cp-cursor]');
|
||||
var hueEl = panel.querySelector('[data-cp-hue]');
|
||||
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]);
|
||||
}
|
||||
|
||||
/* 画色相/饱和度板:底色是当前色相的纯色,叠一层
|
||||
* 左→右白到透明的横向渐变(饱和度)+ 上→下透明的纵向渐变(明度)。
|
||||
* 这是取色器通用画法,比逐像素画快得多。 */
|
||||
function drawSV() {
|
||||
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); // 都放不下:贴住底部
|
||||
}
|
||||
}
|
||||
|
||||
for (var j = 0; j < presets.length; j++) {
|
||||
presets[j].addEventListener('click', function () {
|
||||
var c = this.getAttribute('data-color') || '';
|
||||
input.value = c;
|
||||
syncOn(c);
|
||||
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();
|
||||
});
|
||||
}
|
||||
input.addEventListener('input', function () { syncOn(this.value); });
|
||||
|
||||
// ---- 十六进制手输 ----
|
||||
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 () {
|
||||
// 清空 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'); // 提交时以隐藏字段为准
|
||||
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');
|
||||
@@ -801,6 +1109,256 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 自定义下拉:替换原生 <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() {
|
||||
var btn = document.getElementById('mailTestBtn');
|
||||
@@ -849,10 +1407,12 @@
|
||||
|
||||
function boot() {
|
||||
bindCaptchaRefresh();
|
||||
bindAdminToggle();
|
||||
bindWontfixPanel();
|
||||
bindColorPicker();
|
||||
bindIconPicker();
|
||||
bindIconUpload();
|
||||
bindSelect();
|
||||
bindSoftwarePanel();
|
||||
bindMailTest();
|
||||
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
||||
bindNoDoubleSubmit();
|
||||
|
||||
+866
-51
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
|
||||
/**
|
||||
* 问题列表
|
||||
* 首页 / 当前软件的问题列表
|
||||
*
|
||||
* 两种形态(用户 2026-11-03 定的产品逻辑):
|
||||
* ① **不带 sw**:首页 = 轻反馈系统的介绍页。
|
||||
* 不再列「全部问题」—— 多个软件的反馈混在一起,对访客毫无意义:
|
||||
* 他不是所有软件的使用者,看别人的反馈没价值。
|
||||
* ② **带 sw**:当前软件的问题列表。
|
||||
* 检索/筛选/分类全部只在本软件范围内,弱化其他软件。
|
||||
*/
|
||||
require __DIR__ . '/lib/bootstrap.php';
|
||||
|
||||
$pdo = fb_db();
|
||||
|
||||
/* ---- 软件切换:只配一个软件时自动落到它,不暴露软件选择器 ---- */
|
||||
/* ---- 软件切换 ---- */
|
||||
$swList = fb_softwares(true);
|
||||
$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
|
||||
$sw = null;
|
||||
if ($swKey !== '') {
|
||||
$sw = fb_software($swKey);
|
||||
// 指定的软件不存在或已停用 → 退回默认,不报错(老链接要能打开)
|
||||
// 指定的软件不存在或已停用 → 退回首页(不自动落到第一个软件)
|
||||
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);
|
||||
$statuses = fb_status_keys();
|
||||
@@ -76,36 +98,31 @@ $attImgs = $tids ? fb_attach_map_images($pdo, $tids, 3) : [];
|
||||
$attCount = $tids ? fb_attach_count_map($pdo, $tids) : [];
|
||||
|
||||
$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">
|
||||
<div>
|
||||
<?php if ($sw): ?>
|
||||
<h1 class="page-h1-sw"><?php echo fb_software_badge($sw, 'lg'); ?></h1>
|
||||
<?php else: ?>
|
||||
<h1>全部问题</h1>
|
||||
<?php endif; ?>
|
||||
<?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; ?>
|
||||
<?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>
|
||||
<?php /* 这一整行(软件图标+名字 / 简介 / 「我要反馈」按钮)已删除。
|
||||
*
|
||||
* 用户 2026-11-03:「上面已经有标题和『我要反馈』了,下面又出现一次,
|
||||
* 完全重复。下面这一行不要了…其他内容开始往前提,往上提,这样更简洁。」
|
||||
*
|
||||
* 现在:
|
||||
* - 「同步日历反馈系统」+ 灰色简介 → 只在顶栏出现一次
|
||||
* - 「我要反馈」→ 顶栏右侧那个 btn-mini 就够了(列表页不再放第二个)
|
||||
* - 列表内容直接从下面开始,省掉一整行高度
|
||||
*/
|
||||
|
||||
<div class="software-bar">
|
||||
<span class="sw-label">选择软件:</span>
|
||||
<div class="chips">
|
||||
<?php foreach ($swList as $s): ?>
|
||||
<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 fb_software_badge($s, 'sm'); ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
if (!$sw):
|
||||
?>
|
||||
<div class="page-head">
|
||||
<div><h1>全部问题</h1></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="filters">
|
||||
<div class="chips">
|
||||
@@ -141,12 +158,25 @@ fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]);
|
||||
<?php else: ?>
|
||||
<?php foreach ($threads as $t): ?>
|
||||
<a class="item" href="thread.php?id=<?php echo (int)$t['id']; ?>">
|
||||
<div class="item-top">
|
||||
<span class="badge cat"><?php echo e(fb_category_label($t['category_id'])); ?></span>
|
||||
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
|
||||
<?php /* 标题单独占第一行、从最左边顶头开始 ——
|
||||
用户 2026-11-03:标签放在标题前面会把标题挤到右边,很难看。
|
||||
顺序改为「标题 → 标签」:标题在上,分类/状态另起一行。 */ ?>
|
||||
<div class="item-head">
|
||||
<span class="item-title"><?php echo e($t['title']); ?></span>
|
||||
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
|
||||
</div>
|
||||
<div class="item-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>
|
||||
<?php if (!empty($attImgs[(int)$t['id']])): ?>
|
||||
<div class="thumbs">
|
||||
@@ -192,3 +222,94 @@ fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]);
|
||||
|
||||
<?php
|
||||
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();
|
||||
}
|
||||
|
||||
+286
-26
@@ -5,6 +5,129 @@
|
||||
*/
|
||||
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 校验通过后调用)
|
||||
* 返回 [消息, 类型],类型 ok / error
|
||||
@@ -27,12 +150,27 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
$slug = fb_sw_slugify($name, 'sw', $next);
|
||||
}
|
||||
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'] : '');
|
||||
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
|
||||
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
|
||||
$pdo->prepare('INSERT INTO software(slug,name,note,color,icon,download_url,sort_order,enabled,created_at) VALUES(?,?,?,?,?,?,?,1,?)')
|
||||
->execute([$slug, $name, $note !== '' ? $note : null, $color, $icon, $dl, $sort, $now]);
|
||||
|
||||
// 新建时也可以直接带上图标图片(先入库,再更新 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();
|
||||
// 新软件默认给一组常见分类,省得空着没法发帖
|
||||
$i = 0;
|
||||
@@ -58,10 +196,49 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
||||
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
|
||||
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
|
||||
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, icon=?, download_url=?, sort_order=? WHERE id=?')
|
||||
->execute([$name, $slug, $note !== '' ? $note : null, $color, $icon, $dl,
|
||||
|
||||
/* ---- 图标图片 ----
|
||||
* 三种情况:
|
||||
* ① 上传了新图 → 换掉旧图(旧实体删掉,不留孤儿文件)
|
||||
* ② 勾了「移除图标」→ 删掉图片,退回字符图标
|
||||
* ③ 都没动 → 保持原样(不能因为没传文件就当成清空,
|
||||
* 这与「配置被跨页清空」是同一类错误) */
|
||||
$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]);
|
||||
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': {
|
||||
@@ -77,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 software WHERE id = ?')->execute([$id]);
|
||||
// 顺手删掉它的图标文件,避免 icons/ 下堆孤儿图
|
||||
if (isset($sw['icon_key'])) { fb_icon_delete((string)$sw['icon_key']); }
|
||||
return ['已删除软件「' . $sw['name'] . '」。', 'ok'];
|
||||
}
|
||||
|
||||
@@ -300,7 +479,7 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
||||
if ($editSw > 0) { $esw = fb_software($editSw); }
|
||||
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
|
||||
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()) . '">';
|
||||
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
|
||||
echo ' <div class="row2">';
|
||||
@@ -315,10 +494,48 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
||||
. '<input type="text" name="note" maxlength="120" value="'
|
||||
. e($esw ? (string)($esw['note'] === null ? '' : $esw['note']) : '') . '"></label>';
|
||||
|
||||
// 图标:一个字符即可(emoji / 汉字 / 字母),配合主题色一起区分软件
|
||||
/* ---- 图标 ----
|
||||
* 两种方式并存,图片优先:
|
||||
* ① 上传一张图片(存 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>'
|
||||
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) {
|
||||
@@ -326,32 +543,75 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
||||
}
|
||||
echo '</span></label>';
|
||||
|
||||
// 下载地址:状态改成「已解决」时,邮件里会给用户一个去下新版的按钮
|
||||
echo ' <label class="field"><span>下载地址<i class="hint">可留空。填了之后「已解决」的通知邮件里会多一个「下载最新版」按钮</i></span>'
|
||||
. '<input type="url" name="download_url" maxlength="300" value="'
|
||||
. e($esw ? (string)(isset($esw['download_url']) ? $esw['download_url'] : '') : '')
|
||||
. '" placeholder="https://..."></label>';
|
||||
/* ── 「下载地址」字段已删除 ──
|
||||
* 用户 2026-11-04:「有个什么下载链接,这个东西不要,要它干什么?」
|
||||
*
|
||||
* 它原本的唯一用途:当反馈状态改成「已解决」时,把通知邮件里的按钮
|
||||
* 从「查看详情」换成「下载最新版本」。属于可有可无的小功能,
|
||||
* 却要求管理员为每个软件再维护一条 URL —— 收益远小于维护成本。
|
||||
*
|
||||
* ⚠️ 数据库的 download_url 列**保留不删**:
|
||||
* ① 删列会让历史数据不可逆丢失;
|
||||
* ② 万一以后想恢复,不用再走一次迁移。
|
||||
* 只是前台表单不再暴露,mailer 里也已改为始终用「查看详情」。 */
|
||||
|
||||
// 主题色:前台按软件换色,用短链接进不同软件一眼就能区分
|
||||
$curColor = $esw ? fb_color_norm((string)$esw['color']) : '';
|
||||
$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="color-row">';
|
||||
echo ' <input type="color" name="color" value="' . e($sugColor) . '" class="color-input">';
|
||||
echo ' <div class="color-presets">';
|
||||
foreach ($presets as $pc) {
|
||||
$on = strcasecmp($pc, $sugColor) === 0 ? ' on' : '';
|
||||
echo '<button type="button" class="color-preset' . $on . '" data-color="' . e($pc)
|
||||
. '" style="background:' . e($pc) . '" title="' . e($pc) . '"></button>';
|
||||
}
|
||||
echo ' </div>';
|
||||
echo ' <div class="color-row" data-colorpicker>';
|
||||
echo ' <button type="button" class="color-trigger" data-color-trigger'
|
||||
. ' style="--cur:' . e($sugColor) . '" title="点击选择颜色">'
|
||||
. ' <span class="color-trigger-chip"></span>'
|
||||
. ' <span class="color-trigger-hex">' . e(strtoupper($sugColor)) . '</span>'
|
||||
. ' <span class="color-trigger-caret">▾</span>'
|
||||
. ' </button>';
|
||||
echo ' <input type="hidden" name="color" value="' . e($sugColor) . '" data-color-value>';
|
||||
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>';
|
||||
/* 排序字段:新建和编辑**都要有**。
|
||||
* 之前只在 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) {
|
||||
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> '
|
||||
. '<a class="btn" href="admin.php?tab=taxonomy">取消</a>';
|
||||
} else {
|
||||
|
||||
+114
@@ -149,6 +149,110 @@ function fb_attach_is_local($key) {
|
||||
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 → 本机绝对路径(已做穿越防护) */
|
||||
function fb_local_path_of($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 (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)) {
|
||||
// 本机存储:必须是 local/YYYY/MM/随机名.扩展名
|
||||
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__));
|
||||
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
||||
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
||||
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 17); }
|
||||
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 53); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
||||
+57
-11
@@ -94,17 +94,39 @@ CREATE TABLE IF NOT EXISTS threads (
|
||||
author_name TEXT NOT NULL,
|
||||
author_email TEXT NOT NULL,
|
||||
author_phone TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
status TEXT NOT NULL DEFAULT 'hidden',
|
||||
reply_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
ip_hash TEXT
|
||||
ip_hash TEXT,
|
||||
track_token TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
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_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'
|
||||
CREATE TABLE IF NOT EXISTS replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -325,12 +347,18 @@ SQL
|
||||
$pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
/* ---- 软件增加图标(多软件并存时靠「图标 + 名字 + 颜色」三重区分)----
|
||||
* 存一个字符即可:优先 emoji(日历 📅 / 锁 🔐 / 密码 🔑),也接受单个汉字或字母。
|
||||
* 不存图片路径,避免再管一套上传 / 存储 / 权限 —— 邮件里也用同一个字符渲染,
|
||||
* 前端与邮件天然一致,不会出现「页面有图标、邮件没有」的割裂。 */
|
||||
* 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 ''");
|
||||
@@ -393,18 +421,36 @@ SQL
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 状态:空表时灌 5 个(4 个原有 + 新增「不予考虑」)
|
||||
// 3) 状态:空表时灌全部状态
|
||||
if ((int)$pdo->query('SELECT COUNT(*) FROM statuses')->fetchColumn() === 0) {
|
||||
$rows = [
|
||||
['open', '待处理', '您提交的反馈已收到,我们正在处理中。', 0],
|
||||
['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 1],
|
||||
['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 2],
|
||||
['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 3],
|
||||
['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢你的建议。', 4],
|
||||
// key_name 名称 给用户看的说明(进邮件) 排序
|
||||
['hidden', '未公开', '您的反馈已提交,正在等待管理员查看。', 0],
|
||||
['open', '待处理', '您的反馈已公开,我们已收到,会尽快安排处理。', 1],
|
||||
['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 2],
|
||||
['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 3],
|
||||
['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 4],
|
||||
['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢您的建议。', 5],
|
||||
];
|
||||
$st = $pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled)
|
||||
VALUES(?,?,?,?,1)');
|
||||
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 的,重复执行无副作用)
|
||||
|
||||
+141
-10
@@ -40,11 +40,24 @@ function fb_layout_head($title, array $opt = []) {
|
||||
<link rel="manifest" href="assets/site.webmanifest">
|
||||
<link rel="stylesheet" href="assets/style.css?v=<?php echo (int)FB_ASSET_V; ?>">
|
||||
<?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{
|
||||
--brand:<?php echo e($swColors[0]); ?>;
|
||||
--brand-600:<?php echo e($swColors[1]); ?>;
|
||||
--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>
|
||||
<?php endif; ?>
|
||||
</head>
|
||||
@@ -52,14 +65,78 @@ function fb_layout_head($title, array $opt = []) {
|
||||
<header class="topbar">
|
||||
<div class="wrap">
|
||||
<?php
|
||||
/* 顶栏品牌位:当前在哪個软件下,就显示那个软件的「图标 + 名字」,
|
||||
* 而不是一直挂着站点大标题 —— 多软件共用一套反馈系统,
|
||||
* 用户要一眼看清「我现在是在给哪个软件提问题」。 */
|
||||
/* 顶栏品牌位:当前在哪个软件下,就显示「图标 + XX 反馈系统」。
|
||||
* 这是页面唯一的主标题 —— 多软件共用一套反馈系统,
|
||||
* 用户要一眼看清「我现在是在给哪个软件的反馈系统提问题」。
|
||||
* 内容区不再重复一遍同样的文字,只保留「图标 + 软件名」。
|
||||
*
|
||||
* 字号刻意比「选择软件」那个标签大一号左右(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): ?>
|
||||
<a class="brand-mini" href="./" title="<?php echo e($site); ?>">
|
||||
<?php echo fb_software_badge($curSw, 'md'); ?>
|
||||
<?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); ?>">
|
||||
@@ -68,9 +145,63 @@ function fb_layout_head($title, array $opt = []) {
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<div class="spacer"></div>
|
||||
<a class="navlink hide-sm" href="./">全部问题</a>
|
||||
<?php if (fb_admin_logged_in()): ?><a class="navlink" href="admin.php">管理后台</a><?php endif; ?>
|
||||
<a class="btn-mini" href="new.php">我要反馈</a>
|
||||
<?php
|
||||
/* 🔴 后台模式:这三样全都不渲染。
|
||||
* 用户 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>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
@@ -89,7 +220,7 @@ function fb_layout_foot() {
|
||||
?>
|
||||
<footer class="foot">
|
||||
<div class="wrap">
|
||||
<p class="muted">本站仅保存你提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
|
||||
<p class="muted">本站仅保存您提交的昵称、邮箱与内容,用于问题跟进;页面不会公开展示邮箱与手机号。</p>
|
||||
<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 class="dot">·</span><?php endif; ?>
|
||||
|
||||
+172
-84
@@ -128,8 +128,8 @@ function fb_smtp_send(array $s, $to, $subject, $html) {
|
||||
* echo fb_mail_layout([
|
||||
* 'preheader' => '预览摘要(收件箱列表里显示的那行灰字)',
|
||||
* 'badge' => ['text' => '处理中', 'color' => '#0a6cf5'],
|
||||
* 'title' => '你的反馈已被受理',
|
||||
* 'intro' => '老杨,你好:',
|
||||
* 'title' => '您的反馈已被受理',
|
||||
* 'intro' => '老杨,您好:',
|
||||
* 'blocks' => [ ... ], // 内容块,见下
|
||||
* 'button' => ['text' => '查看详情', 'url' => 'https://...'],
|
||||
* 'footer' => '此邮件由系统自动发送。',
|
||||
@@ -191,13 +191,27 @@ if (!function_exists('fb_mail_sw_header')) {
|
||||
$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;height:30px;background:' . fb_mail_esc($c) . ';'
|
||||
. 'border-radius:8px;text-align:center;vertical-align:middle;'
|
||||
. 'font-size:15px;line-height:30px;color:#ffffff;font-weight:700">'
|
||||
. '<span style="display:inline-block;line-height:30px">' . fb_mail_esc($icon) . '</span>'
|
||||
. '</td>'
|
||||
. '<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>'
|
||||
@@ -353,6 +367,36 @@ if (!function_exists('fb_mail_layout')) {
|
||||
. 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 = '';
|
||||
if ($intro !== '') {
|
||||
@@ -406,8 +450,10 @@ if (!function_exists('fb_mail_layout')) {
|
||||
. 'color:#1b2430">' . fb_mail_esc($title) . '</h1></td></tr>'
|
||||
|
||||
// 正文
|
||||
// 顺序:按钮 → 私人链接提醒 → 纯文本兜底链接。
|
||||
// 提醒要贴在按钮旁边(同一屏可见),不能埋到邮件最底部。
|
||||
. '<tr><td style="padding:16px 32px 8px">'
|
||||
. $introHtml . $body . $btnHtml . $linkHtml
|
||||
. $introHtml . $body . $btnHtml . $privNoteHtml . $linkHtml
|
||||
. '</td></tr>'
|
||||
|
||||
// 页脚
|
||||
@@ -417,7 +463,7 @@ if (!function_exists('fb_mail_layout')) {
|
||||
. ($recipient !== ''
|
||||
? '<p style="margin:0;font-size:11.5px;line-height:1.6;color:#b3b9c2">'
|
||||
. '这封邮件发给了 ' . fb_mail_esc($recipient) . '。'
|
||||
. '如果这不是你,忽略即可。</p>' : '')
|
||||
. '如果这不是您,忽略即可。</p>' : '')
|
||||
. '<p style="margin:8px 0 0;font-size:11.5px;color:#b3b9c2">'
|
||||
. '<a href="' . fb_mail_esc($base) . '" target="_blank"'
|
||||
. ' style="color:#9aa2ad;text-decoration:none">'
|
||||
@@ -431,7 +477,8 @@ if (!function_exists('fb_mail_layout')) {
|
||||
}
|
||||
function fb_notify_html($thread, $reply, $isAdmin) {
|
||||
$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');
|
||||
$name = (string)$thread['author_name'];
|
||||
$title = (string)$thread['title'];
|
||||
@@ -442,8 +489,8 @@ function fb_notify_html($thread, $reply, $isAdmin) {
|
||||
$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">'
|
||||
. '<p>' . $safeName . ',你好:</p>'
|
||||
. '<p>你在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>'
|
||||
. '<p>' . $safeName . ',您好:</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">'
|
||||
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>'
|
||||
. $rbody
|
||||
@@ -490,19 +537,19 @@ function fb_notify_closed_html(array $thread, string $note) {
|
||||
$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">'
|
||||
. '<p>' . $name . ',你好:</p>'
|
||||
. '<p>感谢你反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
|
||||
. '<p>' . $name . ',您好:</p>'
|
||||
. '<p>感谢您反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
|
||||
. '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>'
|
||||
. '<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>'
|
||||
. $noteH
|
||||
. '</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">'
|
||||
. $body
|
||||
. '</div>'
|
||||
. ($mail !== '' ? '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进;'
|
||||
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
|
||||
. ($mail !== '' ? '<p>如果上面的说明没能解决您的疑问,可以直接回复本邮件,我们会继续跟进;'
|
||||
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决您的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
|
||||
. '<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>'
|
||||
. '</div>';
|
||||
@@ -555,7 +602,7 @@ function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
|
||||
* 邮件通知事件(工单 #2)
|
||||
*
|
||||
* 全部事件:
|
||||
* reply 有人回复了你的反馈 → 发帖人
|
||||
* reply 有人回复了您的反馈 → 发帖人
|
||||
* status 处理状态变了 → 发帖人
|
||||
* approve 审核通过、已公开 → 发帖人
|
||||
* hide 转为不公开 → 发帖人
|
||||
@@ -690,12 +737,27 @@ if (!function_exists('fb_notify_event')) {
|
||||
return [false, $why];
|
||||
}
|
||||
|
||||
// ---- 3. 自己通知自己就别发了 ----
|
||||
if (!$isAdminNotice && !empty($extra['is_admin']) === false
|
||||
&& !empty($extra['reply']['author_email'])) {
|
||||
if (strcasecmp(trim((string)$extra['reply']['author_email']), $to) === 0) {
|
||||
$log($to, 0, '自己回复自己,不发');
|
||||
return [false, '自己回复自己,不发'];
|
||||
/* ---- 3. 自己通知自己就别发了 ----
|
||||
*
|
||||
* 用户 2026-11-03:「他自己回复他自己的反馈,不要再给他发邮件了 ——
|
||||
* 他自己说的,他发给他自己,不是很奇怪吗?但如果是别人回复他的,
|
||||
* 就可以给他发邮件。」
|
||||
*
|
||||
* 覆盖三种「其实是同一个人」的情形:
|
||||
* ① 提交人自己回复(带 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, '本人回复自己的反馈,不发信'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,8 +818,16 @@ if (!function_exists('fb_notify_log2')) {
|
||||
}
|
||||
}
|
||||
if (!function_exists('fb_notify_thread_url')) {
|
||||
/**
|
||||
* 邮件里给提问人的链接 —— **一律带 token**,不带 id 裸链。
|
||||
*
|
||||
* 为什么:邮件只发给提问人自己,而 token 只有他和我们(库里)知道。
|
||||
* 带上它,即使这条反馈还没审核通过、甚至最终被关闭,他点进去也能看到
|
||||
* 完整详情与进度。早期给的是 thread.php?id=N,未公开时点进去只看到
|
||||
* 「不存在」或「等待审核」,等于邮件里的链接是废的。
|
||||
*/
|
||||
function fb_notify_thread_url(array $thread) {
|
||||
return fb_base_url() . '/thread.php?id=' . (int)$thread['id'];
|
||||
return fb_thread_link($thread, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,35 +842,44 @@ if (!function_exists('fb_notify_status_meta')) {
|
||||
*/
|
||||
function fb_notify_status_meta($key) {
|
||||
$map = [
|
||||
/* 「未公开」:用户刚提交时就是这个状态。
|
||||
* 一般不单独发信(提交回执已经说了「等待审核」),
|
||||
* 但万一从别的状态退回来,也要有话可说,不能落到兜底文案。 */
|
||||
'hidden' => [
|
||||
'badge' => '未公开', 'color' => '#6b7684', 'tone' => 'muted',
|
||||
'title' => '您的反馈已提交,正在等待审核',
|
||||
'intro' => '管理员会尽快查看您的反馈,确认后才会公开显示。',
|
||||
'tip' => '审核结果我们会通过邮件通知您,无需反复回来查看。',
|
||||
],
|
||||
'open' => [
|
||||
'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted',
|
||||
'title' => '你的反馈已收到,正在等待处理',
|
||||
'intro' => '我们已经收到你的反馈,会尽快安排处理。',
|
||||
'title' => '您的反馈已收到,正在等待处理',
|
||||
'intro' => '我们已经收到您的反馈,会尽快安排处理。',
|
||||
'tip' => null,
|
||||
],
|
||||
'doing' => [
|
||||
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
|
||||
'title' => '你反馈的问题正在处理中',
|
||||
'intro' => '我们已经开始跟进你反馈的问题,有进展会第一时间告诉你。',
|
||||
'tip' => '感谢你的耐心等待,暂时不需要你做任何操作。',
|
||||
'title' => '您反馈的问题正在处理中',
|
||||
'intro' => '我们已经开始跟进您反馈的问题,有进展会第一时间告诉您。',
|
||||
'tip' => '感谢您的耐心等待,暂时不需要您做任何操作。',
|
||||
],
|
||||
'resolved' => [
|
||||
'badge' => '已完成', 'color' => '#1e7b3c', 'tone' => 'ok',
|
||||
'title' => '你反馈的问题已完成并上线',
|
||||
'intro' => '好消息:你反馈的内容我们已经处理完成,并已发布上线。',
|
||||
'title' => '您反馈的问题已完成并上线',
|
||||
'intro' => '好消息:您反馈的内容我们已经处理完成,并已发布上线。',
|
||||
'tip' => '建议更新到最新版本后试一下。如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
|
||||
],
|
||||
'closed' => [
|
||||
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
|
||||
'title' => '你反馈的问题已关闭',
|
||||
'intro' => '你反馈的问题已经关闭。',
|
||||
'title' => '您反馈的问题已关闭',
|
||||
'intro' => '您反馈的问题已经关闭。',
|
||||
'tip' => null,
|
||||
],
|
||||
'wontfix' => [
|
||||
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
|
||||
'title' => '关于你反馈的问题,我们的处理意见',
|
||||
'intro' => '感谢你的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
|
||||
'tip' => '如果你认为这个判断有误,或者有补充信息,直接在本页回复即可。',
|
||||
'title' => '关于您反馈的问题,我们的处理意见',
|
||||
'intro' => '感谢您的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
|
||||
'tip' => '如果您认为这个判断有误,或者有补充信息,直接在本页回复即可。',
|
||||
],
|
||||
];
|
||||
return isset($map[$key]) ? $map[$key] : $map['open'];
|
||||
@@ -857,15 +936,15 @@ if (!function_exists('fb_notify_render')) {
|
||||
|
||||
/* 像博客评论通知那样带上上下文:
|
||||
* 用户可能同时提过好几条反馈,只给「回复内容」根本想不起来是哪一条。
|
||||
* 所以固定三段:① 你反馈了什么 ② 谁回复了什么 ③ 去哪里继续。 */
|
||||
* 所以固定三段:① 您反馈了什么 ② 谁回复了什么 ③ 去哪里继续。 */
|
||||
$origBody = (string)(isset($thread['body']) ? $thread['body'] : '');
|
||||
$blocks = [
|
||||
['type' => 'text', 'text' => ($swName !== '' ? '你在「' . $swName . '」提交的反馈' : '你提交的反馈')
|
||||
['type' => 'text', 'text' => ($swName !== '' ? '您在「' . $swName . '」提交的反馈' : '您提交的反馈')
|
||||
. '有新的回复。'],
|
||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||
];
|
||||
if (trim($origBody) !== '') {
|
||||
$blocks[] = ['type' => 'quote', 'who' => '你当时反馈的内容:', 'text' => fb_excerpt($origBody, 220)];
|
||||
$blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => fb_excerpt($origBody, 220)];
|
||||
}
|
||||
$blocks[] = ['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody];
|
||||
if ($isAdmin) {
|
||||
@@ -877,11 +956,12 @@ if (!function_exists('fb_notify_render')) {
|
||||
'software' => $sw,
|
||||
'preheader' => $rname . ':' . fb_excerpt($rbody, 60),
|
||||
'badge' => ['text' => $isOff, 'color' => $isAdmin ? fb_mail_brand() : '#5c6572'],
|
||||
'title' => $isAdmin ? '官方回复了你的反馈' : '有人回复了你的反馈',
|
||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||
'title' => $isAdmin ? '官方回复了您的反馈' : '有人回复了您的反馈',
|
||||
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||
'blocks' => $blocks,
|
||||
'button' => ['text' => '查看并回复', 'url' => $url],
|
||||
'link' => $url,
|
||||
'personal' => true, // 发给提交人:带 token 的私人链接,需提醒勿外传
|
||||
'recipient' => $to,
|
||||
];
|
||||
}
|
||||
@@ -917,51 +997,50 @@ if (!function_exists('fb_notify_render')) {
|
||||
$blocks[] = ['type' => 'callout', 'tone' => $meta['tone'], 'text' => $meta['tip']];
|
||||
}
|
||||
|
||||
/* 「已解决」要真的推用户一把去验证:
|
||||
* 软件配了下载地址就给一个下载按钮 —— 只说「修好了」用户不知道去哪拿新版本。 */
|
||||
$dlUrl = $sw ? trim((string)(isset($sw['download_url']) ? $sw['download_url'] : '')) : '';
|
||||
/* 「已解决」时给用户一个按钮去验证。
|
||||
*
|
||||
* ⚠️ 2026-11-04:后台的「下载地址」字段已删除,
|
||||
* 所以这里不再判断 $sw['download_url'] —— 那个值只可能是历史遗留。
|
||||
* 按钮统一为「查看详情」,不再有「下载最新版本」分支。
|
||||
* 想要恢复的话:后台加回字段 + 这里加回判断即可。 */
|
||||
$btnText = '查看详情';
|
||||
$btnUrl = $url;
|
||||
if ($newKey === 'resolved' && $dlUrl !== '') {
|
||||
$blocks[] = ['type' => 'callout', 'tone' => 'ok',
|
||||
'text' => '新版本已经发布,建议下载最新版后验证一下。'];
|
||||
$btnText = '下载最新版本';
|
||||
$btnUrl = $dlUrl;
|
||||
}
|
||||
|
||||
$layout = [
|
||||
'software' => $sw,
|
||||
'preheader' => '状态更新为「' . $label . '」',
|
||||
'badge' => ['text' => $meta['badge'], 'color' => $meta['color']],
|
||||
'title' => $meta['title'],
|
||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||
'blocks' => $blocks,
|
||||
'button' => ['text' => $btnText, 'url' => $btnUrl],
|
||||
// 下载按钮之外,始终保留「查看这条反馈」的原文链接
|
||||
'link' => ($btnUrl === $url) ? $url
|
||||
: ('下载地址:' . $dlUrl . "\n" . '查看反馈:' . $url),
|
||||
'personal' => true, // 发给提交人
|
||||
'recipient' => $to,
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------- 3. 审核通过 ---------------- */
|
||||
elseif ($reason === 'approve') {
|
||||
$subject = $pfx . '你的反馈已发布:' . $short;
|
||||
$subject = $pfx . '您的反馈已发布:' . $short;
|
||||
$layout = [
|
||||
'software' => $sw,
|
||||
'preheader' => '你的反馈已经公开可见',
|
||||
'preheader' => '您的反馈已经公开可见',
|
||||
'badge' => ['text' => '已发布', 'color' => '#1e7b3c'],
|
||||
'title' => '你的反馈已通过审核并公开',
|
||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||
'title' => '您的反馈已通过审核并公开',
|
||||
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||
'blocks' => [
|
||||
['type' => 'text', 'text' => '你提交的反馈已通过审核,现在可以在'
|
||||
['type' => 'text', 'text' => '您提交的反馈已通过审核,现在可以在'
|
||||
. ($swName !== '' ? '「' . $swName . '」的' : '') . '公开列表中看到了。'],
|
||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||
['type' => 'callout', 'tone' => 'info',
|
||||
'text' => '后续的处理进展与回复,我们会通过邮件通知你。'],
|
||||
'text' => '后续的处理进展与回复,我们会通过邮件通知您。'],
|
||||
],
|
||||
'button' => ['text' => '查看并跟踪这条反馈', 'url' => $url],
|
||||
'link' => $url,
|
||||
'personal' => true, // 发给提交人
|
||||
'recipient' => $to,
|
||||
];
|
||||
}
|
||||
@@ -969,27 +1048,28 @@ if (!function_exists('fb_notify_render')) {
|
||||
/* ---------------- 4. 转为不公开 ---------------- */
|
||||
elseif ($reason === 'hide') {
|
||||
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
|
||||
$subject = $pfx . '你的反馈已转为不公开:' . $short;
|
||||
$subject = $pfx . '您的反馈已转为不公开:' . $short;
|
||||
|
||||
$blocks = [
|
||||
['type' => 'text', 'text' => '你提交的反馈已从公开列表中撤下,不再对其他人可见。'],
|
||||
['type' => 'text', 'text' => '您提交的反馈已从公开列表中撤下,不再对其他人可见。'],
|
||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||
];
|
||||
if ($note !== '') {
|
||||
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
||||
}
|
||||
$blocks[] = ['type' => 'callout', 'tone' => 'muted',
|
||||
'text' => '这条反馈本身仍然保留,你依然可以通过原链接访问和处理进展。'];
|
||||
'text' => '这条反馈本身仍然保留,您依然可以通过原链接访问和处理进展。'];
|
||||
|
||||
$layout = [
|
||||
'software' => $sw,
|
||||
'preheader' => '这条反馈已不对其他人可见',
|
||||
'badge' => ['text' => '不公开', 'color' => '#5c6572'],
|
||||
'title' => '你的反馈已转为不公开',
|
||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||
'title' => '您的反馈已转为不公开',
|
||||
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||
'blocks' => $blocks,
|
||||
'button' => ['text' => '查看这条反馈', 'url' => $url],
|
||||
'link' => $url,
|
||||
'personal' => true, // 发给提交人(转为不公开)
|
||||
'recipient' => $to,
|
||||
];
|
||||
}
|
||||
@@ -1000,32 +1080,38 @@ if (!function_exists('fb_notify_render')) {
|
||||
$body = (string)$thread['body'];
|
||||
$mail = trim((string)fb_cfg('admin_email', ''));
|
||||
|
||||
$subject = $pfx . '关于你反馈的问题《' . $short . '》';
|
||||
$subject = $pfx . '关于您反馈的问题《' . $short . '》';
|
||||
|
||||
$blocks = [
|
||||
['type' => 'text',
|
||||
'text' => '感谢你的反馈。我们已经核实过,这个情况并不是'
|
||||
'text' => '感谢您的反馈。我们已经核实过,这个情况并不是'
|
||||
. ($swName !== '' ? '「' . $swName . '」' : '应用') . '本身的问题,因此这条反馈已关闭(不会公开显示)。'],
|
||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||
];
|
||||
if ($note !== '') {
|
||||
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
|
||||
}
|
||||
$blocks[] = ['type' => 'quote', 'who' => '你当时反馈的内容:', 'text' => $body];
|
||||
$blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => $body];
|
||||
$blocks[] = [
|
||||
'type' => 'callout', 'tone' => 'info',
|
||||
'text' => ($mail !== ''
|
||||
? '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
|
||||
: '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进。'),
|
||||
? '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
|
||||
: '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进。'),
|
||||
];
|
||||
/* 这条已经「已关闭 + 不公开」,不带 token 的链接打不开 ——
|
||||
* 必须给带 token 的私人链接,用户才能回来看上下文、继续补充。
|
||||
* 注意:button 是 layout 层属性,不是 block 类型。 */
|
||||
$closeBtnUrl = fb_thread_link($thread, true);
|
||||
|
||||
$layout = [
|
||||
'software' => $sw,
|
||||
'preheader' => '关于你反馈的问题,我们的说明',
|
||||
'preheader' => '关于您反馈的问题,我们的说明',
|
||||
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
|
||||
'title' => '关于你反馈的问题,我们的说明',
|
||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||
'title' => '关于您反馈的问题,我们的说明',
|
||||
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||
'blocks' => $blocks,
|
||||
'button' => ['text' => '查看这条反馈', 'url' => $closeBtnUrl],
|
||||
'personal' => true, // 发给提交人(关闭说明)
|
||||
'recipient' => $to,
|
||||
];
|
||||
}
|
||||
@@ -1064,7 +1150,7 @@ if (!function_exists('fb_notify_render')) {
|
||||
'preheader' => $name . ':' . fb_excerpt($body, 60),
|
||||
'badge' => ['text' => '新反馈', 'color' => '#b4553c'],
|
||||
'title' => ($swName !== '' ? $swName . ' 收到一条新反馈' : '收到一条新反馈'),
|
||||
'intro' => '你好:',
|
||||
'intro' => '您好:',
|
||||
'blocks' => $blocks,
|
||||
'button' => ['text' => '去后台处理这条反馈', 'url' => $adminUrl],
|
||||
'link' => $adminUrl,
|
||||
@@ -1077,28 +1163,29 @@ if (!function_exists('fb_notify_render')) {
|
||||
elseif ($reason === 'receipt') {
|
||||
$body = (string)$thread['body'];
|
||||
|
||||
$subject = $pfx . '已收到你的反馈:' . $short;
|
||||
$subject = $pfx . '已收到您的反馈:' . $short;
|
||||
|
||||
$blocks = [
|
||||
['type' => 'text', 'text' => '我们已收到你在'
|
||||
['type' => 'text', 'text' => '我们已收到您在'
|
||||
. ($swName !== '' ? '「' . $swName . '」' : '本站')
|
||||
. '提交的反馈,感谢你的反馈!'],
|
||||
. '提交的反馈,感谢您的反馈!'],
|
||||
['type' => 'field', 'rows' => ['反馈标题' => $title]],
|
||||
['type' => 'quote', 'who' => '你提交的内容:', 'text' => fb_excerpt($body, 400)],
|
||||
['type' => 'quote', 'who' => '您提交的内容:', 'text' => fb_excerpt($body, 400)],
|
||||
['type' => 'callout', 'tone' => 'info',
|
||||
'text' => '这条反馈正在等待管理员审核,请耐心等待。审核通过后会在列表中公开显示。'],
|
||||
['type' => 'text', 'text' => '审核结果与后续处理进展,我们都会通过邮件通知你,无需反复回来查看。'],
|
||||
['type' => 'text', 'text' => '审核结果与后续处理进展,我们都会通过邮件通知您,无需反复回来查看。'],
|
||||
];
|
||||
|
||||
$layout = [
|
||||
'software' => $sw,
|
||||
'preheader' => '已收到你的反馈,正在等待审核',
|
||||
'preheader' => '已收到您的反馈,正在等待审核',
|
||||
'badge' => ['text' => '已收到', 'color' => fb_mail_brand()],
|
||||
'title' => '已收到你的反馈',
|
||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||
'title' => '已收到您的反馈',
|
||||
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||
'blocks' => $blocks,
|
||||
'button' => ['text' => '查看这条反馈', 'url' => $url],
|
||||
'link' => $url,
|
||||
'personal' => true, // 发给提交人(回执)
|
||||
'recipient' => $to,
|
||||
];
|
||||
}
|
||||
@@ -1108,12 +1195,13 @@ if (!function_exists('fb_notify_render')) {
|
||||
$subject = $pfx . $short;
|
||||
$layout = [
|
||||
'software' => $sw,
|
||||
'preheader' => '你有一条新的通知',
|
||||
'title' => '你有一条新的通知',
|
||||
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
|
||||
'blocks' => [['type' => 'text', 'text' => '你关注的反馈有更新。']],
|
||||
'preheader' => '您有一条新的通知',
|
||||
'title' => '您有一条新的通知',
|
||||
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
|
||||
'blocks' => [['type' => 'text', 'text' => '您关注的反馈有更新。']],
|
||||
'button' => ['text' => '查看详情', 'url' => $url],
|
||||
'link' => $url,
|
||||
'personal' => true, // 兜底同样是发给提交人的
|
||||
'recipient' => $to,
|
||||
];
|
||||
}
|
||||
|
||||
+277
-15
@@ -254,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) {
|
||||
$palette = ['#0057B8', '#2E7D5B', '#B4553C', '#6A4C93', '#0E7C86', '#A02C5A', '#8A6D1F', '#3A5A8C'];
|
||||
$palette = fb_color_palette();
|
||||
$id = max(0, (int)$softwareId);
|
||||
return $palette[$id % count($palette)];
|
||||
}
|
||||
@@ -323,26 +380,137 @@ function fb_software_icon($software) {
|
||||
* 图标 + 名字的组合标签(HTML)。
|
||||
* 前端所有「这是哪个软件」的地方都用它,保证视觉一致。
|
||||
*
|
||||
* 🔴 不要背景色:图标本身就是一张图/一个 emoji,再垫一个色块反而糊在一起。
|
||||
* 直接「图标 + 文字」并排,识别度最高(用户 2026-11-03 明确要求)。
|
||||
*
|
||||
* @param array $sw 软件行
|
||||
* @param string $size 'sm' | 'md' | 'lg'
|
||||
* @param string $size 'sm' | 'md' | 'lg' | 'brand'(brand = 顶栏主标题)
|
||||
* @param string $label 覆盖显示文字(默认=软件名)。顶栏用它传「XX 反馈系统」。
|
||||
*/
|
||||
function fb_software_badge($software, $size = 'md') {
|
||||
function fb_software_badge($software, $size = 'md', $label = '') {
|
||||
if (!is_array($software) || empty($software['name'])) { return ''; }
|
||||
list($c) = fb_software_colors($software);
|
||||
if ($c === '') { $c = '#0057B8'; }
|
||||
$icon = fb_software_icon($software);
|
||||
$name = (string)$software['name'];
|
||||
$name = $label !== '' ? (string)$label : (string)$software['name'];
|
||||
|
||||
$px = ['sm' => 20, 'md' => 26, 'lg' => 34];
|
||||
$fs = ['sm' => 11, 'md' => 14, 'lg' => 17];
|
||||
$box = isset($px[$size]) ? $px[$size] : 26;
|
||||
$fnt = isset($fs[$size]) ? $fs[$size] : 14;
|
||||
/* 尺寸档位(以「选择软件」标签的 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) . '">'
|
||||
. '<span class="sw-badge-ic" aria-hidden="true" style="width:' . $box . 'px;height:' . $box
|
||||
. 'px;background:' . e($c) . ';font-size:' . $fnt . 'px">' . e($icon) . '</span>'
|
||||
. '<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))];
|
||||
}
|
||||
|
||||
/** 某软件下的分类;不传软件则返回空数组 */
|
||||
@@ -375,7 +543,11 @@ function fb_category_by_slug($softwareId, $slug) {
|
||||
return $r ? $r : null;
|
||||
}
|
||||
|
||||
/** 分类显示名:只给 id 就能拿到名字(列表渲染用) */
|
||||
/** 分类显示名:只给 id 就能拿到名字(列表渲染用)
|
||||
*
|
||||
* ⚠️ 参数必须是**数字 id**。传字符串 slug(如 'bug')会被 (int) 成 0 而返回空串,
|
||||
* 调用方如果不判空就会渲染出一个空标签 —— 这个坑踩过(见 admin.php 全部问题列表)。
|
||||
* 需要按 slug 取名字请用 fb_category_label_by_slug()。 */
|
||||
function fb_category_label($id) {
|
||||
if (!$id) { return ''; }
|
||||
static $cache = [];
|
||||
@@ -387,6 +559,24 @@ function fb_category_label($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) {
|
||||
$sql = 'SELECT * FROM statuses';
|
||||
@@ -521,6 +711,78 @@ function fb_thread_public(array $thread) {
|
||||
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 开源仓库地址(页脚版权链接的默认值,可在后台改) */
|
||||
|
||||
@@ -28,7 +28,11 @@ function fb_media_fail($code, $msg) {
|
||||
|
||||
$key = isset($_GET['k']) ? trim((string)$_GET['k']) : '';
|
||||
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']) : '';
|
||||
// 图片处理串只允许本站自己生成的形态: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/ 目录本身禁止直连。 */
|
||||
if (fb_attach_is_local($key)) {
|
||||
@@ -60,8 +89,10 @@ if (fb_attach_is_local($key)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ---------------- OSS 存储 ---------------- */
|
||||
if (!fb_media_authorized($key)) { fb_media_fail(403, '无权访问该附件'); }
|
||||
/* ---------------- OSS 存储 ----------------
|
||||
* 图标已在上面单独鉴权过(读公开、写受控),到这里就不再走帖子可见性判断;
|
||||
* 普通附件仍然必须过 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, '对象存储未配置'); }
|
||||
|
||||
$range = isset($_SERVER['HTTP_RANGE']) ? (string)$_SERVER['HTTP_RANGE'] : '';
|
||||
|
||||
@@ -8,7 +8,11 @@ $pdo = fb_db();
|
||||
$err = '';
|
||||
$atts = []; // 校验失败时保留已上传的附件
|
||||
|
||||
/* ---- 软件:URL 带 sw 就用它,否则落到默认软件 ---- */
|
||||
/* ---- 软件:URL 带 sw 就用它 ----
|
||||
* 🔴 不再「没带 sw 就默认落到第一个软件」:
|
||||
* 首页现在是介绍页,直接访问 new.php 没有软件上下文,
|
||||
* 默默替他选一个软件会让反馈进错地方、发错通知邮件。
|
||||
* 没有软件时给一句引导,让用户先选软件。 */
|
||||
$swList = fb_softwares(true);
|
||||
$sw = null;
|
||||
$swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : '';
|
||||
@@ -16,8 +20,32 @@ if ($swKey !== '') {
|
||||
$sw = fb_software($swKey);
|
||||
if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; }
|
||||
}
|
||||
if ($sw === null && $swList) { $sw = $swList[0]; }
|
||||
$swId = $sw ? (int)$sw['id'] : 0;
|
||||
if ($sw === null) {
|
||||
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);
|
||||
$catKeys = [];
|
||||
@@ -89,9 +117,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$atts = $vList;
|
||||
} else {
|
||||
$now = time();
|
||||
// 审核:开启后新提交默认不公开(visible=0),管理员通过后才会出现在列表
|
||||
/* 审核:开启后新提交默认不公开(visible=0),管理员通过后才出现在列表。
|
||||
*
|
||||
* 🔴 状态与可见性必须同时设,两者是同一件事的两个侧面:
|
||||
* 未公开 → status='hidden'(未公开),公开 → status='open'(待处理)。
|
||||
* 以前一律写 'open',导致后台「待审核」里全是「待处理」,
|
||||
* 状态根本反映不出"还没公开"这个事实。 */
|
||||
$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([
|
||||
$form['title'], $form['body'],
|
||||
$curCat ? (string)$curCat['slug'] : 'other', // 老列留个值,便于回退与检索
|
||||
@@ -99,7 +136,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$swId > 0 ? $swId : null,
|
||||
$form['name'], $form['email'],
|
||||
$form['phone'] !== '' ? $form['phone'] : null,
|
||||
'open', $visible, $now, $now, fb_ip_hash(),
|
||||
$initStatus, $visible, $now, $now, fb_ip_hash(), $trackToken,
|
||||
]);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
fb_attach_save($pdo, $newId, null, $vList);
|
||||
@@ -132,7 +169,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// 发信失败绝不能让用户提交失败 —— 反馈已经存库了
|
||||
}
|
||||
|
||||
fb_redirect('thread.php?id=' . $newId . '&submitted=1');
|
||||
/* 提交完成 → 原地渲染「已收到」页面,不跳转。
|
||||
*
|
||||
* 用户 2026-11-03:「提交完怎么直接跳转到首页去了?应该给它一个提示,
|
||||
* 说你反馈我们已经收到…里面有一个标志码,是你以后跟踪它的唯一凭据…
|
||||
* 然后下面再列出来其他功能(返回首页、继续提交、其他软件链接、开源链接),
|
||||
* 不能直接跳转,这给人感觉很突兀。」
|
||||
*
|
||||
* 🔴 关键取舍:**不跳转、不用 302**。跳转会让用户失去"我刚提交成功"这一帧,
|
||||
* 而且详情页此刻还是「未公开」状态,跳过去反而像出错了。
|
||||
* 这里直接把成功页渲染出来,把 token 明明白白摆在眼前。 */
|
||||
fb_render_submitted($sw, $newId, $trackToken, $form);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,15 +188,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
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">
|
||||
<h1>我要反馈</h1>
|
||||
<?php if ($sw): ?>
|
||||
<div class="sw-context"><?php echo fb_software_badge($sw, 'md'); ?>
|
||||
<span class="muted small">你正在为这个软件提交反馈</span></div>
|
||||
<span class="muted small">您正在为「<?php echo e(fb_software_site_title($sw)); ?>」提交反馈</span></div>
|
||||
<?php endif; ?>
|
||||
<p class="muted">请描述你遇到的问题或想要的功能。提交后会先进入审核,确认为有效问题后才会公开显示;<b>若经确认不是应用的问题,我们会把结论与说明通过邮件发送给你</b>(请填写常用邮箱)。</p>
|
||||
<p class="muted">请描述您遇到的问题或想要的功能。提交后会先进入审核,确认为有效问题后才会公开显示;<b>若经确认不是应用的问题,我们会把结论与说明通过邮件发送给您</b>(请填写常用邮箱)。</p>
|
||||
|
||||
<?php fb_flash_box($err, 'error'); ?>
|
||||
|
||||
@@ -161,17 +209,8 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
|
||||
|
||||
<?php if ($sw): ?>
|
||||
<input type="hidden" name="software_id" value="<?php echo (int)$sw['id']; ?>">
|
||||
<div class="field">
|
||||
<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 fb_software_badge($one, 'sm'); ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($sw): ?>
|
||||
<input type="hidden" name="software_id" value="<?php echo (int)$sw['id']; ?>">
|
||||
<?php /* 软件切换统一收到顶栏的汉堡菜单里,表单内不再平铺一排 chip ——
|
||||
免得同一个选择在两个地方出现,用户不知道该点哪个。 */ ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($cats): ?>
|
||||
@@ -187,18 +226,18 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
|
||||
|
||||
<label class="field">
|
||||
<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 class="field">
|
||||
<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>
|
||||
|
||||
<div class="row2">
|
||||
<label class="field">
|
||||
<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 class="field">
|
||||
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
|
||||
@@ -229,8 +268,94 @@ fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="muted small">提示:本页面不会公开展示你的邮箱与手机号。请勿在内容中填写密码等敏感信息。可上传截图或录屏帮助说明问题;文件会直接传到对象存储,不占用本站服务器空间。</p>
|
||||
<p class="muted small">提示:本页面不会公开展示您的邮箱与手机号。请勿在内容中填写密码等敏感信息。可上传截图或录屏帮助说明问题;文件会直接传到对象存储,不占用本站服务器空间。</p>
|
||||
</section>
|
||||
|
||||
<?php
|
||||
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();
|
||||
}
|
||||
|
||||
+102
-47
@@ -11,49 +11,69 @@ if ($id <= 0) { fb_redirect('index.php'); }
|
||||
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||||
$st->execute([$id]);
|
||||
$thread = $st->fetch();
|
||||
|
||||
/* 这条帖子所属的软件 —— 「返回问题列表」要回到它的列表,
|
||||
* 而不是回到首页(首页现在是介绍页)。 */
|
||||
$backUrl = fb_software_list_url(
|
||||
$thread && !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null
|
||||
);
|
||||
if (!$thread) {
|
||||
http_response_code(404);
|
||||
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();
|
||||
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');
|
||||
if ($justSubmitted) {
|
||||
fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']);
|
||||
echo '<div class="empty">'
|
||||
. '<h2 style="margin:0 0 10px">提交成功,等待审核</h2>'
|
||||
. '<p>你的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>'
|
||||
. '<p>您的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>'
|
||||
. '<p>· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;<br>'
|
||||
. '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到你留下的邮箱。</p>'
|
||||
. '<p class="muted">审核通常在 1~2 个工作日内完成,请留意邮箱。</p>'
|
||||
. '<p><a class="btn primary" href="index.php">返回问题列表</a></p>'
|
||||
. '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到您留下的邮箱。</p>'
|
||||
. '<p class="muted">审核通常在 1~2 个工作日内完成。'
|
||||
. '我们会发一封邮件给您,里面的链接可以随时查看这条反馈的处理进度。</p>'
|
||||
. '<p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p>'
|
||||
. '</div>';
|
||||
fb_layout_foot();
|
||||
exit;
|
||||
}
|
||||
http_response_code(404);
|
||||
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();
|
||||
exit;
|
||||
}
|
||||
|
||||
$statuses = fb_status_keys();
|
||||
|
||||
$err = '';
|
||||
$atts = [];
|
||||
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
|
||||
|
||||
/* ---------------- 处理回复提交 ---------------- */
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// 审核中(未公开)的问题不允许回复
|
||||
if (!fb_thread_public($thread)) {
|
||||
/* 未公开的帖子,只有带对 token 的提交人(或管理员)能回复。
|
||||
* 别人连看都看不到,自然也不该能回。 */
|
||||
if (!fb_thread_public($thread) && !$isOwnerView) {
|
||||
http_response_code(403);
|
||||
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();
|
||||
exit;
|
||||
}
|
||||
@@ -61,14 +81,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$form['name'] = trim(isset($_POST['name']) ? (string)$_POST['name'] : '');
|
||||
$form['email'] = trim(isset($_POST['email']) ? (string)$_POST['email'] : '');
|
||||
$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'] : '';
|
||||
$adminPw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
|
||||
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
|
||||
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
|
||||
|
||||
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'] : '')) {
|
||||
$err = '会话已过期,请重新提交。';
|
||||
@@ -107,18 +140,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$replyId = (int)$pdo->lastInsertId();
|
||||
fb_attach_save($pdo, $id, $replyId, $vList);
|
||||
|
||||
// 更新主题:回复数 +1、活跃时间;管理员可同时调整状态
|
||||
$newStatus = null;
|
||||
if ($isAdmin && isset($_POST['status']) && isset($statuses[(string)$_POST['status']])) {
|
||||
$newStatus = (string)$_POST['status'];
|
||||
}
|
||||
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 {
|
||||
/* 更新主题:回复数 +1、活跃时间。
|
||||
* 回复框不再承担改状态的职责 —— 状态流转在后台详情页那排按钮里做。 */
|
||||
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?');
|
||||
$upd->execute([$now, $id]);
|
||||
}
|
||||
fb_rate_hit('reply');
|
||||
|
||||
// 发送通知邮件给发帖人
|
||||
@@ -131,7 +156,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
];
|
||||
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]);
|
||||
?>
|
||||
|
||||
<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">
|
||||
<div class="item-top">
|
||||
<?php
|
||||
$thSw = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
|
||||
$thCat = !empty($thread['category_id']) ? fb_category((int)$thread['category_id']) : null;
|
||||
?>
|
||||
<?php if ($thSw): ?><span class="badge sw"><?php echo fb_software_badge($thSw, 'sm'); ?></span><?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>
|
||||
<?php /* 标题在最上面、顶头开始;软件名/分类/状态另起一行放下面。
|
||||
用户 2026-11-03:这三个标签挤在标题前面,把标题推到右边很难看。
|
||||
⚠️ 顺序是「行1 标题 → 行2 标签 → 行3 作者信息」,别把标签挪回前面。 */ ?>
|
||||
<div class="subject-head">
|
||||
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
|
||||
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>
|
||||
</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">
|
||||
<span class="who"><?php echo e($thread['author_name']); ?></span>
|
||||
<span class="dot">·</span>
|
||||
@@ -206,8 +250,11 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
||||
<section class="reply-form">
|
||||
<h2>我要回复</h2>
|
||||
<?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="t" value="<?php echo e($isOwnerView ? $hasToken : ''); ?>">
|
||||
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
|
||||
<input type="hidden" name="t0" value="<?php echo time(); ?>">
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
@@ -215,19 +262,41 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
||||
</div>
|
||||
|
||||
<div class="row2">
|
||||
<?php if ($isOwnerView): ?>
|
||||
<?php /* 带 token 进来 = 已经证明是提交人本人。
|
||||
*
|
||||
* 🔴 昵称/邮箱在这里**只读**,理由:
|
||||
* 1. token 就是身份凭证,再让他在表单里声明一次身份,自相矛盾;
|
||||
* 2. 邮箱是唯一能联系到提交人的途径。如果能随手改,
|
||||
* 谁拿到这条链接,填个自己的邮箱,后续所有通知就归他了 ——
|
||||
* 等于把这条反馈「过户」给别人,我们再也找不到原作者。
|
||||
* 要改联系方式,是另一件事(见下方说明),不该藏在回复框里顺手改掉。 */ ?>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<label class="field">
|
||||
<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>
|
||||
|
||||
<?php fb_uploaders($atts); ?>
|
||||
@@ -243,20 +312,6 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
||||
</label>
|
||||
</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">
|
||||
<button class="btn primary" type="submit">提交回复</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user