Files
FeedLite/assets/app.js
T
laoyang 9b971a5b92 下拉框改为自绘列表;修复新建软件缺排序字段
用户两个反馈:
① 「排序那个怎么删了?排序还得留着呀」
② 「所有下拉框拉下来之后全是灰色,不好看,重新设计」

一、排序字段(是误解,但确实有缺陷)

排序字段没有被删,一直在 if ($esw) 分支里渲染。
用户看到"没有",是因为他打开的是「添加软件」——
新建时 $esw 为空,整块不渲染,所以确实没有这一项。
这是真实缺陷:新建软件时无法指定排序。

修法:
- 把排序字段移出 if ($esw),新建/编辑都显示
- 新建时默认值 = 当前最大 sort_order + 1(排到最后)
- 后端 sw_add 改为优先用表单值(用 isset 判断,
  用户明确填 0 是合法值——排最前面,不能被当成"没填")

与「下载地址」不同:排序是真实会被用到的字段
(前台软件列表、顶栏三横线菜单都按它排),不能删。

二、下拉框重设计

为什么必须自绘:原生 select 的展开列表由浏览器绘制,
CSS 只能改 option 的底色,改不了圆角/行高/hover 高亮,
各平台样式还不一样(Windows 是灰方框)。用户说的"全是灰色"就是这个。

但不重写 HTML:全站 16 个 select,逐个改容易漏,
也会破坏 onchange 提交这类内联逻辑。

做法:原生 select 保留(视觉隐藏,仍参与提交与 change 事件),
JS 在旁生成按钮 + 浮层列表。
- 浮层用 position:fixed 挂 body,避免被 overflow:hidden 裁(踩过)
- 位置自动避让视口,下方不够翻上方
- 支持键盘:上下键 Home End Enter Esc
- 选中项带对勾
- 无 JS 时原生 select 仍可用

配色踩坑:选中项最初用 var(--brand-soft),而该变量会被软件主题色覆盖 ——
同步日历主题色 #ff3f44 时 --brand-soft 成了 #ffe8e9(粉红),
下拉里一片粉很难看。下拉是工具性控件,不该跟软件换色,
改用中性色:#eef1f5 选中 / #f1f3f6 hover,四个软件下完全一致。

