评论区改为自托管 twikoo 前端,并适配主题

- 前端脚本从 jsDelivr 改为自托管 public/assets/js/twikoo.min.js,
  版本与后端对齐(1.7.20)。国内访问 jsDelivr 实测 3 秒以上且常被墙,
  是评论区空白的根因
- 新增 public/assets/css/twikoo-custom.css 主题适配层:选择器加 #twikoo
  前缀以覆盖运行时注入的样式,颜色引用主题变量,自动跟随亮/暗色
- 修复首屏重复初始化:astro:page-load 在首屏也会触发,用 dataset.mounted 去重
- 评论配置移除已无用的 version 字段;README 与操作指南同步更新
This commit is contained in:
dsh
2026-09-14 20:58:37 +08:00
parent 686b1caa7d
commit 0b99eabfbe
6 changed files with 320 additions and 87 deletions
+9 -4
View File
@@ -215,17 +215,22 @@ epigraph: 题记 # 可选
export const commentConfig = {
twikooEnvId: "https://twikoo.tianxuetao.top",
lang: "zh-CN",
version: "1.7.4",
stripTrailingSlash: true,
}
```
`twikooEnvId` 留空则整个评论区不渲染;也可以用环境变量 `PUBLIC_TWIKOO_ENV_ID` 覆盖,优先级更高。
前端实现在 `src/components/Comments.astro`,从 jsDelivr 加载 twikoo 脚本,另外处理了两件事:
**前端脚本不走 CDN**,而是自托管的 `public/assets/js/twikoo.min.js`。国内访问 jsDelivr 很不稳定(实测 3 秒以上,且常被墙),走 CDN 会让评论区长时间空白甚至加载不出来。该文件的版本要与后端保持一致(当前都是 **1.7.20**,可用 `GET_FUNC_VERSION` 查后端版本),升级后端时记得同步替换它。
- **切页重新挂载**:本站启用了 Astro 的 `ClientRouter`,组件监听 `astro:page-load` 事件重新初始化。
- **评论归属路径**:`trailingSlash` 是 `"always"`,但评论 key 统一去掉结尾斜杠、存成 `/posts/xxx`,与该路径在旧站点上的历史数据保持一致。改掉这条规则,已有评论会对不上。
主题适配样式在 `public/assets/css/twikoo-custom.css`。twikoo 的样式是运行时注入 `<head>` 的、会排在后面,所以那里的选择器统一加了 `#twikoo` 前缀来提高优先级,颜色引用主题变量因而自动跟随亮/暗色。
前端逻辑在 `src/components/Comments.astro`,处理了两件事:
- **切页重新挂载**:本站启用了 Astro 的 `ClientRouter`。注意 `astro:page-load` 在**首屏也会触发**,所以用 `dataset.mounted` 标记防止重复初始化;切页后是新容器、没有该标记,会正常重新挂载。
- **评论归属路径**:`trailingSlash` 是 `"always"`,但评论 key 统一去掉结尾斜杠。浏览器提交的是**百分号编码**过的 `location.pathname`(如 `/posts/%E7%94%9F%E6%B4%BB/...`),后端按原样存储和匹配 —— 所以去斜杠后的编码路径正好与历史数据一致。改掉这条规则,老评论就会对不上。
评论出现在:文章页、关于页、友链页。
主题原作者的自托管评论后端 `cwd-api/` 已随迁移移除。
+23
View File
@@ -213,6 +213,29 @@ sudo sysctl fs.inotify.max_user_watches=524288
检查路径是否以 `/` 开头(`/assets/images/x.webp`),以及文件是否真在 `public/` 下对应位置。
注意 `public/` 里的文件路径就是 URL 路径,**没有 `public` 这一段**。
**评论区空白 / 不显示**
先确认页面类型——评论**只出现在文章页、关于页、友链页**,首页、偶得、相册、书架、专题都没有,这是正常的。
如果文章页也是空的,按顺序查:
1. `src/data/comments.ts` 里的 `twikooEnvId` 是否填了(留空则整个评论区不渲染)
2. 打开浏览器控制台,看有没有 `[twikoo] 初始化失败` 或 404
3. 确认 `public/assets/js/twikoo.min.js` 存在,且版本与后端一致
4. 直接测后端:
```bash
curl -s -X POST https://twikoo.tianxuetao.top \
-H 'Content-Type: application/json' \
-d '{"event":"GET_FUNC_VERSION"}'
```
正常会返回 `{"code":0,"version":{"VERSION":"1.7.20"}}`。若版本和前端脚本不一致,去
<https://cdn.jsdelivr.net/npm/twikoo@<后端版本>/dist/twikoo.min.js> 下载同名文件替换。
> 评论按**页面路径**归属,且存的是浏览器提交的**百分号编码**路径。
> 换域名或改 URL 结构后,老评论会因为路径对不上而「消失」——数据还在后端,只是不再匹配。
**想回退到之前的状态**
```bash
+178
View File
@@ -0,0 +1,178 @@
/*
* twikoo 评论区的主题适配(朱墨留白 / VermilionVoid)
*
* twikoo 的样式是脚本运行时注入到 <head> 的,会排在本文件之后,
* 所以这里的选择器统一加 #twikoo 前缀提高优先级,保证能覆盖它。
*
* 颜色全部引用主题 CSS 变量,因此会自动跟随亮色/暗色,
* 以及 src/data/site.ts 里的 themeHue 主题色。
*/
/* ---------- 容器 ---------- */
#twikoo {
color: var(--foreground);
font-size: 0.9375rem;
line-height: 1.75;
}
#twikoo .tk-comments-title,
#twikoo .tk-comments-title h4 {
color: var(--foreground);
}
/* 评论条数等次要文字 */
#twikoo .tk-comments-count,
#twikoo .tk-comments-count span {
color: var(--muted-foreground);
}
/* ---------- 单条评论 ---------- */
#twikoo .tk-comment {
border-radius: var(--radius);
padding: 0.75rem 0;
}
#twikoo .tk-comment .tk-content {
color: var(--content-secondary);
line-height: 1.8;
}
#twikoo .tk-comment .tk-content a,
#twikoo .tk-content a {
color: var(--primary);
text-decoration: none;
}
#twikoo .tk-comment .tk-content a:hover {
text-decoration: underline;
}
/* 昵称 */
#twikoo .tk-nick,
#twikoo .tk-nick-link {
color: var(--foreground);
font-weight: 500;
}
/* 时间、设备等元信息 */
#twikoo .tk-time,
#twikoo .tk-meta,
#twikoo .tk-tag {
color: var(--muted-foreground);
}
/* 头像圆角跟随主题 */
#twikoo .tk-avatar,
#twikoo .tk-avatar-img {
border-radius: 9999px;
}
/* 展开/回复等操作 */
#twikoo .tk-action-link,
#twikoo .tk-action-icon {
color: var(--muted-foreground);
transition: color 0.2s ease;
}
#twikoo .tk-action-link:hover,
#twikoo .tk-action-icon:hover,
#twikoo .tk-action-icon.tk-is-active {
color: var(--primary);
}
/* 子评论缩进区域 */
#twikoo .tk-replies {
border-left: 2px solid var(--border);
margin-left: 0.5rem;
padding-left: 1rem;
}
/* 分隔线 */
#twikoo hr,
#twikoo .tk-comment + .tk-comment {
border-color: var(--border);
}
/* ---------- 输入区 ---------- */
#twikoo .tk-submit {
color: var(--foreground);
}
#twikoo .tk-input .el-textarea__inner,
#twikoo .el-textarea__inner,
#twikoo .el-input__inner {
background-color: var(--card);
border-color: var(--border);
border-radius: var(--radius);
color: var(--foreground);
box-shadow: none;
}
#twikoo .el-textarea__inner::placeholder,
#twikoo .el-input__inner::placeholder {
color: var(--muted-foreground);
}
#twikoo .el-textarea__inner:focus,
#twikoo .el-input__inner:focus {
border-color: var(--primary);
}
/* 发送按钮 */
#twikoo .el-button--primary {
background-color: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
border-radius: var(--radius);
}
#twikoo .el-button--primary:hover,
#twikoo .el-button--primary:focus {
background-color: var(--primary);
border-color: var(--primary);
opacity: 0.88;
}
#twikoo .el-button {
border-radius: var(--radius);
}
/* 登录方式 / 提示文字 */
#twikoo .tk-login-msg,
#twikoo .tk-login-msg a,
#twikoo .tk-admin-warn {
color: var(--muted-foreground);
}
#twikoo .tk-login-msg a:hover {
color: var(--primary);
}
/* 表情面板 */
#twikoo .tk-panel {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* ---------- 分页 ---------- */
#twikoo .tk-pagination .el-pager li,
#twikoo .tk-pagination button {
background-color: transparent;
color: var(--muted-foreground);
}
#twikoo .tk-pagination .el-pager li.is-active {
color: var(--primary);
}
/* ---------- 暗色模式微调 ---------- */
.dark #twikoo .tk-comment .tk-content {
color: var(--foreground);
}
.dark #twikoo .el-textarea__inner,
.dark #twikoo .el-input__inner {
background-color: var(--surface-subtle);
color: var(--foreground);
}
File diff suppressed because one or more lines are too long
+50 -26
View File
@@ -6,69 +6,87 @@ const envId =
const isEnabled = Boolean(envId)
---
{isEnabled && (
<section class="mt-8 pt-0 pb-12">
{
isEnabled && (
<section class="mt-12 pb-12" data-comments>
{/* twikoo 的主样式由脚本运行时注入,这里只加主题适配层 */}
<link rel="stylesheet" href="/assets/css/twikoo-custom.css" />
<div class="px-6">
<div class="max-w-3xl mx-auto">
<h2 class="mb-6 flex items-center gap-3 text-2xl font-semibold text-foreground">
<span class="h-6 w-1 rounded-full bg-primary" />
评论
</h2>
<div
id="twikoo"
data-env-id={envId}
data-lang={commentConfig.lang}
data-version={commentConfig.version}
data-strip-trailing-slash={commentConfig.stripTrailingSlash ? "true" : "false"}
></div>
<script is:inline>
(() => {
const SCRIPT_ID = "twikoo-script"
/>
const loadScript = (version) => {
<script is:inline>
;(() => {
const SCRIPT_SRC = "/assets/js/twikoo.min.js"
const loadScript = () => {
if (window.twikoo) return Promise.resolve()
const existing = document.getElementById(SCRIPT_ID)
const existing = document.querySelector("script[data-twikoo]")
if (existing) {
if (existing.dataset.loaded === "true") return Promise.resolve()
return new Promise((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true })
existing.addEventListener("error", () => {
reject(new Error("Failed to load twikoo"))
}, { once: true })
existing.addEventListener(
"error",
() => reject(new Error("twikoo 脚本加载失败")),
{ once: true },
)
})
}
return new Promise((resolve, reject) => {
const script = document.createElement("script")
script.id = SCRIPT_ID
script.src = `https://cdn.jsdelivr.net/npm/twikoo@${version}/dist/twikoo.min.js`
script.src = SCRIPT_SRC
script.async = true
script.dataset.twikoo = "true"
script.onload = () => {
script.dataset.loaded = "true"
resolve()
}
script.onerror = () => reject(new Error("Failed to load twikoo"))
script.onerror = () => reject(new Error("twikoo 脚本加载失败: " + SCRIPT_SRC))
document.head.appendChild(script)
})
}
// 评论归属的路径。去掉结尾斜杠,与站点历史评论数据保持一致。
const getCommentPath = (stripTrailingSlash) => {
/**
* 评论归属路径。本站 trailingSlash 为 "always",但评论 key 统一去掉
* 结尾斜杠(/posts/xxx),与历史数据保持一致。
*/
const getCommentPath = (strip) => {
const pathname = window.location.pathname
if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith("/")) {
if (strip && pathname.length > 1 && pathname.endsWith("/")) {
return pathname.slice(0, -1)
}
return pathname
}
const mountComments = async () => {
const mount = async () => {
const container = document.getElementById("twikoo")
if (!container) return
// astro:page-load 在首屏也会触发,用标记避免重复初始化;
// 切页时容器是新节点,没有这个标记,所以会正常重新挂载。
if (container.dataset.mounted === "true") return
const envId = container.dataset.envId
if (!envId) return
container.dataset.mounted = "true"
try {
await loadScript(container.dataset.version)
// 切页时容器已被换成新节点,清空可避免重复渲染
container.innerHTML = ""
await loadScript()
await window.twikoo.init({
envId,
el: "#twikoo",
@@ -76,16 +94,22 @@ const isEnabled = Boolean(envId)
path: getCommentPath(container.dataset.stripTrailingSlash === "true"),
})
} catch (error) {
// 失败时解除标记,允许下次切页重试
container.dataset.mounted = "false"
console.error("[twikoo] 初始化失败:", error)
}
}
mountComments()
// 本站启用了 Astro 的 ClientRouter,切页后需要重新挂载
document.addEventListener("astro:page-load", mountComments)
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount)
} else {
mount()
}
document.addEventListener("astro:page-load", mount)
})()
</script>
</div>
</div>
</section>
)}
)
}
+10 -9
View File
@@ -1,25 +1,26 @@
/**
* 评论系统配置。
*
* 这里配置的是 twikoo(https://twikoo.js.org)。留空 twikooEnvId
* 则整个评论区不渲染。
* 使用 twikoo(https://twikoo.js.org),后端为自托管实例。
*
* 也可以用环境变量 PUBLIC_TWIKOO_ENV_ID 覆盖,优先级高于本文件。
* 前端脚本不走 CDN,而是自托管的 public/assets/js/twikoo.min.js —— 国内访问
* jsDelivr 很不稳定,走 CDN 会导致评论区加载不出来。该文件版本与后端保持一致
* (当前都是 1.7.20),升级后端时记得同步替换它。
*
* 主题适配样式在 public/assets/css/twikoo-custom.css。
*/
export const commentConfig = {
/** twikoo 后端地址(部署 twikoo 的那个域名) */
/** twikoo 后端地址(部署 twikoo 的那个域名)。留空则整个评论区不渲染。 */
twikooEnvId: "https://twikoo.tianxuetao.top",
/** 评论界面语言 */
lang: "zh-CN",
/** twikoo 前端脚本版本,从 jsDelivr 加载 */
version: "1.7.4",
/**
* 评论归属用的路径是否去掉结尾斜杠。
* 评论归属路径是否去掉结尾斜杠。
* 本站 trailingSlash 为 "always",但评论 key 统一存成不带斜杠的
* `/posts/xxx` 形式,与该路径在旧站点上的历史数据保持一致。
* `/posts/xxx` 形式,与后端里的历史数据保持一致 —— 改掉这条,
* 老评论就会对不上。
*/
stripTrailingSlash: true,
} as const