Initial commit
This commit is contained in:
77
.claude/CLAUDE.md
Normal file
77
.claude/CLAUDE.md
Normal 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 D1(SQLite)和 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` 启动本地开发服务器
|
||||||
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal 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
6
.dev.vars.example
Normal 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
12
.editorconfig
Normal 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
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
171
.gitignore
vendored
Normal file
171
.gitignore
vendored
Normal 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
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 140,
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"useTabs": true
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"liveServer.settings.port": 5501
|
||||||
|
}
|
||||||
34
README.md
Normal file
34
README.md
Normal 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)
|
||||||
275
docs/.vitepress/cache/deps/@theme_index.js
vendored
Normal file
275
docs/.vitepress/cache/deps/@theme_index.js
vendored
Normal 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
|
||||||
7
docs/.vitepress/cache/deps/@theme_index.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps/@theme_index.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
40
docs/.vitepress/cache/deps/_metadata.json
vendored
Normal file
40
docs/.vitepress/cache/deps/_metadata.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9719
docs/.vitepress/cache/deps/chunk-LJKO4TMH.js
vendored
Normal file
9719
docs/.vitepress/cache/deps/chunk-LJKO4TMH.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
docs/.vitepress/cache/deps/chunk-LJKO4TMH.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps/chunk-LJKO4TMH.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
12824
docs/.vitepress/cache/deps/chunk-QE257C5J.js
vendored
Normal file
12824
docs/.vitepress/cache/deps/chunk-QE257C5J.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
docs/.vitepress/cache/deps/chunk-QE257C5J.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps/chunk-QE257C5J.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
docs/.vitepress/cache/deps/package.json
vendored
Normal file
3
docs/.vitepress/cache/deps/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
4505
docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js
vendored
Normal file
4505
docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
583
docs/.vitepress/cache/deps/vitepress___@vueuse_core.js
vendored
Normal file
583
docs/.vitepress/cache/deps/vitepress___@vueuse_core.js
vendored
Normal 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
|
||||||
7
docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"sources": [],
|
||||||
|
"sourcesContent": [],
|
||||||
|
"mappings": "",
|
||||||
|
"names": []
|
||||||
|
}
|
||||||
347
docs/.vitepress/cache/deps/vue.js
vendored
Normal file
347
docs/.vitepress/cache/deps/vue.js
vendored
Normal 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
7
docs/.vitepress/cache/deps/vue.js.map
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"sources": [],
|
||||||
|
"sourcesContent": [],
|
||||||
|
"mappings": "",
|
||||||
|
"names": []
|
||||||
|
}
|
||||||
12824
docs/.vitepress/cache/deps_temp_d8b5c95d/chunk-QE257C5J.js
vendored
Normal file
12824
docs/.vitepress/cache/deps_temp_d8b5c95d/chunk-QE257C5J.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
docs/.vitepress/cache/deps_temp_d8b5c95d/chunk-QE257C5J.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps_temp_d8b5c95d/chunk-QE257C5J.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
docs/.vitepress/cache/deps_temp_d8b5c95d/package.json
vendored
Normal file
3
docs/.vitepress/cache/deps_temp_d8b5c95d/package.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
4505
docs/.vitepress/cache/deps_temp_d8b5c95d/vitepress___@vue_devtools-api.js
vendored
Normal file
4505
docs/.vitepress/cache/deps_temp_d8b5c95d/vitepress___@vue_devtools-api.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
docs/.vitepress/cache/deps_temp_d8b5c95d/vitepress___@vue_devtools-api.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps_temp_d8b5c95d/vitepress___@vue_devtools-api.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
9731
docs/.vitepress/cache/deps_temp_d8b5c95d/vitepress___@vueuse_core.js
vendored
Normal file
9731
docs/.vitepress/cache/deps_temp_d8b5c95d/vitepress___@vueuse_core.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
docs/.vitepress/cache/deps_temp_d8b5c95d/vitepress___@vueuse_core.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps_temp_d8b5c95d/vitepress___@vueuse_core.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
347
docs/.vitepress/cache/deps_temp_d8b5c95d/vue.js
vendored
Normal file
347
docs/.vitepress/cache/deps_temp_d8b5c95d/vue.js
vendored
Normal 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_temp_d8b5c95d/vue.js.map
vendored
Normal file
7
docs/.vitepress/cache/deps_temp_d8b5c95d/vue.js.map
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"sources": [],
|
||||||
|
"sourcesContent": [],
|
||||||
|
"mappings": "",
|
||||||
|
"names": []
|
||||||
|
}
|
||||||
43
docs/.vitepress/config.mjs
Normal file
43
docs/.vitepress/config.mjs
Normal 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
150
docs/api/admin.md
Normal 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
48
docs/api/overview.md
Normal 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
90
docs/api/public.md
Normal 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": "评论提交成功,等待审核"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
103
docs/guide/backend-config.md
Normal file
103
docs/guide/backend-config.md
Normal 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
|
||||||
|
```
|
||||||
75
docs/guide/frontend-config.md
Normal file
75
docs/guide/frontend-config.md
Normal 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' });
|
||||||
|
```
|
||||||
34
docs/guide/getting-started.md
Normal file
34
docs/guide/getting-started.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 快速开始
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
|
||||||
|
CWD 评论系统是一个基于 Cloudflare Workers 的轻量级评论解决方案,使用 Hono 框架构建,数据存储使用 Cloudflare D1(SQLite)和 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
28
docs/index.md
Normal 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
14
docs/package.json
Normal 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
28
package.json
Normal 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
2391
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
schemas/comment.sql
Normal file
24
schemas/comment.sql
Normal 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);
|
||||||
23
src/api/admin/deleteComment.ts
Normal file
23
src/api/admin/deleteComment.ts
Normal 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}.`
|
||||||
|
});
|
||||||
|
};
|
||||||
41
src/api/admin/listComments.ts
Normal file
41
src/api/admin/listComments.ts
Normal 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
62
src/api/admin/login.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
};
|
||||||
23
src/api/admin/updateStatus.ts
Normal file
23
src/api/admin/updateStatus.ts
Normal 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}.`
|
||||||
|
});
|
||||||
|
};
|
||||||
108
src/api/public/getComments.ts
Normal file
108
src/api/public/getComments.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/api/public/postComment.ts
Normal file
106
src/api/public/postComment.ts
Normal 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
10
src/bindings.ts
Normal 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
44
src/index.ts
Normal 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
23
src/utils/auth.ts
Normal 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
23
src/utils/cors.ts
Normal 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
107
src/utils/email.ts
Normal 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
19
src/utils/getAvatar.ts
Normal 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
314
src/views/admin.ts
Normal 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
100
src/views/login.ts
Normal 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
45
tsconfig.json
Normal 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
11
vitest.config.mts
Normal 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
78
widget/CONFIG.md
Normal 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
18
widget/README.md
Normal 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
169
widget/index.html
Normal 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
16
widget/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
262
widget/src/components/CommentForm.js
Normal file
262
widget/src/components/CommentForm.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
285
widget/src/components/CommentItem.js
Normal file
285
widget/src/components/CommentItem.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
247
widget/src/components/CommentList.js
Normal file
247
widget/src/components/CommentList.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
widget/src/components/Component.js
Normal file
169
widget/src/components/Component.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
widget/src/components/Loading.js
Normal file
45
widget/src/components/Loading.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
widget/src/components/Pagination.js
Normal file
124
widget/src/components/Pagination.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
widget/src/components/ReplyEditor.js
Normal file
194
widget/src/components/ReplyEditor.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
377
widget/src/core/CWDComments.js
Normal file
377
widget/src/core/CWDComments.js
Normal 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
83
widget/src/core/api.js
Normal 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
375
widget/src/core/store.js
Normal 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
184
widget/src/dev.js
Normal 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
27
widget/src/index.js
Normal 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
620
widget/src/styles/main.css
Normal 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);
|
||||||
|
}
|
||||||
60
widget/src/styles/variables.css
Normal file
60
widget/src/styles/variables.css
Normal 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
78
widget/src/utils/date.js
Normal 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
210
widget/src/utils/dom.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
122
widget/src/utils/validator.js
Normal file
122
widget/src/utils/validator.js
Normal 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
21
widget/src/vite-env.d.ts
vendored
Normal 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
78
widget/test/test.html
Normal 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
36
widget/vite.config.js
Normal 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
10848
worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
15
wrangler.jsonc.example
Normal file
15
wrangler.jsonc.example
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user