feat: 新增CWD评论系统前后端代码及文档
refactor: 移除旧版评论系统代码并重构为Vue3前端 docs: 更新后端配置文档说明 fix: 修复评论提交频率限制和邮件通知逻辑 style: 格式化代码并优化样式 test: 添加Vitest测试配置 build: 更新依赖项和构建配置 chore: 清理无用文件和缓存
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"liveServer.settings.port": 5501
|
||||
}
|
||||
13
cwd-comments-admin/index.html
Normal file
13
cwd-comments-admin/index.html
Normal 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>
|
||||
|
||||
22
cwd-comments-admin/package.json
Normal file
22
cwd-comments-admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
23
cwd-comments-admin/src/App.vue
Normal file
23
cwd-comments-admin/src/App.vue
Normal 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>
|
||||
|
||||
65
cwd-comments-admin/src/api/admin.ts
Normal file
65
cwd-comments-admin/src/api/admin.ts
Normal 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 });
|
||||
}
|
||||
|
||||
47
cwd-comments-admin/src/api/http.ts
Normal file
47
cwd-comments-admin/src/api/http.ts
Normal 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
10
cwd-comments-admin/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
8
cwd-comments-admin/src/main.ts
Normal file
8
cwd-comments-admin/src/main.ts
Normal 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');
|
||||
|
||||
52
cwd-comments-admin/src/router/index.ts
Normal file
52
cwd-comments-admin/src/router/index.ts
Normal 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();
|
||||
});
|
||||
|
||||
254
cwd-comments-admin/src/views/CommentsView.vue
Normal file
254
cwd-comments-admin/src/views/CommentsView.vue
Normal 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>
|
||||
|
||||
130
cwd-comments-admin/src/views/LayoutView.vue
Normal file
130
cwd-comments-admin/src/views/LayoutView.vue
Normal 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>
|
||||
|
||||
127
cwd-comments-admin/src/views/LoginView.vue
Normal file
127
cwd-comments-admin/src/views/LoginView.vue
Normal 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>
|
||||
|
||||
151
cwd-comments-admin/src/views/SettingsView.vue
Normal file
151
cwd-comments-admin/src/views/SettingsView.vue
Normal 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>
|
||||
|
||||
20
cwd-comments-admin/tsconfig.json
Normal file
20
cwd-comments-admin/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
||||
10
cwd-comments-admin/vite.config.ts
Normal file
10
cwd-comments-admin/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5176
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
18
cwd-comments-api/src/api/admin/getAdminEmail.ts
Normal file
18
cwd-comments-api/src/api/admin/getAdminEmail.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
||||
23
cwd-comments-api/src/api/admin/setAdminEmail.ts
Normal file
23
cwd-comments-api/src/api/admin/setAdminEmail.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
||||
151
cwd-comments-api/src/api/public/postComment.ts
Normal file
151
cwd-comments-api/src/api/public/postComment.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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('未配置管理员通知邮箱');
|
||||
}
|
||||
12824
docs/.vitepress/cache/deps_temp_d8b5c95d/chunk-QE257C5J.js
vendored
12824
docs/.vitepress/cache/deps_temp_d8b5c95d/chunk-QE257C5J.js
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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
347
docs/.vitepress/cache/deps_temp_d8b5c95d/vue.js
vendored
347
docs/.vitepress/cache/deps_temp_d8b5c95d/vue.js
vendored
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
@@ -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()` 直接发信。
|
||||
|
||||
|
||||
## 本地测试
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user