主题色改为自绘取色面板(任意取色);图标上传加即时预览与状态提示
一、颜色不够丰富 → 点击弹取色面板
用户:「还是不行,颜色还是不够丰富。让用户选择吧:点击之后弹出一个颜色选择器。」
原来只有一个原生 <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() {
|
||||
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 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 之前
|
||||
|
||||
+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 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.on .sw-badge-nm{color:#fff}
|
||||
.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;
|
||||
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{
|
||||
width:46px; height:34px; padding:2px; border:1px solid var(--line-strong);
|
||||
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-preset{
|
||||
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);
|
||||
/* 🔴 必须用 fb_color_palette(),不要再在这里写死一份数组。
|
||||
* 以前这里硬编码了另外 8 个色值,和 util.php 的调色板完全不同步 ——
|
||||
* 结果后台只能选那 8 个,用户在后台挑不到新颜色,
|
||||
* 而他从没配置过的软件却会被分到「调色板里另一批」颜色。 */
|
||||
* 结果后台只能选那 8 个,用户在后台挑不到新颜色。 */
|
||||
$presets = fb_color_palette();
|
||||
/* 颜色选择:**点色块弹出取色面板**(色相/饱和度渐变 + 明度条 + 十六进制),
|
||||
* 而不是只给一排预设圆点。
|
||||
* 用户 2026-11-04:「还是不够丰富,让用户自己选:点击之后弹出一个颜色选择器。」
|
||||
* ⇒ 预设只是快捷方式,真正要给的是**任意取色**能力。
|
||||
* 原生 <input type="color"> 只有一个小方块,用户不知道能点,
|
||||
* 而且它的系统面板不在我们控制内(各浏览器样式不一)。
|
||||
* 这里自带一个面板:Canvas 渐变 + 拖动,谁都看得懂怎么用。 */
|
||||
echo ' <div class="field"><span>主题色<i class="hint">前台进这个软件时整体配色跟着变,用来区分不同软件</i></span>';
|
||||
echo ' <div class="color-row">';
|
||||
echo ' <input type="color" name="color" value="' . e($sugColor) . '" class="color-input">';
|
||||
echo ' <div class="color-presets">';
|
||||
echo ' <div class="color-row" data-colorpicker>';
|
||||
echo ' <button type="button" class="color-trigger" data-color-trigger'
|
||||
. ' 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) {
|
||||
$on = strcasecmp($pc, $sugColor) === 0 ? ' on' : '';
|
||||
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 ' <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>';
|
||||
if ($esw) {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
|
||||
define('FB_ROOT', dirname(__DIR__));
|
||||
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
||||
// 让浏览器与 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