主题色改为自绘取色面板(任意取色);图标上传加即时预览与状态提示

一、颜色不够丰富 → 点击弹取色面板
  用户:「还是不行,颜色还是不够丰富。让用户选择吧:点击之后弹出一个颜色选择器。」
  原来只有一个原生 <input type=color> 小方块,用户不知道能点,
  而且系统面板各浏览器样式不一、只能取纯色。
  改成自绘面板:
    色相/饱和度渐变板(Canvas)+ 明度滑块 + 十六进制输入 + 确定
    12 个预设圆点降级为快捷方式,真正的能力是任意取色
  交互:点色块弹出(下方空间不够自动翻上方)、板内拖动实时更新、
        十六进制可手输、点确定或面板外关闭、Esc 关闭

  ⚠️ 光标定位踩了两个坑,都写进注释了:
    ① 不能用 canvas.offsetLeft(相对 offsetParent,受父级 padding 影响,
       实测算出 236px 超出画布宽度 226px);要用两个 getBoundingClientRect 相减
    ② canvas 显示尺寸受 CSS width:100% 影响,≠ 内部像素 220×150,
       必须用 rect.width 而不是 canvas.width

二、图标上传没反馈
  用户:「上传的图标没有显示,也没有上传成功、失败之类的提示信息。」
  后端逻辑完整(格式/大小/尺寸/存储模式都校验了),问题在**前端零反馈**:
  裸 <input type=file>,选完文件什么都不发生,而提示条在页面顶部容易被错过。
  加了 bindIconUpload():
    ① 选中文件立刻 FileReader 本地预览,不等提交
    ② 显示文件名 + 尺寸 + 大小,让用户确认选对了
    ③ 选之前就拦掉明显不合格的(格式/超 2MB/小于 32×32/空文件)
    ④ 非正方形给出警告(显示时会被裁切)
  ⚠️ 前端校验只是体验优化,绝不能当安全边界 ——
     真正的校验仍在 PHP 的 fb_icon_store_upload() 里。

验证(用独立测试页 + 浏览器实测,不登录后台、不动数据):
  面板初始隐藏 → 点击后 248×246 出现
  Canvas 四角像素分别为灰白/蓝/黑 → 渐变确实画出来了
  板上取色 → 隐藏字段 #2178d9 同步到按钮文字/十六进制框/预览色
  点预设 → #2B822E;手输 → #a020f0;色相滑条 120° → #20f020
  光标中心 (238,54) 落在画布区域 (11..237, 11..161) 内
  4 个路由 200