验证(真实页面实测):
- 2 个 select 均被增强,原生已隐藏
- 面板白底 rgb(255,255,255)、圆角 11px、双层柔和阴影、7 个列表项
- 选中项 rgb(238,241,245)、hover rgb(241,243,246)、带对勾
- 四个软件(#0057b8/#ff3f44/#a556f7/#3571fb)下选中项底色完全相同,
  不再随主题色变粉
- 键盘下方向键三次 → 高亮「处理中」,底色正确
- 选择后 selectedIndex/value 同步、面板自动关闭、change 事件已触发
- 浮层 fixed 定位、完整可见、不被裁切
- 排序字段 3 处(软件/分类/状态)齐全;5 个路由 200
2026-11-04 19:27:01 +08:00

1429 lines
57 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'; });
});
}
}
/* ---------- (已删)管理员口令切换 ----------
* 回复表单里不再有「管理员口令」和「处理状态」——用户 2026-11-03 要求去掉。
* 管理员身份改由后台登录态判断,状态流转在后台详情页的按钮里做。 */
/* ---------- 防止重复提交(上传未完成时阻止提交) ---------- */
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;
});
}
}
/* ---------- 主题色选择器(后台软件编辑) ----------
* 用户 2026-11-04:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」
*
* 现在的形态是一块**完整色板**(不是小浮窗):
* 340×200 的色相/饱和度画布 + 色相条 + 明度条 + 十六进制输入。
* 右侧那排预设圆点已删除 —— 既能任意取色,再摆一排样例就是重复。
*
* 交互约定:
* - 点色块 → 色板出现(下方空间不够自动翻到上方)
* - 画布内拖动 = 同时调饱和度与明度
* - 色相条单独调 H;明度条单独调 V
* - 十六进制框可手输,合法立即同步
* - 点「完成」/ 点色板外 / Esc → 关闭(值早已写入隐藏字段)
*/
function bindColorPicker() {
var rows = document.querySelectorAll('[data-colorpicker]');
for (var i = 0; i < rows.length; i++) {
(function (row) {
var trigger = row.querySelector('[data-color-trigger]');
var valueEl = row.querySelector('[data-color-value]');
var reset = row.querySelector('.color-reset');
var panel = row.querySelector('[data-color-panel]');
if (!trigger || !valueEl || !panel) { return; }
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); // 都放不下:贴住底部
}
}
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
}
function closePanel() { panel.hidden = true; }
// 窗口尺寸变化 / 滚动时重新定位,避免色板"漂走"
window.addEventListener('resize', positionPanel);
window.addEventListener('scroll', positionPanel, true);
trigger.addEventListener('click', function (e) {
e.stopPropagation();
if (panel.hidden) { openPanel(); } else { closePanel(); }
});
document.addEventListener('click', function (e) {
if (!panel.hidden && !panel.contains(e.target) && e.target !== trigger && !trigger.contains(e.target)) {
closePanel();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) { closePanel(); }
});
// ---- 渐变板拖动 ----
var dragging = false;
function pickSV(ev) {
var rect = canvas.getBoundingClientRect();
var x = (ev.touches ? ev.touches[0].clientX : ev.clientX) - rect.left;
var y = (ev.touches ? ev.touches[0].clientY : ev.clientY) - rect.top;
hsv.s = Math.max(0, Math.min(1, x / rect.width));
hsv.v = Math.max(0, Math.min(1, 1 - y / rect.height));
syncAll();
}
canvas.addEventListener('mousedown', function (e) { dragging = true; pickSV(e); e.preventDefault(); });
canvas.addEventListener('touchstart', function (e) { dragging = true; pickSV(e); }, { passive: true });
document.addEventListener('mousemove', function (e) { if (dragging) { pickSV(e); } });
document.addEventListener('touchmove', function (e) { if (dragging) { pickSV(e); } }, { passive: true });
document.addEventListener('mouseup', function () { dragging = false; });
document.addEventListener('touchend', function () { dragging = false; });
// ---- 色相滑条 ----
hueEl.addEventListener('input', function () {
hsv.h = parseFloat(this.value) || 0;
syncAll();
});
// ---- 明度滑条 ----
if (valEl) {
valEl.addEventListener('input', function () {
hsv.v = (parseFloat(this.value) || 0) / 100;
syncAll();
});
}
// ---- 十六进制手输 ----
hexEl.addEventListener('input', function () {
var v = this.value.trim();
if (/^#?[0-9a-fA-F]{6}$/.test(v)) {
setHex(v[0] === '#' ? v : '#' + v, true);
}
});
okBtn.addEventListener('click', function () { closePanel(); });
// ---- 用默认(清空 = 保存后回落到按 id 取的默认色)----
if (reset) {
reset.addEventListener('click', function () {
valueEl.value = '';
valueEl.setAttribute('name', 'color');
trigger.style.setProperty('--cur', '#cccccc');
var hexTxt = trigger.querySelector('.color-trigger-hex');
if (hexTxt) { hexTxt.textContent = '跟随默认'; }
closePanel();
});
}
// 初始化
if (valueEl.value) { setHex(valueEl.value, false); } else { syncAll(); }
})(rows[i]);
}
}
/* ---------- 图标上传:选中即预览 + 即时校验 ----------
* 用户 2026-11-04:「上传的那个图标没有显示,也没有上传成功、失败之类的提示信息。」
*
* 原来就是一个裸 <input type="file">,选完文件什么都没发生 ——
* 用户不知道选没选中、图片对不对、会不会传不上去。表单整页提交后
* 提示条又在页面顶部,很容易错过。
*
* 这里做三件事(都在前端,最终仍以后端校验为准):
* ① 选中文件立刻用 FileReader 本地预览,不等提交
* ② 显示文件名 + 尺寸 + 大小,让用户确认选对了
* ③ 选之前就拦掉明显不合格的(格式/大小/太小),省一次白提交
* ⚠️ 前端校验只是体验优化,**绝不能**当作安全边界 ——
* 真正的类型/尺寸/大小校验仍在 php 的 fb_icon_store_upload() 里。
*/
function bindIconUpload() {
var inputs = document.querySelectorAll('input[type=file][name=icon_file]');
for (var i = 0; i < inputs.length; i++) {
(function (input) {
var wrap = input.closest('.icon-up');
if (!wrap) { return; }
var prev = wrap.querySelector('.icon-prev');
// 按钮上的文件名(可选,新样式才有)
var nameEl = wrap.querySelector('[data-file-name]');
/* 状态条插在**整个文件按钮之后**(不是 input 之后)。
* input 现在被包在 <label class="file-btn"> 里,如果插到 input.nextSibling
* 会跑到 label 内部,跟按钮文字挤在一起。 */
var btn = input.closest('.file-btn') || input;
var tip = document.createElement('span');
tip.className = 'icon-up-tip';
tip.setAttribute('role', 'status');
if (btn.parentNode) { btn.parentNode.insertBefore(tip, btn.nextSibling); }
function setTip(msg, kind) {
tip.textContent = msg || '';
tip.className = 'icon-up-tip' + (kind ? ' is-' + kind : '');
}
function setName(txt) {
if (nameEl) { nameEl.textContent = txt || ''; }
}
input.addEventListener('change', function () {
var f = this.files && this.files[0];
if (!f) { setTip(''); setName(''); return; }
var KB = Math.round(f.size / 1024);
var sizeTxt = KB >= 1024 ? (KB / 1024).toFixed(1) + ' MB' : KB + ' KB';
// ① 格式
var okType = /^image\/(png|jpeg|webp)$/.test(f.type);
if (!okType) {
setTip('✕ 只能选 PNG / JPG / WEBP 图片,当前是「' + (f.type || '未知类型') + '」', 'err');
setName('');
this.value = '';
return;
}
// ② 大小(后端限 2MB)
if (f.size > 2 * 1024 * 1024) {
setTip('✕ 图片 ' + sizeTxt + ',超过 2 MB 上限,请压缩后再传', 'err');
setName('');
this.value = '';
return;
}
if (f.size <= 0) {
setTip('✕ 这是个空文件', 'err');
setName('');
this.value = '';
return;
}
// ③ 本地预览 + 尺寸检查
var fr = new FileReader();
fr.onload = function (ev) {
var img = new Image();
img.onload = function () {
if (img.width < 32 || img.height < 32) {
setTip('✕ 图片只有 ' + img.width + '×' + img.height + ',至少 32×32', 'err');
setName('');
input.value = '';
return;
}
if (prev) {
prev.innerHTML = '';
var el = document.createElement('img');
el.src = ev.target.result;
el.alt = '';
el.width = 48; el.height = 48;
prev.appendChild(el);
}
var warn = (img.width !== img.height) ? ' ⚠ 不是正方形,显示时会被裁切' : '';
setName(f.name);
setTip('✓ ' + img.width + '×' + img.height + ',' + sizeTxt
+ warn + ' 保存后生效', 'ok');
};
img.onerror = function () {
setTip('✕ 这张图读不出来,可能已损坏', 'err');
setName('');
input.value = '';
};
img.src = ev.target.result;
};
fr.onerror = function () { setTip('✕ 读取文件失败', 'err'); setName(''); };
fr.readAsDataURL(f);
});
})(inputs[i]);
}
}
/* ---------- 图标快选(后台软件编辑) ---------- */
function bindIconPicker() {
var boxes = document.querySelectorAll('.icon-presets');
for (var i = 0; i < boxes.length; i++) {
(function (box) {
// 图标输入框是「图标快选」的前一个兄弟里的 input[name=icon]
var field = box.parentNode;
var input = field ? field.querySelector('input[name=icon]') : null;
if (!input) { return; }
var btns = box.querySelectorAll('.icon-preset');
function syncOn(v) {
for (var k = 0; k < btns.length; k++) {
var same = (btns[k].getAttribute('data-icon') || '') === v;
if (same) { btns[k].classList.add('on'); } else { btns[k].classList.remove('on'); }
}
}
for (var j = 0; j < btns.length; j++) {
btns[j].addEventListener('click', function () {
input.value = this.getAttribute('data-icon') || '';
syncOn(input.value);
});
}
input.addEventListener('input', function () { syncOn(this.value); });
syncOn(input.value);
})(boxes[i]);
}
}
/* ---------- 自定义下拉:替换原生 <select> 的展开列表 ----------
* 用户 2026-11-04:「所有下拉框拉下来之后怎么全是灰色的?不好看,重新设计。」
*
* 🔴 为什么必须自绘:原生 <select> 的展开列表是**浏览器渲染的原生控件**,
* CSS 只能改 <option> 的 background/color,改不了圆角、行高、hover 高亮、
* 分组间距 —— 而且各平台样式完全不同(macOS 是圆角浮层、Windows 是灰方框)。
* 想让"展开后"好看,只能自己画一个列表。
*
* 设计取舍:
* - **保留原生 select 作为数据源**:不重写 HTML(全站 16 个 select,
* 逐个改容易漏、也容易破坏 onchange 内联逻辑)。
* 把原生 select 视觉隐藏,在旁边放一个按钮 + 浮层列表。
* - 原生 select 仍参与表单提交(值同步),onchange 也照样触发
* —— 调用 this.form.submit() 的那些筛选下拉因此不用改动。
* - 无 JS 时原生 select 正常可用(只是样式朴素),不会白屏。
* - 浮层用 position:fixed 定位,避免被 overflow:hidden 容器裁掉(踩过)。
*/
function bindSelect() {
var sels = document.querySelectorAll('select');
for (var i = 0; i < sels.length; i++) {
(function (sel) {
// 已经增强过、或明确排除的跳过
if (sel.getAttribute('data-enhanced') === '1') { return; }
if (sel.hasAttribute('data-no-enhance')) { return; }
// 隐藏域 / 多选 / 尺寸>1 的列表框不动
if (sel.multiple || (parseInt(sel.getAttribute('size'), 10) || 0) > 1) { return; }
sel.setAttribute('data-enhanced', '1');
// 原生 select:保留在 DOM 里(提交用),但视觉隐藏
sel.classList.add('sel-native');
// 包一层,方便定位
var wrap = document.createElement('span');
wrap.className = 'sel';
sel.parentNode.insertBefore(wrap, sel);
wrap.appendChild(sel);
// 触发按钮
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sel-btn';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
wrap.appendChild(btn);
// 浮层列表(挂到 body,避开一切 overflow 裁剪)
var panel = document.createElement('div');
panel.className = 'sel-panel';
panel.hidden = true;
panel.setAttribute('role', 'listbox');
document.body.appendChild(panel);
var active = -1; // 键盘高亮项
function curText() {
var o = sel.options[sel.selectedIndex];
return o ? o.textContent : '';
}
function syncBtn() {
btn.innerHTML = '<span class="sel-txt"></span><span class="sel-caret" aria-hidden="true"></span>';
btn.querySelector('.sel-txt').textContent = curText();
btn.disabled = sel.disabled;
btn.classList.toggle('is-placeholder', sel.selectedIndex <= 0 && sel.options.length > 0
&& (sel.options[0].value === ''));
}
function buildList() {
panel.innerHTML = '';
for (var k = 0; k < sel.options.length; k++) {
(function (idx) {
var o = sel.options[idx];
if (o.hidden) { return; }
var item = document.createElement('button');
item.type = 'button';
item.className = 'sel-item';
item.setAttribute('role', 'option');
var on = idx === sel.selectedIndex;
if (on) { item.classList.add('on'); item.setAttribute('aria-selected', 'true'); }
var label = document.createElement('span');
label.className = 'sel-item-txt';
label.textContent = o.textContent;
item.appendChild(label);
if (on) {
var ck = document.createElement('span');
ck.className = 'sel-item-ck';
ck.setAttribute('aria-hidden', 'true');
item.appendChild(ck);
}
item.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
pick(idx);
});
panel.appendChild(item);
})(k);
}
}
function pick(idx) {
if (idx < 0 || idx >= sel.options.length) { return; }
var changed = sel.selectedIndex !== idx;
sel.selectedIndex = idx;
syncBtn();
close();
// 触发原生 change,兼容那些 onchange="this.form.submit()" 的下拉
if (changed) {
var ev = document.createEvent('HTMLEvents');
ev.initEvent('change', true, false);
sel.dispatchEvent(ev);
}
}
function open() {
if (btn.disabled) { return; }
buildList();
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
btn.classList.add('is-open');
position();
// 打开后键盘可直接上下选
active = sel.selectedIndex;
highlight(active);
document.addEventListener('mousedown', outside, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
}
function close() {
if (panel.hidden) { return; }
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
btn.classList.remove('is-open');
document.removeEventListener('mousedown', outside, true);
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
}
function outside(e) {
if (!panel.contains(e.target) && !btn.contains(e.target)) { close(); }
}
/* 定位:fixed,挂在 body 上,不服从任何祖先的 overflow。
* 优先向下展开;下方空间不足且上方更宽裕时向上翻。 */
function position() {
if (panel.hidden) { return; }
var r = btn.getBoundingClientRect();
var vw = window.innerWidth, vh = window.innerHeight;
var pw = Math.max(panel.offsetWidth, r.width);
var ph = panel.offsetHeight;
var margin = 8;
var left = r.left;
if (left + pw + margin > vw) { left = Math.max(margin, vw - pw - margin); }
var below = vh - r.bottom - margin;
var above = r.top - margin;
var openUp = (ph > below) && (above > below);
var top = openUp ? (r.top - ph - 4) : (r.bottom + 4);
if (top < margin) { top = margin; }
if (top + ph > vh - margin) { top = Math.max(margin, vh - ph - margin); }
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
panel.style.minWidth = Math.round(r.width) + 'px';
panel.classList.toggle('is-up', openUp);
}
function highlight(idx) {
var items = panel.querySelectorAll('.sel-item');
for (var k = 0; k < items.length; k++) {
items[k].classList.toggle('is-active', k === idx);
}
if (idx >= 0 && items[idx]) {
var it = items[idx];
var t = it.offsetTop, b = t + it.offsetHeight;
if (t < panel.scrollTop) { panel.scrollTop = t; }
else if (b > panel.scrollTop + panel.clientHeight) {
panel.scrollTop = b - panel.clientHeight;
}
}
}
function onKey(e) {
var items = panel.querySelectorAll('.sel-item');
if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
active = Math.min(items.length - 1, active + 1); highlight(active);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
active = Math.max(0, active - 1); highlight(active);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (active >= 0) { pick(active); }
} else if (e.key === 'Home') {
e.preventDefault(); active = 0; highlight(active);
} else if (e.key === 'End') {
e.preventDefault(); active = items.length - 1; highlight(active);
}
}
btn.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
if (panel.hidden) { open(); } else { close(); }
});
// 原生 select 若在别处被程序改值,按钮文字要跟着变
sel.addEventListener('change', syncBtn);
syncBtn();
})(sels[i]);
}
}
/* ---------- 顶栏软件下拉菜单(右上角三横线) ----------
* 极简:点了就展开一个小浮窗,点外面 / Esc 就关。
* 不做遮罩、不锁背景滚动 —— 那套是侧边栏的做法,下拉菜单不需要。 */
function bindSoftwarePanel() {
var btn = document.getElementById('swToggle');
var panel = document.getElementById('swPanel');
if (!btn || !panel) { return; }
function close() {
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (panel.hidden) {
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
} else {
close();
}
});
// 点菜单内部不算点外面
panel.addEventListener('click', function (e) { e.stopPropagation(); });
document.addEventListener('click', function () {
if (!panel.hidden) { close(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) { close(); btn.focus(); }
});
}
/* ---------- 邮件测试发送(后台设置页) ---------- */
function bindMailTest() {
var btn = document.getElementById('mailTestBtn');
if (!btn) { return; }
var out = document.getElementById('mailTestOut');
var to = document.getElementById('mailTestTo');
var swSel = document.getElementById('mailTestSw');
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);
if (swSel && swSel.value) { fd.append('sw', swSel.value); }
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();
bindWontfixPanel();
bindColorPicker();
bindIconPicker();
bindIconUpload();
bindSelect();
bindSoftwarePanel();
bindMailTest();
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
bindNoDoubleSubmit();
bindUploaders();
bindLightbox();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();