Files
FeedLite/assets/app.js
T
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00

628 lines
23 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 · 轻反馈 —— 轻量交互(含图片/视频直传 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;
});
}
/* ---------- 附件直传 ---------- */
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') || '附件';
var allowed = (box.getAttribute('data-accept') || '').split(',');
for (var i = 0; i < files.length; i++) {
var file = files[i];
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;
}
if (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]));
}
}
function boot() {
bindCaptchaRefresh();
bindAdminToggle();
bindNoDoubleSubmit();
bindUploaders();
bindLightbox();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();