feat(评论系统): 添加评论配置和头像显示功能

- 新增评论配置管理功能,包括博主邮箱、标签和头像前缀设置
- 在评论列表显示用户头像
- 实现服务端配置存储和获取逻辑
- 优化前端设置页面,拆分不同配置项
- 修改开发预览页面配置保存逻辑
This commit is contained in:
anghunk
2026-01-20 09:23:46 +08:00
parent fe463125ea
commit 456771bae3
8 changed files with 304 additions and 66 deletions

View File

@@ -1,13 +1,15 @@
<!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>
<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

@@ -11,6 +11,7 @@ export type CommentItem = {
pubDate: string;
author: string;
email: string;
avatar: string;
postSlug: string;
url: string | null;
ipAddress: string | null;
@@ -32,6 +33,12 @@ export type AdminEmailResponse = {
email: string | null;
};
export type CommentSettingsResponse = {
adminEmail: string | null;
adminBadge: string | null;
avatarPrefix: 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;
@@ -63,3 +70,14 @@ export function saveAdminEmail(email: string): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/email', { email });
}
export function fetchCommentSettings(): Promise<CommentSettingsResponse> {
return get<CommentSettingsResponse>('/admin/settings/comments');
}
export function saveCommentSettings(data: {
adminEmail?: string;
adminBadge?: string;
avatarPrefix?: string;
}): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/comments', data);
}

View File

@@ -27,9 +27,19 @@
</div>
<div v-for="item in filteredComments" :key="item.id" class="table-row">
<div class="table-cell table-cell-author">
<div class="cell-author-name">{{ item.author }}</div>
<div class="cell-author-email">{{ item.email }}</div>
<span class="cell-time">{{ formatDate(item.pubDate) }}</span>
<div class="cell-author-wrapper">
<img
v-if="item.avatar"
:src="item.avatar"
class="cell-avatar"
:alt="item.author"
/>
<div class="cell-author-main">
<div class="cell-author-name">{{ item.author }}</div>
<div class="cell-author-email">{{ item.email }}</div>
<span class="cell-time">{{ formatDate(item.pubDate) }}</span>
</div>
</div>
</div>
<div class="table-cell table-cell-content">
<div class="cell-content-text">{{ item.contentText }}</div>
@@ -291,8 +301,6 @@ onMounted(() => {
.table-cell-author {
width: 180px;
flex-direction: column;
align-items: flex-start !important;
flex-shrink: 0;
}
@@ -369,6 +377,24 @@ onMounted(() => {
color: #57606a;
}
.cell-author-wrapper {
display: flex;
align-items: flex-start;
gap: 8px;
}
.cell-author-main {
display: flex;
flex-direction: column;
}
.cell-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}
.cell-status {
padding: 3px 8px;
border-radius: 999px;

View File

@@ -19,8 +19,30 @@
{{ message }}
</div>
<div class="card-actions">
<button class="card-button" :disabled="saving" @click="save">
<span v-if="saving">保存中...</span>
<button class="card-button" :disabled="savingEmail" @click="saveEmail">
<span v-if="savingEmail">保存中...</span>
<span v-else>保存</span>
</button>
</div>
</div>
<div class="card">
<h3 class="card-title">评论显示配置</h3>
<div class="form-item">
<label class="form-label">评论博主邮箱用于前台标记</label>
<input v-model="commentAdminEmail" class="form-input" type="email" />
</div>
<div class="form-item">
<label class="form-label">博主标签文字</label>
<input v-model="commentAdminBadge" class="form-input" type="text" />
</div>
<div class="form-item">
<label class="form-label">头像前缀Gravatar/Cravatar</label>
<input v-model="avatarPrefix" class="form-input" type="text" />
</div>
<div class="card-actions">
<button class="card-button" :disabled="savingComment" @click="saveComment">
<span v-if="savingComment">保存中...</span>
<span v-else>保存</span>
</button>
</div>
@@ -31,10 +53,19 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { fetchAdminEmail, saveAdminEmail } from "../api/admin";
import {
fetchAdminEmail,
saveAdminEmail,
fetchCommentSettings,
saveCommentSettings,
} from "../api/admin";
const email = ref("");
const saving = ref(false);
const commentAdminEmail = ref("");
const commentAdminBadge = ref("");
const avatarPrefix = ref("");
const savingEmail = ref(false);
const savingComment = ref(false);
const loading = ref(false);
const message = ref("");
const messageType = ref<"success" | "error">("success");
@@ -42,8 +73,14 @@ const messageType = ref<"success" | "error">("success");
async function load() {
loading.value = true;
try {
const res = await fetchAdminEmail();
email.value = res.email || "";
const [notifyRes, commentRes] = await Promise.all([
fetchAdminEmail(),
fetchCommentSettings(),
]);
email.value = notifyRes.email || "";
commentAdminEmail.value = commentRes.adminEmail || "";
commentAdminBadge.value = commentRes.adminBadge || "博主";
avatarPrefix.value = commentRes.avatarPrefix || "";
} catch (e: any) {
message.value = e.message || "加载失败";
messageType.value = "error";
@@ -52,13 +89,13 @@ async function load() {
}
}
async function save() {
async function saveEmail() {
if (!email.value) {
message.value = "请输入邮箱";
messageType.value = "error";
return;
}
saving.value = true;
savingEmail.value = true;
message.value = "";
try {
const res = await saveAdminEmail(email.value);
@@ -68,7 +105,26 @@ async function save() {
message.value = e.message || "保存失败";
messageType.value = "error";
} finally {
saving.value = false;
savingEmail.value = false;
}
}
async function saveComment() {
savingComment.value = true;
message.value = "";
try {
const res = await saveCommentSettings({
adminEmail: commentAdminEmail.value,
adminBadge: commentAdminBadge.value,
avatarPrefix: avatarPrefix.value,
});
message.value = res.message || "保存成功";
messageType.value = "success";
} catch (e: any) {
message.value = e.message || "保存失败";
messageType.value = "error";
} finally {
savingComment.value = false;
}
}

View File

@@ -1,41 +1,54 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
import { getCravatar } from '../../utils/getAvatar';
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
export const listComments = async (c: Context<{ Bindings: Bindings }>) => {
const page = parseInt(c.req.query('page') || '1');
const limit = 10;
const offset = (page - 1) * limit;
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 }>();
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();
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
}));
await c.env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const avatarRow = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?')
.bind(COMMENT_AVATAR_PREFIX_KEY)
.first<{ value: string }>();
const avatarPrefix = avatarRow?.value || null;
return c.json({
data,
pagination: {
page,
limit,
total: Math.ceil((totalCount?. count || 0) / limit)
}
});
};
const data = await Promise.all(
results.map(async (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,
avatar: await getCravatar(row.email, avatarPrefix || undefined)
}))
);
return c.json({
data,
pagination: {
page,
limit,
total: Math.ceil(((totalCount?.count as number) || 0) / limit)
}
});
};

