feat(评论系统): 添加评论配置和头像显示功能
- 新增评论配置管理功能,包括博主邮箱、标签和头像前缀设置 - 在评论列表显示用户头像 - 实现服务端配置存储和获取逻辑 - 优化前端设置页面,拆分不同配置项 - 修改开发预览页面配置保存逻辑
This commit is contained in:
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user