From 1b71fba4134a725665be43ae8448e60010ee1297 Mon Sep 17 00:00:00 2001 From: zqlit Date: Mon, 5 Oct 2026 12:08:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B5=B0=E5=BF=83=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=EF=BC=88=E7=B2=BE=E9=80=89=E8=AF=84=E8=AE=BA=EF=BC=89+=20?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E8=AF=84=E8=AE=BA=E6=A1=86=E5=BA=95?= =?UTF-8?q?=E6=A0=8F=E5=B8=83=E5=B1=80=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 底栏:≤480px 压缩按钮内边距,SE 等小屏单行放下;换行时按钮组右对齐 - Worker:comments 加 is_featured;PUT /comments/:id 支持切换;新增公开接口 GET /api/v2/featured/comments(排除悄悄话/待审/已删,服务端渲染 markdown) - 前端:评论区注入「精选」按钮(回复右侧,仅管理员可见) - 关于页:走心评论板块(标题寄语通栏 + 卡片瀑布流),排在热力图上方 --- blog-admin/schema.sql | 1 + blog-admin/src/index.ts | 1 + blog-admin/src/lib/cook.ts | 1 + blog-admin/src/routes/comments.ts | 67 ++++++++ blog-admin/src/types.ts | 4 + themes/Ying/assets/css/main.css | 17 ++ .../assets/js/modules/comment-featured.js | 158 ++++++++++++++++++ themes/Ying/layouts/page/about.html | 9 + .../layouts/partials/featured-comments.html | 146 ++++++++++++++++ themes/Ying/layouts/partials/footer.html | 7 +- 10 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 themes/Ying/assets/js/modules/comment-featured.js create mode 100644 themes/Ying/layouts/partials/featured-comments.html diff --git a/blog-admin/schema.sql b/blog-admin/schema.sql index ddd92a34..5ce41bb0 100644 --- a/blog-admin/schema.sql +++ b/blog-admin/schema.sql @@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS comments ( is_pinned INTEGER NOT NULL DEFAULT 0, is_verified INTEGER NOT NULL DEFAULT 0, is_anonymous INTEGER NOT NULL DEFAULT 0, -- 匿名评论:对外显示「匿名」,真实身份只留后台 + is_featured INTEGER NOT NULL DEFAULT 0, -- 走心评论:管理员精选,关于页展示(2026-10-05 追加,存量库需 ALTER) vote_up INTEGER NOT NULL DEFAULT 0, vote_down INTEGER NOT NULL DEFAULT 0, ua TEXT NOT NULL DEFAULT '', diff --git a/blog-admin/src/index.ts b/blog-admin/src/index.ts index 0ea846af..364cb675 100644 --- a/blog-admin/src/index.ts +++ b/blog-admin/src/index.ts @@ -56,6 +56,7 @@ router.post('/comments', C.createComment); router.get('/comments/:id', C.getComment); router.put('/comments/:id', C.updateComment); router.del('/comments/:id', C.deleteComment); +router.get('/featured/comments', C.listFeaturedComments); // ---------------------------------------------------------------- 投票 / 浏览量 // ★ 投票:Artalk 客户端把「方向」编进 target_name(comment_up / comment_down / diff --git a/blog-admin/src/lib/cook.ts b/blog-admin/src/lib/cook.ts index c2c434b7..e4bc6ba5 100644 --- a/blog-admin/src/lib/cook.ts +++ b/blog-admin/src/lib/cook.ts @@ -245,6 +245,7 @@ export async function cookComments( is_allow_reply: !c.is_collapsed && !c.is_pending, is_verified: user?.is_admin ? true : !!c.is_verified, is_anonymous: isAnon, + is_featured: !!c.is_featured, rid: c.rid, badge_name: user?.badge_name ?? '', badge_color: user?.badge_color ?? '', diff --git a/blog-admin/src/routes/comments.ts b/blog-admin/src/routes/comments.ts index 2d623cd4..7036bfae 100644 --- a/blog-admin/src/routes/comments.ts +++ b/blog-admin/src/routes/comments.ts @@ -6,6 +6,7 @@ import { findOrCreatePage, findOrCreateSite, findOrCreateUser, + findPagesByKeys, findUserById, hasCaptchaPassed, isCaptchaEnabled, @@ -18,6 +19,7 @@ import { import { cookComments, cookPage, pageAccessibleUrl } from '../lib/cook'; import { mailEnabled, notifyByEmail } from '../lib/mail'; import { isAdminByNameEmail, isAdminRequest } from '../lib/session'; +import { renderMarkdown } from '../lib/md'; import { md5Lower } from '../lib/md5'; import { fail, @@ -535,6 +537,70 @@ export async function createComment(ctx: Ctx): Promise { return ok(cooked); } +// ============================================================ GET /featured/comments + +/** + * 走心评论公开列表:管理员精选的评论,关于页展示用。 + * + * 只给「精选且未删未待审」的;悄悄话评论(is_anonymous)永远不进这里 —— + * 它的内容本来就只对博主可见,精选展示等于泄露。 + * content_html 是服务端渲染好的 markdown(与后台 content_marked 同一渲染器), + * 前端直接 innerHTML,不用再带一份 marked。 + */ +export async function listFeaturedComments(ctx: Ctx): Promise { + const { env, url } = ctx; + + const limit = Math.min(Math.max(qInt(url, 'limit', 8), 1), 20); + + const rows = ( + await env.DB.prepare( + `SELECT c.* FROM comments c + WHERE c.is_featured = 1 AND c.deleted_at = 0 AND c.is_pending = 0 AND c.is_anonymous = 0 + ORDER BY c.created_at DESC LIMIT ?`, + ) + .bind(limit) + .all() + ).results ?? []; + + if (!rows.length) return ok({ comments: [] }); + + const cooked = await cookComments(env, rows, { ipRegion: false, includeMarked: false }); + + // 页面标题:按 (site_name, page_key) 去重后一次捞齐,避免 N+1 + const titleMap = new Map(); + const bySite = new Map(); + for (const r of rows) { + const arr = bySite.get(r.site_name) ?? []; + arr.push(r.page_key); + bySite.set(r.site_name, arr); + } + for (const [siteName, keys] of bySite) { + for (const [k, v] of await findPagesByKeys(env, keys, siteName)) { + titleMap.set(`${siteName}\u0000${k}`, v.title || ''); + } + } + + const out = cooked.map((c, i) => ({ + id: c.id, + nick: c.nick, + link: c.link, + email_encrypted: c.email_encrypted, // 前端拼 weavatar 头像(与评论区同一套) + badge_name: c.badge_name, + badge_color: c.badge_color, + title_name: c.title_name, + rank_name: c.rank_name, + rank_color: c.rank_color, + rank_bg: c.rank_bg, + date: c.date, + content_html: renderMarkdown(rows[i].content), + page_key: c.page_key, + page_url: c.page_url, + page_title: titleMap.get(`${c.site_name}\u0000${c.page_key}`) || '', + })); + + return ok({ comments: out }); +} + // ============================================================ 单条操作 export async function getComment(ctx: Ctx): Promise { @@ -585,6 +651,7 @@ export async function updateComment(ctx: Ctx): Promise { ['is_pending', 'is_pending'], ['is_pinned', 'is_pinned'], ['is_verified', 'is_verified'], + ['is_featured', 'is_featured'], ] as const) { if (body[field] !== undefined) { sets.push(`${col} = ?`); diff --git a/blog-admin/src/types.ts b/blog-admin/src/types.ts index 3d68afe5..a58144a7 100644 --- a/blog-admin/src/types.ts +++ b/blog-admin/src/types.ts @@ -113,6 +113,8 @@ export interface CommentRow { is_pinned: number; is_verified: number; is_anonymous: number; + /** 走心评论:管理员手动精选,在关于页展示 */ + is_featured: number; vote_up: number; vote_down: number; ua: string; @@ -142,6 +144,8 @@ export interface CookedComment { is_verified: boolean; /** 该评论是否匿名发表(管理员请求时字段原样保留,内容是否脱敏看服务端) */ is_anonymous: boolean; + /** 走心评论(管理员精选),前台据此渲染「精选/取消精选」按钮 */ + is_featured: boolean; rid: number; badge_name: string; badge_color: string; diff --git a/themes/Ying/assets/css/main.css b/themes/Ying/assets/css/main.css index 812d4f3e..b90124e6 100644 --- a/themes/Ying/assets/css/main.css +++ b/themes/Ying/assets/css/main.css @@ -4641,6 +4641,23 @@ input[type=submit] { .artalk > .atk-list > .atk-list-footer { bottom: auto !important; } .atk-main { margin-left: 34px !important; } .atk-bottom { padding: 5px 10px 10px 5px !important; } +/* 底栏右侧(人机验证 + 发送按钮)换行修正: + 移动端窄屏时右侧组会 flex-wrap 到第二行,space-between 对单元素行不生效, + 整组会挤在左边、右侧留大片空白(iPhone SE / 16 Pro 实测)。 + margin-left:auto 单行时与 space-between 等价(仍靠右),换行时把整组推到右边, + 与桌面端「按钮在右下」的观感一致。 */ +.atk-main-editor > .atk-bottom > .atk-item:last-child { margin-left: auto; } +/* 小屏(≤480,含 iPhone SE 375):底栏压进一行 —— 图标+悄悄话靠左、验证+发送靠右。 + 各元素收一点内边距/间距(375px 下自然宽度 324 vs 可用 316,只差一点就溢出换行)。 + 真放不下(如人机验证失败的长文案)仍按上面的规则优雅换行右对齐。 */ +@media (max-width: 480px) { + .atk-main-editor > .atk-bottom .atk-plug-btn { padding: 0 8px; } + .atk-main-editor > .atk-bottom .atk-plug-btn:not(:last-child) { margin-right: 3px; } + .atk-whisper-toggle { margin-left: 6px; } + /* 提高特异性压过 human.js 动态注入的 @media(max-width:640px) 规则 */ + .atk-main-editor .atk-human-tf { padding: 0 6px; gap: 5px; margin-right: 5px; } + .atk-main-editor > .atk-bottom .atk-send-btn { padding: 0 10px !important; } +} .atk-comment-children > .atk-comment-wrap { margin-top: 6px !important; } .atk-comment-children > .atk-comment-wrap > .atk-comment { padding: 0 !important; margin: auto; } .atk-comment .atk-height-limit:after { margin-bottom: 20px; } diff --git a/themes/Ying/assets/js/modules/comment-featured.js b/themes/Ying/assets/js/modules/comment-featured.js new file mode 100644 index 00000000..a0b9d9cc --- /dev/null +++ b/themes/Ying/assets/js/modules/comment-featured.js @@ -0,0 +1,158 @@ +/** + * comment-featured —— 「走心评论」精选按钮(仅管理员可见) + * + * 管理员在任意评论页,操作条(赞同 / 回复 / 折叠…)的「回复」右边多一个 + * 「精选」按钮:点了把该评论标记为精选(PUT /comments/:id {is_featured}), + * 关于页「走心评论」板块会展示它。已精选的显示「取消精选」,再点一次取消。 + * + * 实现要点: + * - 按钮插在「回复」span(纯 回复,无 class)右边,顺序与 + * 用户要求一致:赞同 → 回复 → 精选 → 折叠/已审/置顶/编辑/删除。 + * - 只对管理员可见:挂官方同款 atk-only-admin-show 属性,Artalk 内核在 + * 登录态变化时统一 add/remove .atk-hide,不用自己判断登录态。 + * - 精选状态从列表响应里收集:fetch 拦截读 GET /comments 的 JSON, + * 把 is_featured 的评论 id 记进 Set(单条 PUT 的响应也顺手更新)。 + * - 编辑器会被 Artalk 反复重建(PJAX / 回复模式),用 MutationObserver 补挂, + * 已挂过的评论跳过(data-feat 标记)。 + */ +(function () { + if (window.__atkFeatReady) return; // PJAX 重复执行防抖 + window.__atkFeatReady = true; + + var featured = new Set(); // 已精选的评论 id + + function server() { + return String((window.artalkConfig && window.artalkConfig.server) || '').replace(/\/+$/, ''); + } + + // ── 精选状态收集:拦截 fetch,读评论列表 / 单条响应 ──────────────────── + // 注意只「读取」,请求原样放行;与 comment-anonymous 的注入拦截互不干扰。 + var origFetch = window.fetch; + if (typeof origFetch === 'function' && !window.__atkFeatFetchHooked) { + window.__atkFeatFetchHooked = true; + window.fetch = function (input, init) { + var p = origFetch.apply(this, [input, init]); + try { + var url = String(typeof input === 'string' ? input : (input && input.url) || ''); + if (/\/api\/v2\/comments(\?|$)/.test(url) && !/lookup/.test(url)) { + p.then(function (res) { + try { + if (!res || !res.ok) return; + res.clone().json().then(function (data) { + var list = (data && data.comments) || []; + for (var i = 0; i < list.length; i++) { + if (list[i] && list[i].is_featured !== undefined) { + if (list[i].is_featured) featured.add(Number(list[i].id)); + else featured.delete(Number(list[i].id)); + } + } + refreshButtons(); // 按钮可能先于响应解析渲染,文案要修正 + }).catch(function () {}); + } catch (e) {} + }).catch(function () {}); + } + } catch (e) {} + return p; + }; + } + + function getToken() { + try { + var u = JSON.parse(localStorage.getItem('ArtalkUser') || '{}'); + return u.token || ''; + } catch (e) { return ''; } + } + + function commentIdOf(actions) { + var wrap = actions.closest('.atk-comment-wrap'); + if (!wrap || !wrap.id) return 0; + var m = wrap.id.match(/^atk-comment-(\d+)$/); + return m ? Number(m[1]) : 0; + } + + function toggle(commentId, btn) { + if (!commentId) return; + var next = !featured.has(commentId); + var token = getToken(); + if (!token) { window.showToast && showToast('请先以管理员身份登录', 'warning'); return; } + btn.style.opacity = '0.5'; + fetch(server() + '/api/v2/comments/' + commentId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ is_featured: next }), + }).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + if (next) featured.add(commentId); else featured.delete(commentId); + btn.textContent = next ? '取消精选' : '精选'; + window.showToast && showToast(next ? '已加入走心评论' : '已取消精选', 'success'); + }).catch(function () { + window.showToast && showToast('操作失败,请重试', 'error'); + }).then(function () { btn.style.opacity = ''; }); + } + + // 在 .atk-actions 里找到「回复」span:Artalk 渲染的纯 回复 + function replySpanOf(actions) { + var spans = actions.querySelectorAll(':scope > span'); + for (var i = 0; i < spans.length; i++) { + if (spans[i].textContent.trim() === '回复' && !spans[i].classList.contains('atk-common-action-btn')) { + return spans[i]; + } + } + return null; + } + + function refreshButtons() { + try { + var btns = document.querySelectorAll('.atk-feat-btn'); + for (var i = 0; i < btns.length; i++) { + var id = commentIdOf(btns[i].closest('.atk-actions')); + if (id) btns[i].textContent = featured.has(id) ? '取消精选' : '精选'; + } + } catch (e) {} + } + + function inject(actions) { + if (actions.__atkFeat) return; + var id = commentIdOf(actions); + if (!id) return; // 编辑器回复预览等没有 id 的区域不动 + var replySpan = replySpanOf(actions); + if (!replySpan) return; // 关闭回复的评论没有「回复」按钮,下轮再说 + + actions.__atkFeat = true; + var btn = document.createElement('span'); + btn.className = 'atk-common-action-btn atk-feat-btn'; + btn.setAttribute('atk-only-admin-show', ''); + btn.textContent = featured.has(id) ? '取消精选' : '精选'; + // 初始显隐对齐官方 admin 按钮:官方的 [atk-only-admin-show] 显隐同步 + // 在列表渲染时就跑完了,我们是后注入的赶不上那趟车 —— 抄当前时刻 + // 官方按钮(如「折叠」)的 atk-hide 状态;之后登录/登出的再同步由 + // Artalk 内核统一处理(我们挂着同款属性,会被一起切换)。 + var refAdminBtn = actions.querySelector('[atk-only-admin-show]'); + if (!refAdminBtn || refAdminBtn.classList.contains('atk-hide')) { + btn.classList.add('atk-hide'); + } + btn.addEventListener('click', function (e) { + e.stopPropagation(); + toggle(id, btn); + }); + replySpan.after(btn); + } + + function scan() { + try { + var list = document.querySelectorAll('.atk-actions'); + for (var i = 0; i < list.length; i++) inject(list[i]); + } catch (e) {} + } + + var timer = null; + function scheduleScan() { + if (timer) return; + timer = setTimeout(function () { timer = null; scan(); }, 150); + } + + try { + new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true }); + } catch (e) {} + scan(); +})(); diff --git a/themes/Ying/layouts/page/about.html b/themes/Ying/layouts/page/about.html index 4a06c8b0..13e22cbf 100644 --- a/themes/Ying/layouts/page/about.html +++ b/themes/Ying/layouts/page/about.html @@ -5,6 +5,15 @@ {{ $sectionCount := 0 }} + + {{ if site.Params.about.enablefeatured | default true }} + {{ $sectionCount = add $sectionCount 1 }} +
+
{{ printf "%02d" $sectionCount }} / 走心评论 (Featured)
+ {{ partial "featured-comments.html" . }} +
+ {{ end }} + {{ if site.Params.about.enableheatmap }} {{ $sectionCount = add $sectionCount 1 }} diff --git a/themes/Ying/layouts/partials/featured-comments.html b/themes/Ying/layouts/partials/featured-comments.html new file mode 100644 index 00000000..1a9e9f86 --- /dev/null +++ b/themes/Ying/layouts/partials/featured-comments.html @@ -0,0 +1,146 @@ +{{/* 走心评论:管理员在评论区精选的评论,关于页展示。 + 数据走 Worker 公开接口 /api/v2/featured/comments(悄悄话评论永远不进精选列表)。 + 本地预览 / 接口故障时优雅降级:整块隐藏或显示占位文案。 + 调试参数:?featmock=1 用本地 mock 渲染(仅用于排版预览,生产无害)。 */}} + + +
+
走心评论
+

当我看到各位在不同的时间和故事中,发出一份份走心的评论时,一份温暖之意即笼罩在我的心头。

+
+
加载中…
+
+
+ + diff --git a/themes/Ying/layouts/partials/footer.html b/themes/Ying/layouts/partials/footer.html index b7243f11..5b932ace 100644 --- a/themes/Ying/layouts/partials/footer.html +++ b/themes/Ying/layouts/partials/footer.html @@ -146,8 +146,10 @@ {{/* 匿名评论开关 + 表情包最近使用 */}} {{ $anon := resources.Get "js/modules/comment-anonymous.js" }} {{ $emoRecent := resources.Get "js/modules/emoticon-recent.js" }} + {{/* 走心评论:管理员「精选」按钮 */}} + {{ $feat := resources.Get "js/modules/comment-featured.js" }} - {{ $pageScripts := slice $friendlink $autofill $anon $emoRecent $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} + {{ $pageScripts := slice $friendlink $autofill $anon $emoRecent $feat $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} @@ -162,8 +164,9 @@ {{ $autofill := resources.Get "js/modules/comment-autofill.js" }} {{ $anon := resources.Get "js/modules/comment-anonymous.js" }} {{ $emoRecent := resources.Get "js/modules/emoticon-recent.js" }} + {{ $feat := resources.Get "js/modules/comment-featured.js" }} - {{ $pageScripts := slice $friendlink $autofill $anon $emoRecent $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} + {{ $pageScripts := slice $friendlink $autofill $anon $emoRecent $feat $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} {{ end }}