feat: 新增CWD评论系统前后端代码及文档

refactor: 移除旧版评论系统代码并重构为Vue3前端

docs: 更新后端配置文档说明

fix: 修复评论提交频率限制和邮件通知逻辑

style: 格式化代码并优化样式

test: 添加Vitest测试配置

build: 更新依赖项和构建配置

chore: 清理无用文件和缓存
This commit is contained in:
anghunk
2026-01-19 14:58:18 +08:00
parent 836ac08296
commit 3d0e3a317a
53 changed files with 1201 additions and 28231 deletions

View File

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

View File

@@ -0,0 +1,13 @@
<!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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
{
"name": "cwd-comments-admin",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"typescript": "^5.5.2",
"vite": "^6.0.11",
"vue-tsc": "^2.1.10"
}
}

View File

@@ -0,0 +1,23 @@
<template>
<div class="app-root">
<router-view />
</div>
</template>
<script setup lang="ts"></script>
<style>
html,
body,
#app,
.app-root {
height: 100%;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,65 @@
import { get, post, put, del } from './http';
export type AdminLoginResponse = {
data: {
key: string;
};
};
export type CommentItem = {
id: number;
pubDate: string;
author: string;
email: string;
postSlug: string;
url: string | null;
ipAddress: string | null;
contentText: string;
contentHtml: string;
status: string;
};
export type CommentListResponse = {
data: CommentItem[];
pagination: {
page: number;
limit: number;
total: number;
};
};
export type AdminEmailResponse = {
email: string | null;
};
export async function loginAdmin(name: string, password: string): Promise<string> {
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
const key = res.data.key;
localStorage.setItem('cwd_admin_token', key);
return key;
}
export function logoutAdmin(): void {
localStorage.removeItem('cwd_admin_token');
}
export function fetchComments(page: number): Promise<CommentListResponse> {
return get<CommentListResponse>(`/admin/comments/list?page=${page}`);
}
export function deleteComment(id: number): Promise<{ message: string }> {
return del<{ message: string }>(`/admin/comments/delete?id=${id}`);
}
export function updateCommentStatus(id: number, status: string): Promise<{ message: string }> {
return put<{ message: string }>(`/admin/comments/status?id=${id}&status=${encodeURIComponent(status)}`);
}
export function fetchAdminEmail(): Promise<AdminEmailResponse> {
return get<AdminEmailResponse>('/admin/settings/email');
}
export function saveAdminEmail(email: string): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/email', { email });
}

View File

@@ -0,0 +1,47 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '');
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
async function request<T>(method: HttpMethod, path: string, body?: unknown): Promise<T> {
const token = localStorage.getItem('cwd_admin_token');
const headers: HeadersInit = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE_URL}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
let data: any = null;
try {
data = await res.json();
} catch {
data = null;
}
if (!res.ok) {
const message = data && data.message ? data.message : `请求失败,状态码 ${res.status}`;
throw new Error(message);
}
return data as T;
}
export function get<T>(path: string): Promise<T> {
return request<T>('GET', path);
}
export function post<T>(path: string, body?: unknown): Promise<T> {
return request<T>('POST', path, body);
}
export function put<T>(path: string, body?: unknown): Promise<T> {
return request<T>('PUT', path, body);
}
export function del<T>(path: string): Promise<T> {
return request<T>('DELETE', path);
}

10
cwd-comments-admin/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router';
const app = createApp(App);
app.use(router);
app.mount('#app');

View File

@@ -0,0 +1,52 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import LoginView from '../views/LoginView.vue';
import LayoutView from '../views/LayoutView.vue';
import CommentsView from '../views/CommentsView.vue';
import SettingsView from '../views/SettingsView.vue';
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/',
component: LayoutView,
children: [
{
path: '',
redirect: '/comments'
},
{
path: 'comments',
name: 'comments',
component: CommentsView
},
{
path: 'settings',
name: 'settings',
component: SettingsView
}
]
}
];
export const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to, from, next) => {
if (to.name === 'login') {
next();
return;
}
const token = localStorage.getItem('cwd_admin_token');
if (!token) {
next({ name: 'login' });
return;
}
next();
});

