refactor: 重构后台管理页面
This commit is contained in:
61
src/index.ts
61
src/index.ts
@@ -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;
|
||||||
|
|||||||
@@ -1,85 +1,93 @@
|
|||||||
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 {
|
||||||
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 text-gray-800">
|
<body class="bg-gray-100 text-gray-800">
|
||||||
|
<div id="app" class="min-h-screen flex">
|
||||||
<div id="app" class="min-h-screen flex flex-col">
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="toast.show" :class="toast.type === 'error' ? 'bg-red-500' : 'bg-green-500'"
|
<div
|
||||||
class="fixed top-4 right-4 text-white px-6 py-3 rounded shadow-lg z-50 flex items-center">
|
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>
|
<i :class="toast.type === 'error' ? 'fa-circle-exclamation' : 'fa-check-circle'" class="fa-solid mr-2"></i>
|
||||||
{{ toast.message }}
|
{{ toast.message }}
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<!-- 设置弹窗 -->
|
<!-- 侧边栏 -->
|
||||||
<transition name="fade">
|
<aside class="w-64 bg-white shadow-lg flex flex-col min-h-screen">
|
||||||
<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="p-4 border-b">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<h1 class="text-xl font-bold text-blue-600">CWD 评论系统</h1>
|
||||||
<div class="flex justify-between items-center mb-4">
|
<p class="text-xs text-gray-400 mt-1 truncate">{{ config.baseUrl }}</p>
|
||||||
<h3 class="text-lg font-bold text-gray-700">设置</h3>
|
|
||||||
<button @click="showSettings = false" class="text-gray-400 hover:text-gray-600">
|
|
||||||
<i class="fa-solid fa-xmark text-xl"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 导航栏 -->
|
|
||||||
<nav class="bg-white shadow-sm">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between h-16">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="text-xl font-bold text-blue-600">CWD 评论管理系统</span>
|
|
||||||
<span class="ml-4 text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded">{{ config.baseUrl }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<button @click="fetchComments(pagination.page)" class="mr-4 text-gray-600 hover:text-blue-600">
|
|
||||||
<i class="fa-solid fa-rotate-right"></i> 刷新
|
|
||||||
</button>
|
|
||||||
<button @click="showSettings = true" class="mr-4 text-gray-600 hover:text-blue-600">
|
|
||||||
<i class="fa-solid fa-gear"></i> 设置
|
|
||||||
</button>
|
|
||||||
<button @click="logout" class="text-red-500 hover:text-red-700 font-medium">
|
|
||||||
<i class="fa-solid fa-sign-out-alt"></i> 退出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</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 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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 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 class="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,26 +122,44 @@ export const AdminView = html`
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<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>
|
<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">
|
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 }}
|
{{ config.blogDomain + comment.postSlug }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
|
<span
|
||||||
|
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
|
||||||
:class="{
|
: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 }}
|
{{ comment.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<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">
|
<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>显示
|
<i class="fa-solid fa-check mr-1"></i>显示
|
||||||
</button>
|
</button>
|
||||||
<button v-if="comment.status === 'approved'" @click="updateStatus(comment.id, 'pending')" class="text-yellow-600 hover:text-yellow-900 mr-3">
|
<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>隐藏
|
<i class="fa-solid fa-ban mr-1"></i>隐藏
|
||||||
</button>
|
</button>
|
||||||
<button @click="confirmDelete(comment.id)" class="text-red-600 hover:text-red-900">
|
<button @click="confirmDelete(comment.id)" class="text-red-600 hover:text-red-900">
|
||||||
@@ -150,18 +176,24 @@ export const AdminView = html`
|
|||||||
<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"
|
||||||
|
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>
|
||||||
<button @click="changePage(pagination.page + 1)" :disabled="pagination.page >= pagination.total"
|
<button
|
||||||
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">
|
@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>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -170,9 +202,9 @@ export const AdminView = html`
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const { createApp, reactive, toRefs, onMounted } = Vue;
|
const { createApp, reactive, toRefs, onMounted } = Vue;
|
||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
@@ -182,19 +214,18 @@ export const AdminView = html`
|
|||||||
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) => {
|
||||||
@@ -204,14 +235,15 @@ export const AdminView = html`
|
|||||||
|
|
||||||
const fetchComments = async (page = 1) => {
|
const fetchComments = async (page = 1) => {
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
|
state.comments = [];
|
||||||
try {
|
try {
|
||||||
const url = \`\${state.config.baseUrl}/admin/comments/list?page=\${page}\`;
|
const url = \`\${state.config.baseUrl}/admin/comments/list?page=\${page}\`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { 'Authorization': state.apiKey }
|
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;
|
||||||
}
|
}
|
||||||
@@ -233,10 +265,10 @@ export const AdminView = html`
|
|||||||
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 {
|
||||||
@@ -256,7 +288,7 @@ export const AdminView = html`
|
|||||||
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('删除成功');
|
||||||
@@ -280,12 +312,6 @@ export const AdminView = html`
|
|||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSettings = () => {
|
|
||||||
localStorage.setItem('blogDomain', state.config.blogDomain);
|
|
||||||
state.showSettings = false;
|
|
||||||
showToast('设置已保存');
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const storedKey = localStorage.getItem('adminKey');
|
const storedKey = localStorage.getItem('adminKey');
|
||||||
if (!storedKey) {
|
if (!storedKey) {
|
||||||
@@ -303,12 +329,11 @@ export const AdminView = html`
|
|||||||
confirmDelete,
|
confirmDelete,
|
||||||
changePage,
|
changePage,
|
||||||
logout,
|
logout,
|
||||||
saveSettings,
|
formatDate,
|
||||||
formatDate
|
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,49 +1,78 @@
|
|||||||
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); } }
|
}
|
||||||
|
.loader {
|
||||||
|
border-top-color: #3498db;
|
||||||
|
animation: spinner 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spinner {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 text-gray-800">
|
<body class="bg-gray-100 text-gray-800">
|
||||||
|
<div id="app" class="min-h-screen flex items-center justify-center px-4">
|
||||||
<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">
|
<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>
|
<h2 class="text-2xl font-bold mb-6 text-center text-gray-700">管理员登录</h2>
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-gray-700 text-sm font-bold mb-2">接口地址</label>
|
<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">
|
<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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-gray-700 text-sm font-bold mb-2">用户名</label>
|
<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">
|
<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>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-gray-700 text-sm font-bold mb-2">密码</label>
|
<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">
|
<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>
|
</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">
|
<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>
|
<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 ? '登录中...' : '登录' }}
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="error" class="mt-4 text-red-500 text-sm text-center">{{ error }}</p>
|
<p v-if="error" class="mt-4 text-red-500 text-sm text-center">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const { createApp, reactive, toRefs, onMounted } = Vue;
|
const { createApp, reactive, toRefs, onMounted } = Vue;
|
||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
@@ -52,9 +81,9 @@ export const LoginView = html`
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
config: {
|
config: {
|
||||||
baseUrl: localStorage.getItem('apiBaseUrl') || location.origin
|
baseUrl: localStorage.getItem('apiBaseUrl') || location.origin,
|
||||||
},
|
},
|
||||||
loginForm: { name: '', password: '' }
|
loginForm: { name: '${isDev ? adminName : ''}', password: '${isDev ? adminPassword : ''}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
@@ -66,7 +95,7 @@ export const LoginView = html`
|
|||||||
const response = await fetch(\`\${state.config.baseUrl}/admin/login\`, {
|
const response = await fetch(\`\${state.config.baseUrl}/admin/login\`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(state.loginForm)
|
body: JSON.stringify(state.loginForm),
|
||||||
});
|
});
|
||||||
const resData = await response.json();
|
const resData = await response.json();
|
||||||
const key = resData.key || (resData.data && resData.data.key);
|
const key = resData.key || (resData.data && resData.data.key);
|
||||||
@@ -92,9 +121,9 @@ export const LoginView = html`
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { ...toRefs(state), handleLogin };
|
return { ...toRefs(state), handleLogin };
|
||||||
}
|
},
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|||||||
130
src/views/settings.ts
Normal file
130
src/views/settings.ts
Normal 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>
|
||||||
|
`;
|
||||||
Reference in New Issue
Block a user