Initial commit

This commit is contained in:
anghunk
2026-01-14 10:02:58 +08:00
commit 8f2fde5188
86 changed files with 74925 additions and 0 deletions

77
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,77 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
这是一个基于 Cloudflare Workers 的轻量级评论系统后端Momo Backend Worker使用 Hono 框架构建,数据存储使用 Cloudflare D1SQLite和 KV。
## 常用命令
```bash
# 开发
npm run dev # 本地开发服务器
npm run deploy # 部署到 Cloudflare Workers
# 测试
npm run test # 运行 Vitest 测试
# 类型生成
npm run cf-typegen # 生成 Cloudflare Workers 类型定义
# 数据库操作
npm wrangler d1 execute CWD_DB --remote --file=./schemas/comment.sql # 初始化数据库表
```
## 架构概览
```
src/
├── index.ts # 应用入口,路由定义
├── bindings.ts # TypeScript 类型定义Env 绑定)
├── api/
│ ├── admin/ # 管理员 API需认证
│ │ ├── login.ts, listComments.ts, deleteComment.ts, updateStatus.ts
│ └── public/ # 公开 API
│ ├── getComments.ts, postComment.ts
├── utils/
│ ├── auth.ts # 管理员认证中间件
│ ├── cors.ts # CORS 配置
│ ├── email.ts # Resend 邮件通知
│ └── getAvatar.ts # Gravatar 头像生成
└── views/
└── html.ts # 管理后台 HTML 页面(内嵌 Vue 3
```
## API 路由
| 方法 | 路径 | 功能 | 认证 |
|------|------|------|------|
| GET | `/` | 管理后台页面 | 否 |
| GET | `/api/comments` | 获取评论列表 | 否 |
| POST | `/api/comments` | 提交评论 | 否 |
| POST | `/admin/login` | 管理员登录 | 否 |
| GET | `/admin/comments/list` | 评论管理列表 | Bearer Token |
| PUT | `/admin/comments/status` | 更新评论状态 | Bearer Token |
| DELETE | `/admin/comments/delete` | 删除评论 | Bearer Token |
## 关键绑定
- `CWD_DB`: D1 数据库绑定
- `CWD_AUTH_KV`: KV 存储(用于认证 Token
## 环境变量
| 变量名 | 说明 | 必填 |
|--------|------|------|
| `ADMIN_NAME` | 管理员用户名 | 是 |
| `ADMIN_PASSWORD` | 管理员密码 | 是 |
| `ALLOW_ORIGIN` | CORS 白名单(逗号分隔) | 是 |
| `RESEND_API_KEY` | Resend API 密钥 | 否 |
| `RESEND_FROM_EMAIL` | 发件邮箱 | 否 |
| `EMAIL_ADDRESS` | 站长接收邮箱 | 否 |
## 本地开发
1. 复制 `.dev.vars.example``.dev.vars` 并配置环境变量
2. 运行 `npm run dev` 启动本地开发服务器

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(npm install)",
"Bash(npm run build:*)",
"Bash(npm install -D vitepress)",
"Bash(npm uninstall vitepress)"
]
}
}

6
.dev.vars.example Normal file
View File

@@ -0,0 +1,6 @@
ALLOW_ORIGIN="http://localhost:4321,https://blog.example.top"
RESEND_API_KEY="re_xxxxxxxx"
RESEND_FROM_EMAIL="<notify@notifications.example.top>"
EMAIL_ADDRESS="admin@example.top"
ADMIN_NAME="Admin"
ADMIN_PASSWORD="password"

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

171
.gitignore vendored Normal file
View File

@@ -0,0 +1,171 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
package-lock.json
/docs/package-lock.json
/widget/package-lock.json
# wrangler project
.dev.vars*
!.dev.vars.example
.env*
!.env.example
.wrangler/
wrangler.jsonc

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
> 基于 https://github.com/Motues/Momo-Backend 进行二次开发的 Cloudflare Worker 版本,做了大量扩展更新。
# cwd-comments
Cloudflare Worker 版本基于 Cloudflare Workers + D1 + KV 实现,无需服务器即可部署运行。
## 特性
- ⚡️ **极速响应**:基于 Cloudflare 全球边缘网络
- 🔒 **安全可靠**内置管理员认证、CORS 保护
- 📧 **邮件通知**:支持 Resend 邮件服务
- 🎨 **易于集成**:提供完整的 REST API
## 前置要求
- Node.js 16+
- Cloudflare 账号
- Wrangler CLI
## 安装
```bash
# 克隆项目
git clone https://github.com/anghunk/cwd-comments
cd cwd-comments
# 安装依赖
npm install
```
## 配置
- [后端配置](./backend-config.md)
- [前端配置](./frontend-config.md)

View File

