主题色改为自绘取色面板(任意取色);图标上传加即时预览与状态提示
一、颜色不够丰富 → 点击弹取色面板
用户:「还是不行,颜色还是不够丰富。让用户选择吧:点击之后弹出一个颜色选择器。」
原来只有一个原生 <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:
+289
-19
@@ -709,49 +709,318 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 主题色选择器(后台软件编辑) ---------- */
|
/* ---------- 主题色选择器(后台软件编辑) ----------
|
||||||
|
* 用户 2026-11-04:「还是不够丰富,让用户自己选:点击之后弹出一个颜色选择器。」
|
||||||
|
*
|
||||||
|
* 原来是原生 <input type="color">:只有个小方块,用户不知道能点,
|
||||||
|
* 而且各浏览器系统面板样式不一。现在自带一个面板:
|
||||||
|
* 色相/饱和度渐变板(Canvas)+ 明度滑块 + 十六进制输入。
|
||||||
|
* 预设圆点保留为快捷色,但真正的能力是**任意取色**。
|
||||||
|
*
|
||||||
|
* 交互约定:
|
||||||
|
* - 点色块 → 面板出现(定位在其下方/上方,视空间而定)
|
||||||
|
* - 在渐变板上拖动 → 实时更新;滑条调明度
|
||||||
|
* - 十六进制框可手输,输入合法立即同步
|
||||||
|
* - 点「确定」或点面板外 → 关闭,值已写入隐藏字段
|
||||||
|
*/
|
||||||
function bindColorPicker() {
|
function bindColorPicker() {
|
||||||
var rows = document.querySelectorAll('.color-row');
|
var rows = document.querySelectorAll('[data-colorpicker]');
|
||||||
|
|
||||||
for (var i = 0; i < rows.length; i++) {
|
for (var i = 0; i < rows.length; i++) {
|
||||||
(function (row) {
|
(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 presets = row.querySelectorAll('.color-preset');
|
||||||
var reset = row.querySelector('.color-reset');
|
var reset = row.querySelector('.color-reset');
|
||||||
if (!input) { return; }
|
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++) {
|
for (var k = 0; k < presets.length; k++) {
|
||||||
var same = (presets[k].getAttribute('data-color') || '').toLowerCase() === (v || '').toLowerCase();
|
var same = (presets[k].getAttribute('data-color') || '').toLowerCase() === (v || '').toLowerCase();
|
||||||
if (same) { presets[k].classList.add('on'); } else { presets[k].classList.remove('on'); }
|
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++) {
|
for (var j = 0; j < presets.length; j++) {
|
||||||
presets[j].addEventListener('click', function () {
|
presets[j].addEventListener('click', function () {
|
||||||
var c = this.getAttribute('data-color') || '';
|
setHex(this.getAttribute('data-color') || '');
|
||||||
input.value = c;
|
|
||||||
syncOn(c);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
input.addEventListener('input', function () { syncOn(this.value); });
|
|
||||||
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
reset.addEventListener('click', function () {
|
reset.addEventListener('click', function () {
|
||||||
// 清空 color 字段 = 保存后回落到按 id 取的默认色
|
// 清空 = 保存后回落到按 id 取的默认色
|
||||||
input.value = '#0057b8';
|
valueEl.value = '';
|
||||||
syncOn('');
|
valueEl.setAttribute('name', 'color');
|
||||||
var hidden = document.createElement('input');
|
syncPresets('');
|
||||||
hidden.type = 'hidden';
|
trigger.style.setProperty('--cur', '#cccccc');
|
||||||
hidden.name = 'color';
|
var hexTxt = trigger.querySelector('.color-trigger-hex');
|
||||||
hidden.value = '';
|
if (hexTxt) { hexTxt.textContent = '跟随默认'; }
|
||||||
row.appendChild(hidden);
|
|
||||||
input.removeAttribute('name'); // 提交时以隐藏字段为准
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
if (valueEl.value) { setHex(valueEl.value, false); } else { syncAll(); }
|
||||||
})(rows[i]);
|
})(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() {
|
function bindIconPicker() {
|
||||||
var boxes = document.querySelectorAll('.icon-presets');
|
var boxes = document.querySelectorAll('.icon-presets');
|
||||||
@@ -867,6 +1136,7 @@
|
|||||||
bindWontfixPanel();
|
bindWontfixPanel();
|
||||||
bindColorPicker();
|
bindColorPicker();
|
||||||
bindIconPicker();
|
bindIconPicker();
|
||||||
|
bindIconUpload();
|
||||||
bindSoftwarePanel();
|
bindSoftwarePanel();
|
||||||
bindMailTest();
|
bindMailTest();
|
||||||
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
||||||
|
|||||||
+74
-1
@@ -363,6 +363,14 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
|
|||||||
}
|
}
|
||||||
.icon-up-main{flex:1; min-width:220px; display:flex; flex-direction:column}
|
.icon-up-main{flex:1; min-width:220px; display:flex; flex-direction:column}
|
||||||
.icon-up-main input[type=file]{font-size:13.5px; max-width:100%}
|
.icon-up-main input[type=file]{font-size:13.5px; max-width:100%}
|
||||||
|
/* 上传状态提示:选中文件后立刻告诉用户「选到了什么、合不合格」。
|
||||||
|
* 用户 2026-11-04:「上传的图标没有显示,也没有成功/失败提示。」 */
|
||||||
|
.icon-up-tip{
|
||||||
|
display:block; margin-top:7px; font-size:12.5px; line-height:1.6;
|
||||||
|
color:var(--muted); word-break:break-all;
|
||||||
|
}
|
||||||
|
.icon-up-tip.is-ok{color:var(--ok, #16794a)}
|
||||||
|
.icon-up-tip.is-err{color:var(--danger, #c0392b); font-weight:600}
|
||||||
/* chip 里也不用白底反色了 —— 图标本来就没有底色 */
|
/* chip 里也不用白底反色了 —— 图标本来就没有底色 */
|
||||||
.chip.on .sw-badge-nm{color:#fff}
|
.chip.on .sw-badge-nm{color:#fff}
|
||||||
.chip .sw-badge-ic{background:none !important; color:inherit !important; box-shadow:none}
|
.chip .sw-badge-ic{background:none !important; color:inherit !important; box-shadow:none}
|
||||||
@@ -373,11 +381,76 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
|
|||||||
flex:none; margin-right:2px; vertical-align:middle;
|
flex:none; margin-right:2px; vertical-align:middle;
|
||||||
box-shadow:0 0 0 1px rgba(0,0,0,.12);
|
box-shadow:0 0 0 1px rgba(0,0,0,.12);
|
||||||
}
|
}
|
||||||
.color-row{display:flex; align-items:center; gap:10px; flex-wrap:wrap}
|
.color-row{display:flex; align-items:center; gap:10px; flex-wrap:wrap; position:relative}
|
||||||
.color-input{
|
.color-input{
|
||||||
width:46px; height:34px; padding:2px; border:1px solid var(--line-strong);
|
width:46px; height:34px; padding:2px; border:1px solid var(--line-strong);
|
||||||
border-radius:9px; background:var(--card); cursor:pointer; flex:none;
|
border-radius:9px; background:var(--card); cursor:pointer; flex:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 自绘取色面板 ----------
|
||||||
|
* 用户 2026-11-04:「点击之后弹出一个颜色选择器,让用户自己选。」
|
||||||
|
* 点击色块 → 面板出现在其下方;下方空间不够时翻到上方(JS 加 .cp-up)。 */
|
||||||
|
.color-trigger{
|
||||||
|
display:inline-flex; align-items:center; gap:9px;
|
||||||
|
padding:5px 12px 5px 6px; height:34px;
|
||||||
|
border:1px solid var(--line-strong); border-radius:9px;
|
||||||
|
background:var(--card); cursor:pointer; flex:none;
|
||||||
|
font-size:13px; font-family:ui-monospace,Menlo,Consolas,monospace;
|
||||||
|
color:var(--fg);
|
||||||
|
}
|
||||||
|
.color-trigger:hover{border-color:var(--brand)}
|
||||||
|
.color-trigger-chip{
|
||||||
|
width:22px; height:22px; border-radius:6px; flex:none;
|
||||||
|
background:var(--cur, #0057B8);
|
||||||
|
box-shadow:inset 0 0 0 1px rgba(0,0,0,.10);
|
||||||
|
}
|
||||||
|
.color-trigger-hex{font-weight:600; letter-spacing:.2px}
|
||||||
|
|
||||||
|
.cp{
|
||||||
|
position:absolute; top:calc(100% + 8px); left:0; z-index:80;
|
||||||
|
width:248px; padding:10px;
|
||||||
|
background:var(--card); border:1px solid var(--line-strong);
|
||||||
|
border-radius:12px; box-shadow:0 10px 30px rgba(16,24,40,.16);
|
||||||
|
user-select:none;
|
||||||
|
}
|
||||||
|
.cp.cp-up{top:auto; bottom:calc(100% + 8px)}
|
||||||
|
.cp-sv{
|
||||||
|
display:block; width:100%; height:150px; border-radius:8px;
|
||||||
|
cursor:crosshair;
|
||||||
|
}
|
||||||
|
.cp-cursor{
|
||||||
|
position:absolute; width:14px; height:14px; margin:-7px 0 0 -7px;
|
||||||
|
border:2px solid #fff; border-radius:50%; pointer-events:none;
|
||||||
|
box-shadow:0 0 0 1px rgba(0,0,0,.35);
|
||||||
|
/* 位置由 JS 写入 left/top,相对面板内边距(10px)计算,这里不加偏移 */
|
||||||
|
}
|
||||||
|
.cp-hue{margin:9px 0 8px}
|
||||||
|
.cp-hue input[type=range]{
|
||||||
|
-webkit-appearance:none; appearance:none; width:100%; height:12px; border-radius:6px;
|
||||||
|
background:linear-gradient(to right,
|
||||||
|
#f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
|
||||||
|
outline:none; cursor:pointer;
|
||||||
|
}
|
||||||
|
.cp-hue input[type=range]::-webkit-slider-thumb{
|
||||||
|
-webkit-appearance:none; width:16px; height:16px; border-radius:50%;
|
||||||
|
background:#fff; border:2px solid #fff; box-shadow:0 0 0 1px rgba(0,0,0,.35); cursor:pointer;
|
||||||
|
}
|
||||||
|
.cp-hue input[type=range]::-moz-range-thumb{
|
||||||
|
width:14px; height:14px; border-radius:50%; background:#fff;
|
||||||
|
border:2px solid #fff; box-shadow:0 0 0 1px rgba(0,0,0,.35); cursor:pointer;
|
||||||
|
}
|
||||||
|
.cp-foot{display:flex; align-items:center; gap:7px}
|
||||||
|
.cp-preview{
|
||||||
|
width:26px; height:26px; border-radius:7px; flex:none;
|
||||||
|
box-shadow:inset 0 0 0 1px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
.cp-hex{
|
||||||
|
flex:1; min-width:0; height:28px; padding:0 8px;
|
||||||
|
border:1px solid var(--line-strong); border-radius:7px;
|
||||||
|
font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12.5px;
|
||||||
|
text-transform:uppercase; background:var(--card); color:var(--fg);
|
||||||
|
}
|
||||||
|
.cp-ok{flex:none; padding:5px 11px !important; font-size:12.5px !important}
|
||||||
.color-presets{display:flex; gap:6px; flex-wrap:wrap}
|
.color-presets{display:flex; gap:6px; flex-wrap:wrap}
|
||||||
.color-preset{
|
.color-preset{
|
||||||
width:26px; height:26px; border-radius:50%; border:2px solid transparent;
|
width:26px; height:26px; border-radius:50%; border:2px solid transparent;
|
||||||
|
|||||||
+29
-5
@@ -540,13 +540,25 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
|||||||
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
|
$sugColor = $curColor !== '' ? $curColor : fb_color_default_for($esw ? (int)$esw['id'] : 0);
|
||||||
/* 🔴 必须用 fb_color_palette(),不要再在这里写死一份数组。
|
/* 🔴 必须用 fb_color_palette(),不要再在这里写死一份数组。
|
||||||
* 以前这里硬编码了另外 8 个色值,和 util.php 的调色板完全不同步 ——
|
* 以前这里硬编码了另外 8 个色值,和 util.php 的调色板完全不同步 ——
|
||||||
* 结果后台只能选那 8 个,用户在后台挑不到新颜色,
|
* 结果后台只能选那 8 个,用户在后台挑不到新颜色。 */
|
||||||
* 而他从没配置过的软件却会被分到「调色板里另一批」颜色。 */
|
|
||||||
$presets = fb_color_palette();
|
$presets = fb_color_palette();
|
||||||
|
/* 颜色选择:**点色块弹出取色面板**(色相/饱和度渐变 + 明度条 + 十六进制),
|
||||||
|
* 而不是只给一排预设圆点。
|
||||||
|
* 用户 2026-11-04:「还是不够丰富,让用户自己选:点击之后弹出一个颜色选择器。」
|
||||||
|
* ⇒ 预设只是快捷方式,真正要给的是**任意取色**能力。
|
||||||
|
* 原生 <input type="color"> 只有一个小方块,用户不知道能点,
|
||||||
|
* 而且它的系统面板不在我们控制内(各浏览器样式不一)。
|
||||||
|
* 这里自带一个面板:Canvas 渐变 + 拖动,谁都看得懂怎么用。 */
|
||||||
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
|
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
|
||||||
echo ' <div class="color-row">';
|
echo ' <div class="color-row" data-colorpicker>';
|
||||||
echo ' <input type="color" name="color" value="' . e($sugColor) . '" class="color-input">';
|
echo ' <button type="button" class="color-trigger" data-color-trigger'
|
||||||
echo ' <div class="color-presets">';
|
. ' style="--cur:' . e($sugColor) . '" title="点击选择颜色">'
|
||||||
|
. ' <span class="color-trigger-chip"></span>'
|
||||||
|
. ' <span class="color-trigger-hex">' . e(strtoupper($sugColor)) . '</span>'
|
||||||
|
. ' </button>';
|
||||||
|
echo ' <input type="hidden" name="color" value="' . e($sugColor) . '" data-color-value>';
|
||||||
|
// 预设作为快捷色(点了就直接生效),保留但不占主位
|
||||||
|
echo ' <div class="color-presets" aria-label="常用颜色">';
|
||||||
foreach ($presets as $pc) {
|
foreach ($presets as $pc) {
|
||||||
$on = strcasecmp($pc, $sugColor) === 0 ? ' on' : '';
|
$on = strcasecmp($pc, $sugColor) === 0 ? ' on' : '';
|
||||||
echo '<button type="button" class="color-preset' . $on . '" data-color="' . e($pc)
|
echo '<button type="button" class="color-preset' . $on . '" data-color="' . e($pc)
|
||||||
@@ -554,6 +566,18 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
|||||||
}
|
}
|
||||||
echo ' </div>';
|
echo ' </div>';
|
||||||
echo ' <button type="button" class="btn sm color-reset">用默认</button>';
|
echo ' <button type="button" class="btn sm color-reset">用默认</button>';
|
||||||
|
|
||||||
|
/* 取色面板:默认隐藏,点色块才出现。位置由 JS 定位到触发按钮下方。 */
|
||||||
|
echo ' <div class="cp" data-color-panel hidden>';
|
||||||
|
echo ' <canvas class="cp-sv" width="220" height="150" data-cp-sv></canvas>';
|
||||||
|
echo ' <div class="cp-cursor" data-cp-cursor></div>';
|
||||||
|
echo ' <div class="cp-hue"><input type="range" min="0" max="360" value="0" data-cp-hue aria-label="色相"></div>';
|
||||||
|
echo ' <div class="cp-foot">';
|
||||||
|
echo ' <span class="cp-preview" data-cp-prev></span>';
|
||||||
|
echo ' <input type="text" class="cp-hex" maxlength="7" value="' . e(strtoupper($sugColor)) . '" data-cp-hex aria-label="十六进制颜色值">';
|
||||||
|
echo ' <button type="button" class="btn sm cp-ok" data-cp-ok>确定</button>';
|
||||||
|
echo ' </div>';
|
||||||
|
echo ' </div>';
|
||||||
echo ' </div>';
|
echo ' </div>';
|
||||||
echo ' </div>';
|
echo ' </div>';
|
||||||
if ($esw) {
|
if ($esw) {
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
|
|||||||
define('FB_ROOT', dirname(__DIR__));
|
define('FB_ROOT', dirname(__DIR__));
|
||||||
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
||||||
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
||||||
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 39); }
|
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 41); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user