View File

@@ -0,0 +1,254 @@
<template>
<div class="page">
<h2 class="page-title">评论管理</h2>
<div class="toolbar">
<div class="toolbar-left">
<select v-model="statusFilter" class="toolbar-select">
<option value="">全部状态</option>
<option value="approved">已通过</option>
<option value="pending">待审核</option>
<option value="rejected">已拒绝</option>
</select>
</div>
<div class="toolbar-right">
<button class="toolbar-button" @click="loadComments">刷新</button>
</div>
</div>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>文章</th>
<th>作者</th>
<th>邮箱</th>
<th>内容</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredComments" :key="item.id">
<td>{{ item.id }}</td>
<td>{{ item.postSlug }}</td>
<td>{{ item.author }}</td>
<td>{{ item.email }}</td>
<td class="table-content">{{ item.contentText }}</td>
<td>{{ item.status }}</td>
<td>{{ formatDate(item.pubDate) }}</td>
<td>
<button class="table-button" @click="changeStatus(item, 'approved')" :disabled="item.status === 'approved'">
通过
</button>
<button class="table-button" @click="changeStatus(item, 'pending')" :disabled="item.status === 'pending'">
待审
</button>
<button class="table-button" @click="changeStatus(item, 'rejected')" :disabled="item.status === 'rejected'">
拒绝
</button>
<button class="table-button table-button-danger" @click="removeComment(item)">删除</button>
</td>
</tr>
<tr v-if="filteredComments.length === 0">
<td colspan="8" class="page-hint">暂无数据</td>
</tr>
</tbody>
</table>
<div v-if="pagination.total > 1" class="pagination">
<button class="pagination-button" :disabled="pagination.page <= 1" @click="goPage(pagination.page - 1)">上一页</button>
<span class="pagination-info">{{ pagination.page }} / {{ pagination.total }}</span>
<button class="pagination-button" :disabled="pagination.page >= pagination.total" @click="goPage(pagination.page + 1)">
下一页
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue';
import { CommentItem, CommentListResponse, fetchComments, deleteComment, updateCommentStatus } from '../api/admin';
const comments = ref<CommentItem[]>([]);
const pagination = ref<{ page: number; total: number }>({ page: 1, total: 1 });
const loading = ref(false);
const error = ref('');
const statusFilter = ref('');
const filteredComments = computed(() => {
if (!statusFilter.value) {
return comments.value;
}
return comments.value.filter((item) => item.status === statusFilter.value);
});
function formatDate(value: string) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
return value;
}
return d.toLocaleString();
}
async function loadComments(page = 1) {
loading.value = true;
error.value = '';
try {
const res = await fetchComments(page);
comments.value = res.data;
pagination.value = { page: res.pagination.page, total: res.pagination.total };
} catch (e: any) {
error.value = e.message || '加载失败';
} finally {
loading.value = false;
}
}
async function goPage(page: number) {
if (page < 1 || page > pagination.value.total) {
return;
}
await loadComments(page);
}
async function changeStatus(item: CommentItem, status: string) {
try {
await updateCommentStatus(item.id, status);
item.status = status;
} catch (e: any) {
error.value = e.message || '更新状态失败';
}
}
async function removeComment(item: CommentItem) {
if (!window.confirm(`确认删除评论 ${item.id}`)) {
return;
}
try {
await deleteComment(item.id);
comments.value = comments.value.filter((c) => c.id !== item.id);
} catch (e: any) {
error.value = e.message || '删除失败';
}
}
onMounted(() => {
loadComments();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
color: #24292f;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.toolbar-left {
display: flex;
gap: 8px;
}
.toolbar-right {
display: flex;
gap: 8px;
}
.toolbar-select {
padding: 4px 8px;
font-size: 13px;
}
.toolbar-button {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid #d0d7de;
background-color: #f6f8fa;
cursor: pointer;
font-size: 13px;
}
.page-hint {
font-size: 14px;
color: #57606a;
}
.page-error {
font-size: 14px;
color: #d1242f;
}
.table {
width: 100%;
border-collapse: collapse;
background-color: #ffffff;
}
.table th,
.table td {
border: 1px solid #d0d7de;
padding: 6px 8px;
font-size: 13px;
text-align: left;
vertical-align: top;
}
.table-content {
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table-button {
margin-right: 4px;
padding: 4px 6px;
border-radius: 4px;
border: 1px solid #d0d7de;
background-color: #f6f8fa;
font-size: 12px;
cursor: pointer;
}
.table-button-danger {
border-color: #d1242f;
color: #d1242f;
}
.pagination {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.pagination-button {
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #d0d7de;
background-color: #f6f8fa;
font-size: 12px;
cursor: pointer;
}
.pagination-info {
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="layout">
<header class="layout-header">
<div class="layout-title">CWD 评论后台</div>
<div class="layout-actions">
<button class="layout-button" @click="goSettings">设置</button>
<button class="layout-button" @click="handleLogout">退出</button>
</div>
</header>
<div class="layout-body">
<nav class="layout-sider">
<ul class="menu">
<li class="menu-item" :class="{ active: isRouteActive('comments') }" @click="goComments">评论管理</li>
<li class="menu-item" :class="{ active: isRouteActive('settings') }" @click="goSettings">系统设置</li>
</ul>
</nav>
<main class="layout-content">
<router-view />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router';
import { logoutAdmin } from '../api/admin';
const router = useRouter();
const route = useRoute();
function isRouteActive(name: string) {
return route.name === name;
}
function goComments() {
router.push({ name: 'comments' });
}
function goSettings() {
router.push({ name: 'settings' });
}
function handleLogout() {
logoutAdmin();
router.push({ name: 'login' });
}
</script>
<style scoped>
.layout {
display: flex;
flex-direction: column;
height: 100%;
}
.layout-header {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background-color: #24292f;
color: #ffffff;
}
.layout-title {
font-size: 18px;
font-weight: 600;
}
.layout-actions {
display: flex;
gap: 8px;
}
.layout-button {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid #57606a;
background-color: #24292f;
color: #ffffff;
cursor: pointer;
font-size: 13px;
}
.layout-button:hover {
background-color: #32383f;
}
.layout-body {
display: flex;
flex: 1;
min-height: 0;
}
.layout-sider {
width: 180px;
background-color: #f6f8fa;
border-right: 1px solid #d0d7de;
}
.menu {
list-style: none;
margin: 0;
padding: 12px 0;
}
.menu-item {
padding: 10px 16px;
cursor: pointer;
font-size: 14px;
color: #24292f;
}
.menu-item:hover {
background-color: #eaeef2;
}
.menu-item.active {
background-color: #d0ebff;
font-weight: 600;
}
.layout-content {
flex: 1;
padding: 16px 20px;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="login-page">
<div class="login-card">
<h1 class="login-title">CWD 评论后台</h1>
<form class="login-form" @submit.prevent="handleSubmit">
<div class="form-item">
<label class="form-label">管理员账号</label>
<input v-model="name" class="form-input" type="text" autocomplete="username" />
</div>
<div class="form-item">
<label class="form-label">密码</label>
<input v-model="password" class="form-input" type="password" autocomplete="current-password" />
</div>
<div v-if="error" class="form-error">{{ error }}</div>
<button class="form-button" type="submit" :disabled="submitting">
<span v-if="submitting">登录中...</span>
<span v-else>登录</span>
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { loginAdmin } from '../api/admin';
const router = useRouter();
const name = ref('');
const password = ref('');
const submitting = ref(false);
const error = ref('');
async function handleSubmit() {
if (!name.value || !password.value) {
error.value = '请输入账号和密码';
return;
}
error.value = '';
submitting.value = true;
try {
await loginAdmin(name.value, password.value);
router.push({ name: 'comments' });
} catch (e: any) {
error.value = e.message || '登录失败';
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.login-card {
background-color: #ffffff;
padding: 32px 40px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
width: 360px;
}
.login-title {
margin: 0 0 24px;
font-size: 22px;
text-align: center;
color: #333333;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 14px;
color: #555555;
}
.form-input {
padding: 8px 10px;
border-radius: 4px;
border: 1px solid #d0d7de;
font-size: 14px;
outline: none;
}
.form-input:focus {
border-color: #0969da;
box-shadow: 0 0 0 1px rgba(9, 105, 218, 0.2);
}
.form-error {
font-size: 13px;
color: #d1242f;
}
.form-button {
margin-top: 8px;
padding: 10px 0;
border-radius: 4px;
border: none;
background-color: #0969da;
color: #ffffff;
font-size: 15px;
cursor: pointer;
}
.form-button:disabled {
opacity: 0.7;
cursor: default;
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<div class="page">
<h2 class="page-title">系统设置</h2>
<div class="card">
<h3 class="card-title">通知邮箱设置</h3>
<div class="form-item">
<label class="form-label">管理员通知邮箱</label>
<input v-model="email" class="form-input" type="email" />
</div>
<div v-if="message" :class="['form-message', messageType === 'error' ? 'form-message-error' : 'form-message-success']">
{{ message }}
</div>
<div class="card-actions">
<button class="card-button" :disabled="saving" @click="save">
<span v-if="saving">保存中...</span>
<span v-else>保存</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { fetchAdminEmail, saveAdminEmail } from '../api/admin';
const email = ref('');
const saving = ref(false);
const message = ref('');
const messageType = ref<'success' | 'error'>('success');
async function load() {
try {
const res = await fetchAdminEmail();
email.value = res.email || '';
} catch (e: any) {
message.value = e.message || '加载失败';
messageType.value = 'error';
}
}
async function save() {
if (!email.value) {
message.value = '请输入邮箱';
messageType.value = 'error';
return;
}
saving.value = true;
message.value = '';
try {
const res = await saveAdminEmail(email.value);
message.value = res.message || '保存成功';
messageType.value = 'success';
} catch (e: any) {
message.value = e.message || '保存失败';
messageType.value = 'error';
} finally {
saving.value = false;
}
}
onMounted(() => {
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 520px;
}
.page-title {
margin: 0;
font-size: 18px;
color: #24292f;
}
.card {
background-color: #ffffff;
border-radius: 6px;
border: 1px solid #d0d7de;
padding: 16px 18px;
}
.card-title {
margin: 0 0 12px;
font-size: 15px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.form-label {
font-size: 14px;
color: #555555;
}
.form-input {
padding: 8px 10px;
border-radius: 4px;
border: 1px solid #d0d7de;
font-size: 14px;
outline: none;
}
.form-input:focus {
border-color: #0969da;
box-shadow: 0 0 0 1px rgba(9, 105, 218, 0.2);
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.card-button {
padding: 8px 14px;
border-radius: 4px;
border: none;
background-color: #0969da;
color: #ffffff;
font-size: 14px;
cursor: pointer;
}
.card-button:disabled {
opacity: 0.7;
cursor: default;
}
.form-message {
font-size: 13px;
margin-bottom: 8px;
}
.form-message-success {
color: #1a7f37;
}
.form-message-error {
color: #d1242f;
}
</style>

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5176
}
});

View File

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

View File

@@ -7,12 +7,7 @@
"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"
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.19",

View File

@@ -0,0 +1,18 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
export const getAdminEmail = async (c: Context<{ Bindings: Bindings }>) => {
try {
let email: string | null = null;
if (c.env.CWD_CONFIG_KV) {
email = await c.env.CWD_CONFIG_KV.get('settings:admin_notify_email');
}
if (!email) {
email = c.env.EMAIL_ADDRESS || null;
}
return c.json({ email });
} catch (e: any) {
return c.json({ message: e.message }, 500);
}
};

View File

@@ -0,0 +1,23 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export const setAdminEmail = async (c: Context<{ Bindings: Bindings }>) => {
try {
const { email } = await c.req.json();
if (!email || !isValidEmail(email)) {
return c.json({ message: '邮箱格式不正确' }, 400);
}
if (!c.env.CWD_CONFIG_KV) {
return c.json({ message: '未配置 CWD_CONFIG_KV无法保存设置' }, 500);
}
await c.env.CWD_CONFIG_KV.put('settings:admin_notify_email', email);
return c.json({ message: '保存成功' });
} catch (e: any) {
return c.json({ message: e.message }, 500);
}
};

View File

@@ -0,0 +1,151 @@
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();
if (!data || typeof data !== 'object') {
return c.json({ message: '无效的请求体' }, 400);
}
const { post_slug, content: rawContent, author: rawAuthor, email, url, parent_id, post_title, post_url } = data;
if (!post_slug || typeof post_slug !== 'string') {
return c.json({ message: 'post_slug 必填' }, 400);
}
if (!rawContent || typeof rawContent !== 'string') {
return c.json({ message: '评论内容不能为空' }, 400);
}
if (!rawAuthor || typeof rawAuthor !== 'string') {
return c.json({ message: '昵称不能为空' }, 400);
}
if (!email || typeof email !== 'string') {
return c.json({ message: '邮箱不能为空' }, 400);
}
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) {
return c.json({ message: "评论频繁等10s后再试" }, 429);
}
}
// 初始化邮件日志表(若不存在)
await c.env.CWD_DB.prepare(`
CREATE TABLE IF NOT EXISTS EmailLog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient TEXT NOT NULL,
type TEXT NOT NULL,
ip_address TEXT,
created_at TEXT NOT NULL
)
`).run();
// 3. 准备数据
const content = checkContent(rawContent);
const author = checkContent(rawAuthor);
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(),
post_slug,
author,
email,
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,
parent_id || null,
"approved" // 或者从环境变量读取默认状态
).run();
if (!success) throw new Error("Database insert failed");
// 5. 发送邮件通知 (后台异步执行,不阻塞用户响应)
if (c.env.SEND_EMAIL && c.env.CF_FROM_EMAIL) {
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) {
const recentUserMail = await c.env.CWD_DB.prepare(
"SELECT created_at FROM EmailLog WHERE recipient = ? AND type = 'user-reply' ORDER BY created_at DESC LIMIT 1"
).bind(parentComment.email).first<{ created_at: string }>();
const canSendUserMail = !recentUserMail || (Date.now() - new Date(recentUserMail.created_at).getTime() > 60 * 1000);
if (canSendUserMail) {
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,
});
await c.env.CWD_DB.prepare(
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
).bind(parentComment.email, 'user-reply', ip, new Date().toISOString()).run();
}
}
} else {
// 新评论通知站长
const adminEmailRow = await c.env.CWD_DB.prepare(
"SELECT created_at FROM EmailLog WHERE type = 'admin-notify' ORDER BY created_at DESC LIMIT 1"
).first<{ created_at: string }>();
const canSendAdminMail = !adminEmailRow || (Date.now() - new Date(adminEmailRow.created_at).getTime() > 15 * 1000);
if (canSendAdminMail) {
await sendCommentNotification(c.env, {
postTitle: data.post_title,
postUrl: data.post_url,
commentAuthor: author,
commentContent: content
});
await c.env.CWD_DB.prepare(
"INSERT INTO EmailLog (recipient, type, ip_address, created_at) VALUES (?, ?, ?, ?)"
).bind('admin', 'admin-notify', ip, new Date().toISOString()).run();
}
}
} 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);
}
};

