refactor: 重构后台管理页面

This commit is contained in:
anghunk
2026-01-15 15:02:48 +08:00
parent 6c617f79e3
commit d9949bd8ad
4 changed files with 599 additions and 410 deletions

View File

@@ -1,44 +1,49 @@
import { Hono } from 'hono' import { Hono } from 'hono';
import { cors } from 'hono/cors' import { cors } from 'hono/cors';
import { Bindings } from './bindings' import { Bindings } from './bindings';
import { LoginView } from './views/login' import { loginView } from './views/login';
import { AdminView } from './views/admin' import { AdminView } from './views/admin';
import { customCors } from './utils/cors' import { SettingsView } from './views/settings';
import { adminAuth } from './utils/auth' import { customCors } from './utils/cors';
import { adminAuth } from './utils/auth';
import { getComments } from './api/public/getComments' import { getComments } from './api/public/getComments';
import { postComment } from './api/public/postComment' import { postComment } from './api/public/postComment';
import { adminLogin } from './api/admin/login' import { adminLogin } from './api/admin/login';
import { deleteComment } from './api/admin/deleteComment' import { deleteComment } from './api/admin/deleteComment';
import { listComments } from './api/admin/listComments' import { listComments } from './api/admin/listComments';
import { updateStatus } from './api/admin/updateStatus' import { updateStatus } from './api/admin/updateStatus';
const app = new Hono<{ Bindings: Bindings }>() const app = new Hono<{ Bindings: Bindings }>();
// 跨域 // 跨域
app.use('/api/*', async (c, next) => { app.use('/api/*', async (c, next) => {
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN) const corsMiddleware = customCors(c.env.ALLOW_ORIGIN);
return corsMiddleware(c, next) return corsMiddleware(c, next);
}) });
app.use('/admin/*', async (c, next) => { app.use('/admin/*', async (c, next) => {
const corsMiddleware = customCors(c.env.ALLOW_ORIGIN) const corsMiddleware = customCors(c.env.ALLOW_ORIGIN);
return corsMiddleware(c, next) return corsMiddleware(c, next);
}) });
// 页面路由 // 页面路由
app.get('/', (c) => c.redirect('/login')) app.get('/', (c) => c.redirect('/login'));
app.get('/login', (c) => c.html(LoginView)) app.get('/login', (c) => {
app.get('/admin', (c) => c.html(AdminView)) 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 // API
app.get('/api/comments', getComments) app.get('/api/comments', getComments);
app.post('/api/comments', postComment) app.post('/api/comments', postComment);
app.post('/admin/login', adminLogin) app.post('/admin/login', adminLogin);
app.use('/admin/*', adminAuth) app.use('/admin/*', adminAuth);
app.delete('/admin/comments/delete', deleteComment); app.delete('/admin/comments/delete', deleteComment);
app.get('/admin/comments/list', listComments); app.get('/admin/comments/list', listComments);
app.put('/admin/comments/status', updateStatus); app.put('/admin/comments/status', updateStatus);
export default app export default app;

View File

@@ -1,314 +1,339 @@
import { html } from "hono/html"; import { html } from 'hono/html';
export const AdminView = html` export const AdminView = html`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CWD 评论后台管理系统</title> <title>CWD 评论后台管理系统</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></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"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet" />
<style> <style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } body {
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; } font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
.fade-enter-from, .fade-leave-to { opacity: 0; } }
.loader { border-top-color: #3498db; animation: spinner 1.5s linear infinite; } .fade-enter-active,
@keyframes spinner { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .fade-leave-active {
</style> transition: opacity 0.3s;
</head> }
<body class="bg-gray-100 text-gray-800"> .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>
<div id="app" class="min-h-screen flex flex-col"> <!-- 侧边栏 -->
<!-- Toast --> <aside class="w-64 bg-white shadow-lg flex flex-col min-h-screen">
<transition name="fade"> <div class="p-4 border-b">
<div v-if="toast.show" :class="toast.type === 'error' ? 'bg-red-500' : 'bg-green-500'" <h1 class="text-xl font-bold text-blue-600">CWD 评论系统</h1>
class="fixed top-4 right-4 text-white px-6 py-3 rounded shadow-lg z-50 flex items-center"> <p class="text-xs text-gray-400 mt-1 truncate">{{ config.baseUrl }}</p>
<i :class="toast.type === 'error' ? 'fa-circle-exclamation' : 'fa-check-circle'" class="fa-solid mr-2"></i> </div>
{{ toast.message }} <nav class="flex-1 p-4">
</div> <a href="/admin" class="flex items-center px-4 py-3 rounded-lg mb-2 bg-blue-50 text-blue-600 transition">
</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>
<!-- 设置弹窗 --> <!-- 主内容 -->
<transition name="fade"> <main class="flex-1 p-8 overflow-auto">
<div v-if="showSettings" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-40" @click.self="showSettings = false"> <div class="flex justify-between items-center mb-6">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <h2 class="text-2xl font-bold text-gray-700">评论管理</h2>
<div class="flex justify-between items-center mb-4"> <button @click="fetchComments(pagination.page)" class="text-gray-600 hover:text-blue-600">
<h3 class="text-lg font-bold text-gray-700">设置</h3> <i class="fa-solid fa-rotate-right mr-1"></i> 刷新
<button @click="showSettings = false" class="text-gray-400 hover:text-gray-600"> </button>
<i class="fa-solid fa-xmark text-xl"></i> </div>
</button> <div v-if="loading && comments.length === 0" class="flex justify-center items-center h-64">
</div> <div class="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div>
<div class="mb-4"> </div>
<label class="block text-gray-700 text-sm font-bold mb-2">博客域名前缀</label>
<input v-model="config.blogDomain" type="text" placeholder="例如: https://example.com"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">设置后,文章链接将自动拼接此前缀</p>
</div>
<div class="flex justify-end">
<button @click="saveSettings" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
保存
</button>
</div>
</div>
</div>
</transition>
<!-- 导航栏 --> <div v-else class="bg-white shadow overflow-hidden sm:rounded-lg">
<nav class="bg-white shadow-sm"> <div class="overflow-x-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <table class="min-w-full divide-y divide-gray-200">
<div class="flex justify-between h-16"> <thead class="bg-gray-50">
<div class="flex items-center"> <tr>
<span class="text-xl font-bold text-blue-600">CWD 评论管理系统</span> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">信息</th>
<span class="ml-4 text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded">{{ config.baseUrl }}</span> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/2">内容</th>
</div> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<div class="flex items-center"> <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
<button @click="fetchComments(pagination.page)" class="mr-4 text-gray-600 hover:text-blue-600"> </tr>
<i class="fa-solid fa-rotate-right"></i> 刷新 </thead>
</button> <tbody class="bg-white divide-y divide-gray-200">
<button @click="showSettings = true" class="mr-4 text-gray-600 hover:text-blue-600"> <tr v-if="comments.length === 0">
<i class="fa-solid fa-gear"></i> 设置 <td colspan="4" class="px-6 py-10 text-center text-gray-500">暂无评论数据</td>
</button> </tr>
<button @click="logout" class="text-red-500 hover:text-red-700 font-medium"> <tr v-for="comment in comments" :key="comment.id" class="hover:bg-gray-50 transition">
<i class="fa-solid fa-sign-out-alt"></i> 退出 <td class="px-6 py-4 whitespace-nowrap">
</button> <div class="flex items-center">
</div> <div>
</div> <div>
</div> <span class="text-sm font-medium text-gray-900" v-text="comment.author"></span>
</nav> <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>
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="text-xs text-gray-400 mt-1">IP: <em v-text="comment.ipAddress"></em></div>
<div v-if="loading && comments.length === 0" class="flex justify-center items-center h-64"> <div class="text-xs text-gray-400">{{ formatDate(comment.pubDate) }}</div>
<div class="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div> </div>
</div> </div>
</td>
<div v-else class="bg-white shadow overflow-hidden sm:rounded-lg"> <td class="px-6 py-4">
<div class="overflow-x-auto"> <div
<table class="min-w-full divide-y divide-gray-200"> class="text-sm text-gray-900 break-words whitespace-pre-wrap max-h-32 overflow-y-auto"
<thead class="bg-gray-50"> v-text="comment.contentText"
<tr> ></div>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">信息</th> <a
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/2">内容</th> v-if="comment.postSlug"
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th> :href="config.blogDomain + comment.postSlug"
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th> target="_blank"
</tr> class="text-xs text-blue-500 hover:underline mt-1 inline-block"
</thead> >
<tbody class="bg-white divide-y divide-gray-200"> {{ config.blogDomain + comment.postSlug }}
<tr v-if="comments.length === 0"> </a>
<td colspan="4" class="px-6 py-10 text-center text-gray-500">暂无评论数据</td> </td>
</tr> <td class="px-6 py-4 whitespace-nowrap">
<tr v-for="comment in comments" :key="comment.id" class="hover:bg-gray-50 transition"> <span
<td class="px-6 py-4 whitespace-nowrap"> class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
<div class="flex items-center"> :class="{
<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-green-100 text-green-800': comment.status === 'approved',
'bg-yellow-100 text-yellow-800': comment.status === 'pending', 'bg-yellow-100 text-yellow-800': comment.status === 'pending',
'bg-red-100 text-red-800': ['spam', 'rejected', 'deleted'].includes(comment.status) 'bg-red-100 text-red-800': ['spam', 'rejected', 'deleted'].includes(comment.status)
}"> }"
{{ comment.status }} >
</span> {{ comment.status }}
</td> </span>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> </td>
<button v-if="comment.status !== 'approved'" @click="updateStatus(comment.id, 'approved')" class="text-green-600 hover:text-green-900 mr-3"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<i class="fa-solid fa-check mr-1"></i>显示 <button
</button> v-if="comment.status !== 'approved'"
<button v-if="comment.status === 'approved'" @click="updateStatus(comment.id, 'pending')" class="text-yellow-600 hover:text-yellow-900 mr-3"> @click="updateStatus(comment.id, 'approved')"
<i class="fa-solid fa-ban mr-1"></i>隐藏 class="text-green-600 hover:text-green-900 mr-3"
</button> >
<button @click="confirmDelete(comment.id)" class="text-red-600 hover:text-red-900"> <i class="fa-solid fa-check mr-1"></i>显示
<i class="fa-solid fa-trash mr-1"></i>删除 </button>
</button> <button
</td> v-if="comment.status === 'approved'"
</tr> @click="updateStatus(comment.id, 'pending')"
</tbody> class="text-yellow-600 hover:text-yellow-900 mr-3"
</table> >
</div> <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="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 class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div> <div>
<p class="text-sm text-gray-700"> <p class="text-sm text-gray-700">
第 <span class="font-medium">{{ pagination.page }}</span> 页, 第 <span class="font-medium">{{ pagination.page }}</span> 页,
<span class="font-medium">{{ pagination.total }}</span> 页 <span class="font-medium">{{ pagination.total }}</span> 页
</p> </p>
</div> </div>
<div> <div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"> <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button @click="changePage(pagination.page - 1)" :disabled="pagination.page === 1" <button
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"> @click="changePage(pagination.page - 1)"
上一页 :disabled="pagination.page === 1"
</button> 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 @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>
</button> <button
</nav> @click="changePage(pagination.page + 1)"
</div> :disabled="pagination.page >= pagination.total"
</div> 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"
</div> >
</div> 下一页
</main> </button>
</div> </nav>
</div>
</div>
</div>
</div>
</main>
</div>
<script> <script>
const { createApp, reactive, toRefs, onMounted } = Vue; const { createApp, reactive, toRefs, onMounted } = Vue;
createApp({ createApp({
setup() { setup() {
const state = reactive({ const state = reactive({
loading: false, loading: false,
apiKey: '', apiKey: '',
config: { config: {
baseUrl: localStorage.getItem('apiBaseUrl') || location.origin, baseUrl: localStorage.getItem('apiBaseUrl') || location.origin,
blogDomain: localStorage.getItem('blogDomain') || '' blogDomain: localStorage.getItem('blogDomain') || '',
}, },
comments: [], comments: [],
pagination: { page: 1, limit: 20, total: 1 }, pagination: { page: 1, limit: 20, total: 1 },
toast: { show: false, message: '', type: 'success' }, toast: { show: false, message: '', type: 'success' },
showSettings: false });
});
const showToast = (msg, type = 'success') => { const showToast = (msg, type = 'success') => {
state.toast.message = msg; state.toast.message = msg;
state.toast.type = type; state.toast.type = type;
state.toast.show = true; state.toast.show = true;
setTimeout(() => state.toast.show = false, 3000); setTimeout(() => (state.toast.show = false), 3000);
}; };
const formatDate = (dateString) => { const formatDate = (dateString) => {
if (!dateString) return ''; if (!dateString) return '';
return new Date(dateString).toLocaleString('zh-CN', { hour12: false }); return new Date(dateString).toLocaleString('zh-CN', { hour12: false });
}; };
const fetchComments = async (page = 1) => { const fetchComments = async (page = 1) => {
state.loading = true; state.loading = true;
try { state.comments = [];
const url = \`\${state.config.baseUrl}/admin/comments/list?page=\${page}\`; try {
const response = await fetch(url, { const url = \`\${state.config.baseUrl}/admin/comments/list?page=\${page}\`;
headers: { 'Authorization': state.apiKey } const response = await fetch(url, {
}); headers: { Authorization: state.apiKey },
const result = await response.json(); });
const result = await response.json();
if (result.message === "Invalid key" || result.status === 401) { if (result.message === 'Invalid key' || result.status === 401) {
logout(); logout();
return; return;
} }
state.comments = result.data || []; state.comments = result.data || [];
state.pagination = result.pagination || { page, limit: 10, total: 1 }; state.pagination = result.pagination || { page, limit: 10, total: 1 };
showToast('刷新成功'); showToast('刷新成功');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
showToast('获取数据失败', 'error'); showToast('获取数据失败', 'error');
} finally { } finally {
state.loading = false; state.loading = false;
} }
}; };
const updateStatus = async (id, newStatus) => { const updateStatus = async (id, newStatus) => {
state.loading = true; state.loading = true;
try { try {
const url = \`\${state.config.baseUrl}/admin/comments/status?id=\${id}&status=\${newStatus}\`; const url = \`\${state.config.baseUrl}/admin/comments/status?id=\${id}&status=\${newStatus}\`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'PUT', method: 'PUT',
headers: { 'Authorization': state.apiKey } headers: { Authorization: state.apiKey },
}); });
if (response.ok) { if (response.ok) {
const comment = state.comments.find(c => c.id === id); const comment = state.comments.find((c) => c.id === id);
if (comment) comment.status = newStatus; if (comment) comment.status = newStatus;
showToast('状态更新成功'); showToast('状态更新成功');
} else { } else {
showToast('更新失败', 'error'); showToast('更新失败', 'error');
} }
} catch (error) { } catch (error) {
showToast('请求失败', 'error'); showToast('请求失败', 'error');
} finally { } finally {
state.loading = false; state.loading = false;
} }
}; };
const confirmDelete = async (id) => { const confirmDelete = async (id) => {
if (!confirm('确定删除?')) return; if (!confirm('确定删除?')) return;
state.loading = true; state.loading = true;
try { try {
const url = \`\${state.config.baseUrl}/admin/comments/delete?id=\${id}\`; const url = \`\${state.config.baseUrl}/admin/comments/delete?id=\${id}\`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': state.apiKey } headers: { Authorization: state.apiKey },
}); });
if (response.ok) { if (response.ok) {
showToast('删除成功'); showToast('删除成功');
fetchComments(state.pagination.page); fetchComments(state.pagination.page);
} else { } else {
showToast('删除失败', 'error'); showToast('删除失败', 'error');
} }
} catch (error) { } catch (error) {
showToast('请求失败', 'error'); showToast('请求失败', 'error');
} finally { } finally {
state.loading = false; state.loading = false;
} }
}; };
const changePage = (page) => { const changePage = (page) => {
if (page > 0) fetchComments(page); if (page > 0) fetchComments(page);
}; };
const logout = () => { const logout = () => {
localStorage.removeItem('adminKey'); localStorage.removeItem('adminKey');
window.location.href = '/login'; window.location.href = '/login';
}; };
const saveSettings = () => { onMounted(() => {
localStorage.setItem('blogDomain', state.config.blogDomain); const storedKey = localStorage.getItem('adminKey');
state.showSettings = false; if (!storedKey) {
showToast('设置已保存'); window.location.href = '/login';
}; return;
}
state.apiKey = storedKey;
fetchComments();
});
onMounted(() => { return {
const storedKey = localStorage.getItem('adminKey'); ...toRefs(state),
if (!storedKey) { fetchComments,
window.location.href = '/login'; updateStatus,
return; confirmDelete,
} changePage,
state.apiKey = storedKey; logout,
fetchComments(); formatDate,
}); };
},
return { }).mount('#app');
...toRefs(state), </script>
fetchComments, </body>
updateStatus, </html>
confirmDelete,
changePage,
logout,
saveSettings,
formatDate
};
}
}).mount('#app');
</script>
</body>
</html>
`; `;

View File

@@ -1,100 +1,129 @@
import { html } from "hono/html"; import { html } from 'hono/html';
export const LoginView = html` export const loginView = (isDev: boolean, adminName?: string, adminPassword?: string) => html`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>登录 - CWD 评论后台</title> <title>登录 - CWD 评论后台</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></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"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet" />
<style> <style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } body {
.loader { border-top-color: #3498db; animation: spinner 1.5s linear infinite; } font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
@keyframes spinner { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } }
</style> .loader {
</head> border-top-color: #3498db;
<body class="bg-gray-100 text-gray-800"> 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>
<div id="app" class="min-h-screen flex items-center justify-center px-4"> <script>
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md"> const { createApp, reactive, toRefs, onMounted } = Vue;
<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> createApp({
const { createApp, reactive, toRefs, onMounted } = Vue; setup() {
const state = reactive({
loading: false,
error: '',
config: {
baseUrl: localStorage.getItem('apiBaseUrl') || location.origin,
},
loginForm: { name: '${isDev ? adminName : ''}', password: '${isDev ? adminPassword : ''}' },
});
createApp({ const handleLogin = async () => {
setup() { state.loading = true;
const state = reactive({ state.error = '';
loading: false, localStorage.setItem('apiBaseUrl', state.config.baseUrl);
error: '',
config: {
baseUrl: localStorage.getItem('apiBaseUrl') || location.origin
},
loginForm: { name: '', password: '' }
});
const handleLogin = async () => { try {
state.loading = true; const response = await fetch(\`\${state.config.baseUrl}/admin/login\`, {
state.error = ''; method: 'POST',
localStorage.setItem('apiBaseUrl', state.config.baseUrl); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.loginForm),
});
const resData = await response.json();
const key = resData.key || (resData.data && resData.data.key);
try { if (key) {
const response = await fetch(\`\${state.config.baseUrl}/admin/login\`, { localStorage.setItem('adminKey', key);
method: 'POST', window.location.href = '/admin';
headers: { 'Content-Type': 'application/json' }, } else {
body: JSON.stringify(state.loginForm) state.error = resData.message || '登录失败';
}); }
const resData = await response.json(); } catch (error) {
const key = resData.key || (resData.data && resData.data.key); state.error = '网络错误';
} finally {
state.loading = false;
}
};
if (key) { onMounted(() => {
localStorage.setItem('adminKey', key); const storedKey = localStorage.getItem('adminKey');
window.location.href = '/admin'; if (storedKey) {
} else { window.location.href = '/admin';
state.error = resData.message || '登录失败'; }
} });
} catch (error) {
state.error = '网络错误';
} finally {
state.loading = false;
}
};
onMounted(() => { return { ...toRefs(state), handleLogin };
const storedKey = localStorage.getItem('adminKey'); },
if (storedKey) { }).mount('#app');
window.location.href = '/admin'; </script>
} </body>
}); </html>
return { ...toRefs(state), handleLogin };
}
}).mount('#app');
</script>
</body>
</html>
`; `;

130
src/views/settings.ts Normal file
View File

@@ -0,0 +1,130 @@
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 justify-end 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>
`;