feat(评论管理): 添加编辑评论功能

在评论管理页面添加编辑功能,允许管理员修改评论的昵称、邮箱、网址、内容和状态
This commit is contained in:
anghunk
2026-01-21 17:25:49 +08:00
parent ce7c306acb
commit 27afa2db4f
4 changed files with 338 additions and 0 deletions

View File

@@ -110,6 +110,24 @@ export function updateCommentStatus(id: number, status: string): Promise<{ messa
return put<{ message: string }>(`/admin/comments/status?id=${id}&status=${encodeURIComponent(status)}`);
}
export function updateComment(data: {
id: number;
name: string;
email: string;
url?: string | null;
contentText: string;
status?: string;
}): Promise<{ message: string }> {
return put<{ message: string }>('/admin/comments/update', {
id: data.id,
name: data.name,
email: data.email,
url: data.url ?? null,
content: data.contentText,
status: data.status
});
}
export function fetchAdminEmail(): Promise<AdminEmailResponse> {
return get<AdminEmailResponse>('/admin/settings/email');
}

View File

@@ -103,6 +103,7 @@
>
拒绝
</button>
<button class="table-action" @click="openEdit(item)">编辑</button>
<button
class="table-action table-action-danger"
@click="removeComment(item)"
@@ -187,6 +188,55 @@
</div>
</div>
</div>
<div v-if="editVisible" class="modal-overlay" @click.self="closeEdit">
<div class="modal">
<h3 class="modal-title">编辑评论</h3>
<div v-if="editForm" class="modal-body">
<div class="form-item">
<label class="form-label">访客昵称</label>
<input v-model="editForm.name" class="form-input" type="text" />
</div>
<div class="form-item">
<label class="form-label">访客邮箱</label>
<input v-model="editForm.email" class="form-input" type="email" />
</div>
<div class="form-item">
<label class="form-label">访客网址</label>
<input v-model="editForm.url" class="form-input" type="text" />
</div>
<div class="form-item">
<label class="form-label">评论内容</label>
<textarea
v-model="editForm.contentText"
class="form-input"
rows="4"
></textarea>
</div>
<div class="form-item">
<label class="form-label">评论状态</label>
<select v-model="editForm.status" class="form-input">
<option value="approved">已通过</option>
<option value="pending">待审核</option>
<option value="rejected">已拒绝</option>
</select>
</div>
</div>
<div class="modal-actions">
<button class="modal-btn secondary" type="button" @click="closeEdit">
取消
</button>
<button
class="modal-btn primary"
type="button"
:disabled="editSaving"
@click="submitEdit"
>
<span v-if="editSaving">保存中...</span>
<span v-else>保存</span>
</button>
</div>
</div>
</div>
</div>
</template>
@@ -200,6 +250,7 @@ import {
fetchCommentStats,
deleteComment,
updateCommentStatus,
updateComment,
blockIp,
blockEmail,
} from "../api/admin";
@@ -214,6 +265,16 @@ const error = ref("");
const statusFilter = ref("");
const domainFilter = ref("");
const jumpPageInput = ref("");
const editVisible = ref(false);
const editSaving = ref(false);
const editForm = ref<{
id: number;
name: string;
email: string;
url: string;
contentText: string;
status: string;
} | null>(null);
const domainOptions = ref<string[]>([]);
@@ -371,6 +432,65 @@ async function handleBlockEmail(item: CommentItem) {
}
}
function openEdit(item: CommentItem) {
editForm.value = {
id: item.id,
name: item.name,
email: item.email,
url: item.url || "",
contentText: item.contentText,
status: item.status,
};
editVisible.value = true;
}
function closeEdit() {
if (editSaving.value) {
return;
}
editVisible.value = false;
editForm.value = null;
}
async function submitEdit() {
if (!editForm.value || editSaving.value) {
return;
}
const current = editForm.value;
if (!current.name.trim() || !current.email.trim() || !current.contentText.trim()) {
error.value = "昵称、邮箱和内容不能为空";
return;
}
editSaving.value = true;
error.value = "";
try {
await updateComment({
id: current.id,
name: current.name.trim(),
email: current.email.trim(),
url: current.url.trim() || null,
contentText: current.contentText,
status: current.status,
});
const index = comments.value.findIndex((c) => c.id === current.id);
if (index !== -1) {
comments.value[index] = {
...comments.value[index],
name: current.name.trim(),
email: current.email.trim(),
url: current.url.trim() || null,
contentText: current.contentText,
status: current.status,
};
}
closeEdit();
} catch (e: any) {
error.value = e.message || "更新评论失败";
} finally {
editSaving.value = false;
}
}
onMounted(() => {
const p = route.query.p;
let initialPage = 1;
@@ -747,4 +867,108 @@ onMounted(() => {
.cell-ip-text:hover {
text-decoration: underline;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: stretch;
justify-content: flex-end;
z-index: 2000;
}
.modal {
background-color: #ffffff;
border-radius: 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 600px;
height: 100vh;
padding: 20px 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
transform: translateX(0);
animation: drawer-in 0.2s ease-out;
}
.modal-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #24292f;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
.modal-btn {
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
border: 1px solid transparent;
}
.modal-btn.primary {
background-color: #0969da;
color: #ffffff;
}
.modal-btn.secondary {
background-color: #f6f8fa;
border-color: #d0d7de;
color: #24292f;
}
.modal-btn:hover {
opacity: 0.9;
}
.form-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
color: #57606a;
}
.form-input {
padding: 8px 10px;
border-radius: 4px;
border: 1px solid #d0d7de;
font-size: 13px;
outline: none;
}
.form-input:focus {
border-color: #0969da;
box-shadow: 0 0 0 1px rgba(9, 105, 218, 0.2);
}
@keyframes drawer-in {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
</style>

View File

@@ -0,0 +1,94 @@
import { Context } from 'hono';
import { Bindings } from '../../bindings';
import { checkContent } from '../public/postComment';
import { marked } from 'marked';
import xss from 'xss';
export const updateComment = async (c: Context<{ Bindings: Bindings }>) => {
let body: any;
try {
body = await c.req.json();
} catch {
return c.json({ message: 'Invalid JSON body' }, 400);
}
const rawId = body?.id;
const id =
typeof rawId === 'number'
? rawId
: typeof rawId === 'string' && rawId.trim()
? Number.parseInt(rawId.trim(), 10)
: NaN;
if (!Number.isFinite(id) || id <= 0) {
return c.json({ message: 'Missing or invalid id' }, 400);
}
const existing = await c.env.CWD_DB.prepare(
'SELECT id, status FROM Comment WHERE id = ?'
)
.bind(id)
.first<{ id: number; status: string }>();
if (!existing) {
return c.json({ message: 'Comment not found' }, 404);
}
const rawName = typeof body.name === 'string' ? body.name : '';
const rawEmail = typeof body.email === 'string' ? body.email : '';
const rawUrl = typeof body.url === 'string' ? body.url : '';
const rawStatus = typeof body.status === 'string' ? body.status : existing.status;
const contentSource =
typeof body.content === 'string'
? body.content
: typeof body.contentText === 'string'
? body.contentText
: '';
const name = rawName.trim();
const email = rawEmail.trim();
const url = rawUrl.trim() || null;
const status = rawStatus.trim();
if (!name) {
return c.json({ message: '昵称不能为空' }, 400);
}
if (!email) {
return c.json({ message: '邮箱不能为空' }, 400);
}
const cleanedContent = checkContent(contentSource);
const contentText = cleanedContent;
if (!contentText) {
return c.json({ message: '评论内容不能为空' }, 400);
}
const html = await marked.parse(cleanedContent, { async: true });
const contentHtml = xss(html, {
whiteList: {
...xss.whiteList,
code: ['class'],
span: ['class', 'style'],
pre: ['class'],
div: ['class', 'style'],
img: ['src', 'alt', 'title', 'width', 'height', 'style']
}
});
const { success } = await c.env.CWD_DB.prepare(
'UPDATE Comment SET name = ?, email = ?, url = ?, content_text = ?, content_html = ?, status = ? WHERE id = ?'
)
.bind(name, email, url, contentText, contentHtml, status, id)
.run();
if (!success) {
return c.json({ message: 'Update failed' }, 500);
}
return c.json({
message: `Comment updated, id: ${id}.`
});
};

View File

@@ -18,6 +18,7 @@ import { listComments } from './api/admin/listComments';
import { exportComments } from './api/admin/exportComments';
import { importComments } from './api/admin/importComments';
import { updateStatus } from './api/admin/updateStatus';
import { updateComment } from './api/admin/updateComment';
import { getAdminEmail } from './api/admin/getAdminEmail';
import { setAdminEmail } from './api/admin/setAdminEmail';
import { testEmail } from './api/admin/testEmail';
@@ -237,6 +238,7 @@ app.get('/admin/comments/list', listComments);
app.get('/admin/comments/export', exportComments);
app.post('/admin/comments/import', importComments);
app.put('/admin/comments/status', updateStatus);
app.put('/admin/comments/update', updateComment);
app.get('/admin/stats/comments', getStats);
app.get('/admin/settings/email', getAdminEmail);
app.put('/admin/settings/email', setAdminEmail);