View File

@@ -1,10 +1,13 @@
export type Bindings = {
CWD_DB: D1Database
CWD_AUTH_KV: KVNamespace;
CWD_CONFIG_KV?: KVNamespace;
ALLOW_ORIGIN: string
RESEND_API_KEY?: string
RESEND_FROM_EMAIL?: string
CF_FROM_EMAIL?: string
SEND_EMAIL?: {
send: (message: any) => Promise<any>
}
EMAIL_ADDRESS?: string
ADMIN_NAME: string
ADMIN_PASSWORD: string
}
}

View File

@@ -1,9 +1,6 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { Bindings } from './bindings';
import { loginView } from './views/login';
import { AdminView } from './views/admin';
import { SettingsView } from './views/settings';
import { customCors } from './utils/cors';
import { adminAuth } from './utils/auth';
@@ -13,6 +10,8 @@ import { adminLogin } from './api/admin/login';
import { deleteComment } from './api/admin/deleteComment';
import { listComments } from './api/admin/listComments';
import { updateStatus } from './api/admin/updateStatus';
import { getAdminEmail } from './api/admin/getAdminEmail';
import { setAdminEmail } from './api/admin/setAdminEmail';
const app = new Hono<{ Bindings: Bindings }>();
@@ -26,16 +25,6 @@ app.use('/admin/*', async (c, next) => {
return corsMiddleware(c, next);
});
// 页面路由
app.get('/', (c) => c.redirect('/login'));
app.get('/login', (c) => {
const isDev = new URL(c.req.url).hostname === 'localhost';
return c.html(loginView(isDev, c.env.ADMIN_NAME, c.env.ADMIN_PASSWORD));
});
app.get('/admin', (c) => c.html(AdminView));
app.get('/admin/settings', (c) => c.html(SettingsView));
// API
app.get('/api/comments', getComments);
app.post('/api/comments', postComment);
@@ -45,5 +34,8 @@ app.use('/admin/*', adminAuth);
app.delete('/admin/comments/delete', deleteComment);
app.get('/admin/comments/list', listComments);
app.put('/admin/comments/status', updateStatus);
// 设置接口
app.get('/admin/settings/email', getAdminEmail);
app.put('/admin/settings/email', setAdminEmail);
export default app;

