From cf1e5b5c51e7205ef5e0bb4ad06c192c25397b01 Mon Sep 17 00:00:00 2001 From: anghunk Date: Mon, 9 Feb 2026 13:36:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=86=E7=A6=BB=E6=96=87=E7=AB=A0slu?= =?UTF-8?q?g=E4=B8=8EURL=E5=AD=97=E6=AE=B5=E5=B9=B6=E6=94=AF=E6=8C=81slug?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=8C=B9=E9=85=8D=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `post_url` 字段存储文章完整URL,`post_slug` 仅存储标识符 - 更新管理后台评论列表、编辑、统计及域名获取接口以使用新字段 - 修改公共评论查询接口,支持slug精确匹配及带`?`、`#`参数的模糊匹配 - 更新前端管理界面,在评论编辑表单中增加"评论地址"(`postUrl`)字段 - 同步更新API文档,明确字段用途及查询参数说明 --- cwd-admin/src/api/admin.ts | 3 ++ .../CommentsView/components/ModalEdit.vue | 7 ++- cwd-admin/src/views/CommentsView/index.vue | 5 ++ cwd-api/src/api/admin/getDomains.ts | 6 +-- cwd-api/src/api/admin/getStats.ts | 6 +-- cwd-api/src/api/admin/listComments.ts | 2 +- cwd-api/src/api/admin/updateComment.ts | 26 +++++++-- cwd-api/src/api/public/getComments.ts | 43 +++++++++------ docs/api/admin/comments.md | 35 +++++++++--- docs/api/public/comments.md | 53 +++++++++++++------ 10 files changed, 135 insertions(+), 51 deletions(-) diff --git a/cwd-admin/src/api/admin.ts b/cwd-admin/src/api/admin.ts index 9abb92f..2e38dc6 100644 --- a/cwd-admin/src/api/admin.ts +++ b/cwd-admin/src/api/admin.ts @@ -13,6 +13,7 @@ export type CommentItem = { email: string; avatar: string; postSlug: string; + postUrl: string | null; url: string | null; ipAddress: string | null; contentText: string; @@ -172,6 +173,7 @@ export function updateComment(data: { name: string; email: string; url?: string | null; + postUrl?: string | null; postSlug?: string; contentText: string; status?: string; @@ -182,6 +184,7 @@ export function updateComment(data: { name: data.name, email: data.email, url: data.url ?? null, + postUrl: data.postUrl ?? null, postSlug: data.postSlug, content: data.contentText, status: data.status, diff --git a/cwd-admin/src/views/CommentsView/components/ModalEdit.vue b/cwd-admin/src/views/CommentsView/components/ModalEdit.vue index 50019d4..ac6155a 100644 --- a/cwd-admin/src/views/CommentsView/components/ModalEdit.vue +++ b/cwd-admin/src/views/CommentsView/components/ModalEdit.vue @@ -16,9 +16,13 @@
- +
+
+ + +
@@ -66,6 +70,7 @@ interface EditForm { email: string; url: string; postSlug: string; + postUrl: string; contentText: string; status: string; priority: number; diff --git a/cwd-admin/src/views/CommentsView/index.vue b/cwd-admin/src/views/CommentsView/index.vue index 8cdb023..19ebb78 100644 --- a/cwd-admin/src/views/CommentsView/index.vue +++ b/cwd-admin/src/views/CommentsView/index.vue @@ -263,6 +263,7 @@ const editForm = ref<{ email: string; url: string; postSlug: string; + postUrl: string; contentText: string; status: string; priority: number; @@ -438,6 +439,7 @@ function openEdit(item: CommentItem) { email: item.email, url: item.url || "", postSlug: item.postSlug || "", + postUrl: item.postUrl || "", contentText: item.contentText, status: item.status, priority: @@ -466,6 +468,7 @@ async function submitEdit() { const trimmedContent = current.contentText.trim(); const trimmedUrl = current.url.trim(); const trimmedPostSlug = current.postSlug.trim(); + const trimmedPostUrl = current.postUrl.trim(); const priorityValue = typeof current.priority === "number" && Number.isFinite(current.priority) ? current.priority @@ -485,6 +488,7 @@ async function submitEdit() { name: trimmedName, email: trimmedEmail, url: trimmedUrl || null, + postUrl: trimmedPostUrl || null, postSlug: newPostSlug, contentText: trimmedContent, status: current.status, @@ -497,6 +501,7 @@ async function submitEdit() { email: trimmedEmail, url: trimmedUrl || null, postSlug: newPostSlug, + postUrl: trimmedPostUrl || null, contentText: trimmedContent, status: current.status, priority: priorityValue, diff --git a/cwd-api/src/api/admin/getDomains.ts b/cwd-api/src/api/admin/getDomains.ts index 58ffa1d..1f5036f 100644 --- a/cwd-api/src/api/admin/getDomains.ts +++ b/cwd-api/src/api/admin/getDomains.ts @@ -25,15 +25,15 @@ export const getDomains = async (c: Context<{ Bindings: Bindings }>) => { const domains = new Set(); const { results: commentRows } = await c.env.CWD_DB.prepare( - 'SELECT post_slug, url FROM Comment' + 'SELECT post_slug, post_url FROM Comment' ).all<{ post_slug: string; - url: string | null; + post_url: string | null; }>(); for (const row of commentRows) { const domain = - extractDomain(row.post_slug) || extractDomain(row.url); + extractDomain(row.post_url) || extractDomain(row.post_slug); if (domain) { domains.add(domain); } diff --git a/cwd-api/src/api/admin/getStats.ts b/cwd-api/src/api/admin/getStats.ts index e88b00f..9153b3f 100644 --- a/cwd-api/src/api/admin/getStats.ts +++ b/cwd-api/src/api/admin/getStats.ts @@ -37,11 +37,11 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => { const domainFilter = rawDomain.trim().toLowerCase(); const { results } = await c.env.CWD_DB.prepare( - 'SELECT created, post_slug, url, status FROM Comment' + 'SELECT created, post_slug, post_url, status FROM Comment' ).all<{ created: number; post_slug: string; - url: string | null; + post_url: string | null; status: string; }>(); @@ -69,7 +69,7 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => { for (const row of results) { const domain = - extractDomain(row.post_slug) || extractDomain(row.url) || 'unknown'; + extractDomain(row.post_url) || extractDomain(row.post_slug) || 'unknown'; let counts = domainMap.get(domain); if (!counts) { diff --git a/cwd-api/src/api/admin/listComments.ts b/cwd-api/src/api/admin/listComments.ts index edeebbf..9c87760 100644 --- a/cwd-api/src/api/admin/listComments.ts +++ b/cwd-api/src/api/admin/listComments.ts @@ -17,7 +17,7 @@ export const listComments = async (c: Context<{ Bindings: Bindings }>) => { const params: (string | number)[] = []; if (domain) { const pattern = `%://${domain}/%`; - whereSql = 'WHERE post_slug LIKE ? OR url LIKE ?'; + whereSql = 'WHERE post_slug LIKE ? OR post_url LIKE ?'; params.push(pattern, pattern); } diff --git a/cwd-api/src/api/admin/updateComment.ts b/cwd-api/src/api/admin/updateComment.ts index 38e43d7..6ba4305 100644 --- a/cwd-api/src/api/admin/updateComment.ts +++ b/cwd-api/src/api/admin/updateComment.ts @@ -25,10 +25,16 @@ export const updateComment = async (c: Context<{ Bindings: Bindings }>) => { } const existing = await c.env.CWD_DB.prepare( - 'SELECT id, status, post_slug, priority FROM Comment WHERE id = ?' + 'SELECT id, status, post_slug, post_url, priority FROM Comment WHERE id = ?' ) .bind(id) - .first<{ id: number; status: string; post_slug: string; priority: number | null }>(); + .first<{ + id: number; + status: string; + post_slug: string; + post_url: string | null; + priority: number | null; + }>(); if (!existing) { return c.json({ message: 'Comment not found' }, 404); @@ -48,6 +54,15 @@ export const updateComment = async (c: Context<{ Bindings: Bindings }>) => { : typeof body.post_slug === 'string' ? body.post_slug : ''; + const hasPostUrlField = + Object.prototype.hasOwnProperty.call(body, 'postUrl') || + Object.prototype.hasOwnProperty.call(body, 'post_url'); + const rawPostUrl = + typeof body.postUrl === 'string' + ? body.postUrl + : typeof body.post_url === 'string' + ? body.post_url + : ''; const contentSource = typeof body.content === 'string' @@ -63,6 +78,9 @@ export const updateComment = async (c: Context<{ Bindings: Bindings }>) => { const postSlug = hasPostSlugField ? (rawPostSlug.trim() || existing.post_slug) : existing.post_slug; + const postUrl = hasPostUrlField + ? (rawPostUrl.trim() || null) + : existing.post_url; let priority: number = typeof existing.priority === 'number' && Number.isFinite(existing.priority) ? existing.priority @@ -107,9 +125,9 @@ export const updateComment = async (c: Context<{ Bindings: Bindings }>) => { }); const { success } = await c.env.CWD_DB.prepare( - 'UPDATE Comment SET name = ?, email = ?, url = ?, content_text = ?, content_html = ?, status = ?, post_slug = ?, priority = ? WHERE id = ?' + 'UPDATE Comment SET name = ?, email = ?, url = ?, content_text = ?, content_html = ?, status = ?, post_slug = ?, post_url = ?, priority = ? WHERE id = ?' ) - .bind(name, email, url, contentText, contentHtml, status, postSlug, priority, id) + .bind(name, email, url, contentText, contentHtml, status, postSlug, postUrl, priority, id) .run(); if (!success) { diff --git a/cwd-api/src/api/public/getComments.ts b/cwd-api/src/api/public/getComments.ts index c48a2c0..be25aa9 100644 --- a/cwd-api/src/api/public/getComments.ts +++ b/cwd-api/src/api/public/getComments.ts @@ -33,28 +33,39 @@ export const getComments = async (c: Context<{ Bindings: Bindings }>) => { } try { - let query = ` - SELECT id, name, email, url, content_text as contentText, + const equalSlugs = Array.from(new Set(slugList)) + const likePatternsSet = new Set() + for (const s of equalSlugs) { + likePatternsSet.add(`${s}#%`) + likePatternsSet.add(`${s}?%`) + } + const likePatterns = Array.from(likePatternsSet) + const whereParts: string[] = [] + if (equalSlugs.length === 1) { + whereParts.push('post_slug = ?') + } else if (equalSlugs.length > 1) { + 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"' + const query = ` + SELECT id, name, email, url, content_text as contentText, content_html as contentHtml, created, parent_id as parentId, post_slug as postSlug, post_url as postUrl, priority, COALESCE(likes, 0) as likes - FROM Comment - WHERE status = "approved" AND post_slug = ? + FROM Comment + WHERE ${whereClause} ORDER BY priority DESC, created DESC ` - if (slugList.length > 1) { - const placeholders = slugList.map(() => '?').join(', ') - query = ` - SELECT id, name, email, url, content_text as contentText, - content_html as contentHtml, created, parent_id as parentId, - post_slug as postSlug, post_url as postUrl, priority, COALESCE(likes, 0) as likes - FROM Comment - WHERE status = "approved" AND post_slug IN (${placeholders}) - ORDER BY priority DESC, created DESC - ` - } + const bindParams: unknown[] = [...equalSlugs, ...likePatterns] const [commentsResult, adminEmailRows] = await Promise.all([ - c.env.CWD_DB.prepare(query).bind(...slugList).all(), + c.env.CWD_DB.prepare(query).bind(...bindParams).all(), c.env.CWD_DB.prepare('SELECT key, value FROM Settings WHERE key IN (?, ?)') .bind('comment_admin_email', 'admin_notify_email') .all<{ key: string; value: string }>() diff --git a/docs/api/admin/comments.md b/docs/api/admin/comments.md index 177364e..c1718d5 100644 --- a/docs/api/admin/comments.md +++ b/docs/api/admin/comments.md @@ -44,7 +44,8 @@ GET /admin/comments/list "created": 1736762400000, "name": "张三", "email": "zhangsan@example.com", - "postSlug": "https://your-blog.example.com/blog/hello-world", + "postSlug": "hello-world", + "postUrl": "https://your-blog.example.com/blog/hello-world", "url": "https://zhangsan.me", "ipAddress": "127.0.0.1", "contentText": "很棒的文章!", @@ -63,6 +64,25 @@ GET /admin/comments/list } ``` +返回字段说明: + +| 字段名 | 类型 | 说明 | +| ----------- | ------ | -------------------------- | +| `id` | number | 评论 ID | +| `created` | number | 创建时间戳 | +| `name` | string | 评论者昵称 | +| `email` | string | 评论者邮箱 | +| `postSlug` | string | 文章 slug | +| `postUrl` | string | null) | 文章完整 URL | +| `url` | string | null) | 评论者个人主页地址 | +| `ipAddress` | string | 评论者 IP 地址 | +| `contentText` | string | 评论内容(纯文本) | +| `contentHtml` | string | 评论内容(渲染后的 HTML) | +| `status` | string | 评论状态 | +| `priority` | number | 置顶权重 | +| `ua` | string | 用户代理 | +| `avatar` | string | 头像 URL(Gravatar) | + **鉴权错误** - 未携带 Token 或 Token 失效: @@ -156,7 +176,8 @@ PUT /admin/comments/update "name": "张三", "email": "zhangsan@example.com", "url": "https://zhangsan.me", - "postSlug": "https://example.com/blog/hello-world", + "postSlug": "hello-world", + "postUrl": "https://example.com/blog/hello-world", "contentText": "更新后的评论内容", "status": "approved", "priority": 2 @@ -171,10 +192,12 @@ PUT /admin/comments/update | `name` | string | 是 | 评论者昵称 | | `email` | string | 是 | 评论者邮箱 | | `url` | string | 否 | 评论者个人主页或站点地址 | -| `postSlug` | string | 否 | 评论地址(文章 URL),不传则保持原值 | -| `contentText` | string | 是 | 评论内容(纯文本) | -`| `status` | string | 否 | 评论状态,可选 `approved`、`pending`、`rejected` | -| `priority` | number | 否 | 置顶权重,默认为 1,数值越大越靠前 | +| `postSlug` | string | 否 | 文章 slug,不传则保持原值,支持 `postSlug` 或 `post_slug` | +| `postUrl` | string | 否 | 文章完整 URL,不传则保持原值,支持 `postUrl` 或 `post_url` | +| `content` | string | 是* | 评论内容(Markdown 或纯文本),与 `contentText` 二选一 | +| `contentText` | string | 是* | 评论内容(纯文本),与 `content` 二选一 | +| `status` | string | 否 | 评论状态,可选 `approved`、`pending`、`rejected` | +| `priority` | number | 否 | 置顶权重,默认为 1,数值越大越靠前,必须 >= 1 | **置顶权重说明**: diff --git a/docs/api/public/comments.md b/docs/api/public/comments.md index 77c45fd..bc4aed2 100644 --- a/docs/api/public/comments.md +++ b/docs/api/public/comments.md @@ -16,12 +16,12 @@ GET /api/comments **查询参数** -| 名称 | 位置 | 类型 | 必填 | 说明 | -| ----------- | ----- | ------- | ---- | -------------------------------------------------------------------------------------------- | -| `post_slug` | query | string | 是 | 使用 `window.location.origin + window`location.pathname`,获取带域名的链接,否则后台无法识别 | -| `page` | query | integer | 否 | 页码,默认 `1` | -| `limit` | query | integer | 否 | 每页数量,默认 `20`,最大 `50` | -| `nested` | query | string | 否 | 是否返回嵌套结构,默认 `'true'` | +| 名称 | 位置 | 类型 | 必填 | 说明 | +| ----------- | ----- | ------- | ---- | ------------------------------------------ | +| `post_slug` | query | string | 是 | 文章 slug,与前端 `CWDComments` 的 `postSlug` 参数对应 | +| `page` | query | integer | 否 | 页码,默认 `1` | +| `limit` | query | integer | 否 | 每页数量,默认 `20`,最大 `50` | +| `nested` | query | string | 否 | 是否返回嵌套结构,默认 `'true'` | **成功响应** @@ -70,6 +70,25 @@ GET /api/comments } ``` +返回字段说明: + +| 字段名 | 类型 | 说明 | +| ----------- | ------ | -------------------------- | +| `id` | number | 评论 ID | +| `author` | string | 评论者昵称 | +| `email` | string | 评论者邮箱(hash后) | +| `url` | string | null) | 评论者个人主页地址 | +| `contentText` | string | 评论内容(纯文本) | +| `contentHtml` | string | 评论内容(渲染后的 HTML) | +| `pubDate` | string | 发布时间(ISO 8601 格式) | +| `postSlug` | string | 文章 slug | +| `avatar` | string | 头像 URL(Gravatar) | +| `priority` | number | 置顶权重,数值越大排序越靠前 | +| `likes` | number | 点赞数,默认为 0 | +| `replies` | array | 子评论列表(嵌套结构时) | +| `parentId` | number | null) | 父评论 ID(回复时) | +| `replyToAuthor` | string( | null) | 被回复者的昵称(回复时) | + 说明: - 当 `nested=true`(默认)时,接口返回的是"根评论列表",每条根评论包含其 `replies`。 @@ -121,7 +140,7 @@ POST /api/comments ```json { - "post_slug": "https://example.com/blog/hello-world", + "post_slug": "hello-world", "post_title": "博客标题,可选", "post_url": "https://example.com/blog/hello-world", "name": "张三", @@ -135,16 +154,16 @@ POST /api/comments 字段说明: -| 字段名 | 类型 | 必填 | 说明 | -| ------------ | ------ | ---- | ------------------------------------------------------------------------------------------------------------- | -| `post_slug` | string | 是 | 文章唯一标识符,应与前端组件初始化时的 `postSlug` 值一致,`window.location.origin + window.location.pathname` | -| `post_title` | string | 否 | 文章标题,用于邮件通知内容 | -| `post_url` | string | 否 | 文章 URL,用于邮件通知中的跳转链接 | -| `name` | string | 是 | 评论者昵称 | -| `email` | string | 是 | 评论者邮箱,需为合法邮箱格式 | -| `url` | string | 否 | 评论者个人主页或站点地址 | -| `content` | string | 是 | 评论内容,内部会过滤 `` 片段 | -| `parent_id` | number | 否 | 父评论 ID,用于回复功能;缺省或 `null` 表示根评论 | +| 字段名 | 类型 | 必填 | 说明 | +| ------------ | ------ | ---- | -------------------------------------------------------------------- | +| `post_slug` | string | 是 | 文章 slug,应与前端组件初始化时的 `postSlug` 值一致,如 `hello-world` | +| `post_title` | string | 否 | 文章标题,用于邮件通知内容 | +| `post_url` | string | 否 | 文章完整 URL,用于邮件通知中的跳转链接 | +| `name` | string | 是 | 评论者昵称 | +| `email` | string | | 是 | 评论者邮箱,需为合法邮箱格式 | +| `url` | string | 否 | 评论者个人主页或站点地址 | +| `content` | string | 是 | 评论内容,内部会过滤 `` 片段 | +| `parent_id` | number | 否 | 父评论 ID,用于回复功能;缺省或 `null` 表示根评论 | | `adminToken` | string | 否 | 管理员评论密钥,博主发布评论时需要先通过 `/api/verify-admin` 验证密钥后将密钥传入此字段,评论将直接通过且不受审核设置影响 | **成功响应**