## 1. ECS RAM 角色改为自动探测 原先要用户去阿里云控制台查「实例 RAM 角色名」再手填,纯属多余 —— 机器自己知道答案。 新增 fb_oss_detect_instance_role():读元数据 GET /latest/meta-data/ram/security-credentials/(末尾带斜杠)拿到角色名列表。 - 兼容单行 / 多行 / JSON 数组三种返回 - 未配置角色时 fb_oss_instance_creds() 自动探测,不再报「未配置」 - 后台改成只读展示「已绑定:xxx」,手填入口收进「高级」折叠里 - 保存时留空 = 自动探测后写入,不存空值 - 探测失败给出可操作原因(没绑角色 / 不在 ECS 上 / 元数据被禁用) ## 2. 邮件配置界面(原先完全缺失) lib/mailer.php 有完整 SMTP 实现,但后台一个输入框都没有, 只能改 config.php —— 用户装完根本找不到地方配。 根因有两层: - fb_setting_keys() 白名单里没有任何 smtp.* / notify.* 键, 就算加了表单,fb_set_setting() 也会把值全部丢弃 - 设置页只有「站点信息」「附件存储」两个区块 补上:白名单 15 个键 + 后台「邮件通知」区块 - SMTP 服务器/端口/加密方式(SSL/STARTTLS/不加密) - 登录账号/密码(留空不修改,避免每次重填) - 发件地址/显示名、超时、SSL 校验 - 通知规则:回复通知、每次回复都通知、新反馈通知管理员、发信冷却 - 「发送测试邮件」按钮,AJAX 发完就地显示成功或失败原因, 不用保存再靠猜 ## 3. 每个软件一个主题色 需求:从短链接进不同软件,颜色不同,一眼区分。 - software 表加 color 字段(幂等迁移) - 全站配色靠 --brand / --brand-600 / --brand-soft 三个变量, 按软件覆写这三个即可,不用为每个软件写一套 CSS - fb_color_variants() 由主色推出深一档(×0.85)与浅一档(往白混 88%) - 未配色的软件按 id 从 8 色调色板取默认色,保证同站不撞色 - 后台:颜色选择器(原生取色 + 8 个预设 + 用默认) - 前台:index / new / thread 都按当前软件注入主题色, meta theme-color 也跟着变 验证:三功能专项 30/30;新旧回归全过 (全新安装 33/33、老基线 25/25、附件 22/22、安装向导 60/60、约束检查 20/20)
This commit is contained in:
@@ -108,7 +108,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
||||
$t = fb_admin_thread($pdo, $id);
|
||||
|
||||
// 这些动作不针对某一条反馈(不依赖 $t),单独放行
|
||||
$noThread = in_array($act, ['settings', 'password'], true)
|
||||
$noThread = in_array($act, ['settings', 'password', 'mail_test'], true)
|
||||
|| fb_admin_is_taxonomy_act($act);
|
||||
|
||||
if (!$t && !$noThread) {
|
||||
@@ -200,6 +200,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
|
||||
fb_mod_log('delete', $id, '删除问题:' . fb_excerpt((string)$t['title'], 40));
|
||||
$msg = '#' . $id . ' 已删除。';
|
||||
|
||||
} elseif ($act === 'mail_test') {
|
||||
// 发送测试邮件:只验证 SMTP 通不通,不改任何配置
|
||||
$to = trim((string)(isset($_POST['to']) ? $_POST['to'] : ''));
|
||||
if ($to === '') { $to = trim((string)fb_cfg('admin_email', '')); }
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
$msg = '请填写有效的收件邮箱。';
|
||||
$msgType = 'error';
|
||||
} else {
|
||||
$sc = (array)fb_cfg('smtp', []);
|
||||
$site = (string)fb_cfg('site_name', '轻反馈');
|
||||
$body = '<p>这是一封来自 <b>' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8')
|
||||
. '</b> 的测试邮件。</p>'
|
||||
. '<p>如果你收到了它,说明 SMTP 配置正确,回复通知可以正常发出。</p>'
|
||||
. '<p style="color:#888;font-size:12px">发送时间:'
|
||||
. date('Y-m-d H:i:s') . '</p>';
|
||||
list($ok, $err) = fb_smtp_send($sc, $to, '【' . $site . '】邮件测试', $body);
|
||||
if ($ok) {
|
||||
$msg = '测试邮件已发出,请查收 ' . $to . '(也看一下垃圾箱)。';
|
||||
$msgType = 'ok';
|
||||
} else {
|
||||
$msg = '发送失败:' . $err;
|
||||
$msgType = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
} elseif (fb_admin_is_taxonomy_act($act)) {
|
||||
list($msg, $msgType) = fb_admin_taxonomy_action($pdo, $act, $_POST);
|
||||
|
||||
@@ -297,6 +322,12 @@ function fb_admin_save_settings() {
|
||||
$v = trim((string)$post($k, ''));
|
||||
// 对象键前缀留空会让对象键变成 "/2026/...",回落到出厂默认 'fb'
|
||||
if ($k === 'oss.prefix' && $v === '') { fb_unset_setting($k); continue; }
|
||||
// RAM 角色留空 = 让程序自己去元数据服务探测,不写死一个空值
|
||||
if ($k === 'oss.instance_role' && $v === '') {
|
||||
list($rOk, $rVal) = fb_oss_detect_instance_role();
|
||||
if ($rOk) { fb_set_setting($k, (string)$rVal); }
|
||||
continue; // 探测不到就保持原值不动
|
||||
}
|
||||
fb_set_setting($k, $v);
|
||||
}
|
||||
|
||||
@@ -311,6 +342,48 @@ function fb_admin_save_settings() {
|
||||
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
|
||||
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
|
||||
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
|
||||
/* ---------------- 邮件通知(SMTP) ---------------- */
|
||||
$smtpText = ['smtp.host', 'smtp.user', 'smtp.from', 'smtp.from_name'];
|
||||
foreach ($smtpText as $k) {
|
||||
fb_set_setting($k, trim((string)$post($k, '')));
|
||||
}
|
||||
// 口令留空 = 不修改(避免每次保存都得重填)
|
||||
$sp = (string)(isset($_POST['smtp_pass']) ? $_POST['smtp_pass'] : '');
|
||||
if (trim($sp) !== '') { fb_set_setting('smtp.pass', $sp); }
|
||||
|
||||
$smtpPort = (int)$post('smtp.port', 465);
|
||||
if ($smtpPort <= 0 || $smtpPort > 65535) { $smtpPort = 465; }
|
||||
fb_set_setting('smtp.port', $smtpPort);
|
||||
|
||||
$sec = strtolower(trim((string)$post('smtp.secure', 'ssl')));
|
||||
if (!in_array($sec, ['ssl', 'tls', ''], true)) { $sec = 'ssl'; }
|
||||
fb_set_setting('smtp.secure', $sec);
|
||||
|
||||
$smtpTo = (int)$post('smtp.timeout', 15);
|
||||
fb_set_setting('smtp.timeout', max(5, min(60, $smtpTo)));
|
||||
fb_set_setting('smtp.verify_ssl', (int)(bool)$post('smtp.verify_ssl'));
|
||||
|
||||
// 没填 from 时用 user 兜底(绝大多数 SMTP 要求两者一致)
|
||||
if (trim((string)fb_cfg('smtp.from', '')) === '') {
|
||||
fb_set_setting('smtp.from', trim((string)fb_cfg('smtp.user', '')));
|
||||
}
|
||||
|
||||
// 只有 host 填了才允许开启,否则开着一个空配置只会每次发信都失败
|
||||
$wantOn = (bool)$post('smtp.enabled');
|
||||
$hostNow = trim((string)fb_cfg('smtp.host', ''));
|
||||
fb_set_setting('smtp.enabled', ($wantOn && $hostNow !== '') ? 1 : 0);
|
||||
if ($wantOn && $hostNow === '') {
|
||||
$msg = '邮件通知未开启:请先填写 SMTP 服务器地址。';
|
||||
$msgType = 'error';
|
||||
}
|
||||
|
||||
/* ---------------- 通知开关 ---------------- */
|
||||
fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled'));
|
||||
fb_set_setting('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
|
||||
fb_set_setting('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
|
||||
$cd = (int)$post('notify.cooldown_seconds', 60);
|
||||
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, $cd)));
|
||||
|
||||
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
|
||||
|
||||
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
|
||||
@@ -654,6 +727,88 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
value="<?php echo e((string)fb_cfg('footer.credit_url', '') !== '' ? (string)fb_cfg('footer.credit_url') : fb_credit_url_default()); ?>">
|
||||
<i class="hint">页脚「FeedLite」二字指向的地址,默认是本项目开源仓库。只接受 http:// 或 https:// 开头的网址;填回默认值即恢复。</i></label>
|
||||
|
||||
<h2 style="margin:24px 0 10px">邮件通知</h2>
|
||||
<?php
|
||||
$smOn = (bool)fb_cfg('smtp.enabled', false);
|
||||
$smHost = (string)fb_cfg('smtp.host', '');
|
||||
$smUser = (string)fb_cfg('smtp.user', '');
|
||||
$smFrom = (string)fb_cfg('smtp.from', '');
|
||||
$smName = (string)fb_cfg('smtp.from_name', '');
|
||||
$smPort = (int)fb_cfg('smtp.port', 465);
|
||||
$smSec = (string)fb_cfg('smtp.secure', 'ssl');
|
||||
$smHasPw = (string)fb_cfg('smtp.pass', '') !== '';
|
||||
$smTmo = (int)fb_cfg('smtp.timeout', 15);
|
||||
$smVerify= (bool)fb_cfg('smtp.verify_ssl', true);
|
||||
?>
|
||||
<label class="check"><input type="checkbox" name="smtp_enabled" value="1"<?php echo $smOn ? ' checked' : ''; ?>> 开启邮件通知
|
||||
<span class="hint" style="display:block;margin:2px 0 0 24px">开启后,有人回复你反馈的问题会发信通知提交人。填好下方参数并用「发送测试邮件」验证通了再勾。</span></label>
|
||||
|
||||
<div class="grid2">
|
||||
<label class="field"><span>SMTP 服务器</span>
|
||||
<input type="text" name="smtp_host" maxlength="120" placeholder="smtp.qiye.aliyun.com"
|
||||
value="<?php echo e($smHost); ?>">
|
||||
<i class="hint">阿里云企业邮 smtp.qiye.aliyun.com;腾讯企业邮 smtp.exmail.qq.com;QQ 邮箱 smtp.qq.com。</i></label>
|
||||
<label class="field"><span>端口与加密</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="number" name="smtp_port" min="1" max="65535" style="flex:0 0 110px"
|
||||
value="<?php echo (int)$smPort; ?>">
|
||||
<select name="smtp_secure" style="flex:1">
|
||||
<option value="ssl"<?php echo $smSec === 'ssl' ? ' selected' : ''; ?>>SSL(通常 465)</option>
|
||||
<option value="tls"<?php echo $smSec === 'tls' ? ' selected' : ''; ?>>STARTTLS(通常 587)</option>
|
||||
<option value=""<?php echo $smSec === '' ? ' selected' : ''; ?>>不加密(通常 25,不推荐)</option>
|
||||
</select>
|
||||
</div></label>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<label class="field"><span>登录账号</span>
|
||||
<input type="text" name="smtp_user" maxlength="120" placeholder="noreply@example.com"
|
||||
value="<?php echo e($smUser); ?>"></label>
|
||||
<label class="field"><span>密码 / 授权码 <i class="hint" style="display:inline"><?php echo $smHasPw ? '已设置,留空不修改' : '未设置'; ?></i></span>
|
||||
<input type="password" name="smtp_pass" autocomplete="new-password"
|
||||
placeholder="<?php echo $smHasPw ? '留空保持不变' : '邮箱密码或授权码'; ?>"></label>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<label class="field"><span>发件地址</span>
|
||||
<input type="email" name="smtp_from" maxlength="120" placeholder="留空则同登录账号"
|
||||
value="<?php echo e($smFrom); ?>"></label>
|
||||
<label class="field"><span>发件人显示名称</span>
|
||||
<input type="text" name="smtp_from_name" maxlength="60"
|
||||
placeholder="<?php echo e((string)fb_cfg('site_name', '轻反馈')); ?>"
|
||||
value="<?php echo e($smName); ?>"></label>
|
||||
</div>
|
||||
|
||||
<details style="margin:6px 0 12px">
|
||||
<summary style="cursor:pointer;font-size:13px;color:var(--muted)">高级选项</summary>
|
||||
<div class="grid2" style="margin-top:8px">
|
||||
<label class="field"><span>超时(秒)</span>
|
||||
<input type="number" name="smtp_timeout" min="5" max="60" value="<?php echo (int)$smTmo; ?>"></label>
|
||||
<label class="check" style="align-self:end"><input type="checkbox" name="smtp_verify_ssl" value="1"<?php echo $smVerify ? ' checked' : ''; ?>> 校验服务器 SSL 证书
|
||||
<span class="hint" style="display:block;margin-left:24px">服务器缺 CA 证书时可临时取消,但不建议。</span></label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<?php if ($smHost !== ''): ?>
|
||||
<div class="mail-test">
|
||||
<span class="muted" style="font-size:13.5px">配好后先发一封测试邮件确认能通:</span>
|
||||
<input type="email" id="mailTestTo" placeholder="收件邮箱,默认发给自己"
|
||||
value="<?php echo e((string)fb_cfg('admin_email', '')); ?>">
|
||||
<button type="button" class="btn" id="mailTestBtn" data-csrf="<?php echo e(fb_csrf_token()); ?>">发送测试邮件</button>
|
||||
<span id="mailTestOut" class="mail-test-out"></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3 style="margin:20px 0 8px;font-size:15px">通知规则</h3>
|
||||
<label class="check"><input type="checkbox" name="notify_enabled" value="1"<?php echo fb_cfg('notify.enabled', true) ? ' checked' : ''; ?>> 有人回复时通知提交人</label>
|
||||
<label class="check"><input type="checkbox" name="notify_on_every_reply" value="1"<?php echo fb_cfg('notify.on_every_reply', false) ? ' checked' : ''; ?>> 每次回复都通知(默认只在官方回复时通知,避免刷屏)</label>
|
||||
<label class="check"><input type="checkbox" name="notify_admin_new_thread" value="1"<?php echo fb_cfg('notify.admin_new_thread', false) ? ' checked' : ''; ?>> 有新反馈时通知管理员
|
||||
<span class="hint" style="display:block;margin-left:24px">发到「站点信息」里填的官方邮箱。</span></label>
|
||||
<label class="field" style="max-width:280px"><span>同一帖同一收件人的最短发信间隔(秒)</span>
|
||||
<input type="number" name="notify_cooldown_seconds" min="0" max="86400"
|
||||
value="<?php echo (int)fb_cfg('notify.cooldown_seconds', 60); ?>">
|
||||
<i class="hint">0 = 不限制。</i></label>
|
||||
|
||||
<h2 style="margin:24px 0 10px">附件存储</h2>
|
||||
<label class="field">
|
||||
<span>附件保存在哪</span>
|
||||
@@ -696,8 +851,38 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
|
||||
<option value="instance_role"<?php echo fb_oss_credential_source() === 'instance_role' ? ' selected' : ''; ?>>ECS 实例 RAM 角色(推荐)</option>
|
||||
<option value="static"<?php echo fb_oss_credential_source() === 'static' ? ' selected' : ''; ?>>静态 AccessKey</option>
|
||||
</select></label>
|
||||
<label class="field"><span>ECS 实例 RAM 角色名</span>
|
||||
<input type="text" name="oss_instance_role" placeholder="FeedLiteOssRole" value="<?php echo e((string)fb_cfg('oss.instance_role', '')); ?>"></label>
|
||||
<?php
|
||||
// RAM 角色名不该让用户去控制台查 —— 直接问 ECS 元数据服务。
|
||||
// 已配置的显示配置值;没配置就现场探测一次作为占位提示。
|
||||
$curRole = trim((string)fb_cfg('oss.instance_role', ''));
|
||||
$detect = null;
|
||||
if ($curRole === '') {
|
||||
list($dOk, $dVal) = fb_oss_detect_instance_role();
|
||||
$detect = [$dOk, $dVal];
|
||||
}
|
||||
?>
|
||||
<div class="field">
|
||||
<span>ECS 实例 RAM 角色</span>
|
||||
<?php if ($curRole !== ''): ?>
|
||||
<div class="role-ok">已绑定:<b><?php echo e($curRole); ?></b>
|
||||
<i class="hint" style="display:inline;margin-left:6px">(自动从实例元数据读取,无需手填)</i>
|
||||
</div>
|
||||
<?php elseif ($detect && $detect[0]): ?>
|
||||
<div class="role-ok">自动探测到:<b><?php echo e((string)$detect[1]); ?></b>
|
||||
<i class="hint" style="display:inline;margin-left:6px">保存时若留空就自动用这个</i>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="role-warn">
|
||||
<?php echo e($detect ? (string)$detect[1] : '未能自动探测'); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<details style="margin-top:6px">
|
||||
<summary style="cursor:pointer;font-size:13px;color:var(--muted)">手动指定角色名(一般不需要)</summary>
|
||||
<input type="text" name="oss_instance_role" style="margin-top:6px"
|
||||
placeholder="留空即自动探测"
|
||||
value="<?php echo e($curRole); ?>">
|
||||
</details>
|
||||
</div>
|
||||
<label class="field"><span>AccessKeyId(静态鉴权用)</span>
|
||||
<input type="text" name="oss_access_key" value="<?php echo e((string)fb_cfg('oss.access_key', '')); ?>"></label>
|
||||
<label class="field"><span>AccessKeySecret(留空=不修改)</span>
|
||||
|
||||
@@ -694,9 +694,98 @@
|
||||
for (var k = 0; k < wraps.length; k++) { wraps[k].classList.remove('has-error'); }
|
||||
}
|
||||
|
||||
/* ---------- 主题色选择器(后台软件编辑) ---------- */
|
||||
function bindColorPicker() {
|
||||
var rows = document.querySelectorAll('.color-row');
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
(function (row) {
|
||||
var input = row.querySelector('input[type=color]');
|
||||
var presets = row.querySelectorAll('.color-preset');
|
||||
var reset = row.querySelector('.color-reset');
|
||||
if (!input) { 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'); }
|
||||
}
|
||||
}
|
||||
|
||||
for (var j = 0; j < presets.length; j++) {
|
||||
presets[j].addEventListener('click', function () {
|
||||
var c = this.getAttribute('data-color') || '';
|
||||
input.value = c;
|
||||
syncOn(c);
|
||||
});
|
||||
}
|
||||
input.addEventListener('input', function () { syncOn(this.value); });
|
||||
|
||||
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'); // 提交时以隐藏字段为准
|
||||
});
|
||||
}
|
||||
})(rows[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 邮件测试发送(后台设置页) ---------- */
|
||||
function bindMailTest() {
|
||||
var btn = document.getElementById('mailTestBtn');
|
||||
if (!btn) { return; }
|
||||
var out = document.getElementById('mailTestOut');
|
||||
var to = document.getElementById('mailTestTo');
|
||||
btn.addEventListener('click', function () {
|
||||
var addr = to ? (to.value || '').trim() : '';
|
||||
if (!addr) {
|
||||
if (out) { out.className = 'mail-test-out err'; out.textContent = '请填写收件邮箱。'; }
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
var old = btn.textContent;
|
||||
btn.textContent = '发送中…';
|
||||
if (out) { out.className = 'mail-test-out'; out.textContent = '正在连接 SMTP 服务器…'; }
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('csrf', btn.getAttribute('data-csrf') || '');
|
||||
fd.append('act', 'mail_test');
|
||||
fd.append('to', addr);
|
||||
|
||||
fetch('admin.php', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
// 服务端是整页渲染,从返回的 HTML 里取出提示文字
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var box = doc.querySelector('.flash');
|
||||
var txt = box ? (box.textContent || '').trim() : '已提交,请回页面顶部查看结果。';
|
||||
if (out) {
|
||||
out.className = 'mail-test-out ' + (/失败/.test(txt) ? 'err' : 'ok');
|
||||
out.textContent = txt;
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (out) { out.className = 'mail-test-out err'; out.textContent = '请求失败:' + e; }
|
||||
})
|
||||
.then(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
bindCaptchaRefresh();
|
||||
bindAdminToggle();
|
||||
bindColorPicker();
|
||||
bindMailTest();
|
||||
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
||||
bindNoDoubleSubmit();
|
||||
bindUploaders();
|
||||
|
||||
@@ -81,6 +81,50 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
|
||||
.page-head h1{margin-bottom:4px}
|
||||
.page-head .muted{font-size:14px; margin:0}
|
||||
|
||||
/* ---------- 主题色选择器 ---------- */
|
||||
.sw-dot{
|
||||
display:inline-block; width:10px; height:10px; border-radius:50%;
|
||||
flex:none; margin-right:2px; vertical-align:middle;
|
||||
box-shadow:0 0 0 1px rgba(0,0,0,.12);
|
||||
}
|
||||
.color-row{display:flex; align-items:center; gap:10px; flex-wrap:wrap}
|
||||
.color-input{
|
||||
width:46px; height:34px; padding:2px; border:1px solid var(--line-strong);
|
||||
border-radius:9px; background:var(--card); cursor:pointer; flex:none;
|
||||
}
|
||||
.color-presets{display:flex; gap:6px; flex-wrap:wrap}
|
||||
.color-preset{
|
||||
width:26px; height:26px; border-radius:50%; border:2px solid transparent;
|
||||
cursor:pointer; padding:0; flex:none; transition:transform .12s;
|
||||
}
|
||||
.color-preset:hover{transform:scale(1.12)}
|
||||
.color-preset.on{border-color:var(--fg); box-shadow:0 0 0 2px var(--card) inset}
|
||||
|
||||
/* ---------- 邮件测试 ---------- */
|
||||
.mail-test{
|
||||
display:flex; flex-wrap:wrap; align-items:center; gap:10px;
|
||||
padding:12px 14px; margin:6px 0 16px;
|
||||
background:var(--card); border:1px dashed var(--line); border-radius:11px;
|
||||
}
|
||||
.mail-test input[type=email]{
|
||||
flex:1 1 220px; min-width:180px;
|
||||
border:1px solid var(--line-strong); border-radius:9px; padding:8px 10px;
|
||||
font-size:14px; background:var(--card); color:var(--fg); font-family:inherit;
|
||||
}
|
||||
.mail-test-out{flex:1 1 100%; font-size:13px; line-height:1.6}
|
||||
.mail-test-out.ok{color:var(--ok)}
|
||||
.mail-test-out.err{color:#c9302c}
|
||||
|
||||
/* RAM 角色探测结果 */
|
||||
.role-ok{
|
||||
padding:8px 11px; border-radius:9px; font-size:13.5px;
|
||||
background:var(--ok-soft, #eef8f0); border:1px solid #cfe8d5;
|
||||
}
|
||||
.role-warn{
|
||||
padding:8px 11px; border-radius:9px; font-size:13.5px;
|
||||
background:#fff8e6; border:1px solid #f0e0b0; color:#8a6d1f;
|
||||
}
|
||||
|
||||
/* ---------- 表单就地校验的错误提示 ---------- */
|
||||
.field.has-error > input,
|
||||
.field.has-error > textarea,
|
||||
|
||||
@@ -77,7 +77,7 @@ $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);
|
||||
fb_layout_head($sw ? ((string)$sw['name']) : $headTitle, ['software' => $sw]);
|
||||
?>
|
||||
|
||||
<div class="page-head">
|
||||
|
||||
+26
-4
@@ -28,8 +28,9 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
}
|
||||
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
|
||||
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
|
||||
$pdo->prepare('INSERT INTO software(slug,name,note,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)')
|
||||
->execute([$slug, $name, $note !== '' ? $note : null, $sort, $now]);
|
||||
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
||||
$pdo->prepare('INSERT INTO software(slug,name,note,color,sort_order,enabled,created_at) VALUES(?,?,?,?,?,1,?)')
|
||||
->execute([$slug, $name, $note !== '' ? $note : null, $color, $sort, $now]);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
// 新软件默认给一组常见分类,省得空着没法发帖
|
||||
$i = 0;
|
||||
@@ -52,8 +53,9 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
if ($slug === '') { $slug = (string)$sw['slug']; }
|
||||
$other = fb_software($slug);
|
||||
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
|
||||
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, sort_order=? WHERE id=?')
|
||||
->execute([$name, $slug, $note !== '' ? $note : null,
|
||||
$color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
|
||||
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, color=?, sort_order=? WHERE id=?')
|
||||
->execute([$name, $slug, $note !== '' ? $note : null, $color,
|
||||
(int)(isset($post['sort_order']) ? $post['sort_order'] : $sw['sort_order']), $id]);
|
||||
return ['软件「' . $name . '」已保存。', 'ok'];
|
||||
}
|
||||
@@ -260,6 +262,8 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
||||
$isCur = $sid === $curSwId;
|
||||
echo '<div class="tax-row' . ($isCur ? ' on' : '') . ($on ? '' : ' off') . '">';
|
||||
echo ' <div class="tax-main">';
|
||||
list($swC, $swCd, $swCs) = fb_software_colors($sw);
|
||||
echo ' <span class="sw-dot" style="background:' . e($swC) . '" title="' . e($swC) . '"></span>';
|
||||
echo ' <a class="tax-name" href="admin.php?tab=taxonomy&sw=' . $sid . '">' . e((string)$sw['name']) . '</a>';
|
||||
echo ' <span class="tax-slug">' . e((string)$sw['slug']) . '</span>';
|
||||
if (!$on) { echo ' <span class="badge st">已停用</span>'; }
|
||||
@@ -305,6 +309,24 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
||||
echo ' <label class="field"><span>一句话说明<i class="hint">显示在列表页标题下,可留空</i></span>'
|
||||
. '<input type="text" name="note" maxlength="120" value="'
|
||||
. e($esw ? (string)($esw['note'] === null ? '' : $esw['note']) : '') . '"></label>';
|
||||
|
||||
// 主题色:前台按软件换色,用短链接进不同软件一眼就能区分
|
||||
$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'];
|
||||
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 ' <button type="button" class="btn sm color-reset">用默认</button>';
|
||||
echo ' </div>';
|
||||
echo ' </div>';
|
||||
if ($esw) {
|
||||
echo ' <label class="field"><span>排序<i class="hint">数字小的排前面</i></span>'
|
||||
. '<input type="number" name="sort_order" value="' . (int)$esw['sort_order'] . '"></label>';
|
||||
|
||||
+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', 11); }
|
||||
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 13); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
||||
@@ -305,6 +305,14 @@ SQL
|
||||
);
|
||||
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_st_key ON statuses(key_name)');
|
||||
|
||||
/* ---- 软件增加主题色(用于前台按软件区分视觉)---- */
|
||||
$swCols = $pdo->query('PRAGMA table_info(software)')->fetchAll();
|
||||
$swNames = [];
|
||||
foreach ($swCols as $c2) { if (isset($c2['name'])) { $swNames[(string)$c2['name']] = true; } }
|
||||
if (!isset($swNames['color'])) {
|
||||
$pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
/* ---- threads 增加两列(老 category 文本列保留,不删)---- */
|
||||
$cols = $pdo->query('PRAGMA table_info(threads)')->fetchAll();
|
||||
$names = [];
|
||||
|
||||
+20
-1
@@ -13,12 +13,23 @@ function fb_layout_head($title, array $opt = []) {
|
||||
$site = (string)fb_cfg('site_name');
|
||||
$robots = isset($opt['robots']) ? (string)$opt['robots'] : 'index,follow';
|
||||
$full = $title === '' ? $site : ($title . ' · ' . $site);
|
||||
|
||||
/* ---- 按软件换主题色 ----
|
||||
* 通过 $opt['software'] 传入软件行;全站主题只靠 --brand / --brand-600 /
|
||||
* --brand-soft 三个变量,覆写它们即可,不用为每个软件写一套 CSS。
|
||||
* 没传软件(如后台、安装页)就用主题默认色,保持中性。 */
|
||||
$swColors = null;
|
||||
if (!empty($opt['software'])) {
|
||||
list($cMain, $cDark, $cSoft) = fb_software_colors($opt['software']);
|
||||
if ($cMain !== '') { $swColors = [$cMain, $cDark, $cSoft]; }
|
||||
}
|
||||
$themeColor = $swColors ? $swColors[0] : '#0057B8';
|
||||
?><!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0057B8">
|
||||
<meta name="theme-color" content="<?php echo e($themeColor); ?>">
|
||||
<meta name="robots" content="<?php echo e($robots); ?>">
|
||||
<title><?php echo e($full); ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
|
||||
@@ -28,6 +39,14 @@ function fb_layout_head($title, array $opt = []) {
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
|
||||
<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 /* 按软件覆写主题色:只改这三个变量,其它样式自动跟着变 */ ?>
|
||||
<style>:root{
|
||||
--brand:<?php echo e($swColors[0]); ?>;
|
||||
--brand-600:<?php echo e($swColors[1]); ?>;
|
||||
--brand-soft:<?php echo e($swColors[2]); ?>;
|
||||
}</style>
|
||||
<?php endif; ?>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
||||
+45
@@ -136,9 +136,54 @@ function fb_oss_metadata_token() {
|
||||
return trim((string)$body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动探测本 ECS 实例绑定的 RAM 角色名。
|
||||
*
|
||||
* 元数据接口 GET /latest/meta-data/ram/security-credentials/(末尾带斜杠)
|
||||
* 返回的是「角色名列表」,每行一个。绝大多数实例只绑一个角色。
|
||||
* 所以用户完全不需要知道角色叫什么 —— 我们去问机器就行。
|
||||
*
|
||||
* @return array [bool $ok, string $role|string $err]
|
||||
*/
|
||||
function fb_oss_detect_instance_role() {
|
||||
$url = fb_oss_metadata_base() . '/latest/meta-data/ram/security-credentials/';
|
||||
$headers = [];
|
||||
$tok = fb_oss_metadata_token();
|
||||
if ($tok !== '') { $headers[] = 'X-aliyun-ECS-Metadata-Token: ' . $tok; }
|
||||
|
||||
list($st, , $body) = fb_http('GET', $url, [
|
||||
'timeout' => 3, 'noproxy' => true, 'headers' => $headers,
|
||||
]);
|
||||
if ($st < 200 || $st >= 300) {
|
||||
return [false, '读不到 ECS 元数据(HTTP ' . $st . ')。不在 ECS 上,或元数据服务被禁用。'];
|
||||
}
|
||||
$txt = trim((string)$body);
|
||||
if ($txt === '') {
|
||||
return [false, '这台 ECS 实例没有绑定任何 RAM 角色。请在阿里云控制台给实例绑定一个角色,再回来点「自动探测」。'];
|
||||
}
|
||||
// 可能是多行(多角色)或 JSON 数组,两种都兼容
|
||||
$names = [];
|
||||
$j = json_decode($txt, true);
|
||||
if (is_array($j)) {
|
||||
foreach ($j as $v) { if (is_string($v) && trim($v) !== '') { $names[] = trim($v); } }
|
||||
} else {
|
||||
foreach (preg_split('/\r?\n/', $txt) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line !== '') { $names[] = $line; }
|
||||
}
|
||||
}
|
||||
if (!$names) { return [false, '元数据返回内容无法识别为角色名。']; }
|
||||
return [true, $names[0]]; // 多角色时取第一个,后台会提示可手改
|
||||
}
|
||||
|
||||
/** 从 ECS 元数据读取实例 RAM 角色凭证 @return array [bool, array|string] */
|
||||
function fb_oss_instance_creds() {
|
||||
$role = trim((string)fb_oss_cfg('instance_role', ''));
|
||||
// 没填就自动探测一次,免得用户还要去控制台查角色名
|
||||
if ($role === '') {
|
||||
list($ok, $r) = fb_oss_detect_instance_role();
|
||||
if ($ok) { $role = (string)$r; }
|
||||
}
|
||||
if ($role === '') { return [false, '未配置 oss.instance_role']; }
|
||||
|
||||
$url = fb_oss_metadata_base() . '/latest/meta-data/ram/security-credentials/' . rawurlencode($role);
|
||||
|
||||
@@ -94,6 +94,71 @@ function fb_default_software() {
|
||||
return $list ? $list[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化主题色(#RRGGBB)。
|
||||
* @return string 合法则返回小写 6 位十六进制;非法返回空串
|
||||
*/
|
||||
function fb_color_norm($c) {
|
||||
$c = strtolower(trim((string)$c));
|
||||
if ($c === '') { return ''; }
|
||||
if ($c[0] !== '#') { $c = '#' . $c; }
|
||||
if (!preg_match('/^#[0-9a-f]{6}$/', $c)) { return ''; }
|
||||
return $c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由主色推导出「深一档」和「浅一档」两个色,供 --brand-600 / --brand-soft 用。
|
||||
* 不做真实色彩空间换算,只按通道线性调:深的一档乘 0.85,浅的往白色混 88%。
|
||||
* 对区分软件这个目的来说足够,也不会因为极端颜色算出难看的对比。
|
||||
*
|
||||
* @return array [$hex, $hexDark, $hexSoft]
|
||||
*/
|
||||
function fb_color_variants($hex) {
|
||||
$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));
|
||||
|
||||
$dark = sprintf('#%02x%02x%02x',
|
||||
max(0, (int)round($r * 0.85)),
|
||||
max(0, (int)round($g * 0.85)),
|
||||
max(0, (int)round($b * 0.85)));
|
||||
|
||||
// 往白色混 88%,得到很淡的底色(用于 badge / 选中态背景)
|
||||
$mix = 0.88;
|
||||
$soft = sprintf('#%02x%02x%02x',
|
||||
(int)round($r + (255 - $r) * $mix),
|
||||
(int)round($g + (255 - $g) * $mix),
|
||||
(int)round($b + (255 - $b) * $mix));
|
||||
|
||||
return [$hex, $dark, $soft];
|
||||
}
|
||||
|
||||
/**
|
||||
* 给某个软件挑一个默认主题色(未配置时用)。
|
||||
* 按软件的排序位次从调色板里取,保证同一站点里不同软件颜色不撞。
|
||||
*/
|
||||
function fb_color_default_for($softwareId) {
|
||||
$palette = ['#0057B8', '#2E7D5B', '#B4553C', '#6A4C93', '#0E7C86', '#A02C5A', '#8A6D1F', '#3A5A8C'];
|
||||
$id = max(0, (int)$softwareId);
|
||||
return $palette[$id % count($palette)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 取软件的主题色三元组(主色 / 深 / 浅)。
|
||||
* 没配就用默认调色板,永远返回可用值。
|
||||
* @return array [hex, hexDark, hexSoft]
|
||||
*/
|
||||
function fb_software_colors($software) {
|
||||
$hex = '';
|
||||
if (is_array($software)) {
|
||||
$hex = fb_color_norm(isset($software['color']) ? $software['color'] : '');
|
||||
if ($hex === '') { $hex = fb_color_default_for(isset($software['id']) ? $software['id'] : 0); }
|
||||
}
|
||||
return fb_color_variants($hex);
|
||||
}
|
||||
|
||||
/** 某软件下的分类;不传软件则返回空数组 */
|
||||
function fb_categories($softwareId = null, $onlyEnabled = true) {
|
||||
if ($softwareId === null) { return []; }
|
||||
@@ -301,6 +366,14 @@ function fb_setting_keys() {
|
||||
'moderation.enabled', 'moderation.close_mail_subject',
|
||||
'admin.password_hash',
|
||||
'admin.username',
|
||||
// 邮件通知(SMTP)—— 原先完全没有,后台配不了,只能改 config.php
|
||||
'smtp.enabled',
|
||||
'smtp.host', 'smtp.port', 'smtp.secure',
|
||||
'smtp.user', 'smtp.pass',
|
||||
'smtp.from', 'smtp.from_name',
|
||||
'smtp.timeout', 'smtp.verify_ssl',
|
||||
'notify.enabled', 'notify.on_every_reply', 'notify.cooldown_seconds',
|
||||
'notify.admin_new_thread',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
}
|
||||
|
||||
fb_layout_head('我要反馈', ['robots' => 'noindex,follow']);
|
||||
fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]);
|
||||
?>
|
||||
|
||||
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
||||
|
||||
+2
-1
@@ -149,7 +149,8 @@ $thread = $st->fetch();
|
||||
// 附件(主题级 + 各回复)
|
||||
$attMap = fb_attach_for_thread($pdo, $id);
|
||||
|
||||
fb_layout_head(fb_excerpt($thread['title'], 50));
|
||||
$thSwForColor = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
|
||||
fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
|
||||
?>
|
||||
|
||||
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
|
||||
|
||||
Reference in New Issue
Block a user