From 79c9f2371c72aa4e31292062b1da3b294efdff5a Mon Sep 17 00:00:00 2001 From: zqlit Date: Mon, 5 Oct 2026 17:34:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AF=84=E8=AE=BA):=20=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E8=A1=A8=E6=83=85=E6=94=B9=E4=B8=BA=E5=88=86=E7=BB=84=20tab=20?= =?UTF-8?q?+=20=E6=8F=90=E4=BA=A4=E5=90=8E=E9=9D=A2=E6=9D=BF=E6=94=B6?= =?UTF-8?q?=E8=B5=B7=20+=20=E5=B0=8F=E7=BC=96=E5=BE=BD=E7=AB=A0=E9=85=8D?= =?UTF-8?q?=E8=89=B2=20+=20=E8=B5=B0=E5=BF=83=E8=AF=84=E8=AE=BA=2020=20?= =?UTF-8?q?=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - emoticon-recent.js: 「最近使用」从面板顶部独立栏重构为分组切换条最左侧的 同构 tab(data-index="recent"),无记录时整个 tab 隐藏;复用 Artalk 原生 .atk-grp[data-type=image] 网格样式,零自定义布局。 - artalk.js: · 新建评论成功(res.ok)后,对当前激活的插件按钮派发 click(),收起仍展开的 表情面板(Artalk 上游只 reset 输入内容、不关面板),内部 openedPlug 状态同步复位。 · 徽章处理末尾把徽章文字镜像到 data-badge 属性,供 CSS 按名字配色。 - artalk.html: 新增 .atk-badge[data-badge="小编"] 玫红实底规则(含暗色模式), 特异性高于博主规则,两者互不覆盖。 - featured-comments.html: 走心评论 limit 8 → 20(后端上限)。 - main.css: 移除旧 .atk-recent-emoticons 相关样式。 --- themes/Ying/assets/css/main.css | 55 +----- themes/Ying/assets/js/modules/artalk.js | 25 ++- .../Ying/assets/js/modules/emoticon-recent.js | 182 ++++++++++++------ themes/Ying/layouts/partials/artalk.html | 10 + .../layouts/partials/featured-comments.html | 2 +- 5 files changed, 160 insertions(+), 114 deletions(-) diff --git a/themes/Ying/assets/css/main.css b/themes/Ying/assets/css/main.css index b90124e6..a4d79742 100644 --- a/themes/Ying/assets/css/main.css +++ b/themes/Ying/assets/css/main.css @@ -4259,54 +4259,13 @@ input[type=submit] { color: #8a8f98; } -/* 表情面板「最近使用」栏:面板顶部横向一行,移动端可横滑 */ -.atk-recent-emoticons { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - margin-bottom: 2px; - border-bottom: 1px dashed rgba(128, 128, 128, 0.28); - overflow-x: auto; - scrollbar-width: thin; -} -.atk-recent-emoticons .atk-recent-label { - flex: none; - font-size: 12px; - color: #9b9b9b; - white-space: nowrap; -} -.atk-recent-emoticons ul { - display: flex; - align-items: center; - gap: 2px; - margin: 0; - padding: 0; - list-style: none; -} -.atk-recent-emoticons li { - flex: none; - display: flex; -} -.atk-recent-emoticons img { - max-height: 32px; - width: auto; - vertical-align: middle; - cursor: pointer; - padding: 2px; - border-radius: 4px; -} -.atk-recent-emoticons img:hover { - background: rgba(128, 128, 128, 0.16); -} -[data-theme="dark"] .atk-recent-emoticons { - border-bottom-color: rgba(255, 255, 255, 0.12); -} -[data-theme="dark"] .atk-recent-emoticons .atk-recent-label { - color: #8a8f98; -} -[data-theme="dark"] .atk-recent-emoticons img:hover { - background: rgba(255, 255, 255, 0.12); +/* 表情面板「最近使用」:切换条里最前面的一个 tab(在「抖音」等分组左边)。 + 内容区与原生分组同构(.atk-grp[data-type=image],见 emoticon-recent.js), + 63×63 网格、hover 底色、滚动条等排版全部继承 Artalk 原生样式, + 所以这里只需要给 tab 一点点强调,不需要任何布局代码。 + 最近列表为空时整个 tab 不出现(由 JS 控制)。 */ +.atk-editor-plug-emoticons > .atk-grp-switcher > span.atk-recent-tab { + font-weight: 600; } #back-to-top { diff --git a/themes/Ying/assets/js/modules/artalk.js b/themes/Ying/assets/js/modules/artalk.js index 7dd59fdf..cecd5189 100644 --- a/themes/Ying/assets/js/modules/artalk.js +++ b/themes/Ying/assets/js/modules/artalk.js @@ -1307,7 +1307,16 @@ artalk.on('comment-rendered', function(node) { var allBadges = nickEl.querySelectorAll( '.atk-badge, .atk-pinned-badge, .atk-title-badge, .atk-link-badge, .atk-feed-badge, .atk-rank-badge' ); - for (var bi = 0; bi < allBadges.length; bi++) allBadges[bi].classList.add('atk-cmt-badge'); + for (var bi = 0; bi < allBadges.length; bi++) { + var bdg = allBadges[bi]; + bdg.classList.add('atk-cmt-badge'); + // 7) 把徽章文字镜像到 data-badge —— CSS 就能按「博主 / 小编 / ...」分别配色。 + // 系统徽章(.atk-badge)身上只有 class + 文字可用:badge_color 是 Artalk + // 写在元素内联 style 上的,而主题的 !important 实底规则会盖掉它, + // 所以按文字做一个稳定的选择器钩子(以后加新徽章配色不用再改 JS)。 + var btxt = (bdg.textContent || '').trim(); + if (btxt && bdg.getAttribute('data-badge') !== btxt) bdg.setAttribute('data-badge', btxt); + } } catch (e) { /* 徽章注入失败不影响评论 */ } // ── 友链申请入口卡片:评论内容就是约定 URL(https://usj.cc/link-apply)时, @@ -1717,6 +1726,19 @@ artalk.on('list-loaded', function() { }, 80); } + // ── 提交成功后收起还开着的插件面板(表情 / 图片 …)────────────────────── + // Artalk 的插件面板是 toggle:点「表情」展开、再点收起。提交成功后它只会 + // reset() 输入内容,不会把面板关掉 —— 于是「选完表情 → 发表」之后表情面板 + // 还杵在那里,输入框看起来像没复位。这里补一次收起。 + // 必须走「点当前 active 按钮」这条路:按钮 toggle 依赖内部 openedPlug 状态, + // 直接改 style.display='none' 会让状态和 UI 脱节(下次点按钮要点两下才开)。 + function closePlugPanels() { + try { + var btns = document.querySelectorAll('.atk-plug-btn-wrap .atk-plug-btn.active'); + for (var i = 0; i < btns.length; i++) btns[i].click(); + } catch (e) {} + } + // ── fetch 拦截:只认「新建评论」这一个请求 ──────────────────────────── // 之所以要先 await 读完 body 再放行,是因为 Artalk 在 `await fetch(...)` 之后 // 才触发 editor-submitted;如果这里用 res.clone().json() 异步解析而不 await, @@ -1747,6 +1769,7 @@ artalk.on('list-loaded', function() { var p = origFetch.apply(this, args); return p.then(function (res) { if (res && res.ok) { + closePlugPanels(); // 发表成功 → 顺手把展开的表情/图片面板收起来 // 成功:读一下 body 看是不是落进了审核队列(服务端返回的就是 // 煮熟的评论对象本体,is_pending 是它的字段) return res.clone().json().then(function (d) { diff --git a/themes/Ying/assets/js/modules/emoticon-recent.js b/themes/Ying/assets/js/modules/emoticon-recent.js index 4da82e2d..b6ba3d83 100644 --- a/themes/Ying/assets/js/modules/emoticon-recent.js +++ b/themes/Ying/assets/js/modules/emoticon-recent.js @@ -1,14 +1,26 @@ /** - * emoticon-recent —— 表情包「最近使用」 + * emoticon-recent —— 表情面板「最近使用」分组 * * 场景:表情面板几十套图翻页找常用的太累,用过的排最前,一次点击直达。 * - * 零侵入实现(不碰 Artalk 源码,靠 DOM 契约): - * - Artalk 表情面板里每张原生图是 `key`,点击后插入 `:[key]` 令牌; - * - 这里用事件委托记录点击(localStorage,最多 8 个,去重置顶); - * - 面板顶部注入「最近」一栏;点最近里的图 = 找到面板里原生的同一张 - * (按 alt 匹配)然后 programmatic click(),完全复用 Artalk 自己的插入逻辑。 - * - 面板由 Artalk 动态创建/重建(PJAX、点回复),MutationObserver 兜底重注入。 + * 实现方式(零侵入,不碰 Artalk 源码,靠 DOM 契约): + * - Artalk 表情面板的结构是固定的(见 Artalk.js emoticons 插件): + * .atk-editor-plug-emoticons + * ├── .atk-grp-wrap ← 分组内容区 + * │ └── .atk-grp[data-index=i][data-type=image] + * │ └── span.atk-item[title=key] > img[src alt=key] + * └── .atk-grp-switcher ← 底部分组切换条 + * └── span[data-index=i] (click → openGrp(i)) + * - 这里在切换条**最前面**插一个「最近」tab,在内容区插一个同构的 .atk-grp 装最近用过的表情。 + * 因为 .atk-item / [data-type=image] 的原生样式(63×63 网格、hover 底色)都靠类名命中, + * 同构插入 = 排版自动和「抖音」等原生分组完全一致,一行自定义布局都不用写。 + * - tab 的点击由我们接管(显示自己、隐藏其它、切 active)。反过来,Artalk 的 openGrp 是按 + * data-index 数字匹配来显示/隐藏分组的 —— 我们的分组 data-index="recent" 永远匹配不上, + * 所以用户点原生 tab 时它会被自动 display:none,两边互补,不会打架。 + * - 点最近里的图 = 找到面板里原生的同一张(按 alt=key 匹配)然后 programmatic click(), + * 完全复用 Artalk 自己的插入逻辑(插 :[key] 令牌),不重复实现插入。 + * - 最近列表为空时整个 tab 不出现(没用过就没有「最近」,符合直觉)。 + * - 面板 DOM 首次打开才创建,MutationObserver 兜底重注入。 */ (function () { if (window.__atkEmoticonRecentReady) return; // PJAX 重复执行防抖 @@ -36,63 +48,52 @@ save(list); } - // 最近栏当前展示的键序(用于和存储比对,判断要不要重建) - function blockKeys(panel) { - var imgs = panel.querySelectorAll('.atk-recent-emoticons img'); - var keys = []; - for (var i = 0; i < imgs.length; i++) keys.push(imgs[i].getAttribute('alt')); - return keys; + function itemImg(item) { + return item && item.querySelector ? item.querySelector('img[alt]') : null; } - // ── 记录:点的是面板里的原生表情图(capture,先于 Artalk 的 onclick)──── + // ── 记录:点的是面板里的原生表情(capture,先于 Artalk 绑在 .atk-item 上的 onclick)── + // 监听 .atk-item 而不是 img:点在图与图之间的内边距上也算用过(原生 padding:5px)。 document.addEventListener('click', function (e) { var el = e.target; - if (!el || el.tagName !== 'IMG' || !el.closest) return; + if (!el || !el.closest) return; if (!el.closest('.atk-editor-plug-emoticons')) return; - if (el.closest('.atk-recent-emoticons')) return; // 最近栏自己的点击单独处理 - var key = el.getAttribute('alt'); + if (el.closest('.atk-recent-grp')) return; // 最近分组自己的点击单独处理 + var img = itemImg(el.closest('.atk-item')); + if (!img) return; + var key = img.getAttribute('alt'); if (key) { - record(key, el.getAttribute('src') || ''); - scheduleScan(); // 顺序变了,刷新最近栏 + record(key, img.getAttribute('src') || ''); + scheduleScan(); // 顺序变了,刷新最近分组 } }, true); - // ── 在面板里找原生表情图(排除最近栏自己的),按 alt=key 匹配 ────────── + // ── 在面板里找原生表情图(排除最近分组自己的),按 alt=key 匹配 ────────── function findNativeImg(panel, key) { - var imgs = panel.querySelectorAll('img'); + var imgs = panel.querySelectorAll('.atk-grp-wrap > .atk-grp:not(.atk-recent-grp) img[alt]'); for (var i = 0; i < imgs.length; i++) { - var im = imgs[i]; - if (im.closest('.atk-recent-emoticons')) continue; - if (im.getAttribute('alt') === key) return im; + if (imgs[i].getAttribute('alt') === key) return imgs[i]; } return null; } - function buildBlock() { - var list = load(); - if (!list.length) return null; - var wrap = document.createElement('div'); - wrap.className = 'atk-recent-emoticons'; - - var label = document.createElement('span'); - label.className = 'atk-recent-label'; - label.textContent = '最近'; - wrap.appendChild(label); - - var ul = document.createElement('ul'); - list.forEach(function (it) { - var li = document.createElement('li'); + function buildItems() { + var frag = document.createDocumentFragment(); + load().forEach(function (it) { + var item = document.createElement('span'); + item.className = 'atk-item'; + item.title = it.k; var img = document.createElement('img'); img.src = it.s; img.alt = it.k; - img.title = it.k; img.loading = 'lazy'; - img.onclick = function () { - var panel = img.closest('.atk-editor-plug-emoticons'); + item.appendChild(img); + item.onclick = function () { + var panel = item.closest('.atk-editor-plug-emoticons'); var orig = panel ? findNativeImg(panel, it.k) : null; if (orig) { orig.click(); // 复用 Artalk 的插入逻辑(插 :[key] 令牌) - record(it.k, it.s); // 再点一次最近栏的图,顺序也要更新 + record(it.k, it.s); // 再点一次最近里的图,顺序也要更新 scheduleScan(); } else { // 表情包配置变了,这张已经不存在 → 从最近记录里淘汰 @@ -100,37 +101,90 @@ scheduleScan(); } }; - li.appendChild(img); - ul.appendChild(li); + frag.appendChild(item); }); - wrap.appendChild(ul); - return wrap; + return frag; + } + + // 最近分组当前展示的键序(用于和存储比对,判断要不要重建) + function grpKeys(grp) { + var imgs = grp.querySelectorAll('img[alt]'); + var keys = []; + for (var i = 0; i < imgs.length; i++) keys.push(imgs[i].getAttribute('alt')); + return keys; + } + + // ── 切到「最近」:自己接管显示。原生 openGrp 会在用户点回原生 tab 时 + // 自动把我们的分组藏起来(按 data-index 数字匹配),所以这里不需要反向干预。── + function showRecent(panel) { + var switcher = panel.querySelector('.atk-grp-switcher'); + var wrap = panel.querySelector('.atk-grp-wrap'); + if (!switcher || !wrap) return; + var kids = wrap.children; + for (var i = 0; i < kids.length; i++) { + kids[i].style.display = kids[i].classList.contains('atk-recent-grp') ? '' : 'none'; + } + var spans = switcher.querySelectorAll('span'); + for (var j = 0; j < spans.length; j++) spans[j].classList.remove('active'); + var tab = switcher.querySelector('.atk-recent-tab'); + if (tab) tab.classList.add('active'); } function inject(panel) { if (!panel || !panel.querySelector) return; - var existing = panel.querySelector('.atk-recent-emoticons'); - var want = load(); - if (existing) { - // 内容比对:键序和存储一致就不动(避免无谓重建打断 hover), - // 不一致(点了新表情 / 记录被淘汰)就整块重建 - var have = blockKeys(panel); - var same = have.length === want.length; - if (same) { - for (var i = 0; i < want.length; i++) { - if (want[i].k !== have[i]) { same = false; break; } + var switcher = panel.querySelector('.atk-grp-switcher'); + var wrap = panel.querySelector('.atk-grp-wrap'); + // 只有一组表情时 Artalk 不建切换条(没有 tab 可挂)→ 不注入 + if (!switcher || !wrap) return; + // 原生分组还没渲染出来时先不注入(找原生图会找不到),等下轮扫描 + if (!wrap.querySelector('.atk-grp:not(.atk-recent-grp) img')) return; + + var list = load(); + var tab = switcher.querySelector('.atk-recent-tab'); + var grp = wrap.querySelector('.atk-recent-grp'); + + // 没有最近记录 → 整个 tab 不出现;若正停在「最近」上则切回第一组 + if (!list.length) { + if (tab) { + var wasActive = tab.classList.contains('active'); + switcher.removeChild(tab); + if (grp && grp.parentNode) grp.parentNode.removeChild(grp); + if (wasActive) { + var first = switcher.querySelector('span[data-index="0"]'); + if (first) first.click(); } } - if (same) return; - var fresh = buildBlock(); - if (fresh) panel.replaceChild(fresh, existing); - else existing.parentNode.removeChild(existing); return; } - // 原生分组还没渲染出来时先不注入(找原生图会找不到),等下轮扫描 - if (!panel.querySelector('img')) return; - var block = buildBlock(); - if (block) panel.insertBefore(block, panel.firstChild); + + if (!tab) { + tab = document.createElement('span'); + tab.className = 'atk-recent-tab'; + tab.setAttribute('data-index', 'recent'); + tab.textContent = '最近'; + tab.onclick = function () { showRecent(panel); }; + switcher.insertBefore(tab, switcher.firstChild); // 放在「抖音」左边 + } + if (!grp) { + grp = document.createElement('div'); + grp.className = 'atk-grp atk-recent-grp'; + grp.setAttribute('data-index', 'recent'); + grp.setAttribute('data-grp-name', '最近'); + grp.setAttribute('data-type', 'image'); // 命中原生 63×63 网格样式 + grp.style.display = 'none'; // 与原生分组一致:默认不显示 + wrap.appendChild(grp); + } + // 内容比对:键序一致就跳过重建(避免无谓重建打断 hover) + var have = grpKeys(grp); + var same = have.length === list.length; + if (same) { + for (var i = 0; i < list.length; i++) { + if (list[i].k !== have[i]) { same = false; break; } + } + } + if (same) return; + grp.innerHTML = ''; + grp.appendChild(buildItems()); } var scanTimer = null; diff --git a/themes/Ying/layouts/partials/artalk.html b/themes/Ying/layouts/partials/artalk.html index 5e0ed731..122b1577 100644 --- a/themes/Ying/layouts/partials/artalk.html +++ b/themes/Ying/layouts/partials/artalk.html @@ -77,6 +77,16 @@ color: #ffffff !important; background: #333333 !important; } + /* 小编(.atk-badge[data-badge="小编"]):实底 —— 白字 + 玫红底。 + data-badge 由 artalk.js 按徽章文字镜像上来(badge_color 是 Artalk 写的内联 + 样式,会被上面博主规则的 !important 盖掉,所以只能按文字做钩子)。 + 选择器比博主默认规则多一个属性、特异性更高,所以两种徽章各自成色不互压。 */ + .atk-comment .atk-badge[data-badge="小编"], + .atk-fl-card .atk-badge[data-badge="小编"] { + border-color: #d94f7a; + color: #ffffff !important; + background: #d94f7a !important; + } /* 置顶徽章:实底 —— 白字 + 浅红底(比 #d9534f / #ff6262 更浅,不刺眼) */ .atk-comment .atk-pinned-badge, .atk-fl-card .atk-pinned-badge { diff --git a/themes/Ying/layouts/partials/featured-comments.html b/themes/Ying/layouts/partials/featured-comments.html index 1a9e9f86..d7e05254 100644 --- a/themes/Ying/layouts/partials/featured-comments.html +++ b/themes/Ying/layouts/partials/featured-comments.html @@ -83,7 +83,7 @@