主题色改为自绘取色面板(任意取色);图标上传加即时预览与状态提示
一、颜色不够丰富 → 点击弹取色面板
用户:「还是不行,颜色还是不够丰富。让用户选择吧:点击之后弹出一个颜色选择器。」
原来只有一个原生 <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:
+290
-20
@@ -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 之前
|
||||
|
||||
Reference in New Issue
Block a user