Compare commits

...

14 Commits

Author SHA1 Message Date
shumengya
4a45ba80a4 chore: sync local changes to Gitea
Some checks failed
Create Release / check-release (push) Has been cancelled
Build and Deploy Docs / build-and-deploy (push) Has been cancelled
Publish npm / publish (push) Has been cancelled
2026-06-24 22:10:23 +08:00
anghunk
3cc748736c Merge branch 'main' of https://github.com/anghunk/cwd 2026-03-26 10:04:41 +08:00
anghunk
4ae064d86f chore: 更新至 0.1.11 版本 2026-03-26 10:04:35 +08:00
anghunk
1abd6964a0 Merge pull request #9 from zpj80231/feature/comments-liked-deleted
feat(cwd-api, widget): 实现评论取消点赞功能
2026-03-26 10:00:59 +08:00
anghunk
31421a930a Merge pull request #10 from zpj80231/feature/highlight-like
style(widget): 对于已经点赞的按钮,高亮评论点赞按钮样式
2026-03-26 10:00:43 +08:00
anghunk
d170032b3b Merge pull request #11 from zpj80231/feature/url-multiple-formats
feat(api): 优化获取评论可能导致的DB500问题;兼容url中包含中文、空格、多编码等的评论处理
2026-03-26 10:00:29 +08:00
anghunk
c5f65059cb Merge pull request #12 from zpj80231/fix/next-page
fix(widget): 修复评论列表下一页、上一页超过第二页时的失效问题
2026-03-26 10:00:11 +08:00
anghunk
f5a28d5e8e Merge pull request #13 from zpj80231/feature/emoji-parse
feat(widget): 添加 emoji 表情支持功能
2026-03-26 09:59:57 +08:00
anghunk
7cc69356d5 Update admin-panel.md 2026-03-26 09:59:35 +08:00
zpj80231
bbfa65f78e feat(widget): 添加 emoji 表情支持功能
- 添加 node-emoji 依赖库支持 emoji 解析
- 实现防错处理确保 emoji 渲染失败时返回原始文本
2026-03-25 17:48:46 +08:00
zpj80231
2056ba29d3 fix(widget): 修复评论列表下一页、上一页超过第二页时的失效问题
- 修改 onPrevPage 函数以获取当前状态而不是依赖闭包变量
- 修改 onNextPage 函数以获取当前状态而不是依赖闭包变量
- 添加显式的状态获取逻辑确保分页操作基于最新状态
- 提高代码可读性和状态管理的准确性
2026-03-25 15:32:01 +08:00
zpj80231
75b604e510 feat(api): 优化获取评论可能导致的DB500问题;兼容url中包含中文、空格、多编码等的评论处理
- 在 getComments、getPagePv、like、postComment 和 trackVisit 等 API 中统一使用 decodePostSlug 函数处理文章别名
- 实现更精确的文章别名匹配,支持多种别名格式的查询
- 修复了原始别名处理中的潜在安全问题
- 移除了过时的 LIKE 查询模式,提高查询效率
- 统一了所有涉及文章别名的参数验证逻辑

(cherry picked from commit b9b944d7aff3f2e6008e52ba8f515bc451cfc32d)
2026-03-25 15:09:07 +08:00
zpj80231
fc48deb338 feat(cwd-api, widget): 实现评论取消点赞功能
- 添加了评论取消点赞的逻辑处理
- 新增 DELETE /api/comments/like 接口用于取消点赞
- 修改点赞接口支持根据请求方法判断增加或减少点赞数
- 优化点赞数计算逻辑,支持正确的增减操作
- 更新错误提示信息为更通用的操作失败提示
2026-03-25 15:06:16 +08:00
zpj80231
7b27b0128e style(widget): 对于已经点赞的按钮,高亮评论点赞按钮样式
- 新增 .cwd-comment-like-button-liked 样式类
- 设置已点赞状态的颜色为 cwd-primary 变量值
- 保持与现有主题颜色的一致性