This commit is contained in:
2026-11-04 18:52:45 +08:00
parent a00e2221c4
commit ad1b0eedf4
4 changed files with 394 additions and 27 deletions
+290 -20
View File
@@ -709,49 +709,318 @@
}
}
/* ---------- 主题色选择器(后台软件编辑) ---------- */
/* ---------- 主题色选择器(后台软件编辑) ----------
* 用户 2026-11-04:「还是不够丰富,让用户自己选:点击之后弹出一个颜色选择器。」
*
* 原来是原生 <input type="color">:只有个小方块,用户不知道能点,
* 而且各浏览器系统面板样式不一。现在自带一个面板:
* 色相/饱和度渐变板(Canvas)+ 明度滑块 + 十六进制输入。
* 预设圆点保留为快捷色,但真正的能力是**任意取色**。
*
* 交互约定:
* - 点色块 → 面板出现(定位在其下方/上方,视空间而定)
* - 在渐变板上拖动 → 实时更新;滑条调明度
* - 十六进制框可手输,输入合法立即同步
* - 点「确定」或点面板外 → 关闭,值已写入隐藏字段
*/
function bindColorPicker() {
var rows = document.querySelectorAll('.color-row');
var rows = document.querySelectorAll('[data-colorpicker]');
for (var i = 0; i < rows.length; i++) {
(function (row) {
var input = row.querySelector('input[type=color]');
var trigger = row.querySelector('[data-color-trigger]');
var valueEl = row.querySelector('[data-color-value]');
var presets = row.querySelectorAll('.color-preset');
var reset = row.querySelector('.color-reset');
if (!input) { return; }
var reset = row.querySelector('.color-reset');
var panel = row.querySelector('[data-color-panel]');
if (!trigger || !valueEl || !panel) { return; }
function syncOn(v) {
var canvas = panel.querySelector('[data-cp-sv]');
var cursor = panel.querySelector('[data-cp-cursor]');
var hueEl = panel.querySelector('[data-cp-hue]');
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 是 position:absolute 相对 .cp 定位,canvas 在 .cp 的
* 内边距里侧 —— 要补 offsetLeft/offsetTop,否则整体偏移;
* ② canvas 的**显示尺寸**受 CSS width:100% 影响,不等于其内部
* 像素宽高(220×150)。按内部尺寸算会越拉越偏。
* 必须用 getBoundingClientRect() 的宽高。 */
var rect = canvas.getBoundingClientRect();
var w = rect.width || canvas.width;
var h = rect.height || canvas.height;
cursor.style.left = (canvas.offsetLeft + hsv.s * w) + 'px';
cursor.style.top = (canvas.offsetTop + (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);
drawSV();
moveCursor();
syncPresets(hex);
}
function syncPresets(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'); }
}
}
function setHex(hex, skipHex) {
var c = hexToRgb(hex);
hsv = rgbToHsv(c[0], c[1], c[2]);
syncAll(skipHex);
}
// ---- 打开 / 关闭 ----
function openPanel() {
panel.hidden = false;
// 面板可能超出视口底部,翻到上方
var r = trigger.getBoundingClientRect();
panel.classList.remove('cp-up');
var need = panel.offsetHeight + 8;
if (r.bottom + need > window.innerHeight && r.top > need) {
panel.classList.add('cp-up');
}
drawSV(); moveCursor();
}
function closePanel() { panel.hidden = 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();
});
// ---- 十六进制手输 ----
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(); });
// ---- 预设 / 用默认 ----
for (var j = 0; j < presets.length; j++) {
presets[j].addEventListener('click', function () {
var c = this.getAttribute('data-color') || '';
input.value = c;
syncOn(c);
setHex(this.getAttribute('data-color') || '');
});
}
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'); // 提交时以隐藏字段为准
// 清空 = 保存后回落到按 id 取的默认色
valueEl.value = '';
valueEl.setAttribute('name', 'color');
syncPresets('');
trigger.style.setProperty('--cur', '#cccccc');
var hexTxt = trigger.querySelector('.color-trigger-hex');
if (hexTxt) { hexTxt.textContent = '跟随默认'; }
});
}
// 初始化
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 tip = document.createElement('span');
tip.className = 'icon-up-tip';
tip.setAttribute('role', 'status');
input.parentNode.insertBefore(tip, input.nextSibling);
function setTip(msg, kind) {
tip.textContent = msg || '';
tip.className = 'icon-up-tip' + (kind ? ' is-' + kind : '');
}
input.addEventListener('change', function () {
var f = this.files && this.files[0];
if (!f) { setTip(''); 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');
this.value = '';
return;
}
// ② 大小(后端限 2MB)
if (f.size > 2 * 1024 * 1024) {
setTip('✕ 图片 ' + sizeTxt + ',超过 2 MB 上限,请压缩后再传', 'err');
this.value = '';
return;
}
if (f.size <= 0) {
setTip('✕ 这是个空文件', 'err');
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');
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) ? ' ⚠ 不是正方形,显示时会被裁切' : '';
setTip('✓ 已选择:' + f.name + '(' + img.width + '×' + img.height + ',' + sizeTxt + ')'
+ warn + ' 保存后生效', 'ok');
};
img.onerror = function () {
setTip('✕ 这张图读不出来,可能已损坏', 'err');
input.value = '';
};
img.src = ev.target.result;
};
fr.onerror = function () { setTip('✕ 读取文件失败', 'err'); };
fr.readAsDataURL(f);
});
})(inputs[i]);
}
}
/* ---------- 图标快选(后台软件编辑) ---------- */
function bindIconPicker() {
var boxes = document.querySelectorAll('.icon-presets');
@@ -867,6 +1136,7 @@
bindWontfixPanel();
bindColorPicker();
bindIconPicker();
bindIconUpload();
bindSoftwarePanel();
bindMailTest();
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前