View File

@@ -1,8 +1,8 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { Bindings } from './bindings';
import { customCors } from './utils/cors';
import { adminAuth } from './utils/auth';
import { isValidEmail } from './utils/email';
import { getComments } from './api/public/getComments';
import { postComment } from './api/public/postComment';
@@ -16,6 +16,64 @@ import { setAdminEmail } from './api/admin/setAdminEmail';
const app = new Hono<{ Bindings: Bindings }>();
const VERSION = 'v0.0.1';
const COMMENT_ADMIN_EMAIL_KEY = 'comment_admin_email';
const COMMENT_ADMIN_BADGE_KEY = 'comment_admin_badge';
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
async function loadCommentSettings(env: Bindings) {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const keys = [COMMENT_ADMIN_EMAIL_KEY, COMMENT_ADMIN_BADGE_KEY, COMMENT_AVATAR_PREFIX_KEY];
const { results } = await env.CWD_DB.prepare(
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?)'
)
.bind(...keys)
.all<{ key: string; value: string }>();
const map = new Map<string, string>();
for (const row of results) {
if (row && row.key) {
map.set(row.key, row.value);
}
}
return {
adminEmail: map.get(COMMENT_ADMIN_EMAIL_KEY) ?? null,
adminBadge: map.get(COMMENT_ADMIN_BADGE_KEY) ?? null,
avatarPrefix: map.get(COMMENT_AVATAR_PREFIX_KEY) ?? null
};
}
async function saveCommentSettings(
env: Bindings,
settings: { adminEmail?: string; adminBadge?: string; avatarPrefix?: string }
) {
await env.CWD_DB.prepare(
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
).run();
const entries: { key: string; value: string | null | undefined }[] = [
{ key: COMMENT_ADMIN_EMAIL_KEY, value: settings.adminEmail },
{ key: COMMENT_ADMIN_BADGE_KEY, value: settings.adminBadge },
{ key: COMMENT_AVATAR_PREFIX_KEY, value: settings.avatarPrefix }
];
for (const entry of entries) {
if (entry.value !== undefined) {
const value = entry.value === null ? '' : entry.value;
const trimmed = typeof value === 'string' ? value.trim() : value;
if (trimmed) {
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
.bind(entry.key, trimmed)
.run();
} else {
await env.CWD_DB.prepare('DELETE FROM Settings WHERE key = ?').bind(entry.key).run();
}
}
}
}
app.use('*', async (c, next) => {
console.log('Request:start', {
method: c.req.method,
@@ -49,6 +107,14 @@ app.get('/', (c) => {
app.get('/api/comments', getComments);
app.post('/api/comments', postComment);
app.get('/api/config/comments', async (c) => {
try {
const settings = await loadCommentSettings(c.env);
return c.json(settings);
} catch (e: any) {
return c.json({ message: e.message || '加载评论配置失败' }, 500);
}
});
app.post('/admin/login', adminLogin);
app.use('/admin/*', adminAuth);
@@ -58,5 +124,39 @@ app.put('/admin/comments/status', updateStatus);
// 设置接口
app.get('/admin/settings/email', getAdminEmail);
app.put('/admin/settings/email', setAdminEmail);
app.get('/admin/settings/comments', async (c) => {
try {
const settings = await loadCommentSettings(c.env);
return c.json(settings);
} catch (e: any) {
return c.json({ message: e.message || '加载评论配置失败' }, 500);
}
});
app.put('/admin/settings/comments', async (c) => {
try {
const body = await c.req.json();
const rawAdminEmail = typeof body.adminEmail === 'string' ? body.adminEmail : '';
const rawAdminBadge = typeof body.adminBadge === 'string' ? body.adminBadge : '';
const rawAvatarPrefix = typeof body.avatarPrefix === 'string' ? body.avatarPrefix : '';
const adminEmail = rawAdminEmail.trim();
const adminBadge = rawAdminBadge.trim();
const avatarPrefix = rawAvatarPrefix.trim();
if (adminEmail && !isValidEmail(adminEmail)) {
return c.json({ message: '邮箱格式不正确' }, 400);
}
await saveCommentSettings(c.env, {
adminEmail,
adminBadge,
avatarPrefix
});
return c.json({ message: '保存成功' });
} catch (e: any) {
return c.json({ message: e.message || '保存失败' }, 500);
}
});
export default app;

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -117,6 +118,7 @@
}
</style>
</head>
<body>
<div class="container">
<h1>CWD - 开发预览</h1>
@@ -143,7 +145,7 @@
<input type="text" id="avatarPrefix" value="https://gravatar.com/avatar" />
</div>
<div class="config-actions">
<button class="btn btn-primary" onclick="initWidget()">应用配置</button>
<button class="btn btn-primary" onclick="initWidget()">保存</button>
<button class="btn btn-secondary" onclick="toggleTheme()">切换主题</button>
</div>
</div>
@@ -166,4 +168,5 @@
<script type="module" src="/src/dev.js"></script>
</body>
</html>
</html>

View File

@@ -9,10 +9,10 @@ const STORAGE_KEY = 'cwd-dev-config';
// 默认配置
const DEFAULT_CONFIG = {
el: '#comments',
apiBaseUrl: 'http://localhost:8788',
postSlug: "https://zishu.me/message",
postSlug: window.location.pathname,
theme: 'light',
avatarPrefix: 'https://gravatar.com/avatar',
};
let widgetInstance = null;
@@ -69,10 +69,28 @@ function getConfigFromInputs() {
return { apiBaseUrl, postSlug, theme, avatarPrefix };
}
async function loadServerCommentConfig(apiBaseUrl) {
try {
const res = await fetch(`${apiBaseUrl}/api/config/comments`);
if (!res.ok) {
return {};
}
const data = await res.json();
return {
adminEmail: data.adminEmail || '',
adminBadge: data.adminBadge || '',
avatarPrefix: data.avatarPrefix || '',
};
} catch (e) {
console.warn('[CWDComments] 加载服务端评论配置失败:', e);
return {};
}
}
/**
* 初始化 widget
*/
function initWidget() {
async function initWidget() {
const config = getConfigFromInputs();
// 保存到本地存储
@@ -92,15 +110,17 @@ function initWidget() {
// 创建新实例
try {
const serverConfig = await loadServerCommentConfig(config.apiBaseUrl);
widgetInstance = new CWDComments({
el: '#comments',
apiBaseUrl: config.apiBaseUrl,
postSlug: config.postSlug,
theme: config.theme,
avatarPrefix: config.avatarPrefix,
avatarPrefix: serverConfig.avatarPrefix || config.avatarPrefix,
pageSize: 20,
adminEmail: 'anghunk@gmail.com', // 博主邮箱,留空不进行匹配
adminBadge: '博主', // 自定义标识,默认为"博主"
...(serverConfig.adminEmail ? { adminEmail: serverConfig.adminEmail } : {}),
...(serverConfig.adminBadge ? { adminBadge: serverConfig.adminBadge } : {}),
});
widgetInstance.mount();
console.log('[CWDComments] Widget 初始化成功', config);