Initial commit
This commit is contained in:
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>
|
||||
`;
|
||||
Reference in New Issue
Block a user