View File

@@ -1,37 +1,5 @@
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();
}
/**
*
*/
@@ -49,11 +17,7 @@ export async function sendCommentReplyNotification(
) {
const { toEmail, toName, postTitle, parentComment, replyAuthor, replyContent, postUrl } = params;
return await resendFetch(env, {
from: `评论通知 ${env.RESEND_FROM_EMAIL}`,
to: [toEmail],
subject: `你在 example.com 上的评论有了新回复`,
html: `
const 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>
@@ -72,7 +36,17 @@ export async function sendCommentReplyNotification(
<hr style="border: none; border-top: 1px solid #eee; margin-top: 30px;">
<p style="font-size: 12px; color: #999;"></p>
</div>
`
`;
if (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
throw new Error('未配置邮件发送绑定或发件人地址');
}
await env.SEND_EMAIL.send({
to: [{ email: toEmail }],
from: { email: env.CF_FROM_EMAIL },
subject: `你在 example.com 上的评论有了新回复`,
html
});
}
@@ -88,20 +62,37 @@ export async function sendCommentNotification(
commentContent: string;
}
) {
const { postTitle, postUrl, commentAuthor, commentContent } = params;
const { postTitle, postUrl, commentAuthor, commentContent } = params;
const toEmail = await getAdminNotifyEmail(env);
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>
const 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>
`;
if (!env.SEND_EMAIL || !env.CF_FROM_EMAIL) {
throw new Error('未配置邮件发送绑定或发件人地址');
}
await env.SEND_EMAIL.send({
to: [{ email: toEmail }],
from: { email: env.CF_FROM_EMAIL },
subject: `新评论通知:${postTitle}`,
html
});
}
}
// 读取管理员通知邮箱:优先 KV 设置,其次环境变量
async function getAdminNotifyEmail(env: Bindings): Promise<string> {
if (env.CWD_CONFIG_KV) {
const val = await env.CWD_CONFIG_KV.get('settings:admin_notify_email');
if (val) return val;
}
if (env.EMAIL_ADDRESS) return env.EMAIL_ADDRESS;
throw new Error('未配置管理员通知邮箱');
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -8,6 +8,12 @@
## 部署
**以下部署指令均在该目录下执行,不在根目录下**
```
cd cwd-comments-api
```
### 本地部署
#### 1. 下载代码,安装依赖
@@ -44,9 +50,11 @@ npm install
]
```
如果`binding`字段不是`CWD_DB`,请修改为`CWD_DB`
* **创建 KV 存储**,如果遇到提示,按回车继续
```bash
npx wrangler kv namespace create CWD_AUTH_KV
npx wrangler kv namespace create CWD_CONFIG_KV
```
运行完成后可以确认一下 `wrangler.jsonc` 中是否有如下配置
```jsonc
@@ -54,6 +62,10 @@ npm install
{
"binding": "CWD_AUTH_KV",
"id": "xxxxxxx" // KV 存储 ID
},
{
"binding": "CWD_CONFIG_KV",
"id": "xxxxxxx" // KV 存储 ID
}
]
```
@@ -80,16 +92,15 @@ npm install
所需环境变量如下表所示,请参考源码中 `.dev.vars.example` 文件
| 变量名 | 描述 |
| ------------------- | -------------------------------------------------------------------------------- |
| `ALLOW_ORIGIN` | 允许跨域请求的域名,用逗号分隔 |
| `RESEND_API_KEY` | Resend API Key用于启用邮件通知服务如**果不需要邮件通知服务,可以不填** |
| `RESEND_FROM_EMAIL` | Resend 发送邮件的邮箱,用于邮件通知服务,**如果不需要邮件通知服务,可以不填** |
| `EMAIL_ADDRESS` | 管理员接收通知邮件的邮箱,用于邮件通知服务,**如果不需要邮件通知服务,可以不填** |
| `ADMIN_NAME` | 管理员登录名称,默认为 admin |
| `ADMIN_PASSWORD` | 管理员登录密码,默认密码为 password |
| 变量名 | 描述 |
| -------------------- | ------------------------------------------------------------------------------------ |
| `ALLOW_ORIGIN` | 允许跨域请求的域名,用逗号分隔 |
| `CF_FROM_EMAIL` | 作为发件人显示的邮箱地址(需在 Cloudflare Email 路由中预先配置) |
| `EMAIL_ADDRESS` | 管理员接收通知邮件的默认邮箱(可在后台设置中覆盖) |
| `ADMIN_NAME` | 管理员登录名称,默认为 admin |
| `ADMIN_PASSWORD` | 管理员登录密码,默认密码为 password |
**注:** [Resend 官网](https://resend.com/)
**注:** 需要在 Cloudflare 控制面板中为 Email 路由开启发送权限并配置发件人域和地址,并在 `wrangler.jsonc` 中为 Worker 添加 `send_email` 绑定,以便在代码中通过 `env.SEND_EMAIL.send()` 直接发信。
## 本地测试

View File

@@ -1,106 +0,0 @@
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);
}
};

View File

@@ -1,339 +0,0 @@
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);
}
}
aside {
height: 100vh;
overflow-y: auto;
position: sticky;
top: 0;
}
</style>
</head>
<body class="bg-gray-100 text-gray-800">
<div id="app" class="min-h-screen flex">
<!-- 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>
<!-- 侧边栏 -->
<aside class="w-64 bg-white shadow-lg flex flex-col min-h-screen">
<div class="p-4 border-b">
<h1 class="text-xl font-bold text-blue-600">CWD 评论系统</h1>
<p class="text-xs text-gray-400 mt-1 truncate">{{ config.baseUrl }}</p>
</div>
<nav class="flex-1 p-4">
<a href="/admin" class="flex items-center px-4 py-3 rounded-lg mb-2 bg-blue-50 text-blue-600 transition">
<i class="fa-solid fa-comments w-5"></i>
<span class="ml-3">评论管理</span>
</a>
<a href="/admin/settings" class="flex items-center px-4 py-3 rounded-lg mb-2 text-gray-600 hover:bg-gray-50 transition">
<i class="fa-solid fa-gear w-5"></i>
<span class="ml-3">设置</span>
</a>
</nav>
<div class="p-4 border-t">
<button @click="logout" class="flex items-center w-full px-4 py-3 rounded-lg text-red-500 hover:bg-red-50 transition">
<i class="fa-solid fa-sign-out-alt w-5"></i>
<span class="ml-3">退出登录</span>
</button>
</div>
</aside>
<!-- 主内容 -->
<main class="flex-1 p-8 overflow-auto">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-700">评论管理</h2>
<button @click="fetchComments(pagination.page)" class="text-gray-600 hover:text-blue-600">
<i class="fa-solid fa-rotate-right mr-1"></i> 刷新
</button>
</div>
<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' },
});
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;
state.comments = [];
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';
};
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,
formatDate,
};
},
}).mount('#app');
</script>
</body>
</html>
`;

