diff --git a/cwd-api/src/api/public/postComment.spec.ts b/cwd-api/src/api/public/postComment.spec.ts
new file mode 100644
index 00000000..7de9a801
--- /dev/null
+++ b/cwd-api/src/api/public/postComment.spec.ts
@@ -0,0 +1,58 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../utils/email', () => ({
+ sendCommentNotification: vi.fn(),
+ sendCommentReplyNotification: vi.fn(),
+ isValidEmail: () => true,
+ getAdminNotifyEmail: vi.fn(),
+ loadEmailNotificationSettings: vi.fn(),
+}));
+
+vi.mock('../../utils/telegram', () => ({
+ loadTelegramSettings: vi.fn(),
+ sendTelegramMessage: vi.fn(),
+}));
+
+import { postComment } from './postComment';
+
+describe('postComment Turnstile ordering', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('rejects a recent IP before calling Siteverify', async () => {
+ const fetcher = vi.fn();
+ vi.stubGlobal('fetch', fetcher);
+
+ const first = vi.fn().mockResolvedValue({ created: Date.now() });
+ const bind = vi.fn().mockReturnValue({ first });
+ const prepare = vi.fn().mockReturnValue({ bind });
+ const json = vi.fn((body, status) => ({ body, status }));
+ const context = {
+ req: {
+ json: vi.fn().mockResolvedValue({
+ post_slug: '/post',
+ content: 'hello',
+ name: 'Reader',
+ email: 'reader@example.com',
+ turnstileToken: 'token',
+ }),
+ header: (name: string) => (name === 'cf-connecting-ip' ? '203.0.113.10' : undefined),
+ },
+ env: {
+ TURNSTILE_SECRET_KEY: 'secret',
+ CWD_DB: { prepare },
+ },
+ json,
+ } as any;
+
+ await postComment(context);
+
+ expect(json).toHaveBeenCalledWith(
+ { message: '评论频繁,等10s后再试', turnstileConsumed: false },
+ 429
+ );
+ expect(fetcher).not.toHaveBeenCalled();
+ expect(prepare).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/cwd-api/src/api/public/postComment.ts b/cwd-api/src/api/public/postComment.ts
index 30dc980b..68f1cbab 100644
--- a/cwd-api/src/api/public/postComment.ts
+++ b/cwd-api/src/api/public/postComment.ts
@@ -13,6 +13,7 @@ import {
} from '../../utils/email';
import { loadTelegramSettings, sendTelegramMessage } from '../../utils/telegram';
import { decodePostSlug } from '../../utils/decodePostSlug';
+import { verifyTurnstileToken } from '../../utils/turnstile';
export function checkContent(content: string): string {
return content.replace(/` 片段 |
| `parent_id` | number | 否 | 父评论 ID,用于回复功能;缺省或 `null` 表示根评论 |
| `adminToken` | string | 否 | 管理员评论密钥,博主发布评论时需要先通过 `/api/verify-admin` 验证密钥后将密钥传入此字段,评论将直接通过且不受审核设置影响 |
+| `turnstileToken` | string | 启用 Turnstile 时是 | Cloudflare Turnstile 返回的一次性验证 token |
**请求头说明:**
@@ -204,6 +206,29 @@ POST /api/comments
**错误响应**
+- 同一 IP 在 10 秒内重复评论:
+
+ - 状态码:`429`
+
+ ```json
+ {
+ "message": "评论频繁,等10s后再试",
+ "turnstileConsumed": false
+ }
+ ```
+
+ `turnstileConsumed` 为 `false` 表示请求在调用 Siteverify 前已被拒绝,前端可以保留尚未消费的验证码 token。
+
+- Turnstile 验证失败或缺少 token:
+
+ - 状态码:`403`
+
+ ```json
+ {
+ "message": "请完成人机验证后再提交评论"
+ }
+ ```
+
- 请求体缺失或字段类型错误:
- 状态码:`400`
diff --git a/docs/api/public/config.md b/docs/api/public/config.md
index fc2b83bd..86903c94 100644
--- a/docs/api/public/config.md
+++ b/docs/api/public/config.md
@@ -35,6 +35,7 @@ GET /api/config/comments
"enableCommentLike": true,
"enableArticleLike": true,
"enableImageLightbox": true,
+ "turnstileSiteKey": "0x4AAAAAAA...",
"commentPlaceholder": "发表你的看法...",
"adminLanguage": "zh-CN",
"widgetLanguage": "auto"
@@ -54,6 +55,7 @@ GET /api/config/comments
| `enableCommentLike` | boolean | 是否启用评论点赞功能(默认 true) |
| `enableArticleLike` | boolean | 是否启用文章点赞功能(默认 true) |
| `enableImageLightbox`| boolean | 是否启用评论图片灯箱预览功能(默认 false) |
+| `turnstileSiteKey` | string | Turnstile 站点密钥;未启用服务端验证时为空字符串 |
| `commentPlaceholder` | string \| null | 评论输入框的占位符文本,留空则使用默认值 |
| `adminLanguage` | string \| null | 管理后台界面语言代码,如 `zh-CN`、`en-US`,仅供后台使用 |
| `widgetLanguage` | string \| null | 评论前端组件默认语言代码,支持 `auto` 自动根据浏览器语言选择 |
diff --git a/docs/function/security-settings.md b/docs/function/security-settings.md
index a174aa56..6cbee028 100644
--- a/docs/function/security-settings.md
+++ b/docs/function/security-settings.md
@@ -1,5 +1,9 @@
# 安全设置
+### Cloudflare Turnstile
+
+配置 `TURNSTILE_SITE_KEY` 和 `TURNSTILE_SECRET_KEY` 后,新评论和回复都必须先通过 Turnstile 人机验证。验证码 token 会由 Worker 调用 Siteverify 接口校验,同时检查固定动作 `comment`;可通过 `TURNSTILE_ALLOWED_HOSTNAMES` 进一步限制 token 的来源域名。只配置站点密钥不会启用验证。
+
### 管理员评论密钥
设置管理员评论密钥后,博主在前台使用管理员邮箱发表评论时,需要先输入正确的密钥进行身份验证。验证通过的评论将直接视为已审核,通过「先审核再显示」的限制;未通过验证时,无法使用管理员邮箱发表评论,从而降低管理员邮箱被他人冒用或滥用的风险。
diff --git a/docs/guide/backend-config.md b/docs/guide/backend-config.md
index e5e91460..e8aed10e 100644
--- a/docs/guide/backend-config.md
+++ b/docs/guide/backend-config.md
@@ -114,6 +114,9 @@ npm install
| ------------------ | ----------- | --------------------------------------------------------------------- |
| `ADMIN_NAME` | string | 管理员登录名称 |
| `ADMIN_PASSWORD` | string | 管理员登录密码 |
+| `TURNSTILE_SITE_KEY` | string | Cloudflare Turnstile 站点密钥,配置后由公开配置接口下发 |
+| `TURNSTILE_SECRET_KEY` | string | Cloudflare Turnstile 私密密钥;配置后提交评论必须通过验证码 |
+| `TURNSTILE_ALLOWED_HOSTNAMES` | string | 可选,允许的验证码域名,多个域名以逗号分隔 |
在 Cloudflare 控制台中配置方式:
@@ -122,6 +125,14 @@ npm install
- 在 `D1 Databases` 中绑定 `CWD_DB`(默认已配置好)
- 在 `KV Namespaces` 中绑定 `CWD_AUTH_KV`(默认已配置好)
+Turnstile 私密密钥建议通过 Wrangler Secret 保存:
+
+```bash
+npx wrangler secret put TURNSTILE_SECRET_KEY
+```
+
+`TURNSTILE_SITE_KEY` 和可选的 `TURNSTILE_ALLOWED_HOSTNAMES` 可以作为普通 Worker 变量配置。只有同时配置私密密钥和站点密钥时,前端才会显示验证码。服务端在检测到私密密钥后会强制校验 Turnstile token、`action=comment` 和可选的允许域名,因此不要只配置私密密钥,否则前端无法取得站点密钥,评论请求会被拒绝。
+
## 参考模板
@@ -159,7 +170,9 @@ npm install
],
"vars": {
"ADMIN_NAME": "admin@example.com",
- "ADMIN_PASSWORD": "123456"
+ "ADMIN_PASSWORD": "123456",
+ "TURNSTILE_SITE_KEY": "0x4AAAAAAA...",
+ "TURNSTILE_ALLOWED_HOSTNAMES": "example.com,www.example.com"
}
}
```
\ No newline at end of file
diff --git a/docs/guide/frontend-config.md b/docs/guide/frontend-config.md
index ccdb5a72..2f38ebeb 100644
--- a/docs/guide/frontend-config.md
+++ b/docs/guide/frontend-config.md
@@ -64,6 +64,9 @@ https://cwd.js.org/cwd.js
| `theme` | `'light' \| 'dark'` | 否 | `'light'` | 主题模式 |
| `pageSize` | `number` | 否 | `20` | 每页显示评论数 |
| `customCssUrl` | `string` | 否 | - | 自定义样式表 URL,追加到 Shadow DOM 底部 |
+| `turnstileSiteKey` | `string` | 否 | 后端配置 | Cloudflare Turnstile 站点密钥,通常无需手动传入 |
+
+配置后端 `TURNSTILE_SITE_KEY` 和 `TURNSTILE_SECRET_KEY` 后,组件会从 `/api/config/comments` 自动读取站点密钥,并在新评论和回复提交前要求完成人机验证。
### 多语言配置说明
diff --git a/docs/widget/src/components/CommentForm.js b/docs/widget/src/components/CommentForm.js
index 091e3937..75649d04 100644
--- a/docs/widget/src/components/CommentForm.js
+++ b/docs/widget/src/components/CommentForm.js
@@ -5,6 +5,7 @@
import { Component } from './Component.js';
import { AdminAuthModal } from './AdminAuthModal.js';
import { EmotionPicker } from './EmotionPicker.js';
+import { TurnstileWidget } from './TurnstileWidget.js';
import { auth } from '../utils/auth.js';
import { insertTextAtCursor } from '../utils/emotions.js';
import { renderMarkdown } from '../utils/markdown.js';
@@ -39,13 +40,22 @@ export class CommentForm extends Component {
};
this.modal = null;
this.emotionPicker = null;
+ this.turnstileWidget = null;
+ this.turnstileToken = '';
}
render() {
+ this.turnstileWidget?.destroy();
+ this.turnstileWidget = null;
+ this.turnstileToken = '';
const { formErrors, submitting } = this.props;
const { localForm } = this.state;
- const canSubmit = localForm.name.trim() && localForm.email.trim() && localForm.content.trim();
+ const canSubmit =
+ localForm.name.trim() &&
+ localForm.email.trim() &&
+ localForm.content.trim() &&
+ (!this.props.turnstileSiteKey || this.turnstileToken);
const isAdmin = this.props.adminEmail && localForm.email.trim() === this.props.adminEmail;
const isVerified = isAdmin && auth.hasToken();
const placeholderText = this.props.placeholder || '';
@@ -125,6 +135,10 @@ export class CommentForm extends Component {
],
}),
+ ...(this.props.turnstileSiteKey
+ ? [this.createElement('div', { className: 'cwd-turnstile-container' })]
+ : []),
+
// 操作按钮
this.createElement('div', {
className: 'cwd-form-actions',
@@ -175,6 +189,24 @@ export class CommentForm extends Component {
this.empty(this.container);
this.container.appendChild(root);
this.renderEmotionPicker(root);
+ this.renderTurnstile(root);
+ }
+
+ renderTurnstile(root) {
+ if (!this.props.turnstileSiteKey) {
+ return;
+ }
+ const container = root.querySelector('.cwd-turnstile-container');
+ this.turnstileWidget = new TurnstileWidget(container, {
+ siteKey: this.props.turnstileSiteKey,
+ theme: this.props.turnstileTheme,
+ errorText: this.t('verifyFailed'),
+ onTokenChange: (token) => {
+ this.turnstileToken = token;
+ this.updateFormState();
+ },
+ });
+ this.turnstileWidget.render();
}
/**
@@ -199,6 +231,13 @@ export class CommentForm extends Component {
}
updateProps(prevProps) {
+ if (
+ this.props.turnstileSiteKey !== prevProps.turnstileSiteKey ||
+ this.props.turnstileTheme !== prevProps.turnstileTheme
+ ) {
+ this.render();
+ return;
+ }
// 只在非提交状态时同步表单数据(避免覆盖用户正在输入的内容)
if (!this.props.submitting && this.props.form !== prevProps.form) {
// 保留当前正在输入的内容
@@ -233,7 +272,11 @@ export class CommentForm extends Component {
const { formErrors, submitting } = this.props;
const { localForm } = this.state;
- const canSubmit = localForm.name.trim() && localForm.email.trim() && localForm.content.trim();
+ const canSubmit =
+ localForm.name.trim() &&
+ localForm.email.trim() &&
+ localForm.content.trim() &&
+ (!this.props.turnstileSiteKey || this.turnstileToken);
// 更新提交按钮状态
const submitBtn = this.elements.root.querySelector('button[type="submit"]');
@@ -258,6 +301,7 @@ export class CommentForm extends Component {
} else {
previewBtn.textContent = this.state.showPreview ? this.t('close') : this.t('preview');
}
+ previewBtn.classList.toggle('cwd-btn-active', this.state.showPreview);
}
// 更新输入框禁用状态
@@ -361,7 +405,42 @@ export class CommentForm extends Component {
togglePreview() {
this.state.showPreview = !this.state.showPreview;
- this.render();
+ this.updatePreviewState();
+ }
+
+ updatePreviewState() {
+ const root = this.elements.root;
+ if (!root) {
+ return;
+ }
+
+ const previewBtn = root.querySelector('.cwd-btn-preview');
+ if (previewBtn) {
+ previewBtn.textContent = this.state.showPreview ? this.t('close') : this.t('preview');
+ previewBtn.classList.toggle('cwd-btn-active', this.state.showPreview);
+ }
+
+ let previewContainer = root.querySelector('.cwd-preview-container');
+ if (!this.state.showPreview || !this.state.localForm.content) {
+ previewContainer?.remove();
+ return;
+ }
+
+ if (!previewContainer) {
+ previewContainer = this.createElement('div', {
+ className: 'cwd-preview-container',
+ children: [
+ this.createElement('div', {
+ className: 'cwd-preview-content cwd-comment-content',
+ html: renderMarkdown(this.state.localForm.content),
+ }),
+ ],
+ });
+ const actions = root.querySelector('.cwd-form-actions');
+ root.insertBefore(previewContainer, actions?.nextSibling || null);
+ } else {
+ this.updatePreviewContent(this.state.localForm.content);
+ }
}
handleFieldChange(field, value) {
@@ -414,7 +493,7 @@ export class CommentForm extends Component {
}
}
- handleSubmit(e) {
+ async handleSubmit(e) {
e.preventDefault();
const email = this.state.localForm.email?.trim();
const adminEmail = this.props.adminEmail;
@@ -423,7 +502,14 @@ export class CommentForm extends Component {
return;
}
if (this.props.onSubmit) {
- this.props.onSubmit(this.state.localForm);
+ try {
+ const result = await this.props.onSubmit(this.state.localForm, this.turnstileToken);
+ if (result?.resetTurnstile !== false) {
+ this.turnstileWidget?.reset();
+ }
+ } catch {
+ this.turnstileWidget?.reset();
+ }
}
}
@@ -466,4 +552,10 @@ export class CommentForm extends Component {
});
this.modal.render();
}
+
+ destroy() {
+ this.turnstileWidget?.destroy();
+ this.turnstileWidget = null;
+ super.destroy();
+ }
}
diff --git a/docs/widget/src/components/CommentItem.js b/docs/widget/src/components/CommentItem.js
index 1b02d4b7..c29c2ed8 100644
--- a/docs/widget/src/components/CommentItem.js
+++ b/docs/widget/src/components/CommentItem.js
@@ -38,6 +38,10 @@ export class CommentItem extends Component {
}
render() {
+ this.replyEditor?.destroy();
+ this.childCommentItems.forEach((item) => item.destroy());
+ this.replyEditor = null;
+ this.childCommentItems = [];
const { comment, isReply, adminEmail, adminBadge } = this.props;
const isPinned = typeof comment.priority === 'number' && comment.priority > 1;
const isReplying = this.props.replyingTo === comment.id;
@@ -215,10 +219,12 @@ export class CommentItem extends Component {
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
+ turnstileSiteKey: this.props.turnstileSiteKey,
+ turnstileTheme: this.props.turnstileTheme,
currentUser: this.props.currentUser,
onUpdateUserInfo: this.props.onUpdateUserInfo,
onUpdate: (content) => this.handleUpdateReplyContent(content),
- onSubmit: () => this.handleSubmitReply(),
+ onSubmit: (turnstileToken) => this.handleSubmitReply(turnstileToken),
onCancel: () => this.handleCancelReply(),
onClearError: () => this.handleClearReplyError(),
placeholder: this.props.replyPlaceholder,
@@ -245,6 +251,8 @@ export class CommentItem extends Component {
replyContent: this.props.replyContent,
replyError: this.props.replyError,
submitting: this.props.submitting,
+ turnstileSiteKey: this.props.turnstileSiteKey,
+ turnstileTheme: this.props.turnstileTheme,
currentUser: this.props.currentUser,
onUpdateUserInfo: this.props.onUpdateUserInfo,
// adminEmail 已移除
@@ -299,10 +307,12 @@ export class CommentItem extends Component {
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
+ turnstileSiteKey: this.props.turnstileSiteKey,
+ turnstileTheme: this.props.turnstileTheme,
currentUser: this.props.currentUser,
onUpdateUserInfo: this.props.onUpdateUserInfo,
onUpdate: (content) => this.handleUpdateReplyContent(content),
- onSubmit: () => this.handleSubmitReply(),
+ onSubmit: (turnstileToken) => this.handleSubmitReply(turnstileToken),
onCancel: () => this.handleCancelReply(),
onClearError: () => this.handleClearReplyError(),
placeholder: this.props.replyPlaceholder,
@@ -313,6 +323,7 @@ export class CommentItem extends Component {
this.replyEditor.focus();
} else if (!isReplying && replyContainer) {
// 隐藏回复编辑器
+ this.replyEditor?.destroy();
replyContainer.innerHTML = '';
this.replyEditor = null;
}
@@ -323,6 +334,8 @@ export class CommentItem extends Component {
error: this.props.replyError,
submitting: this.props.submitting,
currentUser: this.props.currentUser,
+ turnstileSiteKey: this.props.turnstileSiteKey,
+ turnstileTheme: this.props.turnstileTheme,
placeholder: this.props.replyPlaceholder,
emotionGroups: this.props.emotionGroups,
});
@@ -337,6 +350,8 @@ export class CommentItem extends Component {
replyError: this.props.replyError,
submitting: this.props.submitting,
currentUser: this.props.currentUser,
+ turnstileSiteKey: this.props.turnstileSiteKey,
+ turnstileTheme: this.props.turnstileTheme,
enableCommentLike: this.props.enableCommentLike,
replyPlaceholder: this.props.replyPlaceholder,
emotionGroups: this.props.emotionGroups,
@@ -346,6 +361,46 @@ export class CommentItem extends Component {
}
}
+ updateCommentLikes(comment) {
+ this.props.comment = comment;
+ const button = this.elements.root?.querySelector('.cwd-comment-like-button');
+ if (button) {
+ const liked = this.hasLiked(comment.id);
+ button.classList.toggle('cwd-comment-like-button-liked', liked);
+ button.setAttribute('aria-label', liked ? '取消点赞' : '点赞');
+
+ const icon = button.querySelector('.cwd-comment-like-icon');
+ if (icon) {
+ icon.setAttribute('fill', liked ? 'currentColor' : 'none');
+ }
+
+ const likeCount =
+ typeof comment.likes === 'number' && Number.isFinite(comment.likes) && comment.likes >= 0
+ ? comment.likes
+ : 0;
+ let countElement = button.querySelector('.cwd-comment-like-count');
+ if (likeCount >= 1) {
+ if (!countElement) {
+ countElement = this.createTextElement('span', String(likeCount), 'cwd-comment-like-count');
+ button.appendChild(countElement);
+ } else {
+ countElement.textContent = String(likeCount);
+ }
+ } else {
+ countElement?.remove();
+ }
+ }
+
+ const replies = Array.isArray(comment.replies) ? comment.replies : [];
+ const repliesById = new Map(replies.map((reply) => [reply.id, reply]));
+ this.childCommentItems.forEach((childItem) => {
+ const nextReply = repliesById.get(childItem.props.comment.id);
+ if (nextReply) {
+ childItem.updateCommentLikes(nextReply);
+ }
+ });
+ }
+
handleReply() {
if (this.props.onReply) {
this.props.onReply(this.props.comment.id);
@@ -452,9 +507,9 @@ export class CommentItem extends Component {
return likedComments.has(String(commentId));
}
- handleSubmitReply() {
+ handleSubmitReply(turnstileToken) {
if (this.props.onSubmitReply) {
- this.props.onSubmitReply(this.props.comment.id);
+ return this.props.onSubmitReply(this.props.comment.id, turnstileToken);
}
}
@@ -475,4 +530,12 @@ export class CommentItem extends Component {
this.props.onClearReplyError();
}
}
+
+ destroy() {
+ this.replyEditor?.destroy();
+ this.childCommentItems.forEach((item) => item.destroy());
+ this.replyEditor = null;
+ this.childCommentItems = [];
+ super.destroy();
+ }
}
diff --git a/docs/widget/src/components/CommentList.js b/docs/widget/src/components/CommentList.js
index 0d235c60..7843adc1 100644
--- a/docs/widget/src/components/CommentList.js
+++ b/docs/widget/src/components/CommentList.js
@@ -6,6 +6,7 @@ import { Component } from './Component.js';
import { CommentItem } from './CommentItem.js';
import { Loading } from './Loading.js';
import { Pagination } from './Pagination.js';
+import { canUpdateLikesInPlace, updateCommentLikesInPlace } from './commentLikeUpdates.js';
export class CommentList extends Component {
/**
@@ -44,6 +45,8 @@ export class CommentList extends Component {
render() {
const { comments, loading, error, currentPage, totalPages } = this.props;
+ this.commentItems.forEach((commentItem) => commentItem.destroy());
+ this.commentItems.clear();
// 清空容器
this.empty(this.container);
@@ -87,9 +90,6 @@ export class CommentList extends Component {
className: 'cwd-comments'
});
- // 清空旧的缓存
- this.commentItems.clear();
-
comments.forEach((comment, index) => {
const commentItem = new CommentItem(commentsContainer, {
comment,
@@ -104,8 +104,10 @@ export class CommentList extends Component {
enableCommentLike: this.props.enableCommentLike,
replyPlaceholder: this.props.replyPlaceholder,
emotionGroups: this.props.emotionGroups,
+ turnstileSiteKey: this.props.turnstileSiteKey,
+ turnstileTheme: this.props.turnstileTheme,
onReply: (commentId) => this.handleReply(commentId),
- onSubmitReply: (commentId) => this.handleSubmitReply(commentId),
+ onSubmitReply: (commentId, turnstileToken) => this.handleSubmitReply(commentId, turnstileToken),
onCancelReply: () => this.handleCancelReply(),
onUpdateReplyContent: (content) => this.handleUpdateReplyContent(content),
onClearReplyError: () => this.handleClearReplyError(),
@@ -157,17 +159,27 @@ export class CommentList extends Component {
return;
}
- // 如果评论列表变化,重新渲染
+ // 点赞只更新对应评论,保留已创建的回复编辑器和 Turnstile challenge。
if (this.props.comments !== prevProps.comments) {
- this.render();
- return;
+ const canUpdateInPlace =
+ canUpdateLikesInPlace(prevProps.comments, this.props.comments) &&
+ this.props.comments.every((comment) => this.commentItems.has(comment.id));
+
+ if (canUpdateInPlace) {
+ updateCommentLikesInPlace(this.commentItems, this.props.comments);
+ } else {
+ this.render();
+ return;
+ }
}
// 如果只是回复状态变化,局部更新 CommentItem 而不是完全重新渲染
if (this.props.replyingTo !== prevProps.replyingTo ||
this.props.replyError !== prevProps.replyError ||
this.props.submitting !== prevProps.submitting ||
- this.props.currentUser !== prevProps.currentUser) {
+ this.props.currentUser !== prevProps.currentUser ||
+ this.props.turnstileSiteKey !== prevProps.turnstileSiteKey ||
+ this.props.turnstileTheme !== prevProps.turnstileTheme) {
// 局部更新所有 CommentItem
this.commentItems.forEach((commentItem) => {
commentItem.setProps({
@@ -178,6 +190,8 @@ export class CommentList extends Component {
currentUser: this.props.currentUser,
enableCommentLike: this.props.enableCommentLike,
emotionGroups: this.props.emotionGroups,
+ turnstileSiteKey: this.props.turnstileSiteKey,
+ turnstileTheme: this.props.turnstileTheme,
onLikeComment: (commentId, isLike) => this.handleLikeComment(commentId, isLike)
});
});
@@ -210,9 +224,9 @@ export class CommentList extends Component {
}
}
- handleSubmitReply(commentId) {
+ handleSubmitReply(commentId, turnstileToken) {
if (this.props.onSubmitReply) {
- this.props.onSubmitReply(commentId);
+ return this.props.onSubmitReply(commentId, turnstileToken);
}
}
@@ -240,6 +254,12 @@ export class CommentList extends Component {
}
}
+ destroy() {
+ this.commentItems.forEach((commentItem) => commentItem.destroy());
+ this.commentItems.clear();
+ super.destroy();
+ }
+
handlePrevPage() {
if (this.props.onPrevPage) {
this.props.onPrevPage();
diff --git a/docs/widget/src/components/ReplyEditor.js b/docs/widget/src/components/ReplyEditor.js
index 91309dea..85b580e5 100644
--- a/docs/widget/src/components/ReplyEditor.js
+++ b/docs/widget/src/components/ReplyEditor.js
@@ -4,6 +4,7 @@
import { Component } from './Component.js';
import { EmotionPicker } from './EmotionPicker.js';
+import { TurnstileWidget } from './TurnstileWidget.js';
import { insertTextAtCursor } from '../utils/emotions.js';
import { renderMarkdown } from '../utils/markdown.js';
@@ -32,9 +33,14 @@ export class ReplyEditor extends Component {
showPreview: false,
};
this.emotionPicker = null;
+ this.turnstileWidget = null;
+ this.turnstileToken = '';
}
render() {
+ this.turnstileWidget?.destroy();
+ this.turnstileWidget = null;
+ this.turnstileToken = '';
const { currentUser } = this.props;
const { showUserInfo } = this.state;
const placeholderText = this.props.placeholder || '';
@@ -110,6 +116,10 @@ export class ReplyEditor extends Component {
]
: []),
+ ...(this.props.turnstileSiteKey
+ ? [this.createElement('div', { className: 'cwd-turnstile-container cwd-reply-turnstile-container' })]
+ : []),
+
// 操作按钮
this.createElement('div', {
className: 'cwd-reply-actions',
@@ -127,13 +137,16 @@ export class ReplyEditor extends Component {
className: 'cwd-btn cwd-btn-primary cwd-btn-small',
attributes: {
type: 'button',
- disabled: this.props.submitting || !this.state.content.trim(),
+ disabled:
+ this.props.submitting ||
+ !this.state.content.trim() ||
+ (!!this.props.turnstileSiteKey && !this.turnstileToken),
onClick: () => this.handleSubmit(),
},
text: this.props.submitting ? this.t('submitting') : this.t('submit'),
}),
this.createElement('button', {
- className: 'cwd-btn cwd-btn-secondary cwd-btn-small',
+ className: 'cwd-btn cwd-btn-secondary cwd-btn-small cwd-btn-cancel',
attributes: {
type: 'button',
disabled: this.props.submitting,
@@ -172,6 +185,24 @@ export class ReplyEditor extends Component {
this.empty(this.container);
this.container.appendChild(root);
this.renderEmotionPicker(root);
+ this.renderTurnstile(root);
+ }
+
+ renderTurnstile(root) {
+ if (!this.props.turnstileSiteKey) {
+ return;
+ }
+ const container = root.querySelector('.cwd-turnstile-container');
+ this.turnstileWidget = new TurnstileWidget(container, {
+ siteKey: this.props.turnstileSiteKey,
+ theme: this.props.turnstileTheme,
+ errorText: this.t('verifyFailed'),
+ onTokenChange: (token) => {
+ this.turnstileToken = token;
+ this.updateActionState();
+ },
+ });
+ this.turnstileWidget.render();
}
/**
@@ -196,30 +227,30 @@ export class ReplyEditor extends Component {
}
updateProps(prevProps) {
- // 如果外部传入的 content 变化,更新内部状态
- if (this.props.content !== this.state.content && this.props.content !== prevProps?.content) {
- this.state.content = this.props.content;
- this.render();
- return;
- }
-
- // 如果用户信息变化,重新渲染
- if (JSON.stringify(this.props.currentUser) !== JSON.stringify(prevProps?.currentUser)) {
+ if (
+ this.props.turnstileSiteKey !== prevProps?.turnstileSiteKey ||
+ this.props.turnstileTheme !== prevProps?.turnstileTheme
+ ) {
this.render();
return;
}
-
- // 如果有错误显示/隐藏变化,重新渲染
- if (this.props.error !== prevProps?.error) {
- this.render();
- return;
+ // 如果外部传入的 content 变化,更新内部状态
+ if (this.props.content !== this.state.content && this.props.content !== prevProps?.content) {
+ this.state.content = this.props.content;
+ const textarea = this.elements.root?.querySelector('.cwd-reply-textarea');
+ if (textarea) {
+ textarea.value = this.state.content;
+ }
+ if (!this.state.content.trim()) {
+ this.state.showPreview = false;
+ }
+ this.updatePreviewState();
}
- // 如果 submitting 状态变化,重新渲染
- if (this.props.submitting !== prevProps?.submitting) {
- this.render();
- return;
- }
+ this.updateUserInfoFields();
+ this.updateErrorState();
+ this.updateSubmittingState();
+ this.updateActionState();
}
handleTextareaKeydown(e) {
@@ -230,7 +261,7 @@ export class ReplyEditor extends Component {
togglePreview() {
this.state.showPreview = !this.state.showPreview;
- this.render();
+ this.updatePreviewState();
}
handleInput(e) {
@@ -239,7 +270,10 @@ export class ReplyEditor extends Component {
// 更新提交按钮的禁用状态
const submitBtn = this.elements.root?.querySelector('.cwd-btn-primary');
if (submitBtn) {
- submitBtn.disabled = this.props.submitting || !this.state.content.trim();
+ submitBtn.disabled =
+ this.props.submitting ||
+ !this.state.content.trim() ||
+ (!!this.props.turnstileSiteKey && !this.turnstileToken);
}
// 更新预览按钮的禁用状态
@@ -286,12 +320,114 @@ export class ReplyEditor extends Component {
updateActionState() {
const submitBtn = this.elements.root?.querySelector('.cwd-btn-primary');
if (submitBtn) {
- submitBtn.disabled = this.props.submitting || !this.state.content.trim();
+ submitBtn.disabled =
+ this.props.submitting ||
+ !this.state.content.trim() ||
+ (!!this.props.turnstileSiteKey && !this.turnstileToken);
}
const previewBtn = this.elements.root?.querySelector('.cwd-btn-preview');
if (previewBtn) {
previewBtn.disabled = this.props.submitting || !this.state.content.trim();
+ previewBtn.textContent = this.state.showPreview ? this.t('close') : this.t('preview');
+ previewBtn.classList.toggle('cwd-btn-active', this.state.showPreview);
+ }
+ }
+
+ updatePreviewState() {
+ const root = this.elements.root;
+ if (!root) {
+ return;
+ }
+
+ this.updateActionState();
+ let previewContainer = root.querySelector('.cwd-preview-container');
+ if (!this.state.showPreview || !this.state.content) {
+ previewContainer?.remove();
+ return;
+ }
+
+ if (!previewContainer) {
+ previewContainer = this.createElement('div', {
+ className: 'cwd-preview-container',
+ children: [
+ this.createElement('div', {
+ className: 'cwd-preview-content cwd-comment-content',
+ html: renderMarkdown(this.state.content),
+ }),
+ ],
+ });
+ const actions = root.querySelector('.cwd-reply-actions');
+ root.insertBefore(previewContainer, actions?.nextSibling || null);
+ } else {
+ this.updatePreviewContent(this.state.content);
+ }
+ }
+
+ updateUserInfoFields() {
+ const currentUser = this.props.currentUser || {};
+ for (const field of ['name', 'email', 'url']) {
+ const input = this.elements.root?.querySelector(`[data-cwd-user-field="${field}"]`);
+ if (input && (typeof document === 'undefined' || input !== document.activeElement)) {
+ input.value = currentUser[field] || '';
+ }
+ }
+ }
+
+ updateErrorState() {
+ const root = this.elements.root;
+ if (!root) {
+ return;
+ }
+
+ let errorElement = root.querySelector('.cwd-error-inline');
+ if (!this.props.error) {
+ errorElement?.remove();
+ return;
+ }
+
+ if (!errorElement) {
+ errorElement = this.createElement('div', {
+ className: 'cwd-error-inline cwd-error-small',
+ children: [
+ this.createTextElement('span', this.props.error),
+ this.createElement('button', {
+ className: 'cwd-error-close',
+ attributes: {
+ type: 'button',
+ onClick: () => this.handleClearError(),
+ },
+ text: '✕',
+ }),
+ ],
+ });
+ const nextElement = root.querySelector('.cwd-turnstile-container') || root.querySelector('.cwd-reply-actions');
+ root.insertBefore(errorElement, nextElement);
+ return;
+ }
+
+ const message = errorElement.querySelector('span');
+ if (message) {
+ message.textContent = this.props.error;
+ }
+ }
+
+ updateSubmittingState() {
+ const root = this.elements.root;
+ if (!root) {
+ return;
+ }
+
+ root.querySelectorAll('input, textarea').forEach((field) => {
+ field.disabled = !!this.props.submitting;
+ });
+ const submitBtn = root.querySelector('.cwd-btn-primary');
+ if (submitBtn) {
+ submitBtn.textContent = this.props.submitting ? this.t('submitting') : this.t('submit');
+ }
+ const cancelBtn = root.querySelector('.cwd-btn-cancel');
+ if (cancelBtn) {
+ cancelBtn.disabled = !!this.props.submitting;
}
}
@@ -302,9 +438,16 @@ export class ReplyEditor extends Component {
}
}
- handleSubmit() {
+ async handleSubmit() {
if (this.props.onSubmit) {
- this.props.onSubmit();
+ try {
+ const result = await this.props.onSubmit(this.turnstileToken);
+ if (result?.resetTurnstile !== false) {
+ this.turnstileWidget?.reset();
+ }
+ } catch {
+ this.turnstileWidget?.reset();
+ }
}
}
@@ -367,6 +510,7 @@ export class ReplyEditor extends Component {
placeholder,
value: value || '',
disabled: this.props.submitting,
+ dataset: { cwdUserField: field },
onInput: (e) => this.handleUserInfoChange(field, e.target.value),
onKeydown: (e) => this.handleTextareaKeydown(e),
},
@@ -374,4 +518,10 @@ export class ReplyEditor extends Component {
],
});
}
+
+ destroy() {
+ this.turnstileWidget?.destroy();
+ this.turnstileWidget = null;
+ super.destroy();
+ }
}
diff --git a/docs/widget/src/components/TurnstileWidget.js b/docs/widget/src/components/TurnstileWidget.js
new file mode 100644
index 00000000..e52c9800
--- /dev/null
+++ b/docs/widget/src/components/TurnstileWidget.js
@@ -0,0 +1,124 @@
+const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
+
+let turnstileScriptPromise = null;
+
+function waitForTurnstile(timeoutMs = 10000) {
+ return new Promise((resolve, reject) => {
+ const startedAt = Date.now();
+ const timer = window.setInterval(() => {
+ if (window.turnstile && typeof window.turnstile.render === 'function') {
+ window.clearInterval(timer);
+ resolve(window.turnstile);
+ return;
+ }
+ if (Date.now() - startedAt >= timeoutMs) {
+ window.clearInterval(timer);
+ reject(new Error('Turnstile script timed out'));
+ }
+ }, 50);
+ });
+}
+
+function loadTurnstileScript() {
+ if (window.turnstile && typeof window.turnstile.render === 'function') {
+ return Promise.resolve(window.turnstile);
+ }
+ if (turnstileScriptPromise) {
+ return turnstileScriptPromise;
+ }
+
+ turnstileScriptPromise = new Promise((resolve, reject) => {
+ let script = document.querySelector('script[data-cwd-turnstile]');
+ if (!script) {
+ script = document.createElement('script');
+ script.src = TURNSTILE_SCRIPT_URL;
+ script.async = true;
+ script.defer = true;
+ script.dataset.cwdTurnstile = 'true';
+ document.head.appendChild(script);
+ }
+
+ const resolveWhenReady = () => {
+ waitForTurnstile().then(resolve).catch(reject);
+ };
+ script.addEventListener('load', resolveWhenReady, { once: true });
+ script.addEventListener('error', () => reject(new Error('Failed to load Turnstile script')), { once: true });
+
+ if (window.turnstile) {
+ resolveWhenReady();
+ }
+ }).catch((error) => {
+ turnstileScriptPromise = null;
+ throw error;
+ });
+
+ return turnstileScriptPromise;
+}
+
+/**
+ * Explicitly renders a Cloudflare Turnstile challenge inside the widget Shadow DOM.
+ */
+export class TurnstileWidget {
+ constructor(container, options = {}) {
+ this.container = container;
+ this.options = options;
+ this.widgetId = null;
+ this.token = '';
+ this.destroyed = false;
+ }
+
+ async render() {
+ if (!this.container || !this.options.siteKey || this.destroyed) {
+ return;
+ }
+
+ try {
+ const turnstile = await loadTurnstileScript();
+ if (this.destroyed || !this.container.isConnected) {
+ return;
+ }
+ this.widgetId = turnstile.render(this.container, {
+ sitekey: this.options.siteKey,
+ theme: this.options.theme === 'dark' ? 'dark' : 'light',
+ size: 'flexible',
+ action: 'comment',
+ callback: (token) => this.setToken(token),
+ 'expired-callback': () => this.setToken(''),
+ 'timeout-callback': () => this.setToken(''),
+ 'error-callback': () => this.setToken(''),
+ });
+ } catch {
+ if (!this.destroyed && this.container) {
+ this.container.textContent = this.options.errorText || '';
+ this.container.classList.add('cwd-turnstile-error');
+ }
+ }
+ }
+
+ setToken(token) {
+ this.token = typeof token === 'string' ? token : '';
+ if (this.options.onTokenChange) {
+ this.options.onTokenChange(this.token);
+ }
+ }
+
+ getToken() {
+ return this.token;
+ }
+
+ reset() {
+ this.setToken('');
+ if (this.widgetId !== null && window.turnstile && typeof window.turnstile.reset === 'function') {
+ window.turnstile.reset(this.widgetId);
+ }
+ }
+
+ destroy() {
+ this.destroyed = true;
+ this.setToken('');
+ if (this.widgetId !== null && window.turnstile && typeof window.turnstile.remove === 'function') {
+ window.turnstile.remove(this.widgetId);
+ }
+ this.widgetId = null;
+ }
+}
diff --git a/docs/widget/src/components/commentLikeUpdates.js b/docs/widget/src/components/commentLikeUpdates.js
new file mode 100644
index 00000000..96d4842c
--- /dev/null
+++ b/docs/widget/src/components/commentLikeUpdates.js
@@ -0,0 +1,30 @@
+export function canUpdateLikesInPlace(previousComments, nextComments) {
+ if (!Array.isArray(previousComments) || !Array.isArray(nextComments) || previousComments.length !== nextComments.length) {
+ return false;
+ }
+
+ return previousComments.every((previousComment, index) => {
+ const nextComment = nextComments[index];
+ if (!previousComment || !nextComment || previousComment.id !== nextComment.id) {
+ return false;
+ }
+
+ const keys = new Set([...Object.keys(previousComment), ...Object.keys(nextComment)]);
+ for (const key of keys) {
+ if (key === 'likes' || key === 'replies') {
+ continue;
+ }
+ if (!Object.is(previousComment[key], nextComment[key])) {
+ return false;
+ }
+ }
+
+ return canUpdateLikesInPlace(previousComment.replies || [], nextComment.replies || []);
+ });
+}
+
+export function updateCommentLikesInPlace(commentItems, comments) {
+ comments.forEach((comment) => {
+ commentItems.get(comment.id).updateCommentLikes(comment);
+ });
+}
diff --git a/docs/widget/src/components/turnstile-lifecycle.test.js b/docs/widget/src/components/turnstile-lifecycle.test.js
new file mode 100644
index 00000000..b796ab82
--- /dev/null
+++ b/docs/widget/src/components/turnstile-lifecycle.test.js
@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { CommentForm } from './CommentForm.js';
+import { ReplyEditor } from './ReplyEditor.js';
+import { canUpdateLikesInPlace, updateCommentLikesInPlace } from './commentLikeUpdates.js';
+
+test('preview toggles update in place without rendering a new challenge', () => {
+ for (const ComponentClass of [CommentForm, ReplyEditor]) {
+ const component = Object.create(ComponentClass.prototype);
+ component.state = { showPreview: false };
+ let renders = 0;
+ let previewUpdates = 0;
+ component.render = () => renders++;
+ component.updatePreviewState = () => previewUpdates++;
+
+ component.togglePreview();
+
+ assert.equal(component.state.showPreview, true);
+ assert.equal(previewUpdates, 1);
+ assert.equal(renders, 0);
+ }
+});
+
+test('reply prop updates do not render a new challenge', () => {
+ const editor = Object.create(ReplyEditor.prototype);
+ editor.props = {
+ turnstileSiteKey: 'site-key',
+ turnstileTheme: 'light',
+ content: 'draft',
+ currentUser: { name: 'Reader' },
+ error: 'failed',
+ submitting: true,
+ };
+ editor.state = { content: 'draft', showPreview: false };
+ editor.elements = {};
+ let renders = 0;
+ editor.render = () => renders++;
+ editor.updateUserInfoFields = () => {};
+ editor.updateErrorState = () => {};
+ editor.updateSubmittingState = () => {};
+ editor.updateActionState = () => {};
+
+ editor.updateProps({
+ turnstileSiteKey: 'site-key',
+ turnstileTheme: 'light',
+ content: 'draft',
+ currentUser: {},
+ error: null,
+ submitting: false,
+ });
+
+ assert.equal(renders, 0);
+});
+
+test('a pre-Siteverify rejection preserves the current challenge', async () => {
+ for (const ComponentClass of [CommentForm, ReplyEditor]) {
+ const component = Object.create(ComponentClass.prototype);
+ component.turnstileToken = 'token';
+ let resets = 0;
+ component.turnstileWidget = { reset: () => resets++ };
+ component.props = {
+ onSubmit: async () => ({ success: false, resetTurnstile: false }),
+ };
+ component.state = { localForm: { email: '' } };
+
+ if (ComponentClass === CommentForm) {
+ await component.handleSubmit({ preventDefault() {} });
+ } else {
+ await component.handleSubmit();
+ }
+
+ assert.equal(resets, 0);
+ }
+});
+
+test('like-only comment updates preserve the active reply challenge', () => {
+ const previousComments = [
+ {
+ id: 1,
+ name: 'Reader',
+ likes: 0,
+ replies: [{ id: 2, name: 'Author', likes: 0 }],
+ },
+ ];
+ const nextComments = [
+ {
+ ...previousComments[0],
+ replies: [{ ...previousComments[0].replies[0], likes: 1 }],
+ },
+ ];
+ const activeReplyEditor = { turnstileToken: 'solved-token' };
+ const commentItem = {
+ replyEditor: activeReplyEditor,
+ updateCommentLikes(comment) {
+ this.comment = comment;
+ },
+ };
+ const commentItems = new Map([[1, commentItem]]);
+
+ assert.equal(canUpdateLikesInPlace(previousComments, nextComments), true);
+ updateCommentLikesInPlace(commentItems, nextComments);
+
+ assert.equal(commentItem.comment, nextComments[0]);
+ assert.equal(commentItem.replyEditor, activeReplyEditor);
+ assert.equal(commentItem.replyEditor.turnstileToken, 'solved-token');
+});
diff --git a/docs/widget/src/core/CWDComments.js b/docs/widget/src/core/CWDComments.js
index 65bb8d8c..d320590b 100644
--- a/docs/widget/src/core/CWDComments.js
+++ b/docs/widget/src/core/CWDComments.js
@@ -23,6 +23,7 @@ export class CWDComments {
* @param {'light'|'dark'} [config.theme] - 主题(可选)
* @param {number} [config.pageSize] - 每页评论数(可选,默认 20)
* @param {string|Object} [config.emotionJson] - 前端表情 JSON 文件链接,留空则不显示表情按钮
+ * @param {string} [config.turnstileSiteKey] - Cloudflare Turnstile site key(通常由后端自动下发)
*
* 以下字段由组件自动推导或从后端读取,无需通过 config 传入:
* - postSlug:window.location.origin + window.location.pathname
@@ -126,6 +127,8 @@ export class CWDComments {
enableCommentLike: typeof data.enableCommentLike === 'boolean' ? data.enableCommentLike : true,
enableArticleLike: typeof data.enableArticleLike === 'boolean' ? data.enableArticleLike : true,
enableImageLightbox: typeof data.enableImageLightbox === 'boolean' ? data.enableImageLightbox : false,
+ requireReview: !!data.requireReview,
+ turnstileSiteKey: typeof data.turnstileSiteKey === 'string' ? data.turnstileSiteKey : '',
commentPlaceholder:
typeof data.commentPlaceholder === 'string' ? data.commentPlaceholder : undefined,
widgetLanguage: typeof data.widgetLanguage === 'string' ? data.widgetLanguage : undefined,
@@ -223,6 +226,7 @@ export class CWDComments {
this.config.adminBadge = serverConfig.adminBadge;
}
this.config.requireReview = !!serverConfig.requireReview;
+ this.config.turnstileSiteKey = serverConfig.turnstileSiteKey || this.config.turnstileSiteKey || '';
this.config.enableCommentLike = serverConfig.enableCommentLike;
this.config.enableArticleLike = serverConfig.enableArticleLike;
this.config.enableImageLightbox = serverConfig.enableImageLightbox;
@@ -423,12 +427,14 @@ export class CWDComments {
form: state.form,
formErrors: state.formErrors,
submitting: state.submitting,
- onSubmit: () => this._handleSubmit(),
+ onSubmit: (_form, turnstileToken) => this._handleSubmit(turnstileToken),
onFieldChange: (field, value) => this.store.updateFormField(field, value),
adminEmail: this.config.adminEmail,
onVerifyAdmin: (key) => this.api.verifyAdminKey(key),
placeholder: this.config.commentPlaceholder,
emotionGroups: this.emotionGroups,
+ turnstileSiteKey: this.config.turnstileSiteKey,
+ turnstileTheme: this.config.theme,
t: this.t
});
this.commentForm.render();
@@ -473,9 +479,11 @@ export class CWDComments {
// adminEmail 已移除,前端展示改用 isAdmin 字段
adminBadge: this.config.adminBadge,
enableCommentLike: this.config.enableCommentLike !== false,
+ turnstileSiteKey: this.config.turnstileSiteKey,
+ turnstileTheme: this.config.theme,
onRetry: () => this.store.loadComments(),
onReply: (commentId) => this.store.startReply(commentId),
- onSubmitReply: (commentId) => this.store.submitReply(commentId),
+ onSubmitReply: (commentId, turnstileToken) => this.store.submitReply(commentId, turnstileToken),
onCancelReply: () => this.store.cancelReply(),
onUpdateReplyContent: (content) => this.store.updateReplyContent(content),
onClearReplyError: () => this.store.clearReplyError(),
@@ -528,6 +536,8 @@ export class CWDComments {
submitting: state.submitting,
adminEmail: this.config.adminEmail,
emotionGroups: this.emotionGroups,
+ turnstileSiteKey: this.config.turnstileSiteKey,
+ turnstileTheme: this.config.theme,
});
}
@@ -608,6 +618,8 @@ export class CWDComments {
submitting: state.submitting,
currentUser: state.form,
emotionGroups: this.emotionGroups,
+ turnstileSiteKey: this.config.turnstileSiteKey,
+ turnstileTheme: this.config.theme,
});
}
@@ -627,9 +639,9 @@ export class CWDComments {
* 处理评论提交
* @private
*/
- async _handleSubmit() {
- const success = await this.store.submitNewComment();
- if (success) {
+ async _handleSubmit(turnstileToken) {
+ const result = await this.store.submitNewComment(turnstileToken);
+ if (result.success) {
// 表单内容已在 store 中清空
// 更新表单组件
if (this.commentForm) {
@@ -638,6 +650,7 @@ export class CWDComments {
this.commentForm.render();
}
}
+ return result;
}
/**
@@ -664,6 +677,8 @@ export class CWDComments {
// 更新主题
if (newConfig.theme && this.mountPoint) {
this.mountPoint.setAttribute('data-theme', newConfig.theme);
+ this.commentForm?.setProps({ turnstileTheme: newConfig.theme });
+ this.commentList?.setProps({ turnstileTheme: newConfig.theme });
}
const shouldReload =
diff --git a/docs/widget/src/core/api.js b/docs/widget/src/core/api.js
index f3d0ebad..5c4f2317 100644
--- a/docs/widget/src/core/api.js
+++ b/docs/widget/src/core/api.js
@@ -87,18 +87,23 @@ export function createApiClient(config) {
content: data.content,
parent_id: data.parentId,
adminToken: data.adminToken,
+ turnstileToken: data.turnstileToken,
site_id: config.siteId
}),
});
if (!response.ok) {
- // Try to parse error message
- let msg = response.statusText;
- try {
- const json = await response.json();
- if (json.message) msg = json.message;
- } catch (e) {}
- throw new Error(msg);
+ let msg = response.statusText;
+ let turnstileConsumed = true;
+ try {
+ const json = await response.json();
+ if (json.message) msg = json.message;
+ turnstileConsumed = json.turnstileConsumed !== false;
+ } catch (e) {}
+ const error = new Error(msg);
+ error.status = response.status;
+ error.turnstileConsumed = turnstileConsumed;
+ throw error;
}
return response.json();
}
diff --git a/docs/widget/src/core/api.test.js b/docs/widget/src/core/api.test.js
new file mode 100644
index 00000000..be89de40
--- /dev/null
+++ b/docs/widget/src/core/api.test.js
@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createApiClient } from './api.js';
+
+test('submitComment exposes whether Turnstile was consumed', async (t) => {
+ const originalFetch = globalThis.fetch;
+ t.after(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ globalThis.fetch = async () =>
+ new Response(
+ JSON.stringify({
+ message: '评论频繁,等10s后再试',
+ turnstileConsumed: false,
+ }),
+ { status: 429, statusText: 'Too Many Requests' }
+ );
+
+ const api = createApiClient({
+ apiBaseUrl: 'https://comments.example.com',
+ postSlug: '/post',
+ });
+
+ await assert.rejects(
+ api.submitComment({
+ name: 'Reader',
+ email: 'reader@example.com',
+ content: 'hello',
+ turnstileToken: 'token',
+ }),
+ (error) => {
+ assert.equal(error.status, 429);
+ assert.equal(error.turnstileConsumed, false);
+ return true;
+ }
+ );
+});
diff --git a/docs/widget/src/core/store.js b/docs/widget/src/core/store.js
index 391fb594..4dafa692 100644
--- a/docs/widget/src/core/store.js
+++ b/docs/widget/src/core/store.js
@@ -259,7 +259,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
/**
* 提交评论
*/
- async function submitNewComment() {
+ async function submitNewComment(turnstileToken = '') {
const state = store.getState();
const form = state.form;
@@ -270,7 +270,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
store.setState({
formErrors: validation.errors,
});
- return false;
+ return { success: false, resetTurnstile: false };
}
// 清空错误
@@ -287,7 +287,8 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
email: form.email,
url: form.url,
content: form.content,
- adminToken: auth.getToken() // Add token if exists
+ adminToken: auth.getToken(), // Add token if exists
+ turnstileToken,
});
const successMessage =
@@ -305,14 +306,17 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
// 重新加载评论
await loadComments(state.pagination.page);
- return true;
+ return { success: true, resetTurnstile: true };
} catch (e) {
store.setState({
error: e instanceof Error ? e.message : '提交评论失败',
submitting: false,
successMessage: '',
});
- return false;
+ return {
+ success: false,
+ resetTurnstile: !e || e.turnstileConsumed !== false,
+ };
}
}
@@ -320,12 +324,12 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
* 提交回复
* @param {number} parentId - 父评论 ID
*/
- async function submitReply(parentId) {
+ async function submitReply(parentId, turnstileToken = '') {
const state = store.getState();
// 验证回复内容
if (!state.replyContent.trim()) {
- return false;
+ return { success: false, resetTurnstile: false };
}
// 验证用户信息
@@ -336,7 +340,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
store.setState({
replyError: errorMessages,
});
- return false;
+ return { success: false, resetTurnstile: false };
}
store.setState({
@@ -352,7 +356,8 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
url: state.form.url,
content: state.replyContent,
parentId,
- adminToken: auth.getToken()
+ adminToken: auth.getToken(),
+ turnstileToken,
});
// 清空回复内容并关闭回复框
@@ -364,13 +369,16 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
// 重新加载评论
await loadComments(state.pagination.page);
- return true;
+ return { success: true, resetTurnstile: true };
} catch (e) {
store.setState({
error: e instanceof Error ? e.message : '提交回复失败',
submitting: false,
});
- return false;
+ return {
+ success: false,
+ resetTurnstile: !e || e.turnstileConsumed !== false,
+ };
}
}
diff --git a/docs/widget/src/index.d.ts b/docs/widget/src/index.d.ts
index 4e0aa388..6cc220cc 100644
--- a/docs/widget/src/index.d.ts
+++ b/docs/widget/src/index.d.ts
@@ -76,6 +76,11 @@ export interface CWDCommentsConfig {
* Custom OwO emotion JSON for the emotion picker. Empty values hide the emotion button.
*/
emotionJson?: string | Record;
+
+ /**
+ * Cloudflare Turnstile site key. Normally loaded from the CWD API.
+ */
+ turnstileSiteKey?: string;
}
export class CWDComments {
diff --git a/docs/widget/src/styles/main.css b/docs/widget/src/styles/main.css
index f8c4d59d..fa2bda91 100644
--- a/docs/widget/src/styles/main.css
+++ b/docs/widget/src/styles/main.css
@@ -360,6 +360,25 @@
gap: 10px;
}
+.cwd-turnstile-container {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+ min-height: 65px;
+ margin-top: 16px;
+}
+
+.cwd-reply-turnstile-container {
+ margin-top: 12px;
+}
+
+.cwd-turnstile-error {
+ align-items: center;
+ min-height: 40px;
+ color: var(--cwd-error, #cf222e);
+ font-size: 12px;
+}
+
.cwd-btn {
padding: 8px 16px;
font-size: 14px;