Files
FeedLite/assets/app.js
T
laoyang 4033170ad7 修复:跨 tab 保存互相清空配置;补齐全流程邮件通知与统一模板 (#2)
## 一、用户报「我明明配过 SMTP,怎么全丢了、信也发不出」

**没有重置数据库**(installed_at 仍是 11-01 15:14,oss.*/site_name/admin.username 全在)。

真因是我上一轮「邮件拆独立 tab」挖的坑:站点设置与邮件通知**共用 act=settings**,
而保存函数把 smtp 字段无条件写库:

    foreach ($smtpText as $k) {
        fb_set_setting($k, trim((string)$post($k, \x27\x27)));   // 没提交 → 空串 → 覆盖
    }

在站点设置页保存附件设置时,该表单没有 smtp_* 字段,$post() 取到空串,
于是 host/user/from 被清成空。smtp.pass 因为单独做了「留空=不修改」而幸存,
所以现场是 pass 有值、其余全空 —— 这个组合本身就是 bug 的指纹。

修法:新增 $has() 判断「本次请求是否真的提交了该字段」,没提交就跳过。
按 tab 分两组保护:
- $isSiteForm:站点信息/页脚/附件/OSS,逐字段 has() 双保险
- $isMailForm:SMTP/通知开关,整段 + 逐字段

checkbox 尤其要保护:未提交会被当成「取消勾选」,
在站点设置页存一次就会把「有人回复时通知」全关掉。

## 二、notify.admin_new_thread 是假的(用户说「提交了反馈收不到邮件」)

这个设置项:白名单里有、后台有勾选框,**但没有任何代码实现它**。
grep 只命中三处(白名单/保存/界面),没有一处真正发信。

已在 new.php 提交成功后实现:查新帖 → 取所属软件名 → fb_notify_event(\x27new\x27)。

## 三、补齐全部事件,统一入口

此前只有 reply 和 close 两个事件,且两套模板风格不一致。
新增 fb_notify_event() 统一入口,处理「开关→收件人→节流→发信→日志」。

覆盖 8 类事件:reply / status / approve / hide / close / new。
- status 按状态给不同文案(待处理/处理中/已解决/关闭/不予考虑),
  「不予考虑」语气克制、必须带理由并留申诉入口
- approve / hide 原先完全不发信,现在补上
- 状态没变化时不发信(不打扰用户)

## 四、邮件模板重做

抽出 fb_mail_layout() 统一外壳,所有事件共用:
- table + 内联样式(Outlook/QQ邮箱/Gmail 兼容)
- 600px 卡片、顶部主色条、圆角徽标、字段表、引用块、callout、CTA 按钮
- 响应式(已验 375px 无溢出、无裁切)
- preheader 摘要、纯文本兜底链接、收件人提示

## 五、后台状态按钮(用户要求)

问题详情页顶部一排按钮,点一下立刻生效并自动发信:
「通过并公开」「处理中」「已解决」「不予考虑」「撤下」
- 当前状态对应的按钮置灰
- 「不予考虑」展开内联面板填理由(不用 window.prompt)
- 新增 setstatus 动作;「通过并公开」会同时置可见

## 六、notify_log 加 reason 列

原表只有 reply_id,无法区分事件类型。加 reason 并幂等迁移;
老库 reply_id 是 NOT NULL,非回复事件用 0 表示。

## 验证

邮件专项 73/73:
- 8 类事件模板全部完整 HTML、table 布局、徽标、页脚、无 script
- 8 个主题互不相同;状态徽标文案正确
- 总开关关闭时 admin_new_thread 仍可独立生效
- 节流只作用于 reply/status,close 不受影响
- SMTP 未启用时留失败日志(含 reason 与原因)
- C1-C5:站点页保存后 smtp.host/user/notify.enabled 均未被清空;
  邮件页保存后 oss.bucket 未被清空

反向验证:完整回退两层保护后 C1/C2/C3 全部 FAIL
(host 从 smtp.qiye.aliyun.com 变成空、notify.enabled 从 1 变 0),
证明测试能真正抓到该 bug,不是假通过。

回归:全新安装 33/33、老基线 25/25、附件 22/22。
2026-11-02 22:57:00 +08:00

837 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* FeedLite · 轻反馈 —— 轻量交互(图片/视频上传:经服务器中转落地,前端对 local/OSS 走同一路径) */
(function () {
'use strict';
/* ---------- 公共小工具 ---------- */
function $(sel, root) { return (root || document).querySelector(sel); }
function $$(sel, root) {
var out = [], list = (root || document).querySelectorAll(sel);
for (var i = 0; i < list.length; i++) { out.push(list[i]); }
return out;
}
function qs(obj) {
var parts = [];
for (var k in obj) {
if (obj.hasOwnProperty(k) && obj[k] !== undefined && obj[k] !== null) {
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(obj[k]));
}
}
return parts.join('&');
}
function human(bytes) {
var b = Number(bytes) || 0;
if (b < 1024) { return b + ' B'; }
if (b < 1048576) { return (b / 1024).toFixed(1) + ' KB'; }
return (b / 1048576).toFixed(1) + ' MB';
}
function extByMime(mime) {
if (mime === 'image/webp') { return 'webp'; }
if (mime === 'image/png') { return 'png'; }
if (mime === 'image/gif') { return 'gif'; }
if (mime === 'video/mp4') { return 'mp4'; }
if (mime === 'video/quicktime') { return 'mov'; }
if (mime === 'video/webm') { return 'webm'; }
return 'jpg';
}
function renameExt(name, mime) {
var base = String(name || 'file').replace(/\.[A-Za-z0-9]{1,8}$/, '') || 'file';
return base + '.' + extByMime(mime);
}
/* ---------- 换一题:AJAX 刷新验证题,不丢失已填内容 ---------- */
function bindCaptchaRefresh() {
var links = document.querySelectorAll('[data-captcha-refresh]');
for (var i = 0; i < links.length; i++) {
links[i].addEventListener('click', function (e) {
e.preventDefault();
var box = this.closest('.captcha-line');
var qEl = box ? box.querySelector('.c-q') : null;
var input = box ? box.querySelector('input[name=captcha]') : null;
var self = this;
self.style.opacity = '.5';
fetch('captcha.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok && qEl) { qEl.textContent = d.q; }
if (input) { input.value = ''; input.focus(); }
})
.catch(function () { window.location.href = 'captcha.php'; })
.then(function () { self.style.opacity = '1'; });
});
}
}
/* ---------- 管理员口令:填写后显示"处理状态",并解除邮箱必填 ---------- */
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();
}
/* ---------- 防止重复提交(上传未完成时阻止提交) ---------- */
function bindNoDoubleSubmit() {
var forms = document.querySelectorAll('form');
for (var i = 0; i < forms.length; i++) {
forms[i].addEventListener('submit', function (ev) {
var box = this.querySelector('[data-uploader-root]');
if (box && Number(box.getAttribute('data-pending') || 0) > 0) {
ev.preventDefault();
alert('还有文件正在上传,请等待上传完成后再提交。');
return;
}
var btn = this.querySelector('button[type=submit]');
if (btn) {
setTimeout(function () { btn.disabled = true; btn.style.opacity = '.6'; }, 0);
setTimeout(function () { btn.disabled = false; btn.style.opacity = '1'; }, 5000);
}
});
}
}
/* ---------- 图片压缩(canvas,保留动图) ---------- */
function prepareImage(file, maxSide) {
return new Promise(function (resolve) {
// GIF 可能带动画,压缩会丢失动画 —— 原样上传
if (file.type === 'image/gif') {
resolve({ blob: file, mime: file.type, name: file.name });
return;
}
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
var w = img.naturalWidth, h = img.naturalHeight;
var scale = Math.min(1, maxSide / Math.max(w, h));
var tw = Math.max(1, Math.round(w * scale));
var th = Math.max(1, Math.round(h * scale));
var small = file.size <= 600 * 1024;
if (small && scale >= 1) {
URL.revokeObjectURL(url);
resolve({ blob: file, mime: file.type, name: file.name });
return;
}
var cv = document.createElement('canvas');
cv.width = tw; cv.height = th;
cv.getContext('2d').drawImage(img, 0, 0, tw, th);
var prefer = (file.type === 'image/png' || file.type === 'image/webp') ? 'image/webp' : 'image/jpeg';
var quality = 0.82;
function done(blob, mime) {
URL.revokeObjectURL(url);
if (!blob || (blob.size >= file.size && small)) {
resolve({ blob: file, mime: file.type, name: file.name });
return;
}
var real = blob.type || mime;
resolve({ blob: blob, mime: real, name: renameExt(file.name, real) });
}
cv.toBlob(function (blob) {
if (blob && blob.type !== prefer) {
cv.toBlob(function (b2) { done(b2, 'image/jpeg'); }, 'image/jpeg', quality);
} else {
done(blob, prefer);
}
}, prefer, quality);
};
img.onerror = function () {
URL.revokeObjectURL(url);
resolve({ blob: file, mime: file.type, name: file.name });
};
img.src = url;
});
}
/* ---------- 附件上传(经服务器中转,local/OSS 同一路径) ---------- */
function initUploaderRoot(root) {
var csrf = root.getAttribute('data-csrf') || '';
var signUrl = root.getAttribute('data-sign-url') || 'upload.php';
var field = root.querySelector('.att-field');
var state = []; // 已成功的附件描述
var pending = 0;
try {
var init = JSON.parse(root.getAttribute('data-init') || '[]');
for (var i = 0; i < init.length; i++) { state.push(init[i]); }
} catch (e) { /* 忽略 */ }
function setPending(n) {
pending = Math.max(0, pending + n);
root.setAttribute('data-pending', String(pending));
}
function sync() {
if (!field) { return; }
var out = [];
for (var i = 0; i < state.length; i++) {
var s = state[i];
out.push({ kind: s.kind, key: s.key, size: s.size, name: s.name || '', mime: s.mime || '', w: s.w || 0, h: s.h || 0 });
}
field.value = out.length ? JSON.stringify(out) : '';
}
function countOf(kind) {
var n = 0;
for (var i = 0; i < state.length; i++) { if (state[i].kind === kind) { n++; } }
return n;
}
function makeNode(box, meta) {
var list = box.querySelector('.up-list');
var el = document.createElement('div');
el.className = 'up-item' + (meta.kind === 'video' ? ' video' : '');
var thumb = document.createElement('div');
thumb.className = 'up-thumb';
if (meta.kind === 'image') {
var im = document.createElement('img');
im.alt = '';
if (meta.thumb) { im.src = meta.thumb; }
thumb.appendChild(im);
} else {
thumb.classList.add('up-thumb-video');
thumb.textContent = 'VIDEO';
}
var info = document.createElement('div');
info.className = 'up-info';
var nm = document.createElement('div');
nm.className = 'up-name';
nm.textContent = meta.name || (meta.kind === 'image' ? '图片' : '视频');
var st = document.createElement('div');
st.className = 'up-state';
st.textContent = '等待上传…';
var bar = document.createElement('div');
bar.className = 'up-bar';
var fill = document.createElement('i');
bar.appendChild(fill);
info.appendChild(nm); info.appendChild(st); info.appendChild(bar);
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'up-del';
rm.textContent = '移除';
el.appendChild(thumb); el.appendChild(info); el.appendChild(rm);
list.appendChild(el);
return { el: el, fill: fill, st: st, rm: rm, thumb: thumb };
}
function removeItem(node, item) {
if (node && node.el && node.el.parentNode) { node.el.parentNode.removeChild(node.el); }
if (item) {
for (var i = 0; i < state.length; i++) {
if (state[i].key === item.key) { state.splice(i, 1); break; }
}
}
sync();
}
function sign(kind, ext, size, mime) {
return fetch(signUrl, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: qs({ csrf: csrf, kind: kind, ext: ext, size: size, mime: mime })
}).then(function (r) {
return r.json().catch(function () { throw new Error('服务器返回异常(' + r.status + ')'); });
}).then(function (d) {
if (!d || !d.ok) { throw new Error((d && d.error) || '获取上传凭证失败'); }
return d;
});
}
function upload(cred, fields, blob, filename, onProgress) {
return new Promise(function (resolve, reject) {
var fd = new FormData();
for (var k in fields) {
if (fields.hasOwnProperty(k)) { fd.append(k, fields[k]); }
}
fd.append('file', blob, filename); // 文件必须是最后一个字段
var xhr = new XMLHttpRequest();
xhr.open('POST', cred.host, true);
xhr.upload.onprogress = function (e) {
if (e.lengthComputable && onProgress) { onProgress(e.loaded / e.total); }
};
xhr.onload = function () {
if (xhr.status === 200 || xhr.status === 204) { resolve(true); }
else {
var msg = String(xhr.responseText || '').replace(/<[^>]*>/g, ' ').slice(0, 160).trim();
reject(new Error('上传到存储失败(' + xhr.status + ')' + (msg ? ':' + msg : '')));
}
};
xhr.onerror = function () { reject(new Error('网络异常,上传中断')); };
xhr.send(fd);
});
}
async function handleFiles(box, files) {
var kind = box.getAttribute('data-kind') || 'image';
var maxCount = parseInt(box.getAttribute('data-max-count') || '1', 10);
var maxBytes = parseInt(box.getAttribute('data-max-bytes') || '0', 10);
var maxSide = parseInt(box.getAttribute('data-max-side') || '1600', 10);
var label = box.getAttribute('data-label') || '附件';
// ⚠️ ''.split(',') 返回 [""](长度 1),不是空数组。
// 直接用 allowed.length 判「有没有配置白名单」是错的 ——
// 空列表会被当成「白名单只有空串」,于是拒绝一切文件。
// 本意应是:没配置 = 不限格式。所以先滤掉空项。
var allowed = (box.getAttribute('data-accept') || '')
.split(',')
.filter(function (t) { return t.trim() !== ''; });
for (var i = 0; i < files.length; i++) {
var file = files[i];
// allowed 为空 = 后台没限制格式,放行
if (allowed.length && allowed.indexOf(file.type) < 0) {
alert('「' + file.name + '」格式不支持,请选择:' + allowed.join(' / '));
continue;
}
if (maxBytes > 0 && file.size > maxBytes) {
alert('「' + file.name + '」超过 ' + human(maxBytes) + ' 上限。');
continue;
}
// maxCount = 0 表示不限个数(后台可填 0),此时不做任何拦截。
// 别写成 `count >= 0`,那样等于一个都不让传。
if (maxCount > 0 && countOf(kind) >= maxCount) {
alert(label + '最多上传 ' + maxCount + ' 个,请先移除已上传的文件。');
break;
}
var node = makeNode(box, { kind: kind, name: file.name, thumb: '' });
setPending(1);
node.rm.disabled = true;
try {
node.st.textContent = '处理中…';
var prepared;
if (kind === 'image') {
prepared = await prepareImage(file, maxSide);
if (prepared.blob !== file && prepared.blob.size > 0) {
node.st.textContent = '已压缩 ' + human(file.size) + ' → ' + human(prepared.blob.size);
try { node.thumb.querySelector('img').src = URL.createObjectURL(prepared.blob); } catch (e2) { /* 忽略 */ }
}
} else {
prepared = { blob: file, mime: file.type, name: file.name };
}
var ext = extByMime(prepared.mime);
var d = await sign(kind, ext, prepared.blob.size, prepared.mime);
node.st.textContent = '上传中…';
await upload(d.credential, d.fields, prepared.blob, prepared.name, function (p) {
node.fill.style.width = Math.round(p * 100) + '%';
node.st.textContent = '上传中 ' + Math.round(p * 100) + '%';
});
var item = {
kind: kind,
key: d.credential.key,
size: prepared.blob.size,
name: prepared.name,
mime: prepared.mime,
w: 0, h: 0,
thumb: d.credential.url || ''
};
state.push(item);
sync();
node.el.classList.add('done');
node.fill.style.width = '100%';
node.st.textContent = '已上传 · ' + human(item.size);
if (kind === 'image' && item.thumb) {
var imgEl = node.thumb.querySelector('img');
if (imgEl) { imgEl.src = item.thumb; }
}
node.rm.disabled = false;
node.rm.addEventListener('click', function (it) {
return function () { removeItem(node, it); };
}(item));
} catch (e) {
node.el.classList.add('fail');
node.st.textContent = (e && e.message) ? e.message : '上传失败';
node.rm.textContent = '删除';
node.rm.disabled = false;
node.rm.addEventListener('click', function () { removeItem(node, null); });
} finally {
setPending(-1);
}
}
}
// 回填服务端已保存的附件(校验失败后重新提交时)
var byKind = {};
for (var s = 0; s < state.length; s++) {
var it0 = state[s];
var box0 = root.querySelector('[data-uploader][data-kind="' + it0.kind + '"]');
if (!box0) { continue; }
byKind[it0.kind] = (byKind[it0.kind] || 0) + 1;
var nd = makeNode(box0, it0);
nd.el.classList.add('done');
nd.fill.style.width = '100%';
nd.st.textContent = '已上传 · ' + human(it0.size);
nd.rm.addEventListener('click', function (item, node) {
return function () { removeItem(node, item); };
}(it0, nd));
}
sync();
var boxes = $$('[data-uploader]', root);
for (var b = 0; b < boxes.length; b++) {
(function (box) {
var pick = box.querySelector('.up-pick');
var input = box.querySelector('.up-file');
if (!pick || !input) { return; }
pick.addEventListener('click', function () { input.click(); });
input.addEventListener('change', function () {
var files = [];
for (var k = 0; k < input.files.length; k++) { files.push(input.files[k]); }
input.value = '';
if (files.length) { handleFiles(box, files); }
});
// 拖拽上传
['dragenter', 'dragover'].forEach(function (ev) {
box.addEventListener(ev, function (e) { e.preventDefault(); box.classList.add('dragging'); });
});
['dragleave', 'drop'].forEach(function (ev) {
box.addEventListener(ev, function (e) { e.preventDefault(); box.classList.remove('dragging'); });
});
box.addEventListener('drop', function (e) {
e.preventDefault();
var files = [];
if (e.dataTransfer && e.dataTransfer.files) {
for (var k = 0; k < e.dataTransfer.files.length; k++) { files.push(e.dataTransfer.files[k]); }
}
if (files.length) { handleFiles(box, files); }
});
}(boxes[b]));
}
}
function bindUploaders() {
var roots = $$('[data-uploader-root]');
for (var i = 0; i < roots.length; i++) { initUploaderRoot(roots[i]); }
}
/* ---------- 附件灯箱:点击放大 + 左右切换 ---------- */
function bindLightbox() {
var galleries = $$('[data-gallery]');
if (!galleries.length) { return; }
var ov = null, items = [], cur = 0, lastFocus = null, built = false;
var media = null, nameEl = null, metaEl = null, origEl = null, prevBtn = null, nextBtn = null;
function buildOverlay() {
ov = document.createElement('div');
ov.className = 'lb';
ov.hidden = true;
ov.setAttribute('role', 'dialog');
ov.setAttribute('aria-modal', 'true');
ov.setAttribute('aria-label', '附件查看');
ov.innerHTML =
'<button type="button" class="lb-x" aria-label="关闭">✕</button>' +
'<div class="lb-stage">' +
'<button type="button" class="lb-nav lb-prev" aria-label="上一个">‹</button>' +
'<div class="lb-media"></div>' +
'<button type="button" class="lb-nav lb-next" aria-label="下一个">›</button>' +
'</div>' +
'<div class="lb-bar">' +
'<span class="lb-name"></span>' +
'<span class="lb-meta"></span>' +
'<a class="lb-orig" href="#" target="_blank" rel="noopener noreferrer">查看原图</a>' +
'</div>';
document.body.appendChild(ov);
media = $('.lb-media', ov);
nameEl = $('.lb-name', ov);
metaEl = $('.lb-meta', ov);
origEl = $('.lb-orig', ov);
prevBtn = $('.lb-prev', ov);
nextBtn = $('.lb-next', ov);
$('.lb-x', ov).addEventListener('click', close);
prevBtn.addEventListener('click', function () { go(-1); });
nextBtn.addEventListener('click', function () { go(1); });
ov.addEventListener('click', function (e) {
// 点空白处(遮罩本身或图片外的区域)关闭
if (e.target === ov || e.target === media) { close(); }
});
// 触摸左右滑动
var tx = 0, ty = 0, tracking = false;
ov.addEventListener('touchstart', function (e) {
if (!e.touches || e.touches.length !== 1) { tracking = false; return; }
tx = e.touches[0].clientX; ty = e.touches[0].clientY; tracking = true;
}, { passive: true });
ov.addEventListener('touchend', function (e) {
if (!tracking || !e.changedTouches || !e.changedTouches.length) { return; }
tracking = false;
var dx = e.changedTouches[0].clientX - tx;
var dy = e.changedTouches[0].clientY - ty;
if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy)) { return; }
go(dx < 0 ? 1 : -1); // 左滑=看下一个,与浏览图片的直觉一致
}, { passive: true });
document.addEventListener('keydown', function (e) {
if (!ov || ov.hidden) { return; }
var k = e.key;
if (k === 'Escape') { close(); }
else if (k === 'ArrowLeft') { go(-1); }
else if (k === 'ArrowRight') { go(1); }
else if (k === 'Home') { cur = 0; render(); }
else if (k === 'End') { cur = items.length - 1; render(); }
else { return; }
e.preventDefault();
});
}
function render() {
if (!items.length) { return; }
var it = items[cur];
media.className = 'lb-media loading';
media.innerHTML = '';
var node;
if (it.kind === 'video') {
node = document.createElement('video');
node.controls = true;
node.autoplay = true;
node.playsInline = true;
node.setAttribute('playsinline', '');
node.src = it.src;
if (it.poster) { node.poster = it.poster; }
} else {
node = document.createElement('img');
node.alt = it.name || '';
node.src = it.src;
}
function ready() { media.classList.remove('loading'); }
node.addEventListener('load', ready);
node.addEventListener('loadeddata', ready);
node.addEventListener('error', ready);
media.appendChild(node);
nameEl.textContent = it.name || (it.kind === 'video' ? '视频' : '图片');
metaEl.textContent = (cur + 1) + ' / ' + items.length + (it.size ? ' · ' + it.size : '');
if (it.kind === 'video') {
origEl.hidden = true;
} else {
origEl.hidden = false;
origEl.href = it.full || it.src;
}
var multi = items.length > 1;
prevBtn.hidden = !multi;
nextBtn.hidden = !multi;
// 预载相邻图片,切换时更快
if (multi) {
var nb = [items[(cur + 1) % items.length], items[(cur - 1 + items.length) % items.length]];
for (var i = 0; i < nb.length; i++) {
if (nb[i].kind === 'image' && nb[i].src) { (new Image()).src = nb[i].src; }
}
}
}
function go(step) {
if (items.length < 2) { return; }
cur = (cur + step + items.length) % items.length;
render();
}
function open(list, idx) {
if (!built) { buildOverlay(); built = true; }
if (!list.length) { return; }
items = list;
cur = Math.max(0, Math.min(idx, list.length - 1));
lastFocus = document.activeElement;
// 打开灯箱时暂停正文里的内嵌视频,避免两个声音一起响
var inline = document.querySelectorAll('.atts video');
for (var i = 0; i < inline.length; i++) { try { inline[i].pause(); } catch (e) {} }
ov.hidden = false;
document.documentElement.classList.add('lb-open');
document.body.classList.add('lb-open');
render();
var x = $('.lb-x', ov);
if (x) { x.focus(); }
}
function close() {
if (!ov || ov.hidden) { return; }
ov.hidden = true;
media.innerHTML = ''; // 顺带停止灯箱里的视频播放
document.documentElement.classList.remove('lb-open');
document.body.classList.remove('lb-open');
if (lastFocus && lastFocus.focus) { try { lastFocus.focus(); } catch (e) {} }
}
// 收集一个画廊里的全部附件(按 DOM 顺序)
function itemsOf(gal) {
var els = $$('[data-lb-kind]', gal), out = [];
for (var i = 0; i < els.length; i++) {
var el = els[i];
out.push({
kind: el.getAttribute('data-lb-kind') || 'image',
src: el.getAttribute('data-lb-src') || el.getAttribute('href') || '',
full: el.getAttribute('data-lb-full') || '',
poster: el.getAttribute('data-lb-poster') || '',
name: el.getAttribute('data-lb-name') || '',
size: el.getAttribute('data-lb-size') || ''
});
}
return out;
}
for (var g = 0; g < galleries.length; g++) {
(function (gal) {
gal.addEventListener('click', function (e) {
var t = e.target, trigger = null;
while (t && t !== gal) {
if (t.getAttribute && t.getAttribute('data-lb-kind')) { trigger = t; break; }
t = t.parentNode;
}
if (!trigger) { return; }
e.preventDefault(); // 图片是 <a>,有 JS 时就在页内放大
var all = $$('[data-lb-kind]', gal), idx = 0;
for (var i = 0; i < all.length; i++) { if (all[i] === trigger) { idx = i; break; } }
open(itemsOf(gal), idx);
});
}(galleries[g]));
}
}
/* ---------- 表单就地校验(不刷新页面,不打扰排版) ----------
* 目标:必填项没填时,在字段下方立刻给出提示并把焦点移过去,
* 而不是先 POST 上去、整页重刷、再在顶部弹一条错误。
* 规则来源:input 上原生 required / minlength / pattern / type=email。
* 用法:给 form 加 data-validate 即可,其余零配置。
*
* ⚠️ 必须在 bindNoDoubleSubmit 之前绑:后者会在 submit 时禁用按钮 5 秒,
* 若校验放在它之后,被拦下的表单按钮会被锁住,用户以为点坏了。
*/
function bindInlineValidate() {
var forms = document.querySelectorAll('form[data-validate]');
for (var i = 0; i < forms.length; i++) {
(function (form) {
form.setAttribute('novalidate', 'novalidate'); // 关掉浏览器默认的整页提示
form.addEventListener('submit', function (ev) {
var bad = null, msg = '';
var fields = form.querySelectorAll('input, textarea, select');
for (var j = 0; j < fields.length; j++) {
var f = fields[j];
if (f.disabled || f.type === 'hidden' || f.type === 'submit') { continue; }
var v = (f.value || '').trim();
msg = '';
if (f.hasAttribute('required') && v === '') {
msg = '这一项必须填写。';
} else if (v !== '' && f.hasAttribute('minlength') &&
v.length < Number(f.getAttribute('minlength'))) {
msg = '至少 ' + f.getAttribute('minlength') + ' 个字符,现在 ' + v.length + ' 个。';
} else if (v !== '' && f.type === 'email' &&
!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) {
msg = '邮箱格式不正确。';
} else if (v !== '' && f.hasAttribute('pattern') &&
!new RegExp('^(?:' + f.getAttribute('pattern') + ')$').test(v)) {
msg = f.getAttribute('data-pattern-msg') || '格式不正确。';
}
if (msg) { bad = f; break; }
}
if (bad) {
ev.preventDefault();
ev.stopImmediatePropagation(); // 拦住后续的"禁用按钮"逻辑
fieldError(bad, msg);
return false;
}
clearAllErrors(form);
}, true); // ★ 捕获阶段:先于 bindNoDoubleSubmit 的冒泡处理
})(forms[i]);
}
}
/** 在字段下显示错误;重复调用只更新文字,不叠加 */
function fieldError(field, msg) {
var wrap = field.closest('.field') || field.parentNode;
if (!wrap) { return; }
wrap.classList.add('has-error');
var tip = wrap.querySelector('.field-error');
if (!tip) {
tip = document.createElement('i');
tip.className = 'field-error';
wrap.appendChild(tip);
}
tip.textContent = msg;
// 卷到可见 + 聚焦,方便直接改
field.focus({ preventScroll: false });
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
// 用户一改就撤掉提示,不要一直红着
var clear = function () {
wrap.classList.remove('has-error');
if (tip && tip.parentNode) { tip.parentNode.removeChild(tip); }
field.removeEventListener('input', clear);
field.removeEventListener('change', clear);
};
field.addEventListener('input', clear);
field.addEventListener('change', clear);
}
function clearAllErrors(form) {
var errs = form.querySelectorAll('.field-error');
for (var i = 0; i < errs.length; i++) {
if (errs[i].parentNode) { errs[i].parentNode.removeChild(errs[i]); }
}
var wraps = form.querySelectorAll('.has-error');
for (var k = 0; k < wraps.length; k++) { wraps[k].classList.remove('has-error'); }
}
/* ---------- 后台「不予考虑」说明面板 ----------
* 为什么不用 window.prompt:
* prompt 不能多行、不能校验、样式不可控,而且移动端体验差。
* 这里展开一个内联面板,用户能看到「这封信会发给谁」,写得更认真。
*/
function bindWontfixPanel() {
var openBtn = document.querySelector('[data-wontfix-open]');
var panel = document.querySelector('[data-wontfix-panel]');
var closeBtn = document.querySelector('[data-wontfix-close]');
if (!openBtn || !panel) { return; }
openBtn.addEventListener('click', function () {
panel.hidden = false;
var ta = panel.querySelector('textarea');
if (ta) {
ta.focus();
ta.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
if (closeBtn) {
closeBtn.addEventListener('click', function () {
panel.hidden = true;
});
}
}
/* ---------- 主题色选择器(后台软件编辑) ---------- */
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();
bindWontfixPanel();
bindColorPicker();
bindMailTest();
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
bindNoDoubleSubmit();
bindUploaders();
bindLightbox();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();