(cherry picked from commit 334ad20720eeed69c05e880c28d7d8171c7f67f1)
2026-03-25 15:04:43 +08:00
20 changed files with 1306 additions and 77 deletions

6
.gitignore vendored
View File

@@ -174,4 +174,8 @@ wrangler.jsonc
.claude
.trae
.npmrc
.npmrc
# idea
.idea

1166
api-docs/cwd-api.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "cwd-admin",
"version": "0.1.10",
"version": "0.1.11",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,6 +1,6 @@
{
"name": "cwd-api",
"version": "0.1.10",
"version": "0.1.11",
"scripts": {
"deploy": "node scripts/index.js && wrangler deploy",
"dev": "wrangler dev",

View File

@@ -1,10 +1,11 @@
import { Context } from 'hono'
import { Bindings } from '../../bindings'
import { getCravatar } from '../../utils/getAvatar'
import { decodePostSlug, getAllSlugFormats, escapeLikePattern } from '../../utils/decodePostSlug'
export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
const rawPostSlug = c.req.query('post_slug') || ''
const postSlug = rawPostSlug.trim()
const postSlug = decodePostSlug(rawPostSlug)
const page = parseInt(c.req.query('page') || '1')
const limit = Math.min(parseInt(c.req.query('limit') || '20'), 50)
const nested = c.req.query('nested') !== 'false'
@@ -42,13 +43,14 @@ export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
}
try {
const equalSlugs = Array.from(new Set(slugList))
const likePatternsSet = new Set<string>()
for (const s of equalSlugs) {
likePatternsSet.add(`${s}#%`)
likePatternsSet.add(`${s}?%`)
const allSlugFormats = new Set<string>()
for (const s of slugList) {
for (const format of getAllSlugFormats(s)) {
allSlugFormats.add(format)
}
}
const likePatterns = Array.from(likePatternsSet)
const equalSlugs = Array.from(allSlugFormats)
const whereParts: string[] = []
if (equalSlugs.length === 1) {
whereParts.push('post_slug = ?')
@@ -56,16 +58,13 @@ export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
const placeholders = equalSlugs.map(() => '?').join(', ')
whereParts.push(`post_slug IN (${placeholders})`)
}
for (let i = 0; i < likePatterns.length; i += 1) {
whereParts.push('post_slug LIKE ?')
}
const whereClause =
whereParts.length > 0
? `status = "approved" AND (${whereParts.join(' OR ')})`
: 'status = "approved"'
? `(${whereParts.join(' OR ')})`
: '1=1'
let finalWhereClause = whereClause
const bindParams: unknown[] = [...equalSlugs, ...likePatterns]
let finalWhereClause = `status = "approved" AND ${whereClause}`
const bindParams: unknown[] = [...equalSlugs]
if (siteId) {
finalWhereClause += ' AND site_id = ?'

View File

@@ -1,10 +1,11 @@
import type { Context } from 'hono';
import type { Bindings } from '../../bindings';
import { decodePostSlug } from '../../utils/decodePostSlug';
export const getPagePv = async (c: Context<{ Bindings: Bindings }>) => {
try {
const rawPostSlug = c.req.query('post_slug') || '';
const postSlug = rawPostSlug.trim();
const postSlug = decodePostSlug(rawPostSlug);
const rawSiteId = c.req.query('siteId') || '';
const siteId = rawSiteId && rawSiteId !== 'default' ? rawSiteId : '';

View File

@@ -1,5 +1,6 @@
import type { Context } from 'hono';
import type { Bindings } from '../../bindings';
import { decodePostSlug } from '../../utils/decodePostSlug';
type LikeStatusResponse = {
liked: boolean;
@@ -36,7 +37,7 @@ export const getLikeStatus = async (
): Promise<Response> => {
try {
const rawPostSlug = c.req.query('post_slug') || '';
const postSlug = rawPostSlug.trim();
const postSlug = decodePostSlug(rawPostSlug);
const siteId = c.req.query('siteId') || '';
if (!postSlug) {
@@ -92,13 +93,14 @@ export const likePage = async (
const rawPostSlug =
typeof body.postSlug === 'string' ? body.postSlug.trim() : '';
const postSlug = decodePostSlug(rawPostSlug);
const rawPostTitle =
typeof body.postTitle === 'string' ? body.postTitle.trim() : '';
const rawPostUrl =
typeof body.postUrl === 'string' ? body.postUrl.trim() : '';
const siteId = typeof body.siteId === 'string' ? body.siteId.trim() : '';
if (!rawPostSlug) {
if (!postSlug) {
return c.json({ message: 'postSlug is required' }, 400);
}
@@ -109,7 +111,7 @@ export const likePage = async (
const existingLike = await c.env.CWD_DB.prepare(
'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ? AND site_id = ?'
)
.bind(rawPostSlug, userId, siteId)
.bind(postSlug, userId, siteId)
.first<{ id: number }>();
let alreadyLiked = false;
@@ -118,7 +120,7 @@ export const likePage = async (
await c.env.CWD_DB.prepare(
'INSERT INTO Likes (page_slug, user_id, created_at, site_id) VALUES (?, ?, ?, ?)'
)
.bind(rawPostSlug, userId, now, siteId)
.bind(postSlug, userId, now, siteId)
.run();
} else {
alreadyLiked = true;
@@ -127,7 +129,7 @@ export const likePage = async (
const pageStatsRow = await c.env.CWD_DB.prepare(
'SELECT id FROM page_stats WHERE post_slug = ? AND site_id = ?'
)
.bind(rawPostSlug, siteId)
.bind(postSlug, siteId)
.first<{ id: number }>();
if (!pageStatsRow) {
@@ -135,7 +137,7 @@ export const likePage = async (
'INSERT INTO page_stats (post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at, site_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(
rawPostSlug,
postSlug,
rawPostTitle || null,
rawPostUrl || null,
0,
@@ -161,7 +163,7 @@ export const likePage = async (
const totalRow = await c.env.CWD_DB.prepare(
'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ? AND site_id = ?'
)
.bind(rawPostSlug, siteId)
.bind(postSlug, siteId)
.first<{ count: number }>();
const totalLikes = totalRow?.count || 0;

View File

@@ -27,6 +27,7 @@ export const likeComment = async (c: Context<{ Bindings: Bindings }>) => {
}
const id = parsed;
const method = c.req.method;
try {
const existing = await c.env.CWD_DB.prepare(
@@ -39,26 +40,20 @@ export const likeComment = async (c: Context<{ Bindings: Bindings }>) => {
return c.json({ message: 'Comment not found' }, 404);
}
const delta = method === 'DELETE' ? -1 : 1;
const currentLikes = typeof existing.likes === 'number' && Number.isFinite(existing.likes) && existing.likes >= 0
? existing.likes
: 0;
const newLikes = Math.max(0, currentLikes + delta);
await c.env.CWD_DB.prepare(
'UPDATE Comment SET likes = COALESCE(likes, 0) + 1 WHERE id = ?'
'UPDATE Comment SET likes = ? WHERE id = ?'
)
.bind(id)
.bind(newLikes, id)
.run();
const updated = await c.env.CWD_DB.prepare(
'SELECT COALESCE(likes, 0) as likes FROM Comment WHERE id = ?'
)
.bind(id)
.first<{ likes?: number }>();
const likes =
updated && typeof updated.likes === 'number' && Number.isFinite(updated.likes) && updated.likes >= 0
? updated.likes
: ((existing.likes || 0) + 1);
return c.json({ id, likes });
return c.json({ id, likes: newLikes });
} catch (e: any) {
return c.json({ message: e?.message || '点赞失败' }, 500);
return c.json({ message: e?.message || '操作失败' }, 500);
}
};

View File

@@ -12,8 +12,8 @@ import {
EmailNotificationSettings
} from '../../utils/email';
import { loadTelegramSettings, sendTelegramMessage } from '../../utils/telegram';
import { decodePostSlug } from '../../utils/decodePostSlug';
// 检查内容,将<script>标签之间的内容删除
export function checkContent(content: string): string {
return content.replace(/<script[\s\S]*?<\/script>/g, "");
}
@@ -23,7 +23,8 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
if (!data || typeof data !== 'object') {
return c.json({ message: '无效的请求体' }, 400);
}
const { post_slug, content: rawContent, name: rawName, email, url, post_title, post_url, adminToken } = data;
const { post_slug: rawPostSlug, content: rawContent, name: rawName, email, url, post_title, post_url, adminToken } = data;
const post_slug = decodePostSlug(rawPostSlug || '');
const site_id = data.site_id ? String(data.site_id).trim() : "";
const parentId = (data as any).parent_id ?? (data as any).parentId ?? null;
if (!post_slug || typeof post_slug !== 'string') {

View File

@@ -1,5 +1,6 @@
import type { Context } from 'hono';
import type { Bindings } from '../../bindings';
import { decodePostSlug } from '../../utils/decodePostSlug';
type TrackVisitBody = {
postSlug?: string;
@@ -32,11 +33,12 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
const body = (await c.req.json().catch(() => ({}))) as TrackVisitBody;
const rawPostSlug = typeof body.postSlug === 'string' ? body.postSlug.trim() : '';
const postSlug = decodePostSlug(rawPostSlug);
const rawPostTitle = typeof body.postTitle === 'string' ? body.postTitle.trim() : '';
const rawPostUrl = typeof body.postUrl === 'string' ? body.postUrl.trim() : '';
const rawSiteId = typeof body.siteId === 'string' ? body.siteId.trim() : '';
if (!rawPostSlug) {
if (!postSlug) {
return c.json({ message: 'postSlug is required' }, 400);
}
@@ -47,10 +49,9 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
const domain =
extractDomain(rawPostUrl) ||
extractDomain(rawPostSlug) ||
extractDomain(postSlug) ||
null;
// Upsert page_stats using ON CONFLICT (site_id, post_slug)
await c.env.CWD_DB.prepare(
`INSERT INTO page_stats (site_id, post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?)
@@ -63,7 +64,7 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
)
.bind(
rawSiteId,
rawPostSlug,
postSlug,
rawPostTitle || null,
rawPostUrl || null,
nowTs,

View File

@@ -273,6 +273,7 @@ app.get('/api/analytics/pv', getPagePv);
app.get('/api/like', getLikeStatus);
app.post('/api/like', likePage);
app.post('/api/comments/like', likeComment);
app.delete('/api/comments/like', likeComment);
app.post('/api/telegram/webhook', telegramWebhook);
app.get('/api/config/comments', async (c) => {
try {

View File

@@ -0,0 +1,37 @@
export function decodePostSlug(slug: string): string {
if (!slug) return slug;
let decoded = slug.trim();
let prev = '';
while (prev !== decoded) {
prev = decoded;
try {
decoded = decodeURIComponent(decoded);
} catch {
break;
}
}
return decoded;
}
export function getAllSlugFormats(slug: string): string[] {
if (!slug) return [];
const decoded = decodePostSlug(slug);
const formats = new Set<string>();
formats.add(decoded);
if (decoded !== slug) {
formats.add(slug);
}
const encoded = encodeURI(decoded);
if (encoded !== decoded && !formats.has(encoded)) {
formats.add(encoded);
}
return Array.from(formats);
}
export function escapeLikePattern(str: string): string {
return str.replace(/[%_]/g, '|$&');
}

View File

@@ -1,20 +0,0 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cwd-api",
"main": "src/index.ts",
"compatibility_date": "2026-01-03",
"compatibility_flags": [
"nodejs_compat"
],
"workers_dev": true,
"preview_urls": true,
"send_email": [
{
"name": "SEND_EMAIL"
}
]
}

View File

@@ -85,7 +85,7 @@ npm run preview
```bash
# 开发环境
cp .env.example .env
cp .env.example .env.development
```
每个环境文件中可配置以下变量:

View File

@@ -1,6 +1,6 @@
{
"name": "cwd-widget",
"version": "0.1.10",
"version": "0.1.11",
"description": "Server-free, extremely fast and secure, plug-and-play commenting system based on Cloudflare Workers and the Global Edge Network.",
"type": "module",
"author": "anghunk",
@@ -34,6 +34,7 @@
},
"dependencies": {
"dompurify": "^3.3.1",
"marked": "^17.0.1"
"marked": "^17.0.1",
"node-emoji": "^2.2.0"
}
}
}

View File

@@ -5,6 +5,7 @@
import { Component } from './Component.js';
import { ReplyEditor } from './ReplyEditor.js';
import { formatRelativeTime } from '@/utils/date.js';
import { replaceEmojiInHtml } from '@/utils/markdown.js';
export class CommentItem extends Component {
// 防抖缓存,防止连续点击
@@ -202,7 +203,7 @@ export class CommentItem extends Component {
// 设置评论内容的 TEXT
const contentEl = root.querySelector('.cwd-comment-content');
if (contentEl) {
contentEl.innerHTML = comment.contentHtml;
contentEl.innerHTML = replaceEmojiInHtml(comment.contentHtml);
}
// 创建回复编辑器
@@ -371,8 +372,12 @@ export class CommentItem extends Component {
likedComments.add(commentId);
this.saveLikedComments(likedComments);
this.props.onLikeComment(commentId, true);
} else {
// 已点赞,执行取消点赞
likedComments.delete(commentId);
this.saveLikedComments(likedComments);
this.props.onLikeComment(commentId, false);
}
// 已点赞则不做任何操作
}
/**

View File

@@ -466,8 +466,14 @@ export class CWDComments {
onUpdateReplyContent: (content) => this.store.updateReplyContent(content),
onClearReplyError: () => this.store.clearReplyError(),
replyPlaceholder: this.config.commentPlaceholder,
onPrevPage: () => this.store.goToPage(state.pagination.page - 1),
onNextPage: () => this.store.goToPage(state.pagination.page + 1),
onPrevPage: () => {
const currentState = this.store.store.getState();
this.store.goToPage(currentState.pagination.page - 1);
},
onNextPage: () => {
const currentState = this.store.store.getState();
this.store.goToPage(currentState.pagination.page + 1);
},
onGoToPage: (page) => this.store.goToPage(page),
onLikeComment: (commentId, isLike) => {
if (this.store && typeof this.store.likeComment === 'function') {

View File

@@ -660,6 +660,10 @@
color: var(--cwd-primary, #0969da);
}
.cwd-comment-like-button-liked {
color: var(--cwd-primary, #0969da);
}
.cwd-comment-like-icon-wrapper {
display: inline-flex;
align-items: center;

View File

@@ -1,5 +1,6 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import * as emoji from 'node-emoji';
// 配置 marked
try {
@@ -11,6 +12,30 @@ try {
console.error('Failed to configure marked:', e);
}
/**
* 替换文本中的 emoji 代码为实际 emoji
* @param {string} text 包含 emoji 代码的文本
* @returns {string} 替换后的文本
*/
function replaceEmoji(text) {
if (!text) return text;
try {
return emoji.emojify(text);
} catch (e) {
return text;
}
}
/**
* 替换 HTML 中的 emoji 代码为实际 emoji
* @param {string} html 包含 emoji 代码的 HTML
* @returns {string} 替换后的 HTML
*/
export function replaceEmojiInHtml(html) {
if (!html) return html;
return replaceEmoji(html);
}
/**
* 渲染 Markdown 为 HTML并进行净化
* @param {string} content Markdown 内容
@@ -19,7 +44,8 @@ try {
export function renderMarkdown(content) {
if (!content) return '';
try {
const html = marked.parse(content);
const contentWithEmoji = replaceEmoji(content);
const html = marked.parse(contentWithEmoji);
// marked.parse can return a Promise if async is enabled, but we are using sync mode
// Just in case, handle potential Promise (though unlikely with current config)
if (html instanceof Promise) {

View File

@@ -1,6 +1,6 @@
{
"name": "cwd",
"version": "0.1.10",
"version": "0.1.11",
"license": "Apache-2.0",
"repository": {
"type": "git",