评论区改为自托管 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:
@@ -215,17 +215,22 @@ epigraph: 题记 # 可选
|
|||||||
export const commentConfig = {
|
export const commentConfig = {
|
||||||
twikooEnvId: "https://twikoo.tianxuetao.top",
|
twikooEnvId: "https://twikoo.tianxuetao.top",
|
||||||
lang: "zh-CN",
|
lang: "zh-CN",
|
||||||
version: "1.7.4",
|
|
||||||
stripTrailingSlash: true,
|
stripTrailingSlash: true,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`twikooEnvId` 留空则整个评论区不渲染;也可以用环境变量 `PUBLIC_TWIKOO_ENV_ID` 覆盖,优先级更高。
|
`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` 事件重新初始化。
|
主题适配样式在 `public/assets/css/twikoo-custom.css`。twikoo 的样式是运行时注入 `<head>` 的、会排在后面,所以那里的选择器统一加了 `#twikoo` 前缀来提高优先级,颜色引用主题变量因而自动跟随亮/暗色。
|
||||||
- **评论归属路径**:`trailingSlash` 是 `"always"`,但评论 key 统一去掉结尾斜杠、存成 `/posts/xxx`,与该路径在旧站点上的历史数据保持一致。改掉这条规则,已有评论会对不上。
|
|
||||||
|
前端逻辑在 `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/` 已随迁移移除。
|
主题原作者的自托管评论后端 `cwd-api/` 已随迁移移除。
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,29 @@ sudo sysctl fs.inotify.max_user_watches=524288
|
|||||||
检查路径是否以 `/` 开头(`/assets/images/x.webp`),以及文件是否真在 `public/` 下对应位置。
|
检查路径是否以 `/` 开头(`/assets/images/x.webp`),以及文件是否真在 `public/` 下对应位置。
|
||||||
注意 `public/` 里的文件路径就是 URL 路径,**没有 `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
|
```bash
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -6,69 +6,87 @@ const envId =
|
|||||||
const isEnabled = Boolean(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="px-6">
|
||||||
<div class="max-w-3xl mx-auto">
|
<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
|
<div
|
||||||
id="twikoo"
|
id="twikoo"
|
||||||
data-env-id={envId}
|
data-env-id={envId}
|
||||||
data-lang={commentConfig.lang}
|
data-lang={commentConfig.lang}
|
||||||
data-version={commentConfig.version}
|
|
||||||
data-strip-trailing-slash={commentConfig.stripTrailingSlash ? "true" : "false"}
|
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()
|
if (window.twikoo) return Promise.resolve()
|
||||||
|
|
||||||
const existing = document.getElementById(SCRIPT_ID)
|
const existing = document.querySelector("script[data-twikoo]")
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.dataset.loaded === "true") return Promise.resolve()
|
if (existing.dataset.loaded === "true") return Promise.resolve()
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
existing.addEventListener("load", () => resolve(), { once: true })
|
existing.addEventListener("load", () => resolve(), { once: true })
|
||||||
existing.addEventListener("error", () => {
|
existing.addEventListener(
|
||||||
reject(new Error("Failed to load twikoo"))
|
"error",
|
||||||
}, { once: true })
|
() => reject(new Error("twikoo 脚本加载失败")),
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement("script")
|
const script = document.createElement("script")
|
||||||
script.id = SCRIPT_ID
|
script.src = SCRIPT_SRC
|
||||||
script.src = `https://cdn.jsdelivr.net/npm/twikoo@${version}/dist/twikoo.min.js`
|
|
||||||
script.async = true
|
script.async = true
|
||||||
|
script.dataset.twikoo = "true"
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
script.dataset.loaded = "true"
|
script.dataset.loaded = "true"
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
script.onerror = () => reject(new Error("Failed to load twikoo"))
|
script.onerror = () => reject(new Error("twikoo 脚本加载失败: " + SCRIPT_SRC))
|
||||||
document.head.appendChild(script)
|
document.head.appendChild(script)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 评论归属的路径。去掉结尾斜杠,与站点历史评论数据保持一致。
|
/**
|
||||||
const getCommentPath = (stripTrailingSlash) => {
|
* 评论归属路径。本站 trailingSlash 为 "always",但评论 key 统一去掉
|
||||||
|
* 结尾斜杠(/posts/xxx),与历史数据保持一致。
|
||||||
|
*/
|
||||||
|
const getCommentPath = (strip) => {
|
||||||
const pathname = window.location.pathname
|
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.slice(0, -1)
|
||||||
}
|
}
|
||||||
return pathname
|
return pathname
|
||||||
}
|
}
|
||||||
|
|
||||||
const mountComments = async () => {
|
const mount = async () => {
|
||||||
const container = document.getElementById("twikoo")
|
const container = document.getElementById("twikoo")
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
|
// astro:page-load 在首屏也会触发,用标记避免重复初始化;
|
||||||
|
// 切页时容器是新节点,没有这个标记,所以会正常重新挂载。
|
||||||
|
if (container.dataset.mounted === "true") return
|
||||||
|
|
||||||
const envId = container.dataset.envId
|
const envId = container.dataset.envId
|
||||||
if (!envId) return
|
if (!envId) return
|
||||||
|
|
||||||
|
container.dataset.mounted = "true"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadScript(container.dataset.version)
|
await loadScript()
|
||||||
// 切页时容器已被换成新节点,清空可避免重复渲染
|
|
||||||
container.innerHTML = ""
|
|
||||||
await window.twikoo.init({
|
await window.twikoo.init({
|
||||||
envId,
|
envId,
|
||||||
el: "#twikoo",
|
el: "#twikoo",
|
||||||
@@ -76,16 +94,22 @@ const isEnabled = Boolean(envId)
|
|||||||
path: getCommentPath(container.dataset.stripTrailingSlash === "true"),
|
path: getCommentPath(container.dataset.stripTrailingSlash === "true"),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 失败时解除标记,允许下次切页重试
|
||||||
|
container.dataset.mounted = "false"
|
||||||
console.error("[twikoo] 初始化失败:", error)
|
console.error("[twikoo] 初始化失败:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mountComments()
|
if (document.readyState === "loading") {
|
||||||
// 本站启用了 Astro 的 ClientRouter,切页后需要重新挂载
|
document.addEventListener("DOMContentLoaded", mount)
|
||||||
document.addEventListener("astro:page-load", mountComments)
|
} else {
|
||||||
|
mount()
|
||||||
|
}
|
||||||
|
document.addEventListener("astro:page-load", mount)
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
|||||||
+10
-9
@@ -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 = {
|
export const commentConfig = {
|
||||||
/** twikoo 后端地址(部署 twikoo 的那个域名) */
|
/** twikoo 后端地址(部署 twikoo 的那个域名)。留空则整个评论区不渲染。 */
|
||||||
twikooEnvId: "https://twikoo.tianxuetao.top",
|
twikooEnvId: "https://twikoo.tianxuetao.top",
|
||||||
|
|
||||||
/** 评论界面语言 */
|
/** 评论界面语言 */
|
||||||
lang: "zh-CN",
|
lang: "zh-CN",
|
||||||
|
|
||||||
/** twikoo 前端脚本版本,从 jsDelivr 加载 */
|
|
||||||
version: "1.7.4",
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 评论归属用的路径是否去掉结尾斜杠。
|
* 评论归属路径是否去掉结尾斜杠。
|
||||||
* 本站 trailingSlash 为 "always",但评论 key 统一存成不带斜杠的
|
* 本站 trailingSlash 为 "always",但评论 key 统一存成不带斜杠的
|
||||||
* `/posts/xxx` 形式,与该路径在旧站点上的历史数据保持一致。
|
* `/posts/xxx` 形式,与后端里的历史数据保持一致 —— 改掉这条,
|
||||||
|
* 老评论就会对不上。
|
||||||
*/
|
*/
|
||||||
stripTrailingSlash: true,
|
stripTrailingSlash: true,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
Reference in New Issue
Block a user