View File

@@ -1,129 +0,0 @@
import { html } from 'hono/html';
export const loginView = (isDev: boolean, adminName?: string, adminPassword?: string) => 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: '${isDev ? adminName : ''}', password: '${isDev ? adminPassword : ''}' },
});
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>
`;

View File

@@ -1,130 +0,0 @@
import { html } from 'hono/html';
export const SettingsView = 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;
}
aside {
height: 100vh;
overflow-y: auto;
position: sticky;
top: 0;
}
</style>
</head>
<body class="bg-gray-100 text-gray-800">
<div id="app" class="min-h-screen flex">
<!-- 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>
<!-- 侧边栏 -->
<aside class="w-64 bg-white shadow-lg flex flex-col min-h-screen">
<div class="p-4 border-b">
<h1 class="text-xl font-bold text-blue-600">CWD 评论系统</h1>
<p class="text-xs text-gray-400 mt-1 truncate">{{ config.baseUrl }}</p>
</div>
<nav class="flex-1 p-4">
<a href="/admin" class="flex items-center px-4 py-3 rounded-lg mb-2 text-gray-600 hover:bg-gray-50 transition">
<i class="fa-solid fa-comments w-5"></i>
<span class="ml-3">评论管理</span>
</a>
<a href="/admin/settings" class="flex items-center px-4 py-3 rounded-lg mb-2 bg-blue-50 text-blue-600 transition">
<i class="fa-solid fa-gear w-5"></i>
<span class="ml-3">设置</span>
</a>
</nav>
<div class="p-4 border-t">
<button @click="logout" class="flex items-center w-full px-4 py-3 rounded-lg text-red-500 hover:bg-red-50 transition">
<i class="fa-solid fa-sign-out-alt w-5"></i>
<span class="ml-3">退出登录</span>
</button>
</div>
</aside>
<!-- 主内容 -->
<main class="flex-1 p-8 overflow-auto">
<h2 class="text-2xl font-bold text-gray-700 mb-6">设置</h2>
<div class="bg-white shadow rounded-lg p-6 max-w-xl">
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2">博客域名前缀</label>
<input
v-model="settingsForm.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>
<div class="flex mt-4">
<button @click="saveSettings" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">确认保存</button>
</div>
</main>
</div>
<script>
const { createApp, reactive, toRefs, onMounted } = Vue;
createApp({
setup() {
const state = reactive({
config: { baseUrl: localStorage.getItem('apiBaseUrl') || location.origin },
settingsForm: { blogDomain: localStorage.getItem('blogDomain') || '' },
toast: { show: false, message: '', type: 'success' },
});
const showToast = (msg, type = 'success') => {
state.toast = { show: true, message: msg, type };
setTimeout(() => (state.toast.show = false), 3000);
};
const logout = () => {
localStorage.removeItem('adminKey');
window.location.href = '/login';
};
const saveSettings = () => {
localStorage.setItem('blogDomain', state.settingsForm.blogDomain);
showToast('设置已保存');
};
onMounted(() => {
if (!localStorage.getItem('adminKey')) {
window.location.href = '/login';
}
});
return { ...toRefs(state), logout, saveSettings };
},
}).mount('#app');
</script>
</body>
</html>
`;