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]` 令牌;
- * - 这里用事件委托记录点击(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 @@