@@ -0,0 +1,275 @@
import {
useMediaQuery
} from "./chunk-LJKO4TMH.js";
import {
computed,
ref,
shallowRef,
watch
} from "./chunk-QE257C5J.js";
// node_modules/vitepress/dist/client/theme-default/index.js
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/fonts.css";
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/vars.css";
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/base.css";
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/icons.css";
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/utils.css";
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/components/custom-block.css";
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code.css";
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css";
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/components/vp-doc.css";
import "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/styles/components/vp-sponsor.css";
import VPBadge from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import Layout from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/Layout.vue";
import { default as default2 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import { default as default3 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPButton.vue";
import { default as default4 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPDocAsideSponsors.vue";
import { default as default5 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPFeatures.vue";
import { default as default6 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPHomeContent.vue";
import { default as default7 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPHomeFeatures.vue";
import { default as default8 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPHomeHero.vue";
import { default as default9 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPHomeSponsors.vue";
import { default as default10 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPImage.vue";
import { default as default11 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPLink.vue";
import { default as default12 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPNavBarSearch.vue";
import { default as default13 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPSocialLink.vue";
import { default as default14 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPSocialLinks.vue";
import { default as default15 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPSponsors.vue";
import { default as default16 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPTeamMembers.vue";
import { default as default17 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPTeamPage.vue";
import { default as default18 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageSection.vue";
import { default as default19 } from "D:/zsh-code/seo/demo/GitHub-Projects/cwd-comments/docs/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageTitle.vue";
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
import { onContentUpdated } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
import { getScrollOffset } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/support/utils.js
import { withBase } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/data.js
import { useData as useData$ } from "vitepress";
var useData = useData$;
// node_modules/vitepress/dist/client/theme-default/support/utils.js
function ensureStartingSlash(path) {
return path.startsWith("/") ? path : `/${path}`;
}
// node_modules/vitepress/dist/client/theme-default/support/sidebar.js
function getSidebar(_sidebar, path) {
if (Array.isArray(_sidebar))
return addBase(_sidebar);
if (_sidebar == null)
return [];
path = ensureStartingSlash(path);
const dir = Object.keys(_sidebar).sort((a, b) => {
return b.split("/").length - a.split("/").length;
}).find((dir2) => {
return path.startsWith(ensureStartingSlash(dir2));
});
const sidebar = dir ? _sidebar[dir] : [];
return Array.isArray(sidebar) ? addBase(sidebar) : addBase(sidebar.items, sidebar.base);
}
function getSidebarGroups(sidebar) {
const groups = [];
let lastGroupIndex = 0;
for (const index in sidebar) {
const item = sidebar[index];
if (item.items) {
lastGroupIndex = groups.push(item);
continue;
}
if (!groups[lastGroupIndex]) {
groups.push({ items: [] });
}
groups[lastGroupIndex].items.push(item);
}
return groups;
}
function addBase(items, _base) {
return [...items].map((_item) => {
const item = { ..._item };
const base = item.base || _base;
if (base && item.link)
item.link = base + item.link;
if (item.items)
item.items = addBase(item.items, base);
return item;
});
}
// node_modules/vitepress/dist/client/theme-default/composables/sidebar.js
function useSidebar() {
const { frontmatter, page, theme: theme2 } = useData();
const is960 = useMediaQuery("(min-width: 960px)");
const isOpen = ref(false);
const _sidebar = computed(() => {
const sidebarConfig = theme2.value.sidebar;
const relativePath = page.value.relativePath;
return sidebarConfig ? getSidebar(sidebarConfig, relativePath) : [];
});
const sidebar = ref(_sidebar.value);
watch(_sidebar, (next, prev) => {
if (JSON.stringify(next) !== JSON.stringify(prev))
sidebar.value = _sidebar.value;
});
const hasSidebar = computed(() => {
return frontmatter.value.sidebar !== false && sidebar.value.length > 0 && frontmatter.value.layout !== "home";
});
const leftAside = computed(() => {
if (hasAside)
return frontmatter.value.aside == null ? theme2.value.aside === "left" : frontmatter.value.aside === "left";
return false;
});
const hasAside = computed(() => {
if (frontmatter.value.layout === "home")
return false;
if (frontmatter.value.aside != null)
return !!frontmatter.value.aside;
return theme2.value.aside !== false;
});
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value);
const sidebarGroups = computed(() => {
return hasSidebar.value ? getSidebarGroups(sidebar.value) : [];
});
function open() {
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
function toggle() {
isOpen.value ? close() : open();
}
return {
isOpen,
sidebar,
sidebarGroups,
hasSidebar,
hasAside,
leftAside,
isSidebarEnabled,
open,
close,
toggle
};
}
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
var ignoreRE = /\b(?:VPBadge|header-anchor|footnote-ref|ignore-header)\b/;
var resolvedHeaders = [];
function getHeaders(range) {
const headers = [
...document.querySelectorAll(".VPDoc :where(h1,h2,h3,h4,h5,h6)")
].filter((el) => el.id && el.hasChildNodes()).map((el) => {
const level = Number(el.tagName[1]);
return {
element: el,
title: serializeHeader(el),
link: "#" + el.id,
level
};
});
return resolveHeaders(headers, range);
}
function serializeHeader(h) {
let ret = "";
for (const node of h.childNodes) {
if (node.nodeType === 1) {
if (ignoreRE.test(node.className))
continue;
ret += node.textContent;
} else if (node.nodeType === 3) {
ret += node.textContent;
}
}
return ret.trim();
}
function resolveHeaders(headers, range) {
if (range === false) {
return [];
}
const levelsRange = (typeof range === "object" && !Array.isArray(range) ? range.level : range) || 2;
const [high, low] = typeof levelsRange === "number" ? [levelsRange, levelsRange] : levelsRange === "deep" ? [2, 6] : levelsRange;
return buildTree(headers, high, low);
}
function buildTree(data, min, max) {
resolvedHeaders.length = 0;
const result = [];
const stack = [];
data.forEach((item) => {
const node = { ...item, children: [] };
let parent = stack[stack.length - 1];
while (parent && parent.level >= node.level) {
stack.pop();
parent = stack[stack.length - 1];
}
if (node.element.classList.contains("ignore-header") || parent && "shouldIgnore" in parent) {
stack.push({ level: node.level, shouldIgnore: true });
return;
}
if (node.level > max || node.level < min)
return;
resolvedHeaders.push({ element: node.element, link: node.link });
if (parent)
parent.children.push(node);
else
result.push(node);
stack.push(node);
});
return result;
}
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
function useLocalNav() {
const { theme: theme2, frontmatter } = useData();
const headers = shallowRef([]);
const hasLocalNav = computed(() => {
return headers.value.length > 0;
});
onContentUpdated(() => {
headers.value = getHeaders(frontmatter.value.outline ?? theme2.value.outline);
});
return {
headers,
hasLocalNav
};
}
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
var theme = {
Layout,
enhanceApp: ({ app }) => {
app.component("Badge", VPBadge);
}
};
var without_fonts_default = theme;
export {
default2 as VPBadge,
default3 as VPButton,
default4 as VPDocAsideSponsors,
default5 as VPFeatures,
default6 as VPHomeContent,
default7 as VPHomeFeatures,
default8 as VPHomeHero,
default9 as VPHomeSponsors,
default10 as VPImage,
default11 as VPLink,
default12 as VPNavBarSearch,
default13 as VPSocialLink,
default14 as VPSocialLinks,
default15 as VPSponsors,
default16 as VPTeamMembers,
default17 as VPTeamPage,
default18 as VPTeamPageSection,
default19 as VPTeamPageTitle,
without_fonts_default as default,
useLocalNav,
useSidebar
};
//# sourceMappingURL=@theme_index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,40 @@
{
"hash": "5f34213c",
"configHash": "7b2d39cb",
"lockfileHash": "7a0ef686",
"browserHash": "6fbd3ab3",
"optimized": {
"vue": {
"src": "../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "b6766662",
"needsInterop": false
},
"vitepress > @vue/devtools-api": {
"src": "../../../node_modules/@vue/devtools-api/dist/index.js",
"file": "vitepress___@vue_devtools-api.js",
"fileHash": "840fef28",
"needsInterop": false
},
"vitepress > @vueuse/core": {
"src": "../../../node_modules/@vueuse/core/index.mjs",
"file": "vitepress___@vueuse_core.js",
"fileHash": "35da9dd7",
"needsInterop": false
},
"@theme/index": {
"src": "../../../node_modules/vitepress/dist/client/theme-default/index.js",
"file": "@theme_index.js",
"fileHash": "ec77e1de",
"needsInterop": false
}
},
"chunks": {
"chunk-LJKO4TMH": {
"file": "chunk-LJKO4TMH.js"
},
"chunk-QE257C5J": {
"file": "chunk-QE257C5J.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,583 @@
import {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
} from "./chunk-LJKO4TMH.js";
import "./chunk-QE257C5J.js";
export {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
computedAsync as asyncComputed,
refAutoReset as autoResetRef,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
computedWithControl as controlledComputed,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
reactify as createReactiveFn,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
refDebounced as debouncedRef,
watchDebounced as debouncedWatch,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
computedEager as eagerComputed,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
watchIgnorable as ignorableWatch,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
watchPausable as pausableWatch,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
refThrottled as throttledRef,
watchThrottled as throttledWatch,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
refDebounced as useDebounce,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
refThrottled as useThrottle,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
};
//# sourceMappingURL=vitepress___@vueuse_core.js.map

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

347
docs/.vitepress/cache/deps/vue.js vendored Normal file
View File

@@ -0,0 +1,347 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-QE257C5J.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

7
docs/.vitepress/cache/deps/vue.js.map vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,347 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-QE257C5J.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'vitepress'
export default defineConfig({
title: 'CWD 评论系统',
description: '基于 Cloudflare Workers 的轻量级评论系统文档',
lang: 'zh-CN',
themeConfig: {
nav: [
{ text: '首页', link: '/' },
{ text: '配置', link: '/guide/getting-started' },
{ text: 'API', link: '/api/overview' }
],
sidebar: [
{
text: '配置',
items: [
{ text: '快速开始', link: '/guide/getting-started' },
{ text: '后端配置', link: '/guide/backend-config' },
{ text: '前端配置', link: '/guide/frontend-config' },
]
},
{
text: 'API 文档',
items: [
{ text: '概览', link: '/api/overview' },
{ text: '公开 API', link: '/api/public' },
{ text: '管理员 API', link: '/api/admin' }
]
}
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/anghunk/cwd-comments' }
],
footer: {
message: '基于 Cloudflare Workers 构建',
copyright: 'Copyright © 2026'
}
}
})

150
docs/api/admin.md Normal file
View File

@@ -0,0 +1,150 @@
# 管理员 API
所有管理员 API 都需要在请求头中携带 Bearer Token
```http
Authorization: Bearer <token>
```
## 管理员登录
获取管理员 Token。
### 请求
```http
POST /admin/login
Content-Type: application/json
```
### 请求体
```json
{
"username": "admin",
"password": "your_password"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 604800
}
}
```
## 获取评论管理列表
获取所有评论列表(包括待审核的评论)。
### 请求
```http
GET /admin/comments/list?page={page}&pageSize={pageSize}&status={status}
Authorization: Bearer <token>
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| -------- | ------ | ---- | ----------------------------------- |
| page | number | 否 | 页码,默认 1 |
| pageSize | number | 否 | 每页数量,默认 20 |
| status | string | 否 | 状态筛选pending/approved/rejected |
### 响应示例
```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,
"page": 1,
"pageSize": 20
}
}
```
## 更新评论状态
审核评论,更新评论状态。
### 请求
```http
PUT /admin/comments/status
Authorization: Bearer <token>
Content-Type: application/json
```
### 请求体
```json
{
"id": 1,
"status": "approved"
}
```
### 参数说明
| 参数 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ---------------------------------------- |
| id | number | 是 | 评论 ID |
| status | string | 是 | 状态approved通过/ rejected拒绝 |
### 响应示例
```json
{
"success": true,
"data": {
"message": "评论状态已更新"
}
}
```
## 删除评论
删除指定评论。
### 请求
```http
DELETE /admin/comments/delete?id={id}
Authorization: Bearer <token>
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | number | 是 | 评论 ID |
### 响应示例
```json
{
"success": true,
"data": {
"message": "评论已删除"
}
}
```

48
docs/api/overview.md Normal file
View File

@@ -0,0 +1,48 @@
# API 概览
## 基础信息
- **Base URL**: `https://your-worker.workers.dev`
- **数据格式**: JSON
- **字符编码**: UTF-8
## 认证方式
管理员 API 需要使用 Bearer Token 认证:
```http
Authorization: Bearer <token>
```
Token 通过登录接口获取,有效期为 24 小时。
## 响应格式
### 成功响应
```json
{
"success": true,
"data": { ... }
}
```
### 错误响应
```json
{
"success": false,
"error": "错误信息"
}
```
## HTTP 状态码
| 状态码 | 说明 |
| ------ | ------------ |
| 200 | 请求成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 403 | 禁止访问 |
| 404 | 资源不存在 |
| 500 | 服务器错误 |

90
docs/api/public.md Normal file
View File

@@ -0,0 +1,90 @@
# 公开 API
## 获取评论列表
获取指定页面的评论列表。
### 请求
```http
GET /api/comments?path={path}&page={page}&pageSize={pageSize}
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| -------- | ------ | ---- | ----------------- |
| path | string | 是 | 页面路径 |
| page | number | 否 | 页码,默认 1 |
| pageSize | number | 否 | 每页数量,默认 10 |
### 响应示例
```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,
"page": 1,
"pageSize": 10
}
}
```
## 提交评论
提交新评论。
### 请求
```http
POST /api/comments
Content-Type: application/json
```
### 请求体
```json
{
"path": "/blog/hello-world",
"author": "张三",
"email": "zhangsan@example.com",
"content": "很棒的文章!",
"parent_id": null
}
```
### 参数说明
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ----------------------- |
| path | string | 是 | 页面路径 |
| author | string | 是 | 评论者昵称 |
| email | string | 是 | 评论者邮箱 |
| content | string | 是 | 评论内容 |
| parent_id | number | 否 | 父评论 ID回复时使用 |
### 响应示例
```json
{
"success": true,
"data": {
"id": 1,
"message": "评论提交成功,等待审核"
}
}
```

View File

@@ -0,0 +1,103 @@
# 后端配置
## 部署条件
* 拥有一个 Cloudflare 账号(使用邮箱即可注册,[官网地址](https://www.cloudflare.com/)
* 拥有一个 Node.js 运行环境,版本 >= 22本地部署需要
* 拥有一个域名并托管在 Cloudflare 上(这个不是必须项,但可以提高国内访问速度,也更方便)
## 部署
### 本地部署
#### 1. 下载代码,安装依赖
可以直接克隆仓库代码
```
npm install
```
#### 2. 配置 Cloudflare Workers
对于 D1 和 KV 配置,有两种方法,第一种是直接使用命令行配置,第二种是使用网页面板创建后填写配置文件,这里推荐使用第一种方法。如果想要使用之前 Cloudflare 上面已经创建的数据库,可以选择自行配置 `wrangler.jsonc` 文件。
下面介绍第一种方法。
* **登录到 Cloudflare**
```bash
npx wrangler login
```
* **创建数据库和数据库表**,如果遇到提示,请按回车继续
```bash
npx wrangler d1 create CWD_DB
npx wrangler d1 execute CWD_DB --remote --file=./schemas/comment.sql
```
运行完成后可以确认一下 `wrangler.jsonc` 中是否有如下配置
```jsonc
"d1_databases": [
{
"binding": "CWD_DB",
"database_name": "CWD_DB",
"database_id": "xxxxxx" // D1 数据库 ID
}
]
```
如果`binding`字段不是`CWD_DB`,请修改为`CWD_DB`
* **创建 KV 存储**,如果遇到提示,按回车继续
```bash
npx wrangler kv namespace create CWD_AUTH_KV
```
运行完成后可以确认一下 `wrangler.jsonc` 中是否有如下配置
```jsonc
"kv_namespaces": [
{
"binding": "CWD_AUTH_KV",
"id": "xxxxxxx" // KV 存储 ID
}
]
```
* **部署上线**
```bash
npm run deploy
```
没有异常报错后可以进入Cloudflare Workers 面板查看是否部署成功,若显示存在一个名称为 `cwd-backend-worker` 的项目即推送成功。
#### 3. 配置环境变量
* 登录 Worker 面板,点击项目右侧的 Settings (设置) 选项卡,选择`查看设置`
* 点击变量和机密右侧的添加按钮,给项目添加环境变量,环境变量[参考](#环境变量)
* 部署生效:点击底部的 Save and deploy (保存并部署)。
#### 4. 检测部署情况
部署成功后回得到一个域名,即为后端的域名(格式一般为`https://cwd-backend-worker.xxx.workers.dev`。访问该域名,如果显示后端管理页面并可以正常登录则部署成功,将此域名填写到博客的配置文件中即可使用评论功能。
当然也可以使用自定义域名,注意不要使用三级域名,即`*.*.example.com`。
## 环境变量
所需环境变量如下表所示,请参考 [`.dev.vars.example`](.dev.vars.example) 文件
| 变量名 | 描述 |
| ------------------- | -------------------------------------------------------------------------------- |
| `ALLOW_ORIGIN` | 允许跨域请求的域名,用逗号分隔 |
| `RESEND_API_KEY` | Resend API Key用于启用邮件通知服务如**果不需要邮件通知服务,可以不填** |
| `RESEND_FROM_EMAIL` | Resend 发送邮件的邮箱,用于邮件通知服务,**如果不需要邮件通知服务,可以不填** |
| `EMAIL_ADDRESS` | 管理员接收通知邮件的邮箱,用于邮件通知服务,**如果不需要邮件通知服务,可以不填** |
| `ADMIN_NAME` | 管理员登录名称,默认为 admin |
| `ADMIN_PASSWORD` | 管理员登录密码,默认密码为 password |
**注:** [Resend 官网](https://resend.com/)
## 本地测试
如果需要本地测试,环境变量可以使用 `.dev.vars` 文件来设置
```bash
cp .dev.vars.example .dev.vars
# 编辑 .dev.vars 文件
npm run dev
```

View File

@@ -0,0 +1,75 @@
# 前端配置
**这里仅提供一套开箱即用的方案,如果是个人开发者可以根据 API 文档自行编写前端评论组件。**
[接口 API](../api/public.md)
## 初始化
在初始化 `CWDComments` 实例时,可以传入以下配置参数:
```html
<script src="cwd-comments.js"></script>
```
```html
<script>
const comments = new CWDComments({
el: '#comments',
apiBaseUrl: 'https://your-api.example.com',
postSlug: 'my-post',
postTitle: '文章标题',
postUrl: 'https://example.com/my-post',
theme: 'light',
pageSize: 20,
avatarPrefix: 'https://gravatar.com/avatar',
adminEmail: 'admin@example.com',
adminBadge: '博主'
});
comments.mount();
</script>
```
## 参数说明
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| -------------- | ----------------------- | ---- | ----------------------------- | -------------------------- |
| `el` | `string \| HTMLElement` | 是 | - | 挂载元素选择器或 DOM 元素 |
| `apiBaseUrl` | `string` | 是 | - | API 基础地址 |
| `postSlug` | `string` | 是 | - | 文章唯一标识符 |
| `postTitle` | `string` | 否 | - | 文章标题,用于邮件通知 |
| `postUrl` | `string` | 否 | - | 文章 URL用于邮件通知 |
| `theme` | `'light' \| 'dark'` | 否 | `'light'` | 主题模式 |
| `pageSize` | `number` | 否 | `20` | 每页显示评论数 |
| `avatarPrefix` | `string` | 否 | `https://gravatar.com/avatar` | 头像服务前缀 |
| `adminEmail` | `string` | 否 | - | 博主邮箱,用于显示博主标识 |
| `adminBadge` | `string` | 否 | `博主` | 博主标识文字 |
## 头像服务前缀
常用的 Gravatar 镜像服务:
| 服务 | 前缀地址 |
| --------------- | -------------------------------- |
| Gravatar 官方 | `https://gravatar.com/avatar` |
| Cravatar (国内) | `https://cravatar.cn/avatar` |
| 自定义镜像 | `https://your-mirror.com/avatar` |
## 实例方法
| 方法 | 说明 |
| ---------------------- | ------------------------------ |
| `mount()` | 挂载组件到 DOM |
| `unmount()` | 卸载组件 |
| `updateConfig(config)` | 更新配置(支持动态切换主题等) |
| `getConfig()` | 获取当前配置 |
## 使用示例
```javascript
// 动态切换主题
comments.updateConfig({ theme: 'dark' });
// 切换文章
comments.updateConfig({ postSlug: 'another-post' });
```

View File

@@ -0,0 +1,34 @@
# 快速开始
## 简介
CWD 评论系统是一个基于 Cloudflare Workers 的轻量级评论解决方案,使用 Hono 框架构建,数据存储使用 Cloudflare D1SQLite和 KV。
## 特性
- ⚡️ **极速响应**:基于 Cloudflare 全球边缘网络
- 🔒 **安全可靠**内置管理员认证、CORS 保护
- 📧 **邮件通知**:支持 Resend 邮件服务
- 🎨 **易于集成**:提供完整的 REST API
## 前置要求
- Node.js 16+
- Cloudflare 账号
- Wrangler CLI
## 安装
```bash
# 克隆项目
git clone https://github.com/anghunk/cwd-comments
cd cwd-comments
# 安装依赖
npm install
```
## 配置
- [后端配置](./backend-config.md)
- [前端配置](./frontend-config.md)

28
docs/index.md Normal file
View File

@@ -0,0 +1,28 @@
---
layout: home
hero:
name: "CWD 评论系统"
tagline: 轻量级、高性能的评论解决方案, 基于 Cloudflare Workers + D1 + KV 构建
actions:
- theme: brand
text: 快速开始
link: /guide/getting-started
- theme: alt
text: API 文档
link: /api/overview
features:
- icon: ⚡️
title: 极速响应
details: 基于 Cloudflare 全球边缘网络,毫秒级响应速度
- icon: 🔒
title: 安全可靠
details: 内置管理员认证、CORS 保护、SQL 注入防护
- icon: 📧
title: 邮件通知
details: 支持 Resend 邮件服务,实时通知新评论
- icon: 🎨
title: 易于集成
details: 提供完整的 REST API支持任意前端框架
---

14
docs/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "docs",
"version": "0.0.1",
"description": "CWD 评论系统文档",
"type": "module",
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.6.4"
}
}

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "cwd-comments",
"version": "0.0.1",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"cf-typegen": "wrangler types",
"widget:dev": "cd widget && npm install && npm run dev",
"widget:build": "cd widget && npm run build",
"docs:dev": "cd docs && npm run dev",
"docs:build": "cd docs && npm run build",
"docs:preview": "cd docs && npm run preview"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.19",
"@types/ua-parser-js": "^0.7.39",
"typescript": "^5.5.2",
"vitest": "~3.2.0",
"wrangler": "^4.58.0"
},
"dependencies": {
"hono": "^4.11.3",
"ua-parser-js": "^2.0.7"
}
}

2391
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

24
schemas/comment.sql Normal file
View File

@@ -0,0 +1,24 @@
-- create comment table
CREATE TABLE IF NOT EXISTS Comment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pub_date DATETIME DEFAULT CURRENT_TIMESTAMP,
post_slug TEXT NOT NULL,
author TEXT NOT NULL,
email TEXT NOT NULL,
url TEXT,
ip_address TEXT,
device TEXT,
os TEXT,
browser TEXT,
user_agent TEXT,
content_text TEXT NOT NULL,
content_html TEXT NOT NULL,
parent_id INTEGER,
status TEXT DEFAULT 'approved',
-- 建立自引用外键约束(父子评论关系)
FOREIGN KEY (parent_id) REFERENCES Comment (id) ON DELETE SET NULL
);
-- 可选:为常用查询字段创建索引以提高性能
CREATE INDEX IF NOT EXISTS idx_post_slug ON Comment(post_slug);
CREATE INDEX IF NOT EXISTS idx_status ON Comment(status);

View File

@@ -0,0 +1,23 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const deleteComment = async (c: Context<{ Bindings: Bindings }>) => {
const id = c.req.query('id');
if (!id) {
return c.json({ message: "Missing id" }, 400);
}
// 从数据库中直接删除评论
const { success } = await c.env.CWD_DB.prepare(
"DELETE FROM Comment WHERE id = ?"
).bind(id).run();
if (!success) {
return c.json({ message: "Delete operation failed" }, 500);
}
return c.json({
message: `Comment deleted, id: ${id}.`
});
};

View File

@@ -0,0 +1,41 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const listComments = async (c: Context<{ Bindings: Bindings }>) => {
const page = parseInt(c.req.query('page') || '1');
const limit = 10;
const offset = (page - 1) * limit;
// 1. 获取总数
const totalCount = await c.env.CWD_DB.prepare(
"SELECT COUNT(*) as count FROM Comment"
).first<{ count: number }>();
// 2. 分页查询数据
const { results } = await c.env.CWD_DB.prepare(
`SELECT * FROM Comment ORDER BY pub_date DESC LIMIT ? OFFSET ?`
).bind(limit, offset).all();
// 3. 映射字段名以符合你的 API 规范
const data = results.map((row: any) => ({
id: row.id,
pubDate: row.pub_date,
author: row.author,
email: row.email,
postSlug: row.post_slug,
url: row.url,
ipAddress: row.ip_address,
contentText: row.content_text,
contentHtml: row.content_html,
status: row.status
}));
return c.json({
data,
pagination: {
page,
limit,
total: Math.ceil((totalCount?. count || 0) / limit)
}
});
};

62
src/api/admin/login.ts Normal file
View File

@@ -0,0 +1,62 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
// 简单配置:允许尝试 5 次,锁定 30 分钟
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 30 * 60; // 秒
export const adminLogin = async (c: Context<{ Bindings: Bindings }>) => {
const data = await c.req.json();
const ip = c.req.header('cf-connecting-ip') || '127.0.0.1';
const blockKey = `block:${ip}`;
const attemptKey = `attempts:${ip}`;
// 1. 检查 IP 是否被封禁
const isBlocked = await c.env.CWD_AUTH_KV.get(blockKey);
if (isBlocked) {
return c.json({ message: 'IP is blocked due to multiple failed login attempts' }, 403);
}
// 2. 验证用户名密码
const ADMIN_NAME = c.env.ADMIN_NAME || 'Admin';
const ADMIN_PASSWORD = c.env.ADMIN_PASSWORD || 'password';
const isValid = data.name === ADMIN_NAME && data.password === ADMIN_PASSWORD;
if (!isValid) {
// --- 登录失败逻辑 ---
// 获取当前失败次数
const attempts = parseInt((await c.env.CWD_AUTH_KV.get(attemptKey)) || '0') + 1;
if (attempts >= MAX_ATTEMPTS) {
// 达到上限,封禁 30 分钟
await c.env.CWD_AUTH_KV.put(blockKey, '1', { expirationTtl: LOCK_TIME });
await c.env.CWD_AUTH_KV.delete(attemptKey); // 清除尝试计数
return c.json({ message: 'IP is blocked due to multiple failed login attempts' }, 403);
} else {
// 记录失败次数,设置 10 分钟内连续失败才计数
await c.env.CWD_AUTH_KV.put(attemptKey, attempts.toString(), { expirationTtl: 600 });
return c.json({ message: 'Invalid username or password', failedAttempts: attempts }, 401);
}
}
// --- 3. 登录成功逻辑 ---
await c.env.CWD_AUTH_KV.delete(attemptKey);
// 生成 Token (你的 tempKey)
const tempKey = crypto.randomUUID();
// 将 Token 存入 KV有效期 24 小时86400秒
await c.env.CWD_AUTH_KV.put(
`token:${tempKey}`,
JSON.stringify({
user: data.name,
ip: ip,
}),
{ expirationTtl: 86400 }
);
return c.json({
data: { key: tempKey },
});
};

View File

@@ -0,0 +1,23 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const updateStatus = async (c: Context<{ Bindings: Bindings }>) => {
const id = c.req.query('id');
const status = c.req.query('status'); // 按照你规范中 URL 参数的形式
if (!id || !status) {
return c.json({ message: "Missing id or status" }, 400);
}
const { success } = await c.env.CWD_DB.prepare(
"UPDATE Comment SET status = ? WHERE id = ?"
).bind(status, id).run();
if (!success) {
return c.json({ message: "Update failed" }, 500);
}
return c.json({
message: `Comment status updated, id: ${id}, status: ${status}.`
});
};

View File

@@ -0,0 +1,108 @@
import { Context } from 'hono'
import { Bindings } from '../../bindings'
import { getCravatar } from '../../utils/getAvatar'
export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
const post_slug = c.req.query('post_slug')
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'
const avatar_prefix = c.req.query('avatar_prefix')
const offset = (page - 1) * limit
if (!post_slug) return c.json({ message: "post_slug is required" }, 400)
try {
// 1. 查询审核通过的评论
const query = `
SELECT id, author, email, url, content_text as contentText,
content_html as contentHtml, pub_date as pubDate, parent_id as parentId,
post_slug as postSlug
FROM Comment
WHERE post_slug = ? AND status = "approved"
ORDER BY pub_date DESC
`
const { results } = await c.env.CWD_DB.prepare(query).bind(post_slug).all()
// 2. 批量处理头像并格式化
const allComments = await Promise.all(results.map(async (row: any) => ({
...row,
avatar: await getCravatar(row.email, avatar_prefix || undefined),
replies: []
})))
// 3. 处理嵌套逻辑扁平化2级往后的回复都放在根评论的 replies 中)
if (nested) {
const commentMap = new Map()
const rootComments: any[] = []
// 建立评论映射
allComments.forEach(comment => commentMap.set(comment.id, comment))
// 找出所有根评论
allComments.forEach(comment => {
if (!comment.parentId) {
rootComments.push(comment)
}
})
// 为每个非根评论找到其根评论,并添加 replyToAuthor 字段
allComments.forEach(comment => {
if (comment.parentId) {
// 获取直接父评论的作者名
const parentComment = commentMap.get(comment.parentId)
if (parentComment) {
comment.replyToAuthor = parentComment.author
}
// 向上查找根评论
let rootId = comment.parentId
let current = commentMap.get(rootId)
while (current && current.parentId) {
rootId = current.parentId
current = commentMap.get(rootId)
}
// 将回复添加到根评论的 replies 中
const rootComment = commentMap.get(rootId)
if (rootComment && !rootComment.parentId) {
rootComment.replies.push(comment)
}
}
})
// 对每个根评论的 replies 按时间正序排列
rootComments.forEach(root => {
root.replies.sort((a: any, b: any) =>
new Date(a.pubDate).getTime() - new Date(b.pubDate).getTime()
)
})
// 对根评论进行分页
const paginatedData = rootComments.slice(offset, offset + limit)
return c.json({
data: paginatedData,
pagination: {
page,
limit,
total: Math.ceil(rootComments.length / limit),
totalCount: allComments.length,
}
})
} else {
// 非嵌套逻辑直接分页
const paginatedData = allComments.slice(offset, offset + limit)
return c.json({
data: paginatedData,
pagination: {
page,
limit,
total: Math.ceil(allComments.length / limit),
totalCount: allComments.length,
}
})
}
} catch (e: any) {
return c.json({ message: e.message }, 500)
}
}

View File

@@ -0,0 +1,106 @@
import { Context } from 'hono';
import { UAParser } from 'ua-parser-js';
import { Bindings } from '../../bindings';
import { sendCommentNotification, sendCommentReplyNotification } from '../../utils/email';
// 检查内容,将<script>标签之间的内容删除
export function checkContent(content: string): string {
return content.replace(/<script[\s\S]*?<\/script>/g, "");
}
export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
const data = await c.req.json();
const userAgent = c.req.header('user-agent') || "";
// 1. 获取 IP (Worker 获取 IP 的标准方式)
const ip = c.req.header('cf-connecting-ip') || "127.0.0.1";
// 2. 检查评论频率控制 (对应 canPostComment)
// 这里建议使用 D1 查最近一条评论的时间,或者直接放行(如果使用了 Cloudflare WAF
const lastComment = await c.env.CWD_DB.prepare(
"SELECT pub_date FROM Comment WHERE ip_address = ? ORDER BY pub_date DESC LIMIT 1"
).bind(ip).first<{ pub_date: string }>();
if (lastComment) {
const lastTime = new Date(lastComment.pub_date).getTime();
if (Date.now() - lastTime < 10 * 1000) { // 10秒限流示例
return c.json({ message: "Time limit exceeded. Please wait." }, 429);
}
}
// 3. 准备数据
const content = checkContent(data.content);
const author = checkContent(data.author);
const uaParser = new UAParser(userAgent);
const uaResult = uaParser.getResult();
// 4. 写入 D1 数据库
try {
const { success } = await c.env.CWD_DB.prepare(`
INSERT INTO Comment (
pub_date, post_slug, author, email, url, ip_address,
os, browser, device, user_agent, content_text, content_html,
parent_id, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
new Date().toISOString(),
data.post_slug,
author,
data.email,
data.url || null,
ip,
`${uaResult.os.name || ""} ${uaResult.os.version || ""}`.trim(),
`${uaResult.browser.name || ""} ${uaResult.browser.version || ""}`.trim(),
uaResult.device.model || uaResult.device.type || "Desktop",
userAgent,
content,
content, // content_html 保持一致
data.parent_id || null,
"approved" // 或者从环境变量读取默认状态
).run();
if (!success) throw new Error("Database insert failed");
// 5. 发送邮件通知 (后台异步执行,不阻塞用户响应)
if (c.env.RESEND_API_KEY) {
c.executionCtx.waitUntil((async () => {
try {
if (data.parent_id) {
// 回复逻辑:查询父评论信息
const parentComment = await c.env.CWD_DB.prepare(
"SELECT author, email, content_text FROM Comment WHERE id = ?"
).bind(data.parent_id).first<{ author: string, email: string, content_text: string }>();
if (parentComment && parentComment.email !== data.email) {
await sendCommentReplyNotification(c.env, {
toEmail: parentComment.email,
toName: parentComment.author,
postTitle: data.post_title,
parentComment: parentComment.content_text,
replyAuthor: author,
replyContent: content,
postUrl: data.post_url,
});
}
} else {
// 新评论通知站长
await sendCommentNotification(c.env, {
postTitle: data.post_title,
postUrl: data.post_url,
commentAuthor: author,
commentContent: content
});
}
} catch (mailError) {
console.error("Mail Notification Failed:", mailError);
}
})());
}
return c.json({ message: "Comment submitted. Awaiting moderation." });
} catch (e: any) {
console.error("Create Comment Error:", e);
return c.json({ message: "Internal Server Error" }, 500);
}
};

10
src/bindings.ts Normal file
View File

@@ -0,0 +1,10 @@
export type Bindings = {
CWD_DB: D1Database
CWD_AUTH_KV: KVNamespace;
ALLOW_ORIGIN: string
RESEND_API_KEY?: string
RESEND_FROM_EMAIL?: string
EMAIL_ADDRESS?: string
ADMIN_NAME: string
ADMIN_PASSWORD: string
}

44
src/index.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { Bindings } from './bindings'
import { LoginView } from './views/login'
import { AdminView } from './views/admin'
import { customCors } from './utils/cors'
import { adminAuth } from './utils/auth'
import { getComments } from './api/public/getComments'
import { postComment } from './api/public/postComment'
import { adminLogin } from './api/admin/login'
import { deleteComment } from './api/admin/deleteComment'
import { listComments } from './api/admin/listComments'
import { updateStatus } from './api/admin/updateStatus'
const app = new Hono<{ Bindings: Bindings }>()
// 跨域
app.use('/api/*', async (c, next) => {
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN)
return corsMiddleware(c, next)
})
app.use('/admin/*', async (c, next) => {
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN)
return corsMiddleware(c, next)
})
// 页面路由
app.get('/', (c) => c.redirect('/login'))
app.get('/login', (c) => c.html(LoginView))
app.get('/admin', (c) => c.html(AdminView))
// API
app.get('/api/comments', getComments)
app.post('/api/comments', postComment)
app.post('/admin/login', adminLogin)
app.use('/admin/*', adminAuth)
app.delete('/admin/comments/delete', deleteComment);
app.get('/admin/comments/list', listComments);
app.put('/admin/comments/status', updateStatus);
export default app

23
src/utils/auth.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Context, Next } from 'hono';
import { Bindings } from '../bindings';
export const adminAuth = async (c: Context<{ Bindings: Bindings }>, next: Next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
if (!token) return c.json({ message: "Unauthorized" }, 401);
const sessionData = await c.env.CWD_AUTH_KV.get(`token:${token}`);
if (!sessionData) {
return c.json({ message: "Token expired or invalid" }, 401);
}
const session = JSON.parse(sessionData);
const currentIp = c.req.header('cf-connecting-ip');
// 安全检查:如果 IP 发生变化(比如 Token 被盗),要求重新登录
// if (session.ip !== currentIp) {
// await c.env.CWD_AUTH_KV.delete(`token:${token}`);
// return c.json({ message: "Security alert: IP changed" }, 401);
// }
await next();
};

23
src/utils/cors.ts Normal file
View File

@@ -0,0 +1,23 @@
import { cors } from 'hono/cors'
export const customCors = (allowOriginStr: string | undefined) => {
// 1. 将环境变量字符串解析为数组
// 如果环境变量不存在,则默认为空数组
const allowedOrigins = allowOriginStr
? allowOriginStr.split(',').map(origin => origin.trim())
: []
return cors({
origin: (origin) => {
// 如果请求的 origin 在白名单中,或者是本地文件(null)
if (!origin || allowedOrigins.includes(origin)) {
return origin
}
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length'],
maxAge: 600,
credentials: true,
})
}

107
src/utils/email.ts Normal file
View File

@@ -0,0 +1,107 @@
import { Bindings } from '../bindings';
/**
* 通用 Resend API 请求函数
*/
async function resendFetch(env: Bindings, body: object) {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const status = response.status;
// 对应你提供的状态码逻辑
const errorMap: Record<number, string> = {
400: '参数错误,请检查格式',
401: 'API Key 缺失',
403: 'API Key 无效',
429: '发送频率过快',
};
const msg = errorMap[status] || `Resend 服务器错误 (${status})`;
throw new Error(`${msg}: ${JSON.stringify(errorData)}`);
}
return await response.json();
}
/**
* 回复通知邮件
*/
export async function sendCommentReplyNotification(
env: Bindings,
params: {
toEmail: string;
toName: string;
postTitle: string;
parentComment: string;
replyAuthor: string;
replyContent: string;
postUrl: string;
}
) {
const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params;
return await resendFetch(env, {
from: `评论通知 ${env.RESEND_FROM_EMAIL}`,
to: [toEmail],
subject: `你在 example.com 上的评论有了新回复`,
html: `
<div style="font-family: sans-serif; line-height: 1.6; color: #333;">
<p>Hi <b>${toName}</b></p>
<p>${replyAuthor} 回复了你在 <b>${postTitle}</b> 中的评论:</p>
<blockquote style="margin: 10px 0; padding: 10px; border-left: 4px solid #e2e8f0; background: #f8fafc;">
${parentComment}
</blockquote>
<p>最新回复:</p>
<blockquote style="margin: 10px 0; padding: 10px; border-left: 4px solid #3b82f6; background: #eff6ff;">
${replyContent}
</blockquote>
<p style="margin-top: 20px;">
<a href="${postUrl}" style="background: #3b82f6; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; display: inline-block;">
查看完整回复
</a>
</p>
<hr style="border: none; border-top: 1px solid #eee; margin-top: 30px;">
<p style="font-size: 12px; color: #999;">此邮件由系统自动发送,请勿直接回复。</p>
</div>
`
});
}
/**
* 站长通知邮件
*/
export async function sendCommentNotification(
env: Bindings,
params: {
postTitle: string;
postUrl: string;
commentAuthor: string;
commentContent: string;
}
) {
const { postTitle, postUrl, commentAuthor, commentContent } = params;
return await resendFetch(env, {
from: `评论提醒 ${env.RESEND_FROM_EMAIL}`,
to: [env.EMAIL_ADDRESS],
subject: `新评论通知:${postTitle}`,
html: `
<div style="font-family: sans-serif;">
<p><b>${commentAuthor}</b> 在文章《${postTitle}》下发表了评论:</p>
<div style="padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
${commentContent}
</div>
<p><a href="${postUrl}">点击跳转到文章</a></p>
</div>
`
});
}

19
src/utils/getAvatar.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* 默认 gravatar.com 前缀
*/
const DEFAULT_AVATAR_PREFIX = 'https://gravatar.com/avatar';
/**
* 辅助函数:生成 gravatar.com 头像地址 (MD5 算法)
* @param email - 邮箱地址
* @param prefix - 头像服务前缀,默认为 https://gravatar.com/avatar
*/
export const getCravatar = async (email: string, prefix?: string): Promise<string> => {
const cleanEmail = email.trim().toLowerCase();
const msgUint8 = new TextEncoder().encode(cleanEmail);
const hashBuffer = await crypto.subtle.digest('MD5', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
const avatarPrefix = prefix || DEFAULT_AVATAR_PREFIX;
return `${avatarPrefix}/${hashHex}?s=200&d=retro`;
};

314
src/views/admin.ts Normal file
View File

@@ -0,0 +1,314 @@
import { html } from "hono/html";
export const AdminView = html`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CWD 评论后台管理系统</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.loader { border-top-color: #3498db; animation: spinner 1.5s linear infinite; }
@keyframes spinner { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body class="bg-gray-100 text-gray-800">
<div id="app" class="min-h-screen flex flex-col">
<!-- Toast -->
<transition name="fade">
<div v-if="toast.show" :class="toast.type === 'error' ? 'bg-red-500' : 'bg-green-500'"
class="fixed top-4 right-4 text-white px-6 py-3 rounded shadow-lg z-50 flex items-center">
<i :class="toast.type === 'error' ? 'fa-circle-exclamation' : 'fa-check-circle'" class="fa-solid mr-2"></i>
{{ toast.message }}
</div>
</transition>
<!-- 设置弹窗 -->
<transition name="fade">
<div v-if="showSettings" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-40" @click.self="showSettings = false">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-gray-700">设置</h3>
<button @click="showSettings = false" class="text-gray-400 hover:text-gray-600">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">博客域名前缀</label>
<input v-model="config.blogDomain" type="text" placeholder="例如: https://example.com"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">设置后,文章链接将自动拼接此前缀</p>
</div>
<div class="flex justify-end">
<button @click="saveSettings" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
保存
</button>
</div>
</div>
</div>
</transition>
<!-- 导航栏 -->
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<span class="text-xl font-bold text-blue-600">CWD 评论管理系统</span>
<span class="ml-4 text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded">{{ config.baseUrl }}</span>
</div>
<div class="flex items-center">
<button @click="fetchComments(pagination.page)" class="mr-4 text-gray-600 hover:text-blue-600">
<i class="fa-solid fa-rotate-right"></i> 刷新
</button>
<button @click="showSettings = true" class="mr-4 text-gray-600 hover:text-blue-600">
<i class="fa-solid fa-gear"></i> 设置
</button>
<button @click="logout" class="text-red-500 hover:text-red-700 font-medium">
<i class="fa-solid fa-sign-out-alt"></i> 退出
</button>
</div>
</div>
</div>
</nav>
<!-- 主内容 -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div v-if="loading && comments.length === 0" class="flex justify-center items-center h-64">
<div class="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div>
</div>
<div v-else class="bg-white shadow overflow-hidden sm:rounded-lg">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">信息</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/2">内容</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-if="comments.length === 0">
<td colspan="4" class="px-6 py-10 text-center text-gray-500">暂无评论数据</td>
</tr>
<tr v-for="comment in comments" :key="comment.id" class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div>
<div>
<span class="text-sm font-medium text-gray-900" v-text="comment.author"></span>
<span class="text-sm text-gray-500"> (<em class="text-xs" v-text="comment.email"></em>)</span>
</div>
<a class="text-xs text-blue-500 hover:underline" :href="comment.url">{{ comment.url }}</a>
<div class="text-xs text-gray-400 mt-1">IP: <em v-text="comment.ipAddress"></em></div>
<div class="text-xs text-gray-400">{{ formatDate(comment.pubDate) }}</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900 break-words whitespace-pre-wrap max-h-32 overflow-y-auto" v-text="comment.contentText"></div>
<a v-if="comment.postSlug" :href="config.blogDomain + comment.postSlug" target="_blank" class="text-xs text-blue-500 hover:underline mt-1 inline-block">
{{ config.blogDomain + comment.postSlug }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': comment.status === 'approved',
'bg-yellow-100 text-yellow-800': comment.status === 'pending',
'bg-red-100 text-red-800': ['spam', 'rejected', 'deleted'].includes(comment.status)
}">
{{ comment.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button v-if="comment.status !== 'approved'" @click="updateStatus(comment.id, 'approved')" class="text-green-600 hover:text-green-900 mr-3">
<i class="fa-solid fa-check mr-1"></i>显示
</button>
<button v-if="comment.status === 'approved'" @click="updateStatus(comment.id, 'pending')" class="text-yellow-600 hover:text-yellow-900 mr-3">
<i class="fa-solid fa-ban mr-1"></i>隐藏
</button>
<button @click="confirmDelete(comment.id)" class="text-red-600 hover:text-red-900">
<i class="fa-solid fa-trash mr-1"></i>删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
第 <span class="font-medium">{{ pagination.page }}</span> 页,
共 <span class="font-medium">{{ pagination.total }}</span> 页
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button @click="changePage(pagination.page - 1)" :disabled="pagination.page === 1"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
上一页
</button>
<button @click="changePage(pagination.page + 1)" :disabled="pagination.page >= pagination.total"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
下一页
</button>
</nav>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
const { createApp, reactive, toRefs, onMounted } = Vue;
createApp({
setup() {
const state = reactive({
loading: false,
apiKey: '',
config: {
baseUrl: localStorage.getItem('apiBaseUrl') || location.origin,
blogDomain: localStorage.getItem('blogDomain') || ''
},
comments: [],
pagination: { page: 1, limit: 20, total: 1 },
toast: { show: false, message: '', type: 'success' },
showSettings: false
});
const showToast = (msg, type = 'success') => {
state.toast.message = msg;
state.toast.type = type;
state.toast.show = true;
setTimeout(() => state.toast.show = false, 3000);
};
const formatDate = (dateString) => {
if (!dateString) return '';
return new Date(dateString).toLocaleString('zh-CN', { hour12: false });
};
const fetchComments = async (page = 1) => {
state.loading = true;
try {
const url = \`\${state.config.baseUrl}/admin/comments/list?page=\${page}\`;
const response = await fetch(url, {
headers: { 'Authorization': state.apiKey }
});
const result = await response.json();
if (result.message === "Invalid key" || result.status === 401) {
logout();
return;
}
state.comments = result.data || [];
state.pagination = result.pagination || { page, limit: 10, total: 1 };
showToast('刷新成功');
} catch (error) {
console.error(error);
showToast('获取数据失败', 'error');
} finally {
state.loading = false;
}
};
const updateStatus = async (id, newStatus) => {
state.loading = true;
try {
const url = \`\${state.config.baseUrl}/admin/comments/status?id=\${id}&status=\${newStatus}\`;
const response = await fetch(url, {
method: 'PUT',
headers: { 'Authorization': state.apiKey }
});
if (response.ok) {
const comment = state.comments.find(c => c.id === id);
if (comment) comment.status = newStatus;
showToast('状态更新成功');
} else {
showToast('更新失败', 'error');
}
} catch (error) {
showToast('请求失败', 'error');
} finally {
state.loading = false;
}
};
const confirmDelete = async (id) => {
if (!confirm('确定删除?')) return;
state.loading = true;
try {
const url = \`\${state.config.baseUrl}/admin/comments/delete?id=\${id}\`;
const response = await fetch(url, {
method: 'DELETE',
headers: { 'Authorization': state.apiKey }
});
if (response.ok) {
showToast('删除成功');
fetchComments(state.pagination.page);
} else {
showToast('删除失败', 'error');
}
} catch (error) {
showToast('请求失败', 'error');
} finally {
state.loading = false;
}
};
const changePage = (page) => {
if (page > 0) fetchComments(page);
};
const logout = () => {
localStorage.removeItem('adminKey');
window.location.href = '/login';
};
const saveSettings = () => {
localStorage.setItem('blogDomain', state.config.blogDomain);
state.showSettings = false;
showToast('设置已保存');
};
onMounted(() => {
const storedKey = localStorage.getItem('adminKey');
if (!storedKey) {
window.location.href = '/login';
return;
}
state.apiKey = storedKey;
fetchComments();
});
return {
...toRefs(state),
fetchComments,
updateStatus,
confirmDelete,
changePage,
logout,
saveSettings,
formatDate
};
}
}).mount('#app');
</script>
</body>
</html>
`;

100
src/views/login.ts Normal file
View File

@@ -0,0 +1,100 @@
import { html } from "hono/html";
export const LoginView = html`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - CWD 评论后台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
.loader { border-top-color: #3498db; animation: spinner 1.5s linear infinite; }
@keyframes spinner { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body class="bg-gray-100 text-gray-800">
<div id="app" class="min-h-screen flex items-center justify-center px-4">
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h2 class="text-2xl font-bold mb-6 text-center text-gray-700">管理员登录</h2>
<form @submit.prevent="handleLogin">
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">接口地址</label>
<input v-model="config.baseUrl" type="text" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">用户名</label>
<input v-model="loginForm.name" type="text" required class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2">密码</label>
<input v-model="loginForm.password" type="password" required class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button :disabled="loading" type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none transition duration-300 flex justify-center items-center">
<span v-if="loading" class="loader ease-linear rounded-full border-2 border-t-2 border-gray-200 h-5 w-5 mr-2"></span>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<p v-if="error" class="mt-4 text-red-500 text-sm text-center">{{ error }}</p>
</div>
</div>
<script>
const { createApp, reactive, toRefs, onMounted } = Vue;
createApp({
setup() {
const state = reactive({
loading: false,
error: '',
config: {
baseUrl: localStorage.getItem('apiBaseUrl') || location.origin
},
loginForm: { name: '', password: '' }
});
const handleLogin = async () => {
state.loading = true;
state.error = '';
localStorage.setItem('apiBaseUrl', state.config.baseUrl);
try {
const response = await fetch(\`\${state.config.baseUrl}/admin/login\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.loginForm)
});
const resData = await response.json();
const key = resData.key || (resData.data && resData.data.key);
if (key) {
localStorage.setItem('adminKey', key);
window.location.href = '/admin';
} else {
state.error = resData.message || '登录失败';
}
} catch (error) {
state.error = '网络错误';
} finally {
state.loading = false;
}
};
onMounted(() => {
const storedKey = localStorage.getItem('adminKey');
if (storedKey) {
window.location.href = '/admin';
}
});
return { ...toRefs(state), handleLogin };
}
}).mount('#app');
</script>
</body>
</html>
`;

45
tsconfig.json Normal file
View File

@@ -0,0 +1,45 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "es2024",
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
"lib": ["es2024"],
/* Specify what JSX code is generated. */
"jsx": "react-jsx",
/* Specify what module code is generated. */
"module": "es2022",
/* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "Bundler",
/* Enable importing .json files */
"resolveJsonModule": true,
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
"allowJs": true,
/* Enable error reporting in type-checked JavaScript files. */
"checkJs": false,
/* Disable emitting files from a compilation. */
"noEmit": true,
/* Ensure that each file can be safely transpiled without relying on other imports. */
"isolatedModules": true,
/* Allow 'import x from y' when a module doesn't have a default export. */
"allowSyntheticDefaultImports": true,
/* Ensure that casing is correct in imports. */
"forceConsistentCasingInFileNames": true,
/* Enable all strict type-checking options. */
"strict": true,
/* Skip type checking all .d.ts files. */
"skipLibCheck": true,
"types": [
"./worker-configuration.d.ts"
]
},
"exclude": ["test"],
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
}

11
vitest.config.mts Normal file
View File

@@ -0,0 +1,11 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
},
},
},
});

78
widget/CONFIG.md Normal file
View File

@@ -0,0 +1,78 @@
# CWDComments 配置参数
## 前端 Widget 配置
在初始化 `CWDComments` 实例时,可以传入以下配置参数:
```javascript
const comments = new CWDComments({
el: '#comments',
apiBaseUrl: 'https://your-api.example.com',
postSlug: 'my-post',
postTitle: '文章标题',
postUrl: 'https://example.com/my-post',
theme: 'light',
pageSize: 20,
avatarPrefix: 'https://gravatar.com/avatar',
adminEmail: 'admin@example.com',
adminBadge: '博主'
});
comments.mount();
```
### 参数说明
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| -------------- | ----------------------- | ---- | ----------------------------- | ------------------------- |
| `el` | `string \| HTMLElement` | 是 | - | 挂载元素选择器或 DOM 元素 |
| `apiBaseUrl` | `string` | 是 | - | API 基础地址 |
| `postSlug` | `string` | 是 | - | 文章唯一标识符 |
| `postTitle` | `string` | 否 | - | 文章标题,用于邮件通知 |
| `postUrl` | `string` | 否 | - | 文章 URL用于邮件通知 |
| `theme` | `'light' \| 'dark'` | 否 | `'light'` | 主题模式 |
| `pageSize` | `number` | 否 | `20` | 每页显示评论数 |
| `avatarPrefix` | `string` | 否 | `https://gravatar.com/avatar` | 头像服务前缀 |
| `adminEmail` | `string` | 否 | - | 博主邮箱,用于显示博主标识 |
| `adminBadge` | `string` | 否 | `博主` | 博主标识文字 |
### 头像服务前缀示例
常用的 Gravatar 镜像服务:
| 服务 | 前缀地址 |
| --------------- | -------------------------------- |
| Gravatar 官方 | `https://gravatar.com/avatar` |
| Cravatar (国内) | `https://gravatar.com/avatar` |
| 自定义镜像 | `https://your-mirror.com/avatar` |
## 后端环境变量
在 Cloudflare Workers 中配置以下环境变量:
| 变量名 | 说明 | 必填 |
| ------------------- | ------------------------------- | ---- |
| `ADMIN_NAME` | 管理员用户名 | 是 |
| `ADMIN_PASSWORD` | 管理员密码 | 是 |
| `ALLOW_ORIGIN` | CORS 白名单(逗号分隔多个域名) | 是 |
| `RESEND_API_KEY` | Resend 邮件服务 API 密钥 | 否 |
| `RESEND_FROM_EMAIL` | 发件邮箱地址 | 否 |
| `EMAIL_ADDRESS` | 站长接收通知的邮箱 | 否 |
## 实例方法
| 方法 | 说明 |
| ---------------------- | ------------------------------ |
| `mount()` | 挂载组件到 DOM |
| `unmount()` | 卸载组件 |
| `updateConfig(config)` | 更新配置(支持动态切换主题等) |
| `getConfig()` | 获取当前配置 |
### 示例
```javascript
// 动态切换主题
comments.updateConfig({ theme: 'dark' });
// 切换文章
comments.updateConfig({ postSlug: 'another-post' });
```

18
widget/README.md Normal file
View File

@@ -0,0 +1,18 @@
# cwd 评论前端模板
## 使用方法
```html
<!-- 评论组件容器 -->
<div id="comments"></div>
<script src="dist/cwd-comments.js"></script>
<script>
new CWDComments({
el: '#comments', // 容器 ID
apiBaseUrl: 'https://message.zishu.me', // 部署的后端地址
postSlug: '/message', // 当前页面路径,可使用博客程序支持的 url 模板路径
}).mount();
</script>
```

169
widget/index.html Normal file
View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CWD - 开发预览</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
margin-bottom: 20px;
color: #333;
}
.config-panel {
background: #f8f9fa;
padding: 20px;
border-radius: 6px;
margin-bottom: 30px;
}
.config-panel h3 {
margin-bottom: 15px;
font-size: 16px;
}
.config-item {
margin-bottom: 12px;
}
.config-item label {
display: block;
margin-bottom: 4px;
font-size: 13px;
color: #666;
}
.config-item input,
.config-item select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.config-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary {
background: #0969da;
color: white;
}
.btn-primary:hover {
background: #0864ca;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.divider {
height: 1px;
background: #e0e0e0;
margin: 30px 0;
}
.demo-post {
margin-bottom: 40px;
}
.demo-post h2 {
font-size: 18px;
margin-bottom: 10px;
}
.demo-post p {
color: #666;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<h1>CWD - 开发预览</h1>
<div class="config-panel">
<h3>配置面板</h3>
<div class="config-item">
<label>API 地址</label>
<input type="text" id="apiBaseUrl" value="http://localhost:8788" />
</div>
<div class="config-item">
<label>文章标识符</label>
<input type="text" id="postSlug" value="demo-post" />
</div>
<div class="config-item">
<label>主题</label>
<select id="theme">
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</div>
<div class="config-item">
<label>头像源前缀</label>
<input type="text" id="avatarPrefix" value="https://gravatar.com/avatar" />
</div>
<div class="config-actions">
<button class="btn btn-primary" onclick="initWidget()">应用配置</button>
<button class="btn btn-secondary" onclick="toggleTheme()">切换主题</button>
</div>
</div>
<div class="divider"></div>
<div class="demo-post">
<h2>这是一篇示例文章</h2>
<p>
这是一个演示 CWD Comments Widget 的示例页面。你可以在上方的配置面板中修改 API 地址和文章标识符,
然后点击"应用配置"按钮重新加载评论组件。
</p>
</div>
<div class="divider"></div>
<!-- 评论组件容器 -->
<div id="comments"></div>
</div>
<script type="module" src="/src/dev.js"></script>
</body>
</html>

16
widget/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "cwd-comments-widget",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"terser": "^5.44.1",
"vite": "^6.0.11",
"vite-plugin-css-injected-by-js": "^3.3.1"
}
}

View File

@@ -0,0 +1,262 @@
/**
* CommentForm 评论表单组件
*/
import { Component } from './Component.js';
export class CommentForm extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {Object} props.form - 表单数据
* @param {Object} props.formErrors - 表单错误
* @param {boolean} props.submitting - 是否正在提交
* @param {Function} props.onSubmit - 提交回调
* @param {Function} props.onFieldChange - 字段变化回调
*/
constructor(container, props = {}) {
super(container, props);
this.state = {
localForm: { ...props.form },
};
}
render() {
const { formErrors, submitting } = this.props;
const { localForm } = this.state;
const canSubmit = localForm.author.trim() && localForm.email.trim() && localForm.content.trim();
const root = this.createElement('form', {
className: 'cwd-comment-form',
attributes: {
novalidate: true,
onSubmit: (e) => this.handleSubmit(e),
},
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', localForm.author, formErrors.author, '昵称 *'),
// 邮箱
this.createFormField('邮箱 *', 'email', 'email', localForm.email, formErrors.email, '邮箱 *'),
// 网址
this.createFormField('网址', 'url', 'url', localForm.url, formErrors.url, '网址'),
],
}),
// 评论内容
this.createElement('div', {
className: 'cwd-form-field',
children: [
this.createTextElement('label', '评论内容', 'cwd-form-label'),
this.createElement('textarea', {
className: `cwd-form-textarea ${formErrors.content ? 'cwd-input-error' : ''}`,
attributes: {
placeholder: '写下你的评论...',
rows: 4,
disabled: submitting,
onInput: (e) => this.handleFieldChange('content', e.target.value),
},
}),
...(formErrors.content ? [this.createTextElement('span', formErrors.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: submitting || !canSubmit,
},
text: submitting ? '提交中...' : '提交评论',
}),
],
}),
],
});
// 设置输入框的值
this.setInputValues(root, localForm);
this.elements.root = root;
this.empty(this.container);
this.container.appendChild(root);
}
updateProps(prevProps) {
// 只在非提交状态时同步表单数据(避免覆盖用户正在输入的内容)
if (!this.props.submitting && this.props.form !== prevProps.form) {
// 保留当前正在输入的内容
const currentAuthor = this.state.localForm.author || '';
const currentEmail = this.state.localForm.email || '';
const currentUrl = this.state.localForm.url || '';
const currentContent = this.state.localForm.content || '';
this.state.localForm = {
author: this.props.form.author || currentAuthor,
email: this.props.form.email || currentEmail,
url: this.props.form.url || currentUrl,
content: this.props.form.content !== undefined ? this.props.form.content : currentContent,
};
// 同步更新 DOM 值(不重新渲染)
if (this.elements.root) {
this.setInputValues(this.elements.root, this.state.localForm);
}
}
// 更新提交按钮状态和错误提示
if (this.elements.root) {
this.updateFormState();
}
}
/**
* 更新表单状态(按钮、错误提示等)
*/
updateFormState() {
const { formErrors, submitting } = this.props;
const { localForm } = this.state;
const canSubmit = localForm.author.trim() && localForm.email.trim() && localForm.content.trim();
// 更新提交按钮状态
const submitBtn = this.elements.root.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = submitting || !canSubmit;
submitBtn.textContent = submitting ? '提交中...' : '提交评论';
}
// 更新输入框禁用状态
const inputs = this.elements.root.querySelectorAll('input, textarea');
inputs.forEach((input) => {
input.disabled = submitting;
});
// 更新错误提示
this.updateErrors(formErrors);
}
/**
* 更新错误提示
*/
updateErrors(formErrors) {
if (!this.elements.root) return;
// 昵称错误
const authorInput = this.elements.root.querySelector('input[placeholder*="昵称"]');
this.updateFieldError(authorInput, formErrors?.author);
// 邮箱错误
const emailInput = this.elements.root.querySelector('input[placeholder*="邮箱"]');
this.updateFieldError(emailInput, formErrors?.email);
// 网址错误
const urlInput = this.elements.root.querySelector('input[placeholder*="网址"]');
this.updateFieldError(urlInput, formErrors?.url);
// 内容错误
const contentTextarea = this.elements.root.querySelector('textarea');
this.updateFieldError(contentTextarea, formErrors?.content);
}
/**
* 更新单个字段的错误状态
*/
updateFieldError(element, error) {
if (!element) return;
// 移除或添加错误样式
if (error) {
element.classList.add('cwd-input-error');
} else {
element.classList.remove('cwd-input-error');
}
// 查找并更新/移除错误提示元素
const parent = element.parentElement;
let errorSpan = parent.querySelector('.cwd-error-text');
if (error) {
if (!errorSpan) {
errorSpan = document.createElement('span');
errorSpan.className = 'cwd-error-text';
parent.appendChild(errorSpan);
}
errorSpan.textContent = error;
} else if (errorSpan) {
errorSpan.remove();
}
}
/**
* 创建表单字段
*/
createFormField(label, type, fieldName, value, error, placeholder = '') {
return this.createElement('div', {
className: 'cwd-form-field',
children: [
this.createTextElement('label', label, 'cwd-form-label'),
this.createElement('input', {
className: `cwd-form-input ${error ? 'cwd-input-error' : ''}`,
attributes: {
type,
placeholder,
disabled: this.props.submitting,
onInput: (e) => this.handleFieldChange(fieldName, e.target.value),
},
}),
...(error ? [this.createTextElement('span', error, 'cwd-error-text')] : []),
],
});
}
/**
* 设置输入框的值
*/
setInputValues(root, form) {
const authorInput = root.querySelector('input[placeholder*="昵称"]');
const emailInput = root.querySelector('input[placeholder*="邮箱"]');
const urlInput = root.querySelector('input[placeholder*="网址"]');
const contentTextarea = root.querySelector('textarea');
if (authorInput) authorInput.value = form.author || '';
if (emailInput) emailInput.value = form.email || '';
if (urlInput) urlInput.value = form.url || '';
if (contentTextarea) contentTextarea.value = form.content || '';
}
handleFieldChange(field, value) {
this.state.localForm[field] = value;
if (this.props.onFieldChange) {
this.props.onFieldChange(field, value);
}
// 实时更新按钮状态
if (this.elements.root) {
this.updateFormState();
}
}
handleSubmit(e) {
e.preventDefault();
if (this.props.onSubmit) {
// 提交当前表单数据
this.props.onSubmit(this.state.localForm);
}
}
}

View File

@@ -0,0 +1,285 @@
/**
* CommentItem 评论项组件
*/
import { Component } from './Component.js';
import { ReplyEditor } from './ReplyEditor.js';
import { formatRelativeTime } from '@/utils/date.js';
export class CommentItem extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {Object} props.comment - 评论数据
* @param {boolean} props.isReply - 是否为回复
* @param {number|null} props.replyingTo - 当前正在回复的评论 ID
* @param {string} props.replyContent - 回复内容
* @param {string|null} props.replyError - 回复错误
* @param {boolean} props.submitting - 是否正在提交
* @param {string} props.adminEmail - 博主邮箱(可选)
* @param {string} props.adminBadge - 博主标识文字(可选)
* @param {Function} props.onReply - 回复回调
* @param {Function} props.onSubmitReply - 提交回复回调
* @param {Function} props.onCancelReply - 取消回复回调
* @param {Function} props.onUpdateReplyContent - 更新回复内容回调
* @param {Function} props.onClearReplyError - 清除回复错误回调
*/
constructor(container, props = {}) {
super(container, props);
this.replyEditor = null;
this.childCommentItems = []; // 缓存嵌套回复的 CommentItem 实例
}
render() {
const { comment, isReply, adminEmail, adminBadge } = this.props;
const isReplying = this.props.replyingTo === comment.id;
const isAdmin = adminEmail && comment.email === adminEmail;
const root = this.createElement('div', {
className: `cwd-comment-item ${isReply ? 'cwd-comment-reply' : ''}`,
children: [
// 头像
this.createElement('div', {
className: 'cwd-comment-avatar',
children: [
this.createElement('img', {
attributes: {
src: comment.avatar,
alt: comment.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: [
comment.url
? this.createElement('span', {
className: 'cwd-author-name',
children: [
this.createElement('a', {
attributes: {
href: comment.url,
target: '_blank',
rel: 'noopener noreferrer'
},
text: comment.author
})
]
})
: this.createTextElement('span', comment.author, 'cwd-author-name'),
// 博主标识
...(isAdmin ? [
this.createTextElement('span', `${adminBadge}`, 'cwd-admin-badge')
] : []),
// 显示回复目标
...(comment.replyToAuthor ? [
this.createTextElement('span', ' 回复 ', 'cwd-reply-to-separator'),
this.createTextElement('span', comment.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', formatRelativeTime(comment.pubDate), 'cwd-comment-time')
]
})
]
}),
// 评论内容
this.createElement('div', {
className: 'cwd-comment-content'
}),
// 回复编辑器容器
this.createElement('div', {
className: 'cwd-reply-editor-container'
}),
// 嵌套回复容器
...(comment.replies && comment.replies.length > 0 ? [
this.createElement('div', {
className: 'cwd-replies'
})
] : [])
]
})
]
});
// 设置评论内容的 HTML
const contentEl = root.querySelector('.cwd-comment-content');
if (contentEl) {
contentEl.innerHTML = comment.contentHtml;
}
// 创建回复编辑器
if (isReplying) {
const replyContainer = root.querySelector('.cwd-reply-editor-container');
if (replyContainer) {
this.replyEditor = new ReplyEditor(replyContainer, {
replyToAuthor: comment.author,
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
onUpdate: (content) => this.handleUpdateReplyContent(content),
onSubmit: () => this.handleSubmitReply(),
onCancel: () => this.handleCancelReply(),
onClearError: () => this.handleClearReplyError()
});
this.replyEditor.render();
this.replyEditor.focus();
}
} else {
this.replyEditor = null;
}
// 渲染嵌套回复
this.childCommentItems = [];
if (comment.replies && comment.replies.length > 0) {
const repliesContainer = root.querySelector('.cwd-replies');
if (repliesContainer) {
comment.replies.forEach(reply => {
const replyItem = new CommentItem(repliesContainer, {
comment: reply,
isReply: true,
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
});
replyItem.render();
this.childCommentItems.push(replyItem);
});
}
}
this.elements.root = root;
// 只在首次渲染时清空容器(当还没有 root 元素时)
if (this.container.contains(root)) {
// 如果 root 已存在,替换它
this.container.replaceChild(root, this.elements.root);
} else {
// 否则直接添加
this.container.appendChild(root);
}
}
updateProps(prevProps) {
const { comment } = this.props;
const wasReplying = prevProps.replyingTo === comment.id;
const isReplying = this.props.replyingTo === comment.id;
// 如果评论数据本身变化,需要完全重新渲染
if (this.props.comment !== prevProps.comment) {
this.render();
return;
}
// 处理回复编辑器的显示/隐藏
if (isReplying !== wasReplying) {
const replyContainer = this.elements.root?.querySelector(':scope > .cwd-comment-body > .cwd-reply-editor-container');
if (isReplying && replyContainer) {
// 显示回复编辑器
this.replyEditor = new ReplyEditor(replyContainer, {
replyToAuthor: comment.author,
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting,
onUpdate: (content) => this.handleUpdateReplyContent(content),
onSubmit: () => this.handleSubmitReply(),
onCancel: () => this.handleCancelReply(),
onClearError: () => this.handleClearReplyError()
});
this.replyEditor.render();
this.replyEditor.focus();
} else if (!isReplying && replyContainer) {
// 隐藏回复编辑器
replyContainer.innerHTML = '';
this.replyEditor = null;
}
} else if (isReplying && this.replyEditor) {
// 更新回复编辑器的 props
this.replyEditor.setProps({
content: this.props.replyContent,
error: this.props.replyError,
submitting: this.props.submitting
});
}
// 递归更新嵌套回复
if (this.childCommentItems && this.childCommentItems.length > 0) {
this.childCommentItems.forEach((childItem) => {
childItem.setProps({
replyingTo: this.props.replyingTo,
replyContent: this.props.replyContent,
replyError: this.props.replyError,
submitting: this.props.submitting
});
});
}
}
handleReply() {
console.log('[CommentItem] handleReply called, comment.id:', this.props.comment.id);
if (this.props.onReply) {
this.props.onReply(this.props.comment.id);
} else {
console.warn('[CommentItem] onReply callback is missing!');
}
}
handleSubmitReply() {
if (this.props.onSubmitReply) {
this.props.onSubmitReply(this.props.comment.id);
}
}
handleCancelReply() {
if (this.props.onCancelReply) {
this.props.onCancelReply();
}
}
handleUpdateReplyContent(content) {
if (this.props.onUpdateReplyContent) {
this.props.onUpdateReplyContent(content);
}
}
handleClearReplyError() {
if (this.props.onClearReplyError) {
this.props.onClearReplyError();
}
}
}

View File

@@ -0,0 +1,247 @@
/**
* CommentList 评论列表容器组件
*/
import { Component } from './Component.js';
import { CommentItem } from './CommentItem.js';
import { Loading } from './Loading.js';
import { Pagination } from './Pagination.js';
export class CommentList extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {Array} props.comments - 评论列表
* @param {boolean} props.loading - 是否正在加载
* @param {string|null} props.error - 错误信息
* @param {number} props.currentPage - 当前页码
* @param {number} props.totalPages - 总页数
* @param {number|null} props.replyingTo - 当前正在回复的评论 ID
* @param {string} props.replyContent - 回复内容
* @param {string|null} props.replyError - 回复错误
* @param {boolean} props.submitting - 是否正在提交
* @param {Function} props.onRetry - 重试回调
* @param {Function} props.onReply - 回复回调
* @param {Function} props.onSubmitReply - 提交回复回调
* @param {Function} props.onCancelReply - 取消回复回调
* @param {Function} props.onUpdateReplyContent - 更新回复内容回调
* @param {Function} props.onClearReplyError - 清除回复错误回调
* @param {Function} props.onPrevPage - 上一页回调
* @param {Function} props.onNextPage - 下一页回调
* @param {Function} props.onGoToPage - 跳转页码回调
* @param {string} props.adminEmail - 博主邮箱(可选)
* @param {string} props.adminBadge - 博主标识文字(可选)
*/
constructor(container, props = {}) {
super(container, props);
this.loadingComponent = null;
this.paginationComponent = null;
this.commentItems = new Map(); // 缓存 CommentItem 实例key 为 comment.id
}
render() {
const { comments, loading, error, currentPage, totalPages } = this.props;
console.log('[CommentList] render called, comments:', comments);
console.log('[CommentList] comments.length:', comments?.length);
console.log('[CommentList] loading:', loading);
// 清空容器
this.empty(this.container);
// 加载状态
if (loading && comments.length === 0) {
this.loadingComponent = new Loading(this.container, { text: '加载评论中...' });
this.loadingComponent.render();
this.elements.root = this.loadingComponent.elements.root;
return;
}
// 错误状态
if (error && comments.length === 0) {
const errorEl = this.createElement('div', {
className: 'cwd-error',
children: [
this.createTextElement('span', error),
this.createElement('button', {
className: 'cwd-error-retry',
attributes: {
type: 'button',
onClick: () => this.handleRetry()
},
text: '重试'
})
]
});
this.elements.root = errorEl;
this.container.appendChild(errorEl);
return;
}
// 评论列表容器
const root = this.createElement('div', {
className: 'cwd-comment-list'
});
// 评论列表
if (comments.length > 0) {
const commentsContainer = this.createElement('div', {
className: 'cwd-comments'
});
// 清空旧的缓存
this.commentItems.clear();
comments.forEach((comment, index) => {
console.log(`[CommentList] Rendering comment ${index}:`, comment);
const commentItem = new CommentItem(commentsContainer, {
comment,
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: (commentId) => this.handleReply(commentId),
onSubmitReply: (commentId) => this.handleSubmitReply(commentId),
onCancelReply: () => this.handleCancelReply(),
onUpdateReplyContent: (content) => this.handleUpdateReplyContent(content),
onClearReplyError: () => this.handleClearReplyError()
});
commentItem.render();
// 缓存 CommentItem 实例
this.commentItems.set(comment.id, commentItem);
});
console.log('[CommentList] Final commentsContainer children count:', commentsContainer.children.length);
console.log('[CommentList] Final commentsContainer innerHTML:', commentsContainer.innerHTML);
root.appendChild(commentsContainer);
} else {
// 空状态
const emptyEl = this.createElement('div', {
className: 'cwd-empty',
children: [
this.createTextElement('p', '暂无评论,快来抢沙发吧!', 'cwd-empty-text')
]
});
root.appendChild(emptyEl);
}
// 分页
if (totalPages > 1) {
const paginationContainer = this.createElement('div');
root.appendChild(paginationContainer);
this.paginationComponent = new Pagination(paginationContainer, {
currentPage,
totalPages,
onPrev: () => this.handlePrevPage(),
onNext: () => this.handleNextPage(),
onGoTo: (page) => this.handleGoToPage(page)
});
this.paginationComponent.render();
} else {
this.paginationComponent = null;
}
this.elements.root = root;
this.container.appendChild(root);
}
updateProps(prevProps) {
// 如果状态从加载变为非加载,需要完全重新渲染
if (this.props.loading !== prevProps.loading && !this.props.loading) {
this.render();
return;
}
// 如果评论列表变化,重新渲染
if (this.props.comments !== prevProps.comments) {
this.render();
return;
}
// 如果只是回复状态变化,局部更新 CommentItem 而不是完全重新渲染
if (this.props.replyingTo !== prevProps.replyingTo ||
this.props.replyError !== prevProps.replyError ||
this.props.submitting !== prevProps.submitting) {
// 局部更新所有 CommentItem
this.commentItems.forEach((commentItem) => {
commentItem.setProps({
replyingTo: this.props.replyingTo,
replyContent: this.props.replyContent,
replyError: this.props.replyError,
submitting: this.props.submitting
});
});
return;
}
// 如果分页信息变化,更新分页组件
if (this.paginationComponent) {
const pageChanged =
this.props.currentPage !== prevProps.currentPage ||
this.props.totalPages !== prevProps.totalPages;
if (pageChanged) {
this.paginationComponent.props.currentPage = this.props.currentPage;
this.paginationComponent.props.totalPages = this.props.totalPages;
this.paginationComponent.updateProps();
}
}
}
handleRetry() {
if (this.props.onRetry) {
this.props.onRetry();
}
}
handleReply(commentId) {
if (this.props.onReply) {
this.props.onReply(commentId);
}
}
handleSubmitReply(commentId) {
if (this.props.onSubmitReply) {
this.props.onSubmitReply(commentId);
}
}
handleCancelReply() {
if (this.props.onCancelReply) {
this.props.onCancelReply();
}
}
handleUpdateReplyContent(content) {
if (this.props.onUpdateReplyContent) {
this.props.onUpdateReplyContent(content);
}
}
handleClearReplyError() {
if (this.props.onClearReplyError) {
this.props.onClearReplyError();
}
}
handlePrevPage() {
if (this.props.onPrevPage) {
this.props.onPrevPage();
}
}
handleNextPage() {
if (this.props.onNextPage) {
this.props.onNextPage();
}
}
handleGoToPage(page) {
if (this.props.onGoToPage) {
this.props.onGoToPage(page);
}
}
}

View File

@@ -0,0 +1,169 @@
/**
* 基础组件类
*/
/**
* 基础组件类
*/
export class Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
*/
constructor(container, props = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
this.props = props;
this.state = {};
this.elements = {};
this.destroyed = false;
}
/**
* 设置状态并触发更新
* @param {Object|Function} newState - 新状态或状态更新函数
*/
setState(newState) {
if (this.destroyed) return;
const prevState = { ...this.state };
if (typeof newState === 'function') {
this.state = { ...this.state, ...newState(prevState) };
} else {
this.state = { ...this.state, ...newState };
}
this.update(prevState);
}
/**
* 更新属性
* @param {Object} newProps - 新属性
*/
setProps(newProps) {
if (this.destroyed) return;
const prevProps = { ...this.props };
this.props = { ...this.props, ...newProps };
this.updateProps(prevProps);
}
/**
* 渲染组件 - 子类实现
*/
render() {
// 子类实现
}
/**
* 更新视图 - 子类可重写
* @param {Object} prevState - 之前的状态
*/
update(prevState) {
// 子类可重写以实现增量更新
this.render();
}
/**
* 更新属性后的回调 - 子类可重写
* @param {Object} prevProps - 之前的属性
*/
updateProps(prevProps) {
// 子类可重写
}
/**
* 销毁组件
*/
destroy() {
this.destroyed = true;
if (this.container && this.elements.root && this.elements.root.parentNode) {
this.elements.root.parentNode.removeChild(this.elements.root);
}
this.elements = {};
}
// ==================== DOM 创建工具方法 ====================
/**
* 创建元素
* @param {string} tag - 标签名
* @param {Object} options - 选项
* @param {string} options.className - 类名
* @param {Object} options.attributes - 属性
* @param {string} options.text - 文本内容
* @param {string} options.html - HTML 内容
* @param {HTMLElement|Array<HTMLElement>} options.children - 子元素
* @returns {HTMLElement}
*/
createElement(tag, options = {}) {
const el = document.createElement(tag);
if (options.className) {
el.className = options.className;
}
if (options.attributes) {
Object.entries(options.attributes).forEach(([key, value]) => {
if (key.startsWith('on')) {
const event = key.slice(2).toLowerCase();
el.addEventListener(event, value);
} else if (key === 'dataset') {
Object.entries(value).forEach(([dataKey, dataValue]) => {
el.dataset[dataKey] = dataValue;
});
} else if (['disabled', 'checked', 'readonly', 'required', 'autofocus'].includes(key)) {
// 布尔属性:只有值为 true 时才设置
if (value) {
el.setAttribute(key, '');
}
} else {
el.setAttribute(key, value);
}
});
}
if (options.text !== undefined) {
el.textContent = options.text;
}
if (options.html !== undefined) {
el.innerHTML = options.html;
}
if (options.children) {
const children = Array.isArray(options.children) ? options.children : [options.children];
children.forEach(child => {
if (child instanceof HTMLElement) {
el.appendChild(child);
}
});
}
return el;
}
/**
* 创建带文本的元素
* @param {string} tag - 标签名
* @param {string} text - 文本内容
* @param {string} className - 类名
* @returns {HTMLElement}
*/
createTextElement(tag, text, className = '') {
return this.createElement(tag, {
className,
text
});
}
/**
* 清空元素
* @param {HTMLElement} el - 目标元素
*/
empty(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
}

View File

@@ -0,0 +1,45 @@
/**
* Loading 组件
*/
import { Component } from './Component.js';
export class Loading extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {string} props.text - 加载文本
*/
constructor(container, props = {}) {
super(container, {
text: '加载中...',
...props
});
}
render() {
const root = 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 = root;
this.empty(this.container);
this.container.appendChild(root);
}
/**
* 更新加载文本
* @param {string} text - 新文本
*/
setText(text) {
this.props.text = text;
const textEl = this.elements.root.querySelector('.cwd-loading-text');
if (textEl) {
textEl.textContent = text;
}
}
}

View File

@@ -0,0 +1,124 @@
/**
* Pagination 分页组件
*/
import { Component } from './Component.js';
export class Pagination extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {number} props.currentPage - 当前页码
* @param {number} props.totalPages - 总页数
* @param {Function} props.onPrev - 上一页回调
* @param {Function} props.onNext - 下一页回调
* @param {Function} props.onGoTo - 跳转页码回调
*/
constructor(container, props = {}) {
super(container, props);
this.state = {
currentPage: props.currentPage || 1,
totalPages: props.totalPages || 1
};
}
/**
* 计算显示的页码最多5个
* @returns {number[]}
*/
getDisplayedPages() {
const { currentPage, totalPages } = this.state;
const pages = [];
const maxVisible = 5;
const start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
const end = Math.min(totalPages, start + maxVisible - 1);
for (let i = end - maxVisible + 1; i <= end; i++) {
if (i >= 1) {
pages.push(i);
}
}
return pages.slice(0, maxVisible);
}
render() {
if (this.state.totalPages <= 1) {
this.empty(this.container);
return;
}
const displayedPages = this.getDisplayedPages();
const root = this.createElement('div', {
className: 'cwd-pagination',
children: [
// 上一页按钮
this.createElement('button', {
className: 'cwd-page-btn',
attributes: {
type: 'button',
disabled: this.state.currentPage === 1,
onClick: () => this.handlePrev()
},
text: '上一页'
}),
// 页码按钮
this.createElement('div', {
className: 'cwd-page-numbers',
children: displayedPages.map(page =>
this.createElement('button', {
className: `cwd-page-num ${page === this.state.currentPage ? 'cwd-page-num-active' : ''}`,
attributes: {
type: 'button',
onClick: () => this.handleGoTo(page)
},
text: page.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 = root;
this.empty(this.container);
this.container.appendChild(root);
}
updateProps() {
// 更新状态并重新渲染
this.state.currentPage = this.props.currentPage;
this.state.totalPages = this.props.totalPages;
this.render();
}
handlePrev() {
if (this.props.onPrev) {
this.props.onPrev();
}
}
handleNext() {
if (this.props.onNext) {
this.props.onNext();
}
}
handleGoTo(page) {
if (this.props.onGoTo) {
this.props.onGoTo(page);
}
}
}

View File

@@ -0,0 +1,194 @@
/**
* ReplyEditor 回复编辑器组件
*/
import { Component } from './Component.js';
export class ReplyEditor extends Component {
/**
* @param {HTMLElement|string} container - 容器元素或选择器
* @param {Object} props - 组件属性
* @param {string} props.replyToAuthor - 被回复的作者名
* @param {string} props.content - 回复内容
* @param {string|null} props.error - 错误信息
* @param {boolean} props.submitting - 是否正在提交
* @param {Function} props.onUpdate - 内容更新回调
* @param {Function} props.onSubmit - 提交回调
* @param {Function} props.onCancel - 取消回调
* @param {Function} props.onClearError - 清除错误回调
*/
constructor(container, props = {}) {
super(container, props);
this.state = {
content: props.content || ''
};
}
render() {
const root = 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: (e) => this.handleInput(e)
}
}),
// 错误提示
...(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: '取消'
})
]
})
]
});
// 设置文本框内容
const textarea = root.querySelector('textarea');
if (textarea) {
textarea.value = this.state.content;
}
this.elements.root = root;
this.empty(this.container);
this.container.appendChild(root);
}
updateProps(prevProps) {
// 如果外部传入的 content 变化,更新内部状态
if (this.props.content !== this.state.content && this.props.content !== prevProps?.content) {
this.state.content = this.props.content;
this.render();
return;
}
// 如果有错误显示/隐藏变化,重新渲染
if (this.props.error !== prevProps?.error) {
this.render();
return;
}
// 如果 submitting 状态变化,重新渲染
if (this.props.submitting !== prevProps?.submitting) {
this.render();
return;
}
}
handleInput(e) {
this.state.content = e.target.value;
// 更新提交按钮的禁用状态
const submitBtn = this.elements.root?.querySelector('.cwd-btn-primary');
if (submitBtn) {
submitBtn.disabled = this.props.submitting || !this.state.content.trim();
}
if (this.props.onUpdate) {
this.props.onUpdate(this.state.content);
}
}
handleSubmit() {
if (this.props.onSubmit) {
this.props.onSubmit();
}
}
handleCancel() {
if (this.props.onCancel) {
this.props.onCancel();
}
}
handleClearError() {
if (this.props.onClearError) {
this.props.onClearError();
}
}
/**
* 设置内容
* @param {string} content - 新内容
*/
setContent(content) {
this.state.content = content;
const textarea = this.elements.root?.querySelector('textarea');
if (textarea) {
textarea.value = content;
}
}
/**
* 获取内容
* @returns {string}
*/
getContent() {
return this.state.content;
}
/**
* 聚焦文本框
*/
focus() {
const textarea = this.elements.root?.querySelector('textarea');
if (textarea) {
textarea.focus();
}
}
}

View File

@@ -0,0 +1,377 @@
/**
* CWDComments 主类
* 使用 Shadow DOM 隔离样式
*/
import { createApiClient } from './api.js';
import { createCommentStore } from './store.js';
import { CommentForm } from '@/components/CommentForm.js';
import { CommentList } from '@/components/CommentList.js';
import styles from '@/styles/main.css?inline';
/**
* CWDComments 评论组件主类
*/
export class CWDComments {
/**
* @param {Object} config - 配置对象
* @param {string|HTMLElement} config.el - 挂载元素选择器或 DOM 元素
* @param {string} config.apiBaseUrl - API 基础地址
* @param {string} config.postSlug - 文章标识符
* @param {string} config.postTitle - 文章标题(可选)
* @param {string} config.postUrl - 文章 URL可选
* @param {'light'|'dark'} config.theme - 主题
* @param {number} config.pageSize - 每页评论数
* @param {string} config.avatarPrefix - 头像服务前缀(可选,默认 https://gravatar.com/avatar/
* @param {string} config.adminEmail - 博主邮箱(可选,用于显示博主标识)
* @param {string} config.adminBadge - 博主标识文字(可选,默认"博主"
*/
constructor(config) {
this.config = { ...config };
this.hostElement = this._resolveElement(config.el);
this.shadowRoot = null;
this.mountPoint = null;
this.commentForm = null;
this.commentList = null;
this.store = null;
this.unsubscribe = null;
// 初始加载标志
this._mounted = false;
}
/**
* 解析挂载元素
* @private
*/
_resolveElement(el) {
if (typeof el === 'string') {
const element = document.querySelector(el);
if (!element) {
throw new Error(`元素未找到: ${el}`);
}
if (!(element instanceof HTMLElement)) {
throw new Error(`目标不是 HTMLElement: ${el}`);
}
return element;
}
return el;
}
/**
* 挂载组件
*/
mount() {
if (this._mounted) {
return;
}
// 创建 Shadow DOM
this.shadowRoot = this.hostElement.attachShadow({ mode: 'open' });
// 注入样式
const styleElement = document.createElement('style');
if (typeof styles === 'string') {
styleElement.textContent = styles;
} else if (styles && typeof styles === 'object' && 'default' in styles) {
styleElement.textContent = styles.default;
}
this.shadowRoot.appendChild(styleElement);
// 创建容器
this.mountPoint = document.createElement('div');
this.mountPoint.className = 'cwd-comments-container';
this.shadowRoot.appendChild(this.mountPoint);
// 设置主题
if (this.config.theme) {
this.mountPoint.setAttribute('data-theme', this.config.theme);
}
// 创建 API 客户端和 Store
const api = createApiClient(this.config);
this.store = createCommentStore(
this.config,
api.fetchComments.bind(api),
api.submitComment.bind(api)
);
// 订阅状态变化
this.unsubscribe = this.store.store.subscribe((state) => {
this._onStateChange(state);
});
// 渲染组件
this._render();
// 加载评论
this.store.loadComments();
this._mounted = true;
}
/**
* 卸载组件
*/
unmount() {
if (!this._mounted) {
return;
}
// 销毁组件
if (this.commentForm) {
this.commentForm.destroy();
this.commentForm = null;
}
if (this.commentList) {
this.commentList.destroy();
this.commentList = null;
}
// 取消订阅
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
// 移除 Shadow DOM - 通过替换所有子节点
if (this.hostElement) {
// Shadow DOM 会在清空子节点时自动移除
while (this.hostElement.firstChild) {
this.hostElement.removeChild(this.hostElement.firstChild);
}
}
this.shadowRoot = null;
this.mountPoint = null;
this.store = null;
this._mounted = false;
console.log('[CWDComments] 组件已卸载');
}
/**
* 渲染组件
* @private
*/
_render() {
if (!this.mountPoint) {
return;
}
const state = this.store.store.getState();
// 创建评论表单
if (!this.commentForm) {
this.commentForm = new CommentForm(this.mountPoint, {
form: state.form,
formErrors: state.formErrors,
submitting: state.submitting,
onSubmit: () => this._handleSubmit(),
onFieldChange: (field, value) => this.store.updateFormField(field, value)
});
this.commentForm.render();
}
// 创建错误提示
const existingError = this.mountPoint.querySelector('.cwd-error-inline');
if (state.error) {
if (!existingError) {
const errorEl = document.createElement('div');
errorEl.className = 'cwd-error-inline';
errorEl.innerHTML = `
<span>${state.error}</span>
<button type="button" class="cwd-error-close" data-action="clear-error">✕</button>
`;
errorEl.querySelector('[data-action="clear-error"]').addEventListener('click', () => {
this.store.clearError();
});
this.mountPoint.insertBefore(errorEl, this.mountPoint.firstChild);
}
} else if (existingError) {
existingError.remove();
}
// 创建头部统计
let header = this.mountPoint.querySelector('.cwd-comments-header');
if (!header) {
header = document.createElement('div');
header.className = 'cwd-comments-header';
header.innerHTML = `
<h3 class="cwd-comments-count">
共 <span class="cwd-comments-count-number">0</span> 条评论
</h3>
`;
this.mountPoint.appendChild(header);
}
const countEl = header.querySelector('.cwd-comments-count-number');
if (countEl) {
countEl.textContent = state.pagination.totalCount;
}
// 创建评论列表
if (!this.commentList) {
const listContainer = document.createElement('div');
this.mountPoint.appendChild(listContainer);
this.commentList = new CommentList(listContainer, {
comments: state.comments,
loading: state.loading,
error: null,
currentPage: state.pagination.page,
totalPages: this.store.getTotalPages(),
replyingTo: state.replyingTo,
replyContent: state.replyContent,
replyError: state.replyError,
submitting: state.submitting,
adminEmail: this.config.adminEmail,
adminBadge: this.config.adminBadge || '博主',
onRetry: () => this.store.loadComments(),
onReply: (commentId) => this.store.startReply(commentId),
onSubmitReply: (commentId) => this.store.submitReply(commentId),
onCancelReply: () => this.store.cancelReply(),
onUpdateReplyContent: (content) => this.store.updateReplyContent(content),
onClearReplyError: () => this.store.clearReplyError(),
onPrevPage: () => this.store.goToPage(state.pagination.page - 1),
onNextPage: () => this.store.goToPage(state.pagination.page + 1),
onGoToPage: (page) => this.store.goToPage(page)
});
this.commentList.render();
}
}
/**
* 状态变化处理
* @private
*/
_onStateChange(state, prevState) {
if (!this._mounted) {
return;
}
// 根据回复状态显示/隐藏主评论表单
if (this.commentForm?.elements?.root) {
const formRoot = this.commentForm.elements.root;
if (state.replyingTo !== null) {
formRoot.style.display = 'none';
} else {
formRoot.style.display = '';
}
}
// 更新评论表单
if (this.commentForm) {
this.commentForm.setProps({
form: state.form,
formErrors: state.formErrors,
submitting: state.submitting
});
}
// 更新错误提示
const existingError = this.mountPoint?.querySelector('.cwd-error-inline');
if (state.error) {
if (!existingError) {
const errorEl = document.createElement('div');
errorEl.className = 'cwd-error-inline';
errorEl.innerHTML = `
<span>${state.error}</span>
<button type="button" class="cwd-error-close" data-action="clear-error">✕</button>
`;
errorEl.querySelector('[data-action="clear-error"]').addEventListener('click', () => {
this.store.clearError();
});
this.mountPoint?.insertBefore(errorEl, this.mountPoint.firstChild);
}
} else if (existingError) {
existingError.remove();
}
// 更新头部统计
const header = this.mountPoint?.querySelector('.cwd-comments-header');
const countEl = header?.querySelector('.cwd-comments-count-number');
if (countEl) {
countEl.textContent = state.pagination.totalCount;
}
// 更新评论列表
if (this.commentList) {
this.commentList.setProps({
comments: state.comments,
loading: state.loading,
currentPage: state.pagination.page,
totalPages: this.store.getTotalPages(),
replyingTo: state.replyingTo,
replyContent: state.replyContent,
replyError: state.replyError,
submitting: state.submitting
});
}
}
/**
* 处理评论提交
* @private
*/
async _handleSubmit() {
const success = await this.store.submitNewComment();
if (success) {
// 表单内容已在 store 中清空
// 更新表单组件
if (this.commentForm) {
this.commentForm.state.localForm = { ...this.store.store.getState().form };
}
}
}
/**
* 更新配置
* @param {Object} newConfig - 新配置
*/
updateConfig(newConfig) {
const prevConfig = { ...this.config };
Object.assign(this.config, newConfig);
// 更新主题
if (newConfig.theme && this.mountPoint) {
this.mountPoint.setAttribute('data-theme', newConfig.theme);
}
// 如果 postSlug 变化,重新加载评论
if (newConfig.postSlug && newConfig.postSlug !== prevConfig.postSlug) {
// 重新创建 API 客户端和 Store
const api = createApiClient(this.config);
// 取消旧订阅
if (this.unsubscribe) {
this.unsubscribe();
}
// 创建新 store
this.store = createCommentStore(
this.config,
api.fetchComments.bind(api),
api.submitComment.bind(api)
);
// 重新订阅
this.unsubscribe = this.store.store.subscribe((state) => {
this._onStateChange(state);
});
// 加载评论
this.store.loadComments();
}
}
/**
* 获取当前配置
* @returns {Object}
*/
getConfig() {
return { ...this.config };
}
}

83
widget/src/core/api.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* API 请求封装
*/
/**
* 创建 API 客户端
* @param {Object} config - 配置对象
* @param {string} config.apiBaseUrl - API 基础地址
* @param {string} config.postSlug - 文章标识符
* @param {string} config.postTitle - 文章标题(可选)
* @param {string} config.postUrl - 文章 URL可选
* @param {string} config.avatarPrefix - 头像服务前缀(可选)
* @returns {Object}
*/
export function createApiClient(config) {
const baseUrl = config.apiBaseUrl.replace(/\/$/, '');
/**
* 获取评论列表
* @param {number} page - 页码
* @param {number} limit - 每页数量
* @returns {Promise<Object>}
*/
async function fetchComments(page = 1, limit = 20) {
const params = new URLSearchParams({
post_slug: config.postSlug,
page: page.toString(),
limit: limit.toString(),
nested: 'true'
});
// 如果配置了头像前缀,添加到请求参数
if (config.avatarPrefix) {
params.set('avatar_prefix', config.avatarPrefix);
}
const response = await fetch(`${baseUrl}/api/comments?${params}`);
if (!response.ok) {
throw new Error(`获取评论失败: ${response.status} ${response.statusText}`);
}
console.log('[API] fetchComments response:', response);
return response.json();
}
/**
* 提交评论
* @param {Object} data - 评论数据
* @param {string} data.author - 昵称
* @param {string} data.email - 邮箱
* @param {string} data.url - 网址(可选)
* @param {string} data.content - 评论内容
* @param {number} data.parentId - 父评论 ID可选用于回复
* @returns {Promise<Object>}
*/
async function submitComment(data) {
const response = await fetch(`${baseUrl}/api/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
post_slug: config.postSlug,
post_title: config.postTitle,
post_url: config.postUrl,
author: data.author,
email: data.email,
url: data.url || undefined,
content: data.content,
parent_id: data.parentId
})
});
if (!response.ok) {
throw new Error(`提交评论失败: ${response.status} ${response.statusText}`);
}
return response.json();
}
return {
fetchComments,
submitComment
};
}

375
widget/src/core/store.js Normal file
View File

@@ -0,0 +1,375 @@
/**
* 状态管理 - 使用发布-订阅模式
*/
// localStorage 键名
const STORAGE_KEY = 'cwd_user_info';
/**
* 从 localStorage 读取用户信息
* @returns {Object}
*/
function loadUserInfo() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
return {
author: parsed.author || '',
email: parsed.email || '',
url: parsed.url || ''
};
}
} catch (e) {
console.error('读取用户信息失败:', e);
}
return { author: '', email: '', url: '' };
}
/**
* 保存用户信息到 localStorage
* @param {string} author - 昵称
* @param {string} email - 邮箱
* @param {string} url - 网址
*/
function saveUserInfo(author, email, url) {
try {
const data = { author, email, url };
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.error('保存用户信息失败:', e);
}
}
/**
* 简单的 Store 类
*/
class Store {
constructor(initialState) {
this.state = { ...initialState };
this.listeners = [];
}
/**
* 获取当前状态
* @returns {Object}
*/
getState() {
return { ...this.state };
}
/**
* 更新状态
* @param {Object} updates - 要更新的属性
*/
setState(updates) {
const prevState = { ...this.state };
this.state = { ...this.state, ...updates };
// 通知所有监听器
this.listeners.forEach(listener => {
listener(this.state, prevState);
});
}
/**
* 订阅状态变化
* @param {Function} listener - 监听器函数
* @returns {Function} - 取消订阅的函数
*/
subscribe(listener) {
this.listeners.push(listener);
// 返回取消订阅的函数
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
}
/**
* 创建评论状态管理 Store
* @param {Object} config - 配置对象
* @param {Function} fetchComments - 获取评论的函数
* @param {Function} submitComment - 提交评论的函数
* @returns {Object}
*/
export function createCommentStore(config, fetchComments, submitComment) {
// 从 localStorage 加载用户信息
const savedInfo = loadUserInfo();
// 创建 store 实例
const store = new Store({
// 评论数据
comments: [],
loading: false,
error: null,
// 分页
pagination: {
page: 1,
limit: config.pageSize || 20,
total: 0,
totalCount: 0
},
// 表单数据
form: {
author: savedInfo.author || '',
email: savedInfo.email || '',
url: savedInfo.url || '',
content: ''
},
formErrors: {},
submitting: false,
// 回复状态
replyingTo: null,
replyContent: '',
replyError: null
});
// 监听用户信息变化,自动保存到 localStorage
store.subscribe((state) => {
if (state.form.author || state.form.email || state.form.url) {
saveUserInfo(state.form.author, state.form.email, state.form.url);
}
});
/**
* 加载评论列表
* @param {number} page - 页码
*/
async function loadComments(page = 1) {
store.setState({
loading: true,
error: null
});
try {
const response = await fetchComments(page, store.getState().pagination.limit);
console.log('[Store] loadComments response:', response);
console.log('[Store] comments data:', response.data);
store.setState({
comments: response.data,
pagination: {
page: response.pagination.page,
limit: response.pagination.limit,
total: response.pagination.total,
totalCount: response.pagination.totalCount
},
loading: false
});
} catch (e) {
store.setState({
error: e instanceof Error ? e.message : '加载评论失败',
loading: false
});
}
}
/**
* 提交评论
*/
async function submitNewComment() {
const state = store.getState();
const form = state.form;
// 验证表单
const { validateCommentForm } = await import('@/utils/validator.js');
const validation = validateCommentForm(form);
if (!validation.valid) {
store.setState({
formErrors: validation.errors
});
return false;
}
// 清空错误
store.setState({
formErrors: {},
submitting: true,
error: null
});
try {
await submitComment({
author: form.author,
email: form.email,
url: form.url,
content: form.content
});
// 清空评论内容
store.setState({
form: { ...form, content: '' },
submitting: false
});
// 重新加载评论
await loadComments(state.pagination.page);
return true;
} catch (e) {
store.setState({
error: e instanceof Error ? e.message : '提交评论失败',
submitting: false
});
return false;
}
}
/**
* 提交回复
* @param {number} parentId - 父评论 ID
*/
async function submitReply(parentId) {
const state = store.getState();
// 验证回复内容
if (!state.replyContent.trim()) {
return false;
}
// 验证用户信息
const { validateReplyUserInfo } = await import('@/utils/validator.js');
const validation = validateReplyUserInfo(state.form);
if (!validation.valid) {
const errorMessages = Object.values(validation.errors).join('');
store.setState({
replyError: errorMessages
});
return false;
}
store.setState({
formErrors: {},
submitting: true,
replyError: null
});
try {
await submitComment({
author: state.form.author,
email: state.form.email,
url: state.form.url,
content: state.replyContent,
parentId
});
// 清空回复内容并关闭回复框
store.setState({
replyContent: '',
replyingTo: null,
submitting: false
});
// 重新加载评论
await loadComments(state.pagination.page);
return true;
} catch (e) {
store.setState({
error: e instanceof Error ? e.message : '提交回复失败',
submitting: false
});
return false;
}
}
/**
* 开始回复
* @param {number} commentId - 评论 ID
*/
function startReply(commentId) {
console.log('[Store] startReply called with commentId:', commentId);
store.setState({
replyingTo: commentId,
replyContent: '',
replyError: null
});
console.log('[Store] New state:', store.getState());
}
/**
* 取消回复
*/
function cancelReply() {
store.setState({
replyingTo: null,
replyContent: '',
replyError: null
});
}
/**
* 更新表单字段
* @param {string} field - 字段名
* @param {string} value - 值
*/
function updateFormField(field, value) {
const form = { ...store.getState().form };
form[field] = value;
store.setState({ form });
}
/**
* 更新回复内容
* @param {string} content - 回复内容
*/
function updateReplyContent(content) {
store.setState({
replyContent: content
});
}
/**
* 清除回复错误
*/
function clearReplyError() {
store.setState({
replyError: null
});
}
/**
* 清除错误
*/
function clearError() {
store.setState({
error: null
});
}
/**
* 切换页码
* @param {number} page - 页码
*/
function goToPage(page) {
const totalPages = store.getState().pagination.total;
if (page >= 1 && page <= totalPages) {
loadComments(page);
}
}
return {
// Store 实例
store,
// 计算属性方法
getTotalPages: () => {
const state = store.getState();
return state.pagination.total;
},
// 操作方法
loadComments,
submitNewComment,
submitReply,
startReply,
cancelReply,
updateFormField,
updateReplyContent,
clearReplyError,
clearError,
goToPage
};
}

184
widget/src/dev.js Normal file
View File

@@ -0,0 +1,184 @@
/**
* 开发调试脚本
*/
import { CWDComments } from './index.js';
// 本地存储的 key
const STORAGE_KEY = 'cwd-dev-config';
// 默认配置
const DEFAULT_CONFIG = {
apiBaseUrl: 'http://localhost:8788',
postSlug: 'demo-post',
theme: 'light',
avatarPrefix: 'https://gravatar.com/avatar',
};
let widgetInstance = null;
/**
* 从本地存储加载配置
*/
function loadConfigFromStorage() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
return { ...DEFAULT_CONFIG, ...JSON.parse(saved) };
}
} catch (e) {
console.warn('[CWDComments] 读取本地存储失败:', e);
}
return DEFAULT_CONFIG;
}
/**
* 保存配置到本地存储
*/
function saveConfigToStorage(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (e) {
console.warn('[CWDComments] 保存到本地存储失败:', e);
}
}
/**
* 将配置填充到输入框
*/
function populateInputs(config) {
const apiBaseUrlInput = document.getElementById('apiBaseUrl');
const postSlugInput = document.getElementById('postSlug');
const themeSelect = document.getElementById('theme');
const avatarPrefixInput = document.getElementById('avatarPrefix');
if (apiBaseUrlInput) apiBaseUrlInput.value = config.apiBaseUrl || DEFAULT_CONFIG.apiBaseUrl;
if (postSlugInput) postSlugInput.value = config.postSlug || DEFAULT_CONFIG.postSlug;
if (themeSelect) themeSelect.value = config.theme || DEFAULT_CONFIG.theme;
if (avatarPrefixInput) avatarPrefixInput.value = config.avatarPrefix || DEFAULT_CONFIG.avatarPrefix;
}
/**
* 从输入框获取当前配置
*/
function getConfigFromInputs() {
const apiBaseUrl = document.getElementById('apiBaseUrl')?.value || DEFAULT_CONFIG.apiBaseUrl;
const postSlug = document.getElementById('postSlug')?.value || DEFAULT_CONFIG.postSlug;
const theme = document.getElementById('theme')?.value || DEFAULT_CONFIG.theme;
const avatarPrefix = document.getElementById('avatarPrefix')?.value || DEFAULT_CONFIG.avatarPrefix;
return { apiBaseUrl, postSlug, theme, avatarPrefix };
}
/**
* 初始化 widget
*/
function initWidget() {
const config = getConfigFromInputs();
// 保存到本地存储
saveConfigToStorage(config);
// 如果已存在实例,先卸载
if (widgetInstance) {
widgetInstance.unmount();
widgetInstance = null;
}
// 清空容器
const container = document.getElementById('comments');
if (container) {
container.innerHTML = '';
}
// 创建新实例
try {
widgetInstance = new CWDComments({
el: '#comments',
apiBaseUrl: config.apiBaseUrl,
postSlug: config.postSlug,
theme: config.theme,
avatarPrefix: config.avatarPrefix,
pageSize: 20,
adminEmail: 'anghunk@gmail.com', // 博主邮箱,留空不进行匹配
adminBadge: '博主', // 自定义标识,默认为"博主"
});
widgetInstance.mount();
console.log('[CWDComments] Widget 初始化成功', config);
} catch (error) {
console.error('[CWDComments] Widget 初始化失败:', error);
}
}
/**
* 切换主题
*/
function toggleTheme() {
if (!widgetInstance) {
console.warn('[CWDComments] 请先初始化 widget');
return;
}
const currentConfig = widgetInstance.getConfig();
const newTheme = currentConfig.theme === 'light' ? 'dark' : 'light';
widgetInstance.updateConfig({ theme: newTheme });
// 更新下拉框
const themeSelect = document.getElementById('theme');
if (themeSelect) {
themeSelect.value = newTheme;
}
// 保存到本地存储
const config = getConfigFromInputs();
config.theme = newTheme;
saveConfigToStorage(config);
console.log('[CWDComments] 主题已切换为:', newTheme);
}
/**
* 清除本地存储的配置
*/
function clearConfig() {
try {
localStorage.removeItem(STORAGE_KEY);
populateInputs(DEFAULT_CONFIG);
console.log('[CWDComments] 配置已重置为默认值');
} catch (e) {
console.error('[CWDComments] 重置配置失败:', e);
}
}
// 将函数挂载到 window 对象,使其在 HTML 中可访问
window.initWidget = initWidget;
window.toggleTheme = toggleTheme;
window.clearConfig = clearConfig;
// 页面加载完成后自动初始化
document.addEventListener('DOMContentLoaded', () => {
console.log('[CWDComments] 开发模式 - 页面加载完成,正在初始化...');
// 从本地存储加载配置并填充到输入框
const savedConfig = loadConfigFromStorage();
populateInputs(savedConfig);
// 初始化 widget
initWidget();
});
// 监听输入框变化,实时保存
document.addEventListener('DOMContentLoaded', () => {
const inputs = ['apiBaseUrl', 'postSlug', 'theme', 'avatarPrefix'];
inputs.forEach((id) => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('change', () => {
const config = getConfigFromInputs();
saveConfigToStorage(config);
});
}
});
});
// 导出类型(用于调试)
window.CWDComments = CWDComments;

27
widget/src/index.js Normal file
View File

@@ -0,0 +1,27 @@
/**
* Momo Comments Widget 入口文件
*
* 使用方法:
* ```html
* <div id="comments"></div>
* <script src="cwd-comments.js"></script>
* <script>
* new CWDComments({
* el: '#comments',
* apiBaseUrl: 'https://api.example.com',
* postSlug: 'my-post'
* }).mount();
* </script>
* ```
*/
import { CWDComments } from './core/CWDComments.js';
// 导出为全局变量(用于 UMD 构建)
if (typeof window !== 'undefined') {
window.CWDComments = CWDComments;
}
// ES Module 默认导出
export default CWDComments;
export { CWDComments };

620
widget/src/styles/main.css Normal file
View File

@@ -0,0 +1,620 @@
/**
* 主样式文件
* 包含所有组件样式,在 Shadow DOM 中使用
*/
@import './variables.css';
/* ========== 全局重置 ========== */
* {
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);
}
/* ========== Loading 组件 ========== */
.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 0.6s linear infinite;
}
@keyframes cwd-spin {
to {
transform: rotate(360deg);
}
}
.cwd-loading-text {
font-size: 14px;
}
/* ========== CommentForm 组件 ========== */
.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 0.2s, box-shadow 0.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 rgba(9, 105, 218, 0.1);
}
.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 0.2s;
font-family: inherit;
}
.cwd-btn-primary {
color: #ffffff;
background: var(--cwd-primary, #0969da);
}
.cwd-btn-primary:hover:not(:disabled) {
background: var(--cwd-primary-hover, #0864ca);
}
.cwd-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ========== ReplyEditor 组件 ========== */
.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 0.2s;
}
.cwd-btn-close:hover {
background: var(--cwd-bg-hover, rgba(0, 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 rgba(9, 105, 218, 0.1);
}
.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);
}
/* ========== CommentItem 组件 ========== */
.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 0.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);
}
/* ========== CommentList 组件 ========== */
.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: #ffffff;
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);
}
/* ========== Pagination 组件 ========== */
.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 0.2s;
font-family: inherit;
}
.cwd-page-btn:hover:not(:disabled) {
background: var(--cwd-bg-hover, #f6f8fa);
}
.cwd-page-btn:disabled {
opacity: 0.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 0.2s;
font-family: inherit;
}
.cwd-page-num:hover {
background: var(--cwd-bg-hover, #f6f8fa);
}
.cwd-page-num-active {
color: #ffffff;
background: var(--cwd-primary, #0969da);
border-color: var(--cwd-primary, #0969da);
}
/* ========== App 组件 ========== */
.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 0.2s;
}
.cwd-error-close:hover {
background: rgba(0, 0, 0, 0.1);
}
/* ========== 其他 ========== */
.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 0.2s;
font-family: inherit;
}
.cwd-load-more:hover {
background: var(--cwd-bg-hover);
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.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);
}

View File

@@ -0,0 +1,60 @@
/**
* CSS 变量定义
* 支持主题切换
*/
: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;
}

78
widget/src/utils/date.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* 日期时间格式化工具
*/
/**
* 格式化时间3天内显示相对时间超过3天显示完整日期
* @param {string} dateStr - 日期字符串
* @returns {string}
*/
export function formatRelativeTime(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
// 3天内显示相对时间
if (days < 3) {
if (days > 0) {
return days === 1 ? '昨天' : `${days}天前`;
}
if (hours > 0) {
return `${hours}小时前`;
}
if (minutes > 0) {
return `${minutes}分钟前`;
}
return '刚刚';
}
// 超过3天显示完整日期
return formatDateTime(dateStr);
}
/**
* 格式化日期时间
* @param {string} dateStr - 日期字符串
* @returns {string}
*/
export function formatDateTime(dateStr) {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}
/**
* 格式化日期
* @param {string} dateStr - 日期字符串
* @returns {string}
*/
export function formatDate(dateStr) {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}/${month}/${day}`;
}
/**
* 格式化时间
* @param {string} dateStr - 日期字符串
* @returns {string}
*/
export function formatTime(dateStr) {
const date = new Date(dateStr);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}

210
widget/src/utils/dom.js Normal file
View File

@@ -0,0 +1,210 @@
/**
* DOM 操作工具函数
*/
/**
* 创建元素
* @param {string} tag - 标签名
* @param {string} className - 类名
* @param {Object} attributes - 属性对象,支持 onClick 等事件监听器
* @param {string|HTMLElement|HTMLElement[]} content - 内容
* @returns {HTMLElement}
*/
export function createElement(tag, className = '', attributes = {}, content = null) {
const el = document.createElement(tag);
if (className) {
el.className = className;
}
Object.entries(attributes).forEach(([key, value]) => {
if (key.startsWith('on')) {
// 事件监听器,如 onClick -> click
const event = key.slice(2).toLowerCase();
el.addEventListener(event, value);
} else if (key === 'dataset') {
// 设置 data-* 属性
Object.entries(value).forEach(([dataKey, dataValue]) => {
el.dataset[dataKey] = dataValue;
});
} else {
el.setAttribute(key, value);
}
});
// 处理内容
if (content !== null) {
appendContent(el, content);
}
return el;
}
/**
* 添加内容到元素
* @param {HTMLElement} el - 目标元素
* @param {string|HTMLElement|HTMLElement[]} content - 内容
*/
export function appendContent(el, content) {
if (typeof content === 'string') {
el.textContent = content;
} else if (Array.isArray(content)) {
content.forEach(child => {
if (child instanceof HTMLElement) {
el.appendChild(child);
}
});
} else if (content instanceof HTMLElement) {
el.appendChild(content);
}
}
/**
* 设置元素的 HTML 内容
* @param {HTMLElement} el - 目标元素
* @param {string} html - HTML 字符串
*/
export function setHTML(el, html) {
el.innerHTML = html;
}
/**
* 清空元素内容
* @param {HTMLElement} el - 目标元素
*/
export function empty(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
/**
* 显示元素
* @param {HTMLElement} el - 目标元素
*/
export function show(el) {
el.style.display = '';
}
/**
* 隐藏元素
* @param {HTMLElement} el - 目标元素
*/
export function hide(el) {
el.style.display = 'none';
}
/**
* 切换元素显示状态
* @param {HTMLElement} el - 目标元素
* @param {boolean} visible - 是否显示
*/
export function toggle(el, visible) {
if (visible) {
show(el);
} else {
hide(el);
}
}
/**
* 添加类名
* @param {HTMLElement} el - 目标元素
* @param {string} className - 类名
*/
export function addClass(el, className) {
el.classList.add(className);
}
/**
* 移除类名
* @param {HTMLElement} el - 目标元素
* @param {string} className - 类名
*/
export function removeClass(el, className) {
el.classList.remove(className);
}
/**
* 切换类名
* @param {HTMLElement} el - 目标元素
* @param {string} className - 类名
* @param {boolean} force - 强制添加或移除
*/
export function toggleClass(el, className, force) {
el.classList.toggle(className, force);
}
/**
* 查找元素
* @param {string|HTMLElement} selector - 选择器或元素
* @param {HTMLElement} context - 上下文元素
* @returns {HTMLElement|null}
*/
export function query(selector, context = document) {
if (typeof selector === 'string') {
return context.querySelector(selector);
}
return selector;
}
/**
* 查找所有元素
* @param {string} selector - 选择器
* @param {HTMLElement} context - 上下文元素
* @returns {NodeList}
*/
export function queryAll(selector, context = document) {
return context.querySelectorAll(selector);
}
/**
* 委托事件监听
* @param {HTMLElement} el - 目标元素
* @param {string} event - 事件名
* @param {string} selector - 选择器
* @param {Function} handler - 处理函数
*/
export function delegate(el, event, selector, handler) {
el.addEventListener(event, (e) => {
const target = e.target.closest(selector);
if (target && el.contains(target)) {
handler.call(target, e);
}
});
}
/**
* 防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} wait - 等待时间
* @returns {Function}
*/
export function debounce(func, wait = 300) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 节流函数
* @param {Function} func - 要节流的函数
* @param {number} limit - 限制时间
* @returns {Function}
*/
export function throttle(func, limit = 300) {
let inThrottle;
return function executedFunction(...args) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}

View File

@@ -0,0 +1,122 @@
/**
* 表单验证工具
*/
/**
* 验证邮箱格式
* @param {string} email - 邮箱地址
* @returns {boolean}
*/
export function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* 验证 URL 格式
* @param {string} url - URL 地址
* @returns {boolean}
*/
export function isValidUrl(url) {
if (!url) return true; // URL 是可选的
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* 验证评论内容
* @param {string} content - 评论内容
* @returns {{valid: boolean, error?: string}}
*/
export function validateCommentContent(content) {
if (!content || content.trim().length === 0) {
return { valid: false, error: '请输入评论内容' };
}
if (content.length > 1000) {
return { valid: false, error: '评论内容不能超过 1000 字' };
}
return { valid: true };
}
/**
* 验证评论表单
* @param {Object} data - 表单数据
* @param {string} data.author - 昵称
* @param {string} data.email - 邮箱
* @param {string} data.url - 网址
* @param {string} data.content - 评论内容
* @returns {{valid: boolean, errors: Object<string, string>}}
*/
export function validateCommentForm(data) {
const errors = {};
// 验证昵称
if (!data.author || data.author.trim().length === 0) {
errors.author = '请输入昵称';
} else if (data.author.length > 50) {
errors.author = '昵称不能超过 50 字';
}
// 验证邮箱
if (!data.email || data.email.trim().length === 0) {
errors.email = '请输入邮箱';
} else if (!isValidEmail(data.email)) {
errors.email = '邮箱格式不正确';
}
// 验证网址(可选)
if (data.url && !isValidUrl(data.url)) {
errors.url = '网址格式不正确';
}
// 验证评论内容
const contentValidation = validateCommentContent(data.content);
if (!contentValidation.valid) {
errors.content = contentValidation.error;
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}
/**
* 验证回复所需的用户信息
* @param {Object} data - 用户信息
* @param {string} data.author - 昵称
* @param {string} data.email - 邮箱
* @param {string} data.url - 网址
* @returns {{valid: boolean, errors: Object<string, string>}}
*/
export function validateReplyUserInfo(data) {
const errors = {};
// 验证昵称
if (!data.author || data.author.trim().length === 0) {
errors.author = '请输入昵称';
} else if (data.author.length > 50) {
errors.author = '昵称不能超过 50 字';
}
// 验证邮箱
if (!data.email || data.email.trim().length === 0) {
errors.email = '请输入邮箱';
} else if (!isValidEmail(data.email)) {
errors.email = '邮箱格式不正确';
}
// 验证网址(可选)
if (data.url && !isValidUrl(data.url)) {
errors.url = '网址格式不正确';
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}

21
widget/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module '*?inline' {
const content: string;
export default content;
}
interface ImportMetaEnv {
readonly DEV: boolean;
readonly PROD: boolean;
readonly MODE: string;
readonly BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

78
widget/test/test.html Normal file
View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CWD Comments Widget - 测试页面</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
margin-bottom: 20px;
color: #333;
}
.demo-post {
margin-bottom: 40px;
}
.demo-post h2 {
font-size: 20px;
margin-bottom: 10px;
}
.demo-post p {
color: #666;
line-height: 1.8;
font-size: 16px;
}
</style>
</head>
<body>
<div class="container">
<h1>CWD Comments Widget - 测试页面</h1>
<div class="demo-post">
<h2>这是一篇示例文章</h2>
<p>
这是一个用于测试 CWD Comments Widget 的示例页面。组件已经通过 UMD 格式打包,
可以在任何网站上通过简单的配置来使用。评论数据将存储在你的 Cloudflare D1 数据库中。
</p>
</div>
<!-- 评论组件容器 -->
<div id="comments"></div>
</div>
<script src="../dist/cwd-comments.js"></script>
<script>
new CWDComments({
el: '#comments',
apiBaseUrl: 'https://message.zishu.me',
postSlug: '/message'
}).mount();
</script>
</body>
</html>

36
widget/vite.config.js Normal file
View File

@@ -0,0 +1,36 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
export default defineConfig({
plugins: [cssInjectedByJsPlugin()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
build: {
lib: {
name: 'CWDComments',
entry: resolve(__dirname, 'src/index.js'),
formats: ['umd'],
fileName: (format) => `cwd-comments.js`
},
rollupOptions: {
output: {
exports: 'named'
}
},
sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: false
}
}
},
server: {
port: 5173,
open: false
}
});

10848
worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

15
wrangler.jsonc.example Normal file
View File

@@ -0,0 +1,15 @@
/**
* 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-comments",
"main": "src/index.ts",
"compatibility_date": "2026-01-03",
"observability": {
"enabled": true
},
"workers_dev": true,
"preview_urls": true
}