diff --git a/assets/app.js b/assets/app.js index 3fd9d7c..c00e02c 100644 --- a/assets/app.js +++ b/assets/app.js @@ -709,49 +709,318 @@ } } - /* ---------- 主题色选择器(后台软件编辑) ---------- */ + /* ---------- 主题色选择器(后台软件编辑) ---------- + * 用户 2026-11-04:「还是不够丰富,让用户自己选:点击之后弹出一个颜色选择器。」 + * + * 原来是原生 :只有个小方块,用户不知道能点, + * 而且各浏览器系统面板样式不一。现在自带一个面板: + * 色相/饱和度渐变板(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:「上传的那个图标没有显示,也没有上传成功、失败之类的提示信息。」 + * + * 原来就是一个裸 ,选完文件什么都没发生 —— + * 用户不知道选没选中、图片对不对、会不会传不上去。表单整页提交后 + * 提示条又在页面顶部,很容易错过。 + * + * 这里做三件事(都在前端,最终仍以后端校验为准): + * ① 选中文件立刻用 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 之前 diff --git a/assets/style.css b/assets/style.css index 9a766b0..a33a615 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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; diff --git a/lib/admin_taxonomy.php b/lib/admin_taxonomy.php index f745a1d..caeb524 100644 --- a/lib/admin_taxonomy.php +++ b/lib/admin_taxonomy.php @@ -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:「还是不够丰富,让用户自己选:点击之后弹出一个颜色选择器。」 + * ⇒ 预设只是快捷方式,真正要给的是**任意取色**能力。 + * 原生 只有一个小方块,用户不知道能点, + * 而且它的系统面板不在我们控制内(各浏览器样式不一)。 + * 这里自带一个面板:Canvas 渐变 + 拖动,谁都看得懂怎么用。 */ echo '
主题色前台进这个软件时整体配色跟着变,用来区分不同软件'; - echo '
'; - echo ' '; - echo '
'; + echo '
'; + echo ' '; + echo ' '; + // 预设作为快捷色(点了就直接生效),保留但不占主位 + echo '
'; foreach ($presets as $pc) { $on = strcasecmp($pc, $sugColor) === 0 ? ' on' : ''; echo ''; + + /* 取色面板:默认隐藏,点色块才出现。位置由 JS 定位到触发按钮下方。 */ + echo ' '; echo '
'; echo '
'; if ($esw) { diff --git a/lib/bootstrap.php b/lib/bootstrap.php index fa2109c..eb4113a 100644 --- a/lib/bootstrap.php +++ b/lib/bootstrap.php @@ -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); } } /* ------------------------------------------------------------------