From 9b90f589d5e2dafe088545458b6d6e012815ef0c Mon Sep 17 00:00:00 2001 From: anghunk Date: Tue, 20 Jan 2026 09:51:55 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E3=80=81=E6=B7=BB=E5=8A=A0favicon=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84API=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新widget/README.md中的文档链接 - 在cwd-comments-admin/index.html中添加favicon - 更新docs/.vitepress/config.mjs中的标题和favicon配置 - 在cwd-comments-admin/src/views/LayoutView.vue中添加文档链接 - 全面重写并完善API文档结构,包括overview.md、public.md和admin.md - 更新前端配置文档frontend-config.md和后端配置文档backend-config.md --- cwd-comments-admin/index.html | 2 + cwd-comments-admin/src/views/LayoutView.vue | 7 + docs/.vitepress/config.mjs | 11 +- docs/api/admin.md | 441 ++++++++++++++++---- docs/api/overview.md | 113 +++-- docs/api/public.md | 280 ++++++++++--- docs/guide/backend-config.md | 192 ++++++++- docs/guide/frontend-config.md | 170 +++++++- docs/public/cwd-comments.js | 1 - icon.png | Bin 0 -> 15620 bytes widget/README.md | 2 +- 11 files changed, 1034 insertions(+), 185 deletions(-) delete mode 100644 docs/public/cwd-comments.js create mode 100644 icon.png diff --git a/cwd-comments-admin/index.html b/cwd-comments-admin/index.html index 07829fa..c74628a 100644 --- a/cwd-comments-admin/index.html +++ b/cwd-comments-admin/index.html @@ -4,6 +4,8 @@ + CWD 评论系统后台 diff --git a/cwd-comments-admin/src/views/LayoutView.vue b/cwd-comments-admin/src/views/LayoutView.vue index 777fbd2..2a2164d 100644 --- a/cwd-comments-admin/src/views/LayoutView.vue +++ b/cwd-comments-admin/src/views/LayoutView.vue @@ -3,6 +3,13 @@
CWD 评论后台
+ + 文档 + ``` -## 管理员登录 +Token 通过登录接口获取,有效期为 24 小时。 -获取管理员 Token。 +本节按 OpenAPI 风格描述每个接口的请求 / 响应结构,并补充鉴权和错误码说明。 -### 请求 +## POST /admin/login -```http -POST /admin/login -Content-Type: application/json -``` +管理员登录,获取后续调用其他管理员接口所需的临时 Token。 + +- 方法:`POST` +- 路径:`/admin/login` +- 鉴权:不需要 + +### 请求头 + +| 名称 | 必填 | 示例 | +| -------------- | ---- | ------------------- | +| `Content-Type` | 是 | `application/json` | ### 请求体 ```json { - "username": "admin", + "name": "admin@example.com", "password": "your_password" } ``` -### 响应示例 +字段说明: + +| 字段名 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | ------------------ | +| `name` | string | 是 | 管理员登录名 | +| `password`| string | 是 | 管理员登录密码 | + +管理员登录名和密码由后端环境变量控制,字段为: + +- `ADMIN_NAME` +- `ADMIN_PASSWORD` + +### 成功响应 + +- 状态码:`200` ```json { - "success": true, "data": { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "expiresIn": 604800 + "key": "f6e1e0e3-3c9b-4a24-9e88-3b1f709f1e4a" } } ``` -## 获取评论管理列表 +前端需要将 `data.key` 作为 Token 存储(例如 LocalStorage),并在后续请求中通过 `Authorization: Bearer ` 携带。 -获取所有评论列表(包括待审核的评论)。 +### 错误与风控 -### 请求 +- 登录失败(用户名或密码错误): -```http -GET /admin/comments/list?page={page}&pageSize={pageSize}&status={status} -Authorization: Bearer -``` + - 状态码:`401` -### 参数 + ```json + { + "message": "Invalid username or password", + "failedAttempts": 3 + } + ``` -| 参数 | 类型 | 必填 | 说明 | -| -------- | ------ | ---- | ----------------------------------- | -| page | number | 否 | 页码,默认 1 | -| pageSize | number | 否 | 每页数量,默认 20 | -| status | string | 否 | 状态筛选:pending/approved/rejected | +- 登录失败次数过多导致 IP 被封禁: -### 响应示例 + - 状态码:`403` + + ```json + { + "message": "IP is blocked due to multiple failed login attempts" + } + ``` + +- 内部错误: + + - 状态码:`500` + + ```json + { + "message": "错误信息" + } + ``` + +## GET /admin/comments/list + +获取评论列表,用于后台管理页面展示。 + +- 方法:`GET` +- 路径:`/admin/comments/list` +- 鉴权:需要(Bearer Token) + +### 查询参数 + +| 名称 | 位置 | 类型 | 必填 | 说明 | +| ------- | ----- | ------- | ---- | ----------------------- | +| `page` | query | integer | 否 | 页码,默认 `1` | + +说明: + +- 当前实现中每页固定大小为 `10`,暂不支持 `pageSize` 或状态过滤。 + +### 成功响应 + +- 状态码:`200` ```json { - "success": true, - "data": { - "comments": [ - { - "id": 1, - "path": "/blog/hello-world", - "author": "张三", - "email": "zhangsan@example.com", - "content": "很棒的文章!", - "parent_id": null, - "status": "pending", - "created_at": "2026-01-13T10:00:00Z" - } - ], - "total": 1, + "data": [ + { + "id": 1, + "pubDate": "2026-01-13T10:00:00Z", + "author": "张三", + "email": "zhangsan@example.com", + "postSlug": "/blog/hello-world", + "url": "https://zhangsan.me", + "ipAddress": "127.0.0.1", + "contentText": "很棒的文章!", + "contentHtml": "很棒的文章!", + "status": "approved", + "avatar": "https://gravatar.com/avatar/..." + } + ], + "pagination": { "page": 1, - "pageSize": 20 + "limit": 10, + "total": 1 } } ``` -## 更新评论状态 +### 鉴权错误 -审核评论,更新评论状态。 +- 未携带 Token 或 Token 失效: -### 请求 + - 状态码:`401` -```http -PUT /admin/comments/status -Authorization: Bearer -Content-Type: application/json + ```json + { + "message": "Unauthorized" + } + ``` + +## PUT /admin/comments/status + +更新评论状态(例如通过 / 拒绝)。 + +- 方法:`PUT` +- 路径:`/admin/comments/status` +- 鉴权:需要(Bearer Token) + +### 查询参数 + +| 名称 | 位置 | 类型 | 必填 | 说明 | +| -------- | ----- | ------ | ---- | -------------------------------- | +| `id` | query | number | 是 | 评论 ID | +| `status` | query | string | 是 | 评论状态,例如 `approved`、`rejected` | + +当前实现中未对 `status` 值进行枚举校验,但推荐仅使用: + +- `approved`:已通过 +- `rejected`:已拒绝 + +### 成功响应 + +- 状态码:`200` + +```json +{ + "message": "Comment status updated, id: 1, status: approved." +} ``` +### 错误响应 + +- 缺少参数: + + - 状态码:`400` + + ```json + { + "message": "Missing id or status" + } + ``` + +- 更新失败: + + - 状态码:`500` + + ```json + { + "message": "Update failed" + } + ``` + +## DELETE /admin/comments/delete + +删除指定评论。 + +- 方法:`DELETE` +- 路径:`/admin/comments/delete` +- 鉴权:需要(Bearer Token) + +### 查询参数 + +| 名称 | 位置 | 类型 | 必填 | 说明 | +| ---- | ----- | ------ | ---- | ------ | +| `id` | query | number | 是 | 评论 ID | + +### 成功响应 + +- 状态码:`200` + +```json +{ + "message": "Comment deleted, id: 1." +} +``` + +### 错误响应 + +- 缺少 ID: + + - 状态码:`400` + + ```json + { + "message": "Missing id" + } + ``` + +- 删除失败: + + - 状态码:`500` + + ```json + { + "message": "Delete operation failed" + } + ``` + +## GET /admin/settings/email + +获取当前通知邮箱配置。 + +- 方法:`GET` +- 路径:`/admin/settings/email` +- 鉴权:需要(Bearer Token) + +### 成功响应 + +- 状态码:`200` + +```json +{ + "email": "admin@example.com" +} +``` + +### 错误响应 + +- 状态码:`500` + +```json +{ + "message": "错误信息" +} +``` + +## PUT /admin/settings/email + +设置通知邮箱,用于接收新评论提醒。 + +- 方法:`PUT` +- 路径:`/admin/settings/email` +- 鉴权:需要(Bearer Token) + +### 请求头 + +| 名称 | 必填 | 示例 | +| -------------- | ---- | ------------------- | +| `Content-Type` | 是 | `application/json` | + ### 请求体 ```json { - "id": 1, - "status": "approved" + "email": "admin@example.com" } ``` -### 参数说明 +### 成功响应 -| 参数 | 类型 | 必填 | 说明 | -| ------ | ------ | ---- | ---------------------------------------- | -| id | number | 是 | 评论 ID | -| status | string | 是 | 状态:approved(通过)/ rejected(拒绝) | - -### 响应示例 +- 状态码:`200` ```json { - "success": true, - "data": { - "message": "评论状态已更新" - } + "message": "保存成功" } ``` -## 删除评论 +### 错误响应 -删除指定评论。 +- 邮箱格式不正确: -### 请求 + - 状态码:`400` -```http -DELETE /admin/comments/delete?id={id} -Authorization: Bearer -``` + ```json + { + "message": "邮箱格式不正确" + } + ``` -### 参数 +- 服务器错误: -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| id | number | 是 | 评论 ID | + - 状态码:`500` -### 响应示例 + ```json + { + "message": "错误信息" + } + ``` + +## GET /admin/settings/comments + +获取评论配置,例如博主邮箱、徽标和头像前缀等。 + +- 方法:`GET` +- 路径:`/admin/settings/comments` +- 鉴权:需要(Bearer Token) + +### 成功响应 + +- 状态码:`200` ```json { - "success": true, - "data": { - "message": "评论已删除" - } + "adminEmail": "admin@example.com", + "adminBadge": "博主", + "avatarPrefix": "https://gravatar.com/avatar", + "adminEnabled": true } ``` + +字段说明与公开接口 `/api/config/comments` 一致。 + +### 错误响应 + +- 状态码:`500` + +```json +{ + "message": "加载评论配置失败" +} +``` + +## PUT /admin/settings/comments + +更新评论配置。 + +- 方法:`PUT` +- 路径:`/admin/settings/comments` +- 鉴权:需要(Bearer Token) + +### 请求头 + +| 名称 | 必填 | 示例 | +| -------------- | ---- | ------------------- | +| `Content-Type` | 是 | `application/json` | + +### 请求体 + +```json +{ + "adminEmail": "admin@example.com", + "adminBadge": "站长", + "avatarPrefix": "https://cravatar.cn/avatar", + "adminEnabled": true +} +``` + +字段说明: + +| 字段名 | 类型 | 必填 | 说明 | +| -------------- | ------- | ---- | ------------------------------------------------ | +| `adminEmail` | string | 否 | 博主邮箱地址,需为合法邮箱 | +| `adminBadge` | string | 否 | 博主标识文字,例如 `"博主"` | +| `avatarPrefix` | string | 否 | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 | +| `adminEnabled` | boolean | 否 | 是否启用博主标识相关功能 | + +### 成功响应 + +- 状态码:`200` + +```json +{ + "message": "保存成功" +} +``` + +### 错误响应 + +- 邮箱格式错误: + + - 状态码:`400` + + ```json + { + "message": "邮箱格式不正确" + } + ``` + +- 内部错误: + + - 状态码:`500` + + ```json + { + "message": "保存失败" + } + ``` diff --git a/docs/api/overview.md b/docs/api/overview.md index 116ddc6..c3d61ce 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -2,47 +2,102 @@ ## 基础信息 -- **Base URL**: `https://your-worker.workers.dev` -- **数据格式**: JSON -- **字符编码**: UTF-8 +- **Base URL**:`https://your-worker.workers.dev` 或你的自定义域名 +- **数据格式**:JSON +- **字符编码**:UTF-8 -## 认证方式 +所有 API 均为 RESTful 风格,无会话 Cookie,认证全部通过 `Authorization` 请求头完成。 -管理员 API 需要使用 Bearer Token 认证: +## 版本信息 + +当前后端版本号在根路径返回: + +```http +GET / +``` + +成功时返回 HTML,其中包含类似文案: + +```text +CWD 评论部署成功,当前版本 v0.0.1 +``` + +说明: + +- 当前 API 路径未在 URL 中显式区分版本(如 `/v1`),版本号仅通过根路径展示。 +- 后续若引入不兼容变更,建议通过自定义域名路径前缀或 Worker 路由实现接口版本化,例如: + - `https://comments-api.example.com/v1` + - `https://api.example.com/comments/v1` + +## 鉴权方式 + +公开接口与管理员接口的鉴权要求不同: + +- 公开接口(Public API) + - `/api/comments` + - `/api/config/comments` + - 默认无需认证,可直接访问。 +- 管理员接口(Admin API) + - 路径前缀:`/admin/*` + - 除 `/admin/login` 外,其余接口都需要携带管理员 Token。 + +管理员接口需要使用 Bearer Token 认证: ```http Authorization: Bearer ``` -Token 通过登录接口获取,有效期为 24 小时。 +Token 通过登录接口获取,有效期为 24 小时,服务端会在 KV 中存储会话信息并在每次请求时进行校验。 -## 响应格式 +## 统一字段与约定 -### 成功响应 +虽然当前实现没有使用统一的 `success` 包装字段,但存在一些通用约定: -```json -{ - "success": true, - "data": { ... } -} -``` +- 错误响应: + - 始终包含 `message` 字段,描述错误原因。 + - HTTP 状态码用于表达错误类型(例如 400/401/403/429/500)。 +- 列表类响应: + - 使用 `data` + `pagination` 结构: -### 错误响应 + ```json + { + "data": [ /* 列表数据 */ ], + "pagination": { + "page": 1, + "limit": 20, + "total": 5, + "totalCount": 100 + } + } + ``` -```json -{ - "success": false, - "error": "错误信息" -} -``` +- 单项结果或配置类响应: + - 直接返回对象,例如: + + ```json + { + "email": "admin@example.com" + } + ``` + +- 操作类响应(创建、更新、删除): + - 一般返回 `{ "message": "说明文本" }`。 ## HTTP 状态码 -| 状态码 | 说明 | -| ------ | ------------ | -| 200 | 请求成功 | -| 400 | 请求参数错误 | -| 401 | 未授权 | -| 403 | 禁止访问 | -| 404 | 资源不存在 | -| 500 | 服务器错误 | +常见状态码及含义如下: + +| 状态码 | 说明 | 典型场景 | +| ------ | -------------------------- | ---------------------------------------------------- | +| 200 | 请求成功 | 正常查询、操作成功 | +| 400 | 请求参数错误 | 缺少必填字段、格式不正确等 | +| 401 | 未授权 | 未携带 Token 或 Token 失效 | +| 403 | 禁止访问 | 登录失败次数过多导致 IP 被暂时封禁 | +| 404 | 资源不存在(当前未显式使用)| 预留给未来可能的资源不存在场景 | +| 429 | 请求过于频繁 | 评论频率超过限制(默认同一 IP 10 秒内只能评论一次) | +| 500 | 服务器内部错误 | 未捕获异常、数据库错误等 | + +具体到每个接口的详细请求 / 响应体和错误码,请参考: + +- [公开 API](./public.md) +- [管理员 API](./admin.md) diff --git a/docs/api/public.md b/docs/api/public.md index 44004e0..3ede6ca 100644 --- a/docs/api/public.md +++ b/docs/api/public.md @@ -1,90 +1,262 @@ # 公开 API -## 获取评论列表 +本节描述无需认证即可访问的公开接口,包括评论获取、评论提交以及评论设置获取。 -获取指定页面的评论列表。 +遵循 OpenAPI 风格进行组织,包含路径、方法、参数、请求体和响应示例。 -### 请求 +## GET /api/comments -```http -GET /api/comments?path={path}&page={page}&pageSize={pageSize} -``` +获取指定文章的评论列表。 -### 参数 +- 方法:`GET` +- 路径:`/api/comments` +- 鉴权:不需要 -| 参数 | 类型 | 必填 | 说明 | -| -------- | ------ | ---- | ----------------- | -| path | string | 是 | 页面路径 | -| page | number | 否 | 页码,默认 1 | -| pageSize | number | 否 | 每页数量,默认 10 | +### 查询参数 -### 响应示例 +| 名称 | 位置 | 类型 | 必填 | 说明 | +| -------------- | ------ | ------- | ---- | -------------------------------------------------------------------- | +| `post_slug` | query | string | 是 | 文章唯一标识符,与前端配置中的 `postSlug` 保持一致 | +| `page` | query | integer | 否 | 页码,默认 `1` | +| `limit` | query | integer | 否 | 每页数量,默认 `20`,最大 `50` | +| `nested` | query | string | 否 | 是否返回嵌套结构,默认 `'true'` | +| `avatar_prefix`| query | string | 否 | 覆盖头像地址前缀,优先级高于服务端设置和前端配置 | + +### 成功响应 + +- 状态码:`200` ```json { - "success": true, - "data": { - "comments": [ - { - "id": 1, - "path": "/blog/hello-world", - "author": "张三", - "email": "zhangsan@example.com", - "content": "很棒的文章!", - "parent_id": null, - "status": "approved", - "created_at": "2026-01-13T10:00:00Z", - "avatar": "https://gravatar.com/avatar/..." - } - ], - "total": 1, + "data": [ + { + "id": 1, + "author": "张三", + "email": "zhangsan@example.com", + "url": "https://example.com", + "contentText": "很棒的文章!", + "contentHtml": "很棒的文章!", + "pubDate": "2026-01-13T10:00:00Z", + "postSlug": "/blog/hello-world", + "avatar": "https://gravatar.com/avatar/...", + "replies": [ + { + "id": 2, + "author": "李四", + "email": "lisi@example.com", + "url": null, + "contentText": "同感!", + "contentHtml": "同感!", + "pubDate": "2026-01-13T11:00:00Z", + "postSlug": "/blog/hello-world", + "avatar": "https://gravatar.com/avatar/...", + "parentId": 1, + "replyToAuthor": "张三" + } + ] + } + ], + "pagination": { "page": 1, - "pageSize": 10 + "limit": 20, + "total": 1, + "totalCount": 2 } } ``` -## 提交评论 +说明: -提交新评论。 +- 当 `nested=true`(默认)时,接口返回的是“根评论列表”,每条根评论包含其 `replies`。 +- 当 `nested=false` 时,接口返回扁平列表,所有评论都在 `data` 中,`replies` 为空。 -### 请求 +### 错误响应 -```http -POST /api/comments -Content-Type: application/json -``` +- 缺少 `post_slug`: -### 请求体 + - 状态码:`400` + + ```json + { + "message": "post_slug is required" + } + ``` + +- 服务器内部错误: + + - 状态码:`500` + + ```json + { + "message": "错误信息" + } + ``` + +## POST /api/comments + +提交新评论或回复。 + +- 方法:`POST` +- 路径:`/api/comments` +- 鉴权:不需要 + +### 请求头 + +| 名称 | 必填 | 示例 | +| -------------- | ---- | ---------------------------- | +| `Content-Type` | 是 | `application/json` | + +### 请求体(Request Body) ```json { - "path": "/blog/hello-world", + "post_slug": "/blog/hello-world", + "post_title": "博客标题,可选", + "post_url": "https://example.com/blog/hello-world", "author": "张三", "email": "zhangsan@example.com", + "url": "https://zhangsan.me", "content": "很棒的文章!", - "parent_id": null + "parent_id": 1 } ``` -### 参数说明 +#### 字段说明 -| 参数 | 类型 | 必填 | 说明 | -| --------- | ------ | ---- | ----------------------- | -| path | string | 是 | 页面路径 | -| author | string | 是 | 评论者昵称 | -| email | string | 是 | 评论者邮箱 | -| content | string | 是 | 评论内容 | -| parent_id | number | 否 | 父评论 ID(回复时使用) | +| 字段名 | 类型 | 必填 | 说明 | +| ------------ | ------ | ---- | ------------------------------------------------------------ | +| `post_slug` | string | 是 | 文章唯一标识符,应与前端组件初始化时的 `postSlug` 值一致 | +| `post_title` | string | 否 | 文章标题,用于邮件通知内容 | +| `post_url` | string | 否 | 文章 URL,用于邮件通知中的跳转链接 | +| `author` | string | 是 | 评论者昵称 | +| `email` | string | 是 | 评论者邮箱,需为合法邮箱格式 | +| `url` | string | 否 | 评论者个人主页或站点地址 | +| `content` | string | 是 | 评论内容,内部会过滤 `` 片段 | +| `parent_id` | number | 否 | 父评论 ID,用于回复功能;缺省或 `null` 表示根评论 | -### 响应示例 +### 成功响应 + +- 状态码:`200` ```json { - "success": true, - "data": { - "id": 1, - "message": "评论提交成功,等待审核" - } + "message": "Comment submitted. Awaiting moderation." +} +``` + +当前实现中评论会直接以 `approved` 状态写入数据库,后续如引入人工审核可在实现中调整为“待审核”状态。 + +### 错误响应与限流 + +- 请求体缺失或字段类型错误: + + - 状态码:`400` + + ```json + { + "message": "无效的请求体" + } + ``` + +- 缺少必填字段示例: + + - `post_slug` 为空: + + ```json + { + "message": "post_slug 必填" + } + ``` + + - `content` 为空: + + ```json + { + "message": "评论内容不能为空" + } + ``` + + - `author` 为空: + + ```json + { + "message": "昵称不能为空" + } + ``` + + - `email` 为空或格式不正确: + + ```json + { + "message": "邮箱不能为空" + } + ``` + + 或 + + ```json + { + "message": "邮箱格式不正确" + } + ``` + +- 评论频率限制: + + - 状态码:`429` + - 逻辑:同一 IP 最近一条评论时间在 10 秒内,则拒绝此次请求。 + + ```json + { + "message": "评论频繁,等10s后再试" + } + ``` + +- 服务器内部错误: + + - 状态码:`500` + + ```json + { + "message": "Internal Server Error" + } + ``` + +## GET /api/config/comments + +获取评论相关的公开配置,用于前端组件读取博主邮箱、徽标等信息。 + +- 方法:`GET` +- 路径:`/api/config/comments` +- 鉴权:不需要 + +### 成功响应 + +- 状态码:`200` + +```json +{ + "adminEmail": "admin@example.com", + "adminBadge": "博主", + "avatarPrefix": "https://gravatar.com/avatar", + "adminEnabled": true +} +``` + +字段说明: + +| 字段名 | 类型 | 说明 | +| -------------- | ------- | ------------------------------------------------ | +| `adminEmail` | string | 博主邮箱地址,用于在前端展示“博主”标识 | +| `adminBadge` | string | 博主标识文字,例如 `"博主"` | +| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 | +| `adminEnabled` | boolean | 是否启用博主标识相关功能 | + +### 错误响应 + +- 状态码:`500` + +```json +{ + "message": "加载评论配置失败" } ``` diff --git a/docs/guide/backend-config.md b/docs/guide/backend-config.md index 5015cce..535e8b8 100644 --- a/docs/guide/backend-config.md +++ b/docs/guide/backend-config.md @@ -6,6 +6,8 @@ * 拥有一个 Node.js 运行环境,版本 >= 22(本地部署需要) * 拥有一个域名并托管在 Cloudflare 上(这个不是必须项,但可以提高国内访问速度,也更方便) +后端项目目录为 `cwd-comments-api/`,基于 Cloudflare Workers + D1 + KV 实现。 + ## 部署 **以下部署指令均在该目录下执行,不在根目录下** @@ -93,15 +95,119 @@ npm install 当然也可以使用自定义域名。 -## 环境变量 +## 服务启动与运行参数 + +### 本地开发 + +在本地开发阶段,可以通过 `wrangler dev` 启动本地 Worker: + +```bash +npm run dev +# 等价于 +wrangler dev +``` + +常用参数: + +- `--port `:指定本地开发端口,例如: + + ```bash + wrangler dev --port 8788 + ``` + + 如果你使用评论组件开发页面(`widget/`)进行联调,可以将前端中的 `apiBaseUrl` 配置为: + + ```text + http://localhost:8788 + ``` + +- `--env `:指定 wrangler 环境(如有配置多环境)。 + +> 注意:线上环境的运行参数完全由 Cloudflare Workers 控制,一般无需额外手动配置,只需在控制台或 `wrangler.jsonc` 中正确配置绑定和变量。 + +## 数据库与 KV 连接配置 + +后端使用 Cloudflare D1 作为数据库,使用 KV 作为会话存储。 + +### D1 数据库 + +1. 创建数据库和表结构: + + ```bash + npx wrangler d1 create CWD_DB + npx wrangler d1 execute CWD_DB --remote --file=./schemas/comment.sql + ``` + +2. 在 `wrangler.jsonc` 中确保存在如下配置: + + ```jsonc + "d1_databases": [ + { + "binding": "CWD_DB", + "database_name": "CWD_DB", + "database_id": "xxxxxx" + } + ] + ``` + + 其中: + + - `binding` 必须为 `CWD_DB`,与代码中的 `env.CWD_DB` 一致。 + - `database_name` 和 `database_id` 根据 Cloudflare 实际创建结果填写。 + +数据库结构定义见 [`schemas/comment.sql`](../../cwd-comments-api/schemas/comment.sql)。 + +### KV 存储 + +1. 创建 KV 命名空间: + + ```bash + npx wrangler kv namespace create CWD_AUTH_KV + ``` + +2. 在 `wrangler.jsonc` 中添加: + + ```jsonc + "kv_namespaces": [ + { + "binding": "CWD_AUTH_KV", + "id": "xxxxxxx" + } + ] + ``` + + 其中: + + - `binding` 必须为 `CWD_AUTH_KV`,与代码中的 `env.CWD_AUTH_KV` 一致。 + +KV 主要用于: + +- 管理员登录 Token 存储与校验 +- 登录失败次数和封禁状态记录 + +## 环境变量与绑定 + +后端通过 Cloudflare Worker 的绑定和环境变量控制行为,类型定义见 [`cwd-comments-api/src/bindings.ts`](../../cwd-comments-api/src/bindings.ts)。 所需环境变量如下表所示。 -| 变量名 | 描述 | -| ---------------- | -------------------------------------------------------------------- | -| `ADMIN_NAME` | 管理员登录名称 | -| `ADMIN_PASSWORD` | 管理员登录密码 | -| `CF_FROM_EMAIL` | 作为发件人显示的邮箱地址(需在 Cloudflare Email 路由中预先配置)选填 | +| 名称 | 类型 | 描述 | +| ----------------- | ----------- | -------------------------------------------------------------------- | +| `CWD_DB` | D1 绑定 | 评论数据存储数据库 | +| `CWD_AUTH_KV` | KV 绑定 | 管理员登录 Token、登录尝试计数等 | +| `ALLOW_ORIGIN` | string | 预留的允许跨域来源配置,目前实现中仍使用 `*` | +| `CF_FROM_EMAIL` | string | 作为发件人显示的邮箱地址(需在 Cloudflare Email 路由中预先配置)选填 | +| `SEND_EMAIL` | send_email | Cloudflare Email 发送绑定,供通知邮件使用 | +| `ADMIN_NAME` | string | 管理员登录名称 | +| `ADMIN_PASSWORD` | string | 管理员登录密码 | + +在 Cloudflare 控制台中配置方式: + +- 打开 Worker 项目 -> `Settings` -> `Variables` +- 在 `Environment Variables` 中添加 `ADMIN_NAME`、`ADMIN_PASSWORD` 等变量 +- 在 `D1 Databases` 中绑定 `CWD_DB` +- 在 `KV Namespaces` 中绑定 `CWD_AUTH_KV` +- 在 `Email` 中绑定 `SEND_EMAIL`(如需启用邮件通知) **注:** 需要在 Cloudflare 控制面板中为 Email 路由开启发送权限并配置发件人域和地址,并在 `wrangler.jsonc` 中为 Worker 添加 `send_email` 绑定,以便在代码中通过 `env.SEND_EMAIL.send()` 直接发信。 @@ -121,4 +227,76 @@ npm install } ``` -参数 `CF_FROM_EMAIL` 这里填写的邮箱是你绑定域名后,创建的 email 路由,两者需保持一致 +参数 `CF_FROM_EMAIL` 这里填写的邮箱是你绑定域名后创建的 Email 路由,两者需保持一致。 + +## 中间件配置说明 + +后端使用 Hono 框架,在入口文件中统一配置了 CORS 和管理员认证中间件。 + +入口文件位置:[`cwd-comments-api/src/index.ts`](../../cwd-comments-api/src/index.ts) + +### CORS 中间件 + +当前实现位于 [`cwd-comments-api/src/utils/cors.ts`](../../cwd-comments-api/src/utils/cors.ts),对 `/api/*` 和 `/admin/*` 路径统一应用: + +- 允许来源:`*` +- 允许方法:`GET, POST, PUT, DELETE, OPTIONS` +- 允许请求头:`Content-Type, Authorization` +- 暴露响应头:`Content-Length` +- 不允许携带凭证(`credentials: false`) + +这意味着: + +- 评论组件和管理后台可以在任意域名下通过 HTTP 调用后端接口,无需浏览器端额外跨域配置。 +- 由于不允许跨域携带 Cookie,认证完全通过 `Authorization: Bearer ` 头完成。 + +代码中预留了 `ALLOW_ORIGIN` 绑定,目前默认行为是允许所有来源。如果你有严格的安全需求,可以在此基础上自定义 CORS 逻辑,将 `origin` 收紧到指定域名。 + +### 管理员认证中间件 + +管理员认证中间件位于 [`cwd-comments-api/src/utils/auth.ts`](../../cwd-comments-api/src/utils/auth.ts),对 `/admin/*` 路径统一生效(登录接口除外): + +- 从请求头 `Authorization` 中解析 Bearer Token。 +- 在 `CWD_AUTH_KV` 中校验 `token:` 对应的会话信息。 +- Token 由 `/admin/login` 接口生成,有效期为 24 小时。 + +认证失败时返回: + +- 状态码:`401` +- 响应体:`{ "message": "Unauthorized" }` 或 `Token expired or invalid` + +## 日志配置与规范 + +后端主要通过 `console.log` 输出结构化日志,便于在 Cloudflare 控制台或日志采集系统中查看。 + +### 请求级别日志 + +在入口中为所有请求记录起止日志: + +- `Request:start`: + - `method`:HTTP 方法 + - `path`:请求路径 + - `url`:完整 URL + - `hasDb`:是否成功注入 D1 绑定 + - `hasAuthKv`:是否成功注入 KV 绑定 +- `Request:end`: + - `method`:HTTP 方法 + - `path`:请求路径 + +### 业务级别日志 + +示例(评论提交流程): + +- `PostComment:request`:记录 `postSlug`、是否为回复、邮箱是否存在、IP 等信息。 +- `PostComment:inserted`:记录评论已写入数据库。 +- `PostComment:mailDispatch:*`:记录邮件通知相关流程和限流结果。 + +错误情况: + +- 统一使用 `console.error` 输出错误对象,例如邮件发送失败或数据库写入异常。 + +### 日志使用建议 + +- 不在日志中输出管理员密码、完整 Token 等敏感信息。 +- 如果接入外部日志系统,可以基于日志前缀(如 `Request:*`、`PostComment:*`)做过滤和告警。 +- 在调试阶段可以保留日志,生产环境如需减少日志量,可根据需要在代码中调整输出。 diff --git a/docs/guide/frontend-config.md b/docs/guide/frontend-config.md index 835dd67..2fb4dfb 100644 --- a/docs/guide/frontend-config.md +++ b/docs/guide/frontend-config.md @@ -2,42 +2,190 @@ **这里仅提供一套开箱即用的方案,如果是个人开发者可以根据 API 文档自行编写前端评论组件。** -[接口 API](../api/public.md) +当前前端部分主要由两块组成: -## 初始化 +- 评论组件(widget):以 UMD 库形式输出 `cwd-comments.js`,可在任意站点中直接通过 ` +## 环境与项目结构 + +前端目录结构与运行时环境: + +- `widget/`:评论组件源码 + - 运行环境:浏览器(支持现代浏览器) + - 构建工具:Vite +- `cwd-comments-admin/`:管理后台源码 + - 运行环境:浏览器 + - 构建工具:Vite + Vue 3 + +Node.js 版本建议使用 `>=18`,与后端保持一致即可。 + +### 环境变量与多环境配置 + +评论组件本身不依赖打包时的环境变量,只需要在运行时传入 `apiBaseUrl` 即可。 +管理后台使用 Vite 环境变量进行多环境配置,推荐按以下方式区分开发 / 测试 / 生产环境: + +在 `cwd-comments-admin` 目录下创建对应的环境文件: + +```bash +# 开发环境 +cp .env.example .env.development + +# 测试环境 +cp .env.example .env.test + +# 生产环境 +cp .env.example .env.production ``` +每个环境文件中可配置以下变量: + +| 变量名 | 说明 | 示例 | +| -------------------- | ----------------------------------------------- | ----------------------------------- | +| `VITE_API_BASE_URL` | 后端 API 地址(Cloudflare Worker 域名或自定义) | `https://cwd-comments-api.test.com` | +| `VITE_ADMIN_NAME` | 登录页默认管理员账号占位值 | `admin@example.com` | +| `VITE_ADMIN_PASSWORD`| 登录页默认密码占位值 | `123456` | + +说明: + +- `VITE_API_BASE_URL` 会作为管理后台的默认 API 地址,实际请求地址可以在登录页修改,并持久化到 `localStorage`。 +- `VITE_ADMIN_NAME` 和 `VITE_ADMIN_PASSWORD` 仅用于自动填充登录表单,真正的认证信息以后端环境变量 `ADMIN_NAME`、`ADMIN_PASSWORD` 为准。 + +## 依赖安装与启动流程 + +### 评论组件(widget) + +开发和构建评论组件: + +```bash +cd widget + +# 安装依赖 +npm install + +# 本地开发预览 +npm run dev + +# 构建 UMD 库(生成 cwd-comments.js) +npm run build +``` + +构建完成后,将 `widget/dist/cwd-comments.js` 部署到你的静态资源服务器或 CDN,示例: + ```html + +
``` -## 参数说明 +### 管理后台(cwd-comments-admin) + +管理后台用于审核评论、删除评论和管理评论设置。 + +```bash +cd cwd-comments-admin + +# 安装依赖 +npm install + +# 开发环境启动(默认端口见 vite.config.ts,一般为 1226) +npm run dev + +# 生产环境构建 +npm run build + +# 本地预览生产构建结果 +npm run preview +``` + +将 `cwd-comments-admin/dist` 目录部署到任意静态站点托管服务(如 Cloudflare Pages、Vercel、Netlify 等),并确保浏览器可以访问到后端 API 地址。 + +## 评论组件初始化 + +在初始化 `CWDComments` 实例时,可以传入以下配置参数: + +```html + +
+ +``` + +### 参数说明 | 参数 | 类型 | 必填 | 默认值 | 说明 | | -------------- | ----------------------- | ---- | ----------------------------- | -------------------------- | | `el` | `string \| HTMLElement` | 是 | - | 挂载元素选择器或 DOM 元素 | | `apiBaseUrl` | `string` | 是 | - | API 基础地址 | | `postSlug` | `string` | 是 | - | 文章唯一标识符 | -| `postTitle` | `string` | 否 | - | 文章标题,用于邮件通知 | -| `postUrl` | `string` | 否 | - | 文章 URL,用于邮件通知 | +| `postTitle` | `string` | 否 | 页面标题或 `postSlug` | 文章标题,用于邮件通知 | +| `postUrl` | `string` | 否 | 当前页面 URL | 文章 URL,用于邮件通知 | | `theme` | `'light' \| 'dark'` | 否 | `'light'` | 主题模式 | | `pageSize` | `number` | 否 | `20` | 每页显示评论数 | | `avatarPrefix` | `string` | 否 | `https://gravatar.com/avatar` | 头像服务前缀 | | `adminEmail` | `string` | 否 | - | 博主邮箱,用于显示博主标识 | | `adminBadge` | `string` | 否 | `博主` | 博主标识文字 | +## 跨域访问配置 + +前端评论组件与管理后台均通过 HTTP 与后端交互。当前后端在 `/api/*` 和 `/admin/*` 路径下统一开启了 CORS: + +- `Access-Control-Allow-Origin: *` +- 允许方法:`GET, POST, PUT, DELETE, OPTIONS` +- 允许请求头:`Content-Type, Authorization` +- 不允许携带 Cookie 等凭证(`Access-Control-Allow-Credentials: false`) + +因此在常见部署方式下,你只需要在前端将 `apiBaseUrl` 指向后端 Worker 地址即可,无需额外前端跨域配置,例如: + +```javascript +const comments = new CWDComments({ + el: '#comments', + apiBaseUrl: 'https://cwd-comments-api.example.com', + postSlug: window.location.pathname +}); +comments.mount(); +``` + +如果你在本地同时运行前端和后端,可以按以下方式联调: + +- 使用 `wrangler dev` 启动后端(默认端口一般为 8787,如有需要可使用 `wrangler dev --port 8788` 指定端口)。 +- 在评论组件开发页面(`widget/index.html`)中,将 API 地址设置为对应本地端口,例如 `http://localhost:8787`。 + +后端关于 CORS 的更详细说明见:[后端配置](./backend-config.md#跨域配置与安全性)。 + +## 静态资源部署规范 + +为了保证前端资源加载稳定,建议按如下规范部署静态资源: + +- 使用 HTTPS 域名托管 `cwd-comments.js` 以及管理后台构建产物。 +- 将评论组件脚本放在具备缓存能力的静态资源域名或 CDN 上,例如: + - `https://static.example.com/cwd-comments/v0.0.1/cwd-comments.js` +- 当发布新版本时,建议通过路径或文件名中的版本号进行区分,避免缓存错乱。 +- 页面中引入脚本时保持路径稳定,便于后续版本升级: + +```html + +``` + +管理后台的构建结果同样建议部署到独立的静态站点域名下,例如: + +- `https://comments-admin.example.com` 部署 `cwd-comments-admin/dist` + ## 头像服务前缀 常用的 Gravatar 镜像服务: diff --git a/docs/public/cwd-comments.js b/docs/public/cwd-comments.js deleted file mode 100644 index eee6541..0000000 --- a/docs/public/cwd-comments.js +++ /dev/null @@ -1 +0,0 @@ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).CWDComments={})}(this,function(t){"use strict";function e(t){const e=t.apiBaseUrl.replace(/\/$/,"");return{fetchComments:async function(r=1,o=20){const n=new URLSearchParams({post_slug:t.postSlug,page:r.toString(),limit:o.toString(),nested:"true"});t.avatarPrefix&&n.set("avatar_prefix",t.avatarPrefix);const i=await fetch(`${e}/api/comments?${n}`);if(!i.ok)throw new Error(`获取评论失败: ${i.status} ${i.statusText}`);return console.log("[API] fetchComments response:",i),i.json()},submitComment:async function(r){const o=await fetch(`${e}/api/comments`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({post_slug:t.postSlug,post_title:t.postTitle,post_url:t.postUrl,author:r.author,email:r.email,url:r.url||void 0,content:r.content,parent_id:r.parentId})});if(!o.ok)throw new Error(`提交评论失败: ${o.status} ${o.statusText}`);return o.json()}}}const r="cwd_user_info";class o{constructor(t){this.state={...t},this.listeners=[]}getState(){return{...this.state}}setState(t){const e={...this.state};this.state={...this.state,...t},this.listeners.forEach(t=>{t(this.state,e)})}subscribe(t){return this.listeners.push(t),()=>{this.listeners=this.listeners.filter(e=>e!==t)}}}function n(t,e,n){const i=function(){try{const t=localStorage.getItem(r);if(t){const e=JSON.parse(t);return{author:e.author||"",email:e.email||"",url:e.url||""}}}catch(t){console.error("读取用户信息失败:",t)}return{author:"",email:"",url:""}}(),s=new o({comments:[],loading:!1,error:null,pagination:{page:1,limit:t.pageSize||20,total:0,totalCount:0},form:{author:i.author||"",email:i.email||"",url:i.url||"",content:""},formErrors:{},submitting:!1,replyingTo:null,replyContent:"",replyError:null});async function a(t=1){s.setState({loading:!0,error:null});try{const r=await e(t,s.getState().pagination.limit);console.log("[Store] loadComments response:",r),console.log("[Store] comments data:",r.data),s.setState({comments:r.data,pagination:{page:r.pagination.page,limit:r.pagination.limit,total:r.pagination.total,totalCount:r.pagination.totalCount},loading:!1})}catch(r){s.setState({error:r instanceof Error?r.message:"加载评论失败",loading:!1})}}return s.subscribe(t=>{(t.form.author||t.form.email||t.form.url)&&function(t,e,o){try{const n={author:t,email:e,url:o};localStorage.setItem(r,JSON.stringify(n))}catch(n){console.error("保存用户信息失败:",n)}}(t.form.author,t.form.email,t.form.url)}),{store:s,getTotalPages:()=>s.getState().pagination.total,loadComments:a,submitNewComment:async function(){const t=s.getState(),e=t.form,{validateCommentForm:r}=await Promise.resolve().then(()=>b),o=r(e);if(!o.valid)return s.setState({formErrors:o.errors}),!1;s.setState({formErrors:{},submitting:!0,error:null});try{return await n({author:e.author,email:e.email,url:e.url,content:e.content}),s.setState({form:{...e,content:""},submitting:!1}),await a(t.pagination.page),!0}catch(i){return s.setState({error:i instanceof Error?i.message:"提交评论失败",submitting:!1}),!1}},submitReply:async function(t){const e=s.getState();if(!e.replyContent.trim())return!1;const{validateReplyUserInfo:r}=await Promise.resolve().then(()=>b),o=r(e.form);if(!o.valid){const t=Object.values(o.errors).join(";");return s.setState({replyError:t}),!1}s.setState({formErrors:{},submitting:!0,replyError:null});try{return await n({author:e.form.author,email:e.form.email,url:e.form.url,content:e.replyContent,parentId:t}),s.setState({replyContent:"",replyingTo:null,submitting:!1}),await a(e.pagination.page),!0}catch(i){return s.setState({error:i instanceof Error?i.message:"提交回复失败",submitting:!1}),!1}},startReply:function(t){console.log("[Store] startReply called with commentId:",t),s.setState({replyingTo:t,replyContent:"",replyError:null}),console.log("[Store] New state:",s.getState())},cancelReply:function(){s.setState({replyingTo:null,replyContent:"",replyError:null})},updateFormField:function(t,e){const r={...s.getState().form};r[t]=e,s.setState({form:r})},updateReplyContent:function(t){s.setState({replyContent:t})},clearReplyError:function(){s.setState({replyError:null})},clearError:function(){s.setState({error:null})},goToPage:function(t){const e=s.getState().pagination.total;t>=1&&t<=e&&a(t)}}}class i{constructor(t,e={}){this.container="string"==typeof t?document.querySelector(t):t,this.props=e,this.state={},this.elements={},this.destroyed=!1}setState(t){if(this.destroyed)return;const e={...this.state};this.state="function"==typeof t?{...this.state,...t(e)}:{...this.state,...t},this.update(e)}setProps(t){if(this.destroyed)return;const e={...this.props};this.props={...this.props,...t},this.updateProps(e)}render(){}update(t){this.render()}updateProps(t){}destroy(){this.destroyed=!0,this.container&&this.elements.root&&this.elements.root.parentNode&&this.elements.root.parentNode.removeChild(this.elements.root),this.elements={}}createElement(t,e={}){const r=document.createElement(t);if(e.className&&(r.className=e.className),e.attributes&&Object.entries(e.attributes).forEach(([t,e])=>{if(t.startsWith("on")){const o=t.slice(2).toLowerCase();r.addEventListener(o,e)}else"dataset"===t?Object.entries(e).forEach(([t,e])=>{r.dataset[t]=e}):["disabled","checked","readonly","required","autofocus"].includes(t)?e&&r.setAttribute(t,""):r.setAttribute(t,e)}),void 0!==e.text&&(r.textContent=e.text),void 0!==e.html&&(r.innerHTML=e.html),e.children){(Array.isArray(e.children)?e.children:[e.children]).forEach(t=>{t instanceof HTMLElement&&r.appendChild(t)})}return r}createTextElement(t,e,r=""){return this.createElement(t,{className:r,text:e})}empty(t){for(;t.firstChild;)t.removeChild(t.firstChild)}}class s extends i{constructor(t,e={}){super(t,e),this.state={localForm:{...e.form}}}render(){const{formErrors:t,submitting:e}=this.props,{localForm:r}=this.state,o=r.author.trim()&&r.email.trim()&&r.content.trim(),n=this.createElement("form",{className:"cwd-comment-form",attributes:{novalidate:!0,onSubmit:t=>this.handleSubmit(t)},children:[this.createTextElement("h3","发表评论","cwd-form-title"),this.createElement("div",{className:"cwd-form-fields",children:[this.createElement("div",{className:"cwd-form-row",children:[this.createFormField("昵称 *","text","author",r.author,t.author),this.createFormField("邮箱 *","email","email",r.email,t.email),this.createFormField("网址","url","url",r.url,t.url)]}),this.createElement("div",{className:"cwd-form-field",children:[this.createTextElement("label","写下你的评论...","cwd-form-label"),this.createElement("textarea",{className:"cwd-form-textarea "+(t.content?"cwd-input-error":""),attributes:{placeholder:"",rows:4,disabled:e,onInput:t=>this.handleFieldChange("content",t.target.value)}}),...t.content?[this.createTextElement("span",t.content,"cwd-error-text")]:[]]})]}),this.createElement("div",{className:"cwd-form-actions",children:[this.createElement("button",{className:"cwd-btn cwd-btn-primary",attributes:{type:"submit",disabled:e||!o},text:e?"提交中...":"提交评论"})]})]});this.setInputValues(n,r),this.elements.root=n,this.empty(this.container),this.container.appendChild(n)}updateProps(t){if(!this.props.submitting&&this.props.form!==t.form){const t=this.state.localForm.author||"",e=this.state.localForm.email||"",r=this.state.localForm.url||"",o=this.state.localForm.content||"";this.state.localForm={author:this.props.form.author||t,email:this.props.form.email||e,url:this.props.form.url||r,content:void 0!==this.props.form.content?this.props.form.content:o},this.elements.root&&this.setInputValues(this.elements.root,this.state.localForm)}this.elements.root&&this.updateFormState()}updateFormState(){const{formErrors:t,submitting:e}=this.props,{localForm:r}=this.state,o=r.author.trim()&&r.email.trim()&&r.content.trim(),n=this.elements.root.querySelector('button[type="submit"]');n&&(n.disabled=e||!o,n.textContent=e?"提交中...":"提交评论");this.elements.root.querySelectorAll("input, textarea").forEach(t=>{t.disabled=e}),this.updateErrors(t)}updateErrors(t){if(!this.elements.root)return;const e=this.elements.root.querySelector('input[placeholder*="昵称"]');this.updateFieldError(e,null==t?void 0:t.author);const r=this.elements.root.querySelector('input[placeholder*="邮箱"]');this.updateFieldError(r,null==t?void 0:t.email);const o=this.elements.root.querySelector('input[placeholder*="网址"]');this.updateFieldError(o,null==t?void 0:t.url);const n=this.elements.root.querySelector("textarea");this.updateFieldError(n,null==t?void 0:t.content)}updateFieldError(t,e){if(!t)return;e?t.classList.add("cwd-input-error"):t.classList.remove("cwd-input-error");const r=t.parentElement;let o=r.querySelector(".cwd-error-text");e?(o||(o=document.createElement("span"),o.className="cwd-error-text",r.appendChild(o)),o.textContent=e):o&&o.remove()}createFormField(t,e,r,o,n,i=""){return this.createElement("div",{className:"cwd-form-field",children:[this.createTextElement("label",t,"cwd-form-label"),this.createElement("input",{className:"cwd-form-input "+(n?"cwd-input-error":""),attributes:{type:e,placeholder:i,disabled:this.props.submitting,onInput:t=>this.handleFieldChange(r,t.target.value)}}),...n?[this.createTextElement("span",n,"cwd-error-text")]:[]]})}setInputValues(t,e){const r=t.querySelector('input[placeholder*="昵称"]'),o=t.querySelector('input[placeholder*="邮箱"]'),n=t.querySelector('input[placeholder*="网址"]'),i=t.querySelector("textarea");r&&(r.value=e.author||""),o&&(o.value=e.email||""),n&&(n.value=e.url||""),i&&(i.value=e.content||"")}handleFieldChange(t,e){this.state.localForm[t]=e,this.props.onFieldChange&&this.props.onFieldChange(t,e),this.elements.root&&this.updateFormState()}handleSubmit(t){t.preventDefault(),this.props.onSubmit&&this.props.onSubmit(this.state.localForm)}}class a extends i{constructor(t,e={}){super(t,e),this.state={content:e.content||""}}render(){const t=this.createElement("div",{className:"cwd-reply-editor",children:[this.createElement("div",{className:"cwd-reply-header",children:[this.createTextElement("span",`回复 @${this.props.replyToAuthor}`,"cwd-reply-to"),this.createElement("button",{className:"cwd-btn-close",attributes:{type:"button",onClick:()=>this.handleCancel()},text:"✕"})]}),this.createElement("textarea",{className:"cwd-reply-textarea",attributes:{rows:3,placeholder:"写下你的回复...",disabled:this.props.submitting,onInput:t=>this.handleInput(t)}}),...this.props.error?[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:"✕"})]})]:[],this.createElement("div",{className:"cwd-reply-actions",children:[this.createElement("button",{className:"cwd-btn cwd-btn-primary cwd-btn-small",attributes:{type:"button",disabled:this.props.submitting||!this.state.content.trim(),onClick:()=>this.handleSubmit()},text:this.props.submitting?"提交中...":"提交回复"}),this.createElement("button",{className:"cwd-btn cwd-btn-secondary cwd-btn-small",attributes:{type:"button",disabled:this.props.submitting,onClick:()=>this.handleCancel()},text:"取消"})]})]}),e=t.querySelector("textarea");e&&(e.value=this.state.content),this.elements.root=t,this.empty(this.container),this.container.appendChild(t)}updateProps(t){if(this.props.content!==this.state.content&&this.props.content!==(null==t?void 0:t.content))return this.state.content=this.props.content,void this.render();this.props.error===(null==t?void 0:t.error)&&this.props.submitting===(null==t?void 0:t.submitting)||this.render()}handleInput(t){var e;this.state.content=t.target.value;const r=null==(e=this.elements.root)?void 0:e.querySelector(".cwd-btn-primary");r&&(r.disabled=this.props.submitting||!this.state.content.trim()),this.props.onUpdate&&this.props.onUpdate(this.state.content)}handleSubmit(){this.props.onSubmit&&this.props.onSubmit()}handleCancel(){this.props.onCancel&&this.props.onCancel()}handleClearError(){this.props.onClearError&&this.props.onClearError()}setContent(t){var e;this.state.content=t;const r=null==(e=this.elements.root)?void 0:e.querySelector("textarea");r&&(r.value=t)}getContent(){return this.state.content}focus(){var t;const e=null==(t=this.elements.root)?void 0:t.querySelector("textarea");e&&e.focus()}}function l(t){const e=new Date(t),r=(new Date).getTime()-e.getTime(),o=Math.floor(r/1e3),n=Math.floor(o/60),i=Math.floor(n/60),s=Math.floor(i/24);return s<3?s>0?1===s?"昨天":`${s}天前`:i>0?`${i}小时前`:n>0?`${n}分钟前`:"刚刚":function(t){const e=new Date(t),r=e.getFullYear(),o=String(e.getMonth()+1).padStart(2,"0"),n=String(e.getDate()).padStart(2,"0"),i=String(e.getHours()).padStart(2,"0"),s=String(e.getMinutes()).padStart(2,"0"),a=String(e.getSeconds()).padStart(2,"0");return`${r}/${o}/${n} ${i}:${s}:${a}`}(t)}class c extends i{constructor(t,e={}){super(t,e),this.replyEditor=null,this.childCommentItems=[]}render(){const{comment:t,isReply:e,adminEmail:r,adminBadge:o}=this.props,n=this.props.replyingTo===t.id,i=r&&t.email===r,s=this.createElement("div",{className:"cwd-comment-item "+(e?"cwd-comment-reply":""),children:[this.createElement("div",{className:"cwd-comment-avatar",children:[this.createElement("img",{attributes:{src:t.avatar,alt:t.author,loading:"lazy"}})]}),this.createElement("div",{className:"cwd-comment-body",children:[this.createElement("div",{className:"cwd-comment-header",children:[this.createElement("div",{className:"cwd-comment-author",children:[t.url?this.createElement("span",{className:"cwd-author-name",children:[this.createElement("a",{attributes:{href:t.url,target:"_blank",rel:"noopener noreferrer"},text:t.author})]}):this.createTextElement("span",t.author,"cwd-author-name"),...i?[this.createTextElement("span",`${o}`,"cwd-admin-badge")]:[],...t.replyToAuthor?[this.createTextElement("span"," 回复 ","cwd-reply-to-separator"),this.createTextElement("span",t.replyToAuthor,"cwd-reply-to-author")]:[]]}),this.createElement("div",{className:"cwd-comment-actions",children:[this.createElement("span",{className:"cwd-action-btn",attributes:{onClick:()=>this.handleReply()},text:"回复"}),this.createTextElement("span",l(t.pubDate),"cwd-comment-time")]})]}),this.createElement("div",{className:"cwd-comment-content"}),this.createElement("div",{className:"cwd-reply-editor-container"}),...t.replies&&t.replies.length>0?[this.createElement("div",{className:"cwd-replies"})]:[]]})]}),d=s.querySelector(".cwd-comment-content");if(d&&(d.innerHTML=t.contentHtml),n){const e=s.querySelector(".cwd-reply-editor-container");e&&(this.replyEditor=new a(e,{replyToAuthor:t.author,content:this.props.replyContent,error:this.props.replyError,submitting:this.props.submitting,onUpdate:t=>this.handleUpdateReplyContent(t),onSubmit:()=>this.handleSubmitReply(),onCancel:()=>this.handleCancelReply(),onClearError:()=>this.handleClearReplyError()}),this.replyEditor.render(),this.replyEditor.focus())}else this.replyEditor=null;if(this.childCommentItems=[],t.replies&&t.replies.length>0){const e=s.querySelector(".cwd-replies");e&&t.replies.forEach(t=>{const r=new c(e,{comment:t,isReply:!0,replyingTo:this.props.replyingTo,replyContent:this.props.replyContent,replyError:this.props.replyError,submitting:this.props.submitting,adminEmail:this.props.adminEmail,adminBadge:this.props.adminBadge,onReply:this.props.onReply,onSubmitReply:this.props.onSubmitReply,onCancelReply:this.props.onCancelReply,onUpdateReplyContent:this.props.onUpdateReplyContent,onClearReplyError:this.props.onClearReplyError});r.render(),this.childCommentItems.push(r)})}this.elements.root=s,this.container.contains(s)?this.container.replaceChild(s,this.elements.root):this.container.appendChild(s)}updateProps(t){var e;const{comment:r}=this.props,o=t.replyingTo===r.id,n=this.props.replyingTo===r.id;if(this.props.comment===t.comment){if(n!==o){const t=null==(e=this.elements.root)?void 0:e.querySelector(":scope > .cwd-comment-body > .cwd-reply-editor-container");n&&t?(this.replyEditor=new a(t,{replyToAuthor:r.author,content:this.props.replyContent,error:this.props.replyError,submitting:this.props.submitting,onUpdate:t=>this.handleUpdateReplyContent(t),onSubmit:()=>this.handleSubmitReply(),onCancel:()=>this.handleCancelReply(),onClearError:()=>this.handleClearReplyError()}),this.replyEditor.render(),this.replyEditor.focus()):!n&&t&&(t.innerHTML="",this.replyEditor=null)}else n&&this.replyEditor&&this.replyEditor.setProps({content:this.props.replyContent,error:this.props.replyError,submitting:this.props.submitting});this.childCommentItems&&this.childCommentItems.length>0&&this.childCommentItems.forEach(t=>{t.setProps({replyingTo:this.props.replyingTo,replyContent:this.props.replyContent,replyError:this.props.replyError,submitting:this.props.submitting})})}else this.render()}handleReply(){console.log("[CommentItem] handleReply called, comment.id:",this.props.comment.id),this.props.onReply?this.props.onReply(this.props.comment.id):console.warn("[CommentItem] onReply callback is missing!")}handleSubmitReply(){this.props.onSubmitReply&&this.props.onSubmitReply(this.props.comment.id)}handleCancelReply(){this.props.onCancelReply&&this.props.onCancelReply()}handleUpdateReplyContent(t){this.props.onUpdateReplyContent&&this.props.onUpdateReplyContent(t)}handleClearReplyError(){this.props.onClearReplyError&&this.props.onClearReplyError()}}class d extends i{constructor(t,e={}){super(t,{text:"加载中...",...e})}render(){const t=this.createElement("div",{className:"cwd-loading",children:[this.createElement("div",{className:"cwd-spinner"}),this.createTextElement("span",this.props.text,"cwd-loading-text")]});this.elements.root=t,this.empty(this.container),this.container.appendChild(t)}setText(t){this.props.text=t;const e=this.elements.root.querySelector(".cwd-loading-text");e&&(e.textContent=t)}}class p extends i{constructor(t,e={}){super(t,e),this.state={currentPage:e.currentPage||1,totalPages:e.totalPages||1}}getDisplayedPages(){const{currentPage:t,totalPages:e}=this.state,r=[],o=Math.max(1,t-Math.floor(2.5)),n=Math.min(e,o+5-1);for(let i=n-5+1;i<=n;i++)i>=1&&r.push(i);return r.slice(0,5)}render(){if(this.state.totalPages<=1)return void this.empty(this.container);const t=this.getDisplayedPages(),e=this.createElement("div",{className:"cwd-pagination",children:[this.createElement("button",{className:"cwd-page-btn",attributes:{type:"button",disabled:1===this.state.currentPage,onClick:()=>this.handlePrev()},text:"上一页"}),this.createElement("div",{className:"cwd-page-numbers",children:t.map(t=>this.createElement("button",{className:"cwd-page-num "+(t===this.state.currentPage?"cwd-page-num-active":""),attributes:{type:"button",onClick:()=>this.handleGoTo(t)},text:t.toString()}))}),this.createElement("button",{className:"cwd-page-btn",attributes:{type:"button",disabled:this.state.currentPage===this.state.totalPages,onClick:()=>this.handleNext()},text:"下一页"})]});this.elements.root=e,this.empty(this.container),this.container.appendChild(e)}updateProps(){this.state.currentPage=this.props.currentPage,this.state.totalPages=this.props.totalPages,this.render()}handlePrev(){this.props.onPrev&&this.props.onPrev()}handleNext(){this.props.onNext&&this.props.onNext()}handleGoTo(t){this.props.onGoTo&&this.props.onGoTo(t)}}class m extends i{constructor(t,e={}){super(t,e),this.loadingComponent=null,this.paginationComponent=null,this.commentItems=new Map}render(){const{comments:t,loading:e,error:r,currentPage:o,totalPages:n}=this.props;if(console.log("[CommentList] render called, comments:",t),console.log("[CommentList] comments.length:",null==t?void 0:t.length),console.log("[CommentList] loading:",e),this.empty(this.container),e&&0===t.length)return this.loadingComponent=new d(this.container,{text:"加载评论中..."}),this.loadingComponent.render(),void(this.elements.root=this.loadingComponent.elements.root);if(r&&0===t.length){const t=this.createElement("div",{className:"cwd-error",children:[this.createTextElement("span",r),this.createElement("button",{className:"cwd-error-retry",attributes:{type:"button",onClick:()=>this.handleRetry()},text:"重试"})]});return this.elements.root=t,void this.container.appendChild(t)}const i=this.createElement("div",{className:"cwd-comment-list"});if(t.length>0){const e=this.createElement("div",{className:"cwd-comments"});this.commentItems.clear(),t.forEach((t,r)=>{console.log(`[CommentList] Rendering comment ${r}:`,t);const o=new c(e,{comment:t,replyingTo:this.props.replyingTo,replyContent:this.props.replyContent,replyError:this.props.replyError,submitting:this.props.submitting,adminEmail:this.props.adminEmail,adminBadge:this.props.adminBadge,onReply:t=>this.handleReply(t),onSubmitReply:t=>this.handleSubmitReply(t),onCancelReply:()=>this.handleCancelReply(),onUpdateReplyContent:t=>this.handleUpdateReplyContent(t),onClearReplyError:()=>this.handleClearReplyError()});o.render(),this.commentItems.set(t.id,o)}),console.log("[CommentList] Final commentsContainer children count:",e.children.length),console.log("[CommentList] Final commentsContainer innerHTML:",e.innerHTML),i.appendChild(e)}else{const t=this.createElement("div",{className:"cwd-empty",children:[this.createTextElement("p","暂无评论,快来抢沙发吧!","cwd-empty-text")]});i.appendChild(t)}if(n>1){const t=this.createElement("div");i.appendChild(t),this.paginationComponent=new p(t,{currentPage:o,totalPages:n,onPrev:()=>this.handlePrevPage(),onNext:()=>this.handleNextPage(),onGoTo:t=>this.handleGoToPage(t)}),this.paginationComponent.render()}else this.paginationComponent=null;this.elements.root=i,this.container.appendChild(i)}updateProps(t){if(this.props.loading===t.loading||this.props.loading)if(this.props.comments===t.comments)if(this.props.replyingTo===t.replyingTo&&this.props.replyError===t.replyError&&this.props.submitting===t.submitting){if(this.paginationComponent){(this.props.currentPage!==t.currentPage||this.props.totalPages!==t.totalPages)&&(this.paginationComponent.props.currentPage=this.props.currentPage,this.paginationComponent.props.totalPages=this.props.totalPages,this.paginationComponent.updateProps())}}else this.commentItems.forEach(t=>{t.setProps({replyingTo:this.props.replyingTo,replyContent:this.props.replyContent,replyError:this.props.replyError,submitting:this.props.submitting})});else this.render();else this.render()}handleRetry(){this.props.onRetry&&this.props.onRetry()}handleReply(t){this.props.onReply&&this.props.onReply(t)}handleSubmitReply(t){this.props.onSubmitReply&&this.props.onSubmitReply(t)}handleCancelReply(){this.props.onCancelReply&&this.props.onCancelReply()}handleUpdateReplyContent(t){this.props.onUpdateReplyContent&&this.props.onUpdateReplyContent(t)}handleClearReplyError(){this.props.onClearReplyError&&this.props.onClearReplyError()}handlePrevPage(){this.props.onPrevPage&&this.props.onPrevPage()}handleNextPage(){this.props.onNextPage&&this.props.onNextPage()}handleGoToPage(t){this.props.onGoToPage&&this.props.onGoToPage(t)}}class h{constructor(t){this.config={...t},this.hostElement=this._resolveElement(t.el),this.shadowRoot=null,this.mountPoint=null,this.commentForm=null,this.commentList=null,this.store=null,this.unsubscribe=null,this._mounted=!1}_resolveElement(t){if("string"==typeof t){const e=document.querySelector(t);if(!e)throw new Error(`元素未找到: ${t}`);if(!(e instanceof HTMLElement))throw new Error(`目标不是 HTMLElement: ${t}`);return e}return t}mount(){if(this._mounted)return;this.shadowRoot=this.hostElement.attachShadow({mode:"open"});const t=document.createElement("style");t.textContent=':root,[data-theme=light]{--cwd-primary: #0969da;--cwd-primary-hover: #0864ca;--cwd-text: #24292f;--cwd-text-secondary: #6e7781;--cwd-bg: #ffffff;--cwd-bg-input: #ffffff;--cwd-bg-secondary: #f6f8fa;--cwd-bg-reply: #f6f8fa;--cwd-bg-hover: #f6f8fa;--cwd-bg-disabled: #f6f8fa;--cwd-bg-avatar: #f6f8fa;--cwd-border: #d0d7de;--cwd-border-light: #eaeef2;--cwd-error: #cf222e;--cwd-bg-error: #ffebe9;--cwd-border-error: #fd8c73;--cwd-radius: 6px}[data-theme=dark]{--cwd-primary: #58a6ff;--cwd-primary-hover: #4094ff;--cwd-text: #c9d1d9;--cwd-text-secondary: #8b949e;--cwd-bg: #0d1117;--cwd-bg-input: #0d1117;--cwd-bg-secondary: #161b22;--cwd-bg-reply: #161b22;--cwd-bg-hover: #161b22;--cwd-bg-disabled: #161b22;--cwd-bg-avatar: #161b22;--cwd-border: #30363d;--cwd-border-light: #21262d;--cwd-error: #f85149;--cwd-bg-error: #3d1614;--cwd-border-error: #f85149}*{box-sizing:border-box;margin:0;padding:0}.cwd-comments-container{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Noto Sans,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";font-size:14px;line-height:1.5;color:var(--cwd-text);background:var(--cwd-bg);word-wrap:break-word}.cwd-comments-header{display:flex;align-items:center;justify-content:space-between;padding:16px 0;margin-bottom:16px;border-bottom:1px solid var(--cwd-border)}.cwd-comments-count{font-size:16px;font-weight:600;color:var(--cwd-text)}.cwd-loading{display:flex;align-items:center;justify-content:center;gap:12px;padding:40px 20px;color:var(--cwd-text-secondary, #6e7781)}.cwd-spinner{width:20px;height:20px;border:2px solid var(--cwd-border, #d0d7de);border-top-color:var(--cwd-primary, #0969da);border-radius:50%;animation:cwd-spin .6s linear infinite}@keyframes cwd-spin{to{transform:rotate(360deg)}}.cwd-loading-text{font-size:14px}.cwd-comment-form{background:var(--cwd-bg, #ffffff);border:1px solid var(--cwd-border, #d0d7de);border-radius:var(--cwd-radius, 6px);padding:20px;margin-bottom:24px}.cwd-form-title{margin:0 0 16px;font-size:16px;font-weight:600;color:var(--cwd-text, #24292f)}.cwd-form-fields{display:flex;flex-direction:column;gap:12px}.cwd-form-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}@media(max-width:640px){.cwd-form-row{grid-template-columns:1fr}}.cwd-form-field{display:flex;flex-direction:column;gap:4px}.cwd-form-label{font-size:13px;font-weight:500;color:var(--cwd-text, #24292f)}.cwd-form-field input,.cwd-form-field textarea{width:100%;padding:8px 12px;font-size:14px;line-height:1.5;color:var(--cwd-text, #24292f);background:var(--cwd-bg-input, #ffffff);border:1px solid var(--cwd-border, #d0d7de);border-radius:var(--cwd-radius, 6px);transition:border-color .2s,box-shadow .2s;font-family:inherit}.cwd-form-field input:focus,.cwd-form-field textarea:focus{outline:none;border-color:var(--cwd-primary, #0969da);box-shadow:0 0 0 3px #0969da1a}.cwd-form-field input:disabled,.cwd-form-field textarea:disabled{background:var(--cwd-bg-disabled, #f6f8fa);cursor:not-allowed}.cwd-form-field input.cwd-input-error,.cwd-form-field textarea.cwd-input-error{border-color:var(--cwd-error, #cf222e)}.cwd-form-field textarea{resize:vertical;min-height:80px}.cwd-error-text{font-size:12px;color:var(--cwd-error, #cf222e)}.cwd-form-actions{display:flex;justify-content:flex-end;margin-top:16px}.cwd-btn{padding:8px 16px;font-size:14px;font-weight:500;line-height:1.5;border:none;border-radius:var(--cwd-radius, 6px);cursor:pointer;transition:background-color .2s;font-family:inherit}.cwd-btn-primary{color:#fff;background:var(--cwd-primary, #0969da)}.cwd-btn-primary:hover:not(:disabled){background:var(--cwd-primary-hover, #0864ca)}.cwd-btn:disabled{opacity:.6;cursor:not-allowed}.cwd-reply-editor{margin-top:12px;padding:12px;background:var(--cwd-bg-reply, #f6f8fa);border:1px solid var(--cwd-border, #d0d7de);border-radius:var(--cwd-radius, 6px)}.cwd-reply-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.cwd-reply-to{font-size:13px;font-weight:500;color:var(--cwd-text-secondary, #6e7781)}.cwd-btn-close{width:20px;height:20px;padding:0;font-size:14px;color:var(--cwd-text-secondary, #6e7781);background:transparent;border:none;border-radius:4px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.cwd-btn-close:hover{background:var(--cwd-bg-hover, rgba(0, 0, 0, .05))}.cwd-reply-textarea{width:100%;padding:8px 12px;font-size:14px;line-height:1.5;color:var(--cwd-text, #24292f);background:var(--cwd-bg-input, #ffffff);border:1px solid var(--cwd-border, #d0d7de);border-radius:var(--cwd-radius, 6px);resize:vertical;font-family:inherit;margin-bottom:8px}.cwd-reply-textarea:focus{outline:none;border-color:var(--cwd-primary, #0969da);box-shadow:0 0 0 3px #0969da1a}.cwd-reply-actions{display:flex;gap:8px;justify-content:flex-end}.cwd-btn-small{padding:4px 10px;font-size:12px}.cwd-btn-secondary{color:var(--cwd-text, #24292f);background:var(--cwd-bg-secondary, #f6f8fa);border:1px solid var(--cwd-border, #d0d7de)}.cwd-btn-secondary:hover:not(:disabled){background:var(--cwd-bg-hover, #eaeef2)}.cwd-comment-item{display:flex;gap:12px;padding:20px 0;border-bottom:1px solid var(--cwd-border-light, #eaeef2)}.cwd-comment-body:hover .cwd-action-btn{display:inline-flex!important}.cwd-comment-item:last-child{border-bottom:none}.cwd-comment-reply{padding-top:12px;padding-bottom:12px}.cwd-comment-avatar{flex-shrink:0;width:40px;height:40px;border-radius:5px;border:1px solid var(--cwd-border, #d0d7de);overflow:hidden;background:var(--cwd-bg-avatar, #f6f8fa)}.cwd-comment-avatar img{width:100%;height:100%;object-fit:cover}.cwd-comment-reply .cwd-comment-avatar{width:32px;height:32px}.cwd-comment-body{flex:1;min-width:0}.cwd-comment-header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}.cwd-author-name{font-size:16px;font-weight:600;color:var(--cwd-text, #24292f)}.cwd-author-name a{color:inherit;text-decoration:none}.cwd-author-name a:hover{color:var(--cwd-primary, #0969da);text-decoration:underline}.cwd-comment-author{display:inline-flex;align-items:center}.cwd-admin-badge{background:#db850d;color:#fff;border-radius:3px;padding:1px 4px;font-size:12px;margin:0 4px}.cwd-reply-to-separator{margin:0 4px}.cwd-reply-to-author{font-style:italic}.cwd-comment-time{font-size:12px;color:var(--cwd-text-secondary, #6e7781)}.cwd-comment-content{font-size:14px;line-height:1.6;color:var(--cwd-text, #24292f);word-wrap:break-word;margin-top:10px}.cwd-comment-content p{margin:0 0 8px}.cwd-comment-content p:last-child{margin-bottom:0}.cwd-comment-content a{color:var(--cwd-primary, #0969da);text-decoration:none}.cwd-comment-content a:hover{text-decoration:underline}.cwd-comment-actions{display:flex;align-items:center;gap:12px}.cwd-action-btn{display:inline-flex;align-items:center;font-size:12px;color:var(--cwd-text-secondary, #6e7781);background:transparent;border:none;border-radius:4px;cursor:pointer;transition:background-color .2s;font-family:inherit;display:none}.cwd-action-btn:hover{color:var(--cwd-primary, #0969da)}.cwd-replies{margin-top:12px;padding-left:12px;border-left:2px solid var(--cwd-border-light, #eaeef2)}.cwd-comment-list{min-height:100px}.cwd-comments{display:flex;flex-direction:column}.cwd-error{padding:20px;text-align:center;color:var(--cwd-error, #cf222e);background:var(--cwd-bg-error, #ffebe9);border:1px solid var(--cwd-border-error, #fd8c73);border-radius:var(--cwd-radius, 6px)}.cwd-error-retry{margin-left:12px;padding:4px 12px;font-size:13px;color:#fff;background:var(--cwd-primary, #0969da);border:none;border-radius:4px;cursor:pointer;font-family:inherit}.cwd-error-retry:hover{background:var(--cwd-primary-hover, #0864ca)}.cwd-empty{padding:40px 20px;text-align:center}.cwd-empty-icon{font-size:48px;margin-bottom:12px}.cwd-empty-text{margin:0;font-size:14px;color:var(--cwd-text-secondary, #6e7781)}.cwd-pagination{display:flex;align-items:center;justify-content:center;gap:8px;padding:20px 0}.cwd-page-btn{padding:6px 12px;font-size:13px;color:var(--cwd-text, #24292f);background:var(--cwd-bg, #ffffff);border:1px solid var(--cwd-border, #d0d7de);border-radius:var(--cwd-radius, 6px);cursor:pointer;transition:background-color .2s;font-family:inherit}.cwd-page-btn:hover:not(:disabled){background:var(--cwd-bg-hover, #f6f8fa)}.cwd-page-btn:disabled{opacity:.5;cursor:not-allowed}.cwd-page-numbers{display:flex;gap:4px}.cwd-page-num{min-width:32px;padding:6px 8px;font-size:13px;color:var(--cwd-text, #24292f);background:var(--cwd-bg, #ffffff);border:1px solid var(--cwd-border, #d0d7de);border-radius:var(--cwd-radius, 6px);cursor:pointer;transition:background-color .2s;font-family:inherit}.cwd-page-num:hover{background:var(--cwd-bg-hover, #f6f8fa)}.cwd-page-num-active{color:#fff;background:var(--cwd-primary, #0969da);border-color:var(--cwd-primary, #0969da)}.cwd-error-inline{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;margin-bottom:16px;font-size:14px;color:var(--cwd-error, #cf222e);background:var(--cwd-bg-error, #ffebe9);border:1px solid var(--cwd-border-error, #fd8c73);border-radius:var(--cwd-radius, 6px)}.cwd-error-close{width:20px;height:20px;padding:0;font-size:14px;color:inherit;background:transparent;border:none;border-radius:4px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.cwd-error-close:hover{background:#0000001a}.cwd-load-more{display:block;width:100%;padding:12px;margin:16px 0;font-size:14px;color:var(--cwd-primary);background:var(--cwd-bg-secondary);border:1px solid var(--cwd-border);border-radius:var(--cwd-radius);cursor:pointer;transition:background-color .2s;font-family:inherit}.cwd-load-more:hover{background:var(--cwd-bg-hover)}.fade-enter-active,.fade-leave-active{transition:opacity .2s ease}.fade-enter-from,.fade-leave-to{opacity:0}.cwd-comments-container::-webkit-scrollbar{width:8px;height:8px}.cwd-comments-container::-webkit-scrollbar-track{background:transparent}.cwd-comments-container::-webkit-scrollbar-thumb{background:var(--cwd-border);border-radius:4px}.cwd-comments-container::-webkit-scrollbar-thumb:hover{background:var(--cwd-text-secondary)}',this.shadowRoot.appendChild(t),this.mountPoint=document.createElement("div"),this.mountPoint.className="cwd-comments-container",this.shadowRoot.appendChild(this.mountPoint),this.config.theme&&this.mountPoint.setAttribute("data-theme",this.config.theme);const r=e(this.config);this.store=n(this.config,r.fetchComments.bind(r),r.submitComment.bind(r)),this.unsubscribe=this.store.store.subscribe(t=>{this._onStateChange(t)}),this._render(),this.store.loadComments(),this._mounted=!0}unmount(){if(this._mounted){if(this.commentForm&&(this.commentForm.destroy(),this.commentForm=null),this.commentList&&(this.commentList.destroy(),this.commentList=null),this.unsubscribe&&(this.unsubscribe(),this.unsubscribe=null),this.hostElement)for(;this.hostElement.firstChild;)this.hostElement.removeChild(this.hostElement.firstChild);this.shadowRoot=null,this.mountPoint=null,this.store=null,this._mounted=!1,console.log("[CWDComments] 组件已卸载")}}_render(){if(!this.mountPoint)return;const t=this.store.store.getState();this.commentForm||(this.commentForm=new s(this.mountPoint,{form:t.form,formErrors:t.formErrors,submitting:t.submitting,onSubmit:()=>this._handleSubmit(),onFieldChange:(t,e)=>this.store.updateFormField(t,e)}),this.commentForm.render());const e=this.mountPoint.querySelector(".cwd-error-inline");if(t.error){if(!e){const e=document.createElement("div");e.className="cwd-error-inline",e.innerHTML=`\n ${t.error}\n \n `,e.querySelector('[data-action="clear-error"]').addEventListener("click",()=>{this.store.clearError()}),this.mountPoint.insertBefore(e,this.mountPoint.firstChild)}}else e&&e.remove();let r=this.mountPoint.querySelector(".cwd-comments-header");r||(r=document.createElement("div"),r.className="cwd-comments-header",r.innerHTML='\n

\n 共 0 条评论\n

\n ',this.mountPoint.appendChild(r));const o=r.querySelector(".cwd-comments-count-number");if(o&&(o.textContent=t.pagination.totalCount),!this.commentList){const e=document.createElement("div");this.mountPoint.appendChild(e),this.commentList=new m(e,{comments:t.comments,loading:t.loading,error:null,currentPage:t.pagination.page,totalPages:this.store.getTotalPages(),replyingTo:t.replyingTo,replyContent:t.replyContent,replyError:t.replyError,submitting:t.submitting,adminEmail:this.config.adminEmail,adminBadge:this.config.adminBadge||"博主",onRetry:()=>this.store.loadComments(),onReply:t=>this.store.startReply(t),onSubmitReply:t=>this.store.submitReply(t),onCancelReply:()=>this.store.cancelReply(),onUpdateReplyContent:t=>this.store.updateReplyContent(t),onClearReplyError:()=>this.store.clearReplyError(),onPrevPage:()=>this.store.goToPage(t.pagination.page-1),onNextPage:()=>this.store.goToPage(t.pagination.page+1),onGoToPage:t=>this.store.goToPage(t)}),this.commentList.render()}}_onStateChange(t,e){var r,o,n,i,s;if(!this._mounted)return;if(null==(o=null==(r=this.commentForm)?void 0:r.elements)?void 0:o.root){const e=this.commentForm.elements.root;null!==t.replyingTo?e.style.display="none":e.style.display=""}this.commentForm&&this.commentForm.setProps({form:t.form,formErrors:t.formErrors,submitting:t.submitting});const a=null==(n=this.mountPoint)?void 0:n.querySelector(".cwd-error-inline");if(t.error){if(!a){const e=document.createElement("div");e.className="cwd-error-inline",e.innerHTML=`\n ${t.error}\n \n `,e.querySelector('[data-action="clear-error"]').addEventListener("click",()=>{this.store.clearError()}),null==(i=this.mountPoint)||i.insertBefore(e,this.mountPoint.firstChild)}}else a&&a.remove();const l=null==(s=this.mountPoint)?void 0:s.querySelector(".cwd-comments-header"),c=null==l?void 0:l.querySelector(".cwd-comments-count-number");c&&(c.textContent=t.pagination.totalCount),this.commentList&&this.commentList.setProps({comments:t.comments,loading:t.loading,currentPage:t.pagination.page,totalPages:this.store.getTotalPages(),replyingTo:t.replyingTo,replyContent:t.replyContent,replyError:t.replyError,submitting:t.submitting})}async _handleSubmit(){await this.store.submitNewComment()&&this.commentForm&&(this.commentForm.state.localForm={...this.store.store.getState().form})}updateConfig(t){const r={...this.config};if(Object.assign(this.config,t),t.theme&&this.mountPoint&&this.mountPoint.setAttribute("data-theme",t.theme),t.postSlug&&t.postSlug!==r.postSlug){const t=e(this.config);this.unsubscribe&&this.unsubscribe(),this.store=n(this.config,t.fetchComments.bind(t),t.submitComment.bind(t)),this.unsubscribe=this.store.store.subscribe(t=>{this._onStateChange(t)}),this.store.loadComments()}}getConfig(){return{...this.config}}}function u(t){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)}function g(t){if(!t)return!0;try{return new URL(t),!0}catch{return!1}}function f(t){return t&&0!==t.trim().length?t.length>1e3?{valid:!1,error:"评论内容不能超过 1000 字"}:{valid:!0}:{valid:!1,error:"请输入评论内容"}}"undefined"!=typeof window&&(window.CWDComments=h);const b=Object.freeze(Object.defineProperty({__proto__:null,isValidEmail:u,isValidUrl:g,validateCommentContent:f,validateCommentForm:function(t){const e={};t.author&&0!==t.author.trim().length?t.author.length>50&&(e.author="昵称不能超过 50 字"):e.author="请输入昵称",t.email&&0!==t.email.trim().length?u(t.email)||(e.email="邮箱格式不正确"):e.email="请输入邮箱",t.url&&!g(t.url)&&(e.url="网址格式不正确");const r=f(t.content);return r.valid||(e.content=r.error),{valid:0===Object.keys(e).length,errors:e}},validateReplyUserInfo:function(t){const e={};return t.author&&0!==t.author.trim().length?t.author.length>50&&(e.author="昵称不能超过 50 字"):e.author="请输入昵称",t.email&&0!==t.email.trim().length?u(t.email)||(e.email="邮箱格式不正确"):e.email="请输入邮箱",t.url&&!g(t.url)&&(e.url="网址格式不正确"),{valid:0===Object.keys(e).length,errors:e}}},Symbol.toStringTag,{value:"Module"}));t.CWDComments=h,t.default=h,Object.defineProperties(t,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}); diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..df2257e12116612e6b56bf9e70a979de71650430 GIT binary patch literal 15620 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 e71{g`xc?96Z;hIEsLm_^0000