chore: 重构项目结构
This commit is contained in:
23
cwd-admin/src/App.vue
Normal file
23
cwd-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>
|
||||
|
||||
162
cwd-admin/src/api/admin.ts
Normal file
162
cwd-admin/src/api/admin.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { get, post, put, del } from './http';
|
||||
|
||||
export type AdminLoginResponse = {
|
||||
data: {
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CommentItem = {
|
||||
id: number;
|
||||
created: number;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
postSlug: string;
|
||||
url: string | null;
|
||||
ipAddress: string | null;
|
||||
contentText: string;
|
||||
contentHtml: string;
|
||||
status: string;
|
||||
ua?: string | null;
|
||||
};
|
||||
|
||||
export type CommentListResponse = {
|
||||
data: CommentItem[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminEmailResponse = {
|
||||
email: string | null;
|
||||
};
|
||||
|
||||
export type CommentSettingsResponse = {
|
||||
adminEmail: string | null;
|
||||
adminBadge: string | null;
|
||||
avatarPrefix: string | null;
|
||||
adminEnabled: boolean;
|
||||
allowedDomains?: string[];
|
||||
adminKey?: string | null;
|
||||
adminKeySet?: boolean;
|
||||
requireReview?: boolean;
|
||||
blockedIps?: string[];
|
||||
blockedEmails?: string[];
|
||||
};
|
||||
|
||||
export type EmailNotifySettingsResponse = {
|
||||
globalEnabled: boolean;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
secure: boolean;
|
||||
};
|
||||
templates?: {
|
||||
reply?: string;
|
||||
admin?: string;
|
||||
};
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
export function fetchEmailNotifySettings(): Promise<EmailNotifySettingsResponse> {
|
||||
return get<EmailNotifySettingsResponse>('/admin/settings/email-notify');
|
||||
}
|
||||
|
||||
export function saveEmailNotifySettings(data: {
|
||||
globalEnabled?: boolean;
|
||||
smtp?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
secure?: boolean;
|
||||
};
|
||||
templates?: {
|
||||
reply?: string;
|
||||
admin?: string;
|
||||
};
|
||||
}): Promise<{ message: string }> {
|
||||
return put<{ message: string }>('/admin/settings/email-notify', data);
|
||||
}
|
||||
|
||||
export function sendTestEmail(data: {
|
||||
toEmail: string;
|
||||
smtp?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
secure?: boolean;
|
||||
};
|
||||
}): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/admin/settings/email-test', data);
|
||||
}
|
||||
|
||||
export function fetchCommentSettings(): Promise<CommentSettingsResponse> {
|
||||
return get<CommentSettingsResponse>('/admin/settings/comments');
|
||||
}
|
||||
|
||||
export function saveCommentSettings(data: {
|
||||
adminEmail?: string;
|
||||
adminBadge?: string;
|
||||
avatarPrefix?: string;
|
||||
adminEnabled?: boolean;
|
||||
allowedDomains?: string[];
|
||||
adminKey?: string;
|
||||
requireReview?: boolean;
|
||||
blockedIps?: string[];
|
||||
blockedEmails?: string[];
|
||||
}): Promise<{ message: string }> {
|
||||
return put<{ message: string }>('/admin/settings/comments', data);
|
||||
}
|
||||
|
||||
export function blockIp(ip: string): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/admin/comments/block-ip', { ip });
|
||||
}
|
||||
|
||||
export function blockEmail(email: string): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/admin/comments/block-email', { email });
|
||||
}
|
||||
|
||||
export function exportComments(): Promise<any[]> {
|
||||
return get<any[]>('/admin/comments/export');
|
||||
}
|
||||
|
||||
export function importComments(data: any[]): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/admin/comments/import', data);
|
||||
}
|
||||
57
cwd-admin/src/api/http.ts
Normal file
57
cwd-admin/src/api/http.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const rawEnvApiBaseUrl = (import.meta.env.VITE_API_BASE_URL || '').trim();
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const stored = (localStorage.getItem('cwd_admin_api_base_url') || '').trim();
|
||||
const source = stored || rawEnvApiBaseUrl;
|
||||
const apiBaseUrl = source.replace(/\/+$/, '');
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error('未配置 API 地址,请在登录页填写后重试');
|
||||
}
|
||||
return apiBaseUrl;
|
||||
}
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
async function request<T>(method: HttpMethod, path: string, body?: unknown): Promise<T> {
|
||||
const apiBaseUrl = getApiBaseUrl();
|
||||
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(`${apiBaseUrl}${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-admin/src/env.d.ts
vendored
Normal file
10
cwd-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-admin/src/main.ts
Normal file
8
cwd-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');
|
||||
|
||||
58
cwd-admin/src/router/index.ts
Normal file
58
cwd-admin/src/router/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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';
|
||||
import DataView from '../views/DataView.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
|
||||
},
|
||||
{
|
||||
path: 'data',
|
||||
name: 'data',
|
||||
component: DataView
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
683
cwd-admin/src/views/CommentsView.vue
Normal file
683
cwd-admin/src/views/CommentsView.vue
Normal file
@@ -0,0 +1,683 @@
|
||||
<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>
|
||||
<div class="comment-table">
|
||||
<div class="table-header">
|
||||
<div class="table-cell table-cell-author">用户</div>
|
||||
<div class="table-cell table-cell-content">评论信息</div>
|
||||
<div class="table-cell table-cell-path">评论地址</div>
|
||||
<div class="table-cell table-cell-status">状态</div>
|
||||
<div class="table-cell table-cell-actions">操作</div>
|
||||
</div>
|
||||
<div v-for="item in filteredComments" :key="item.id" class="table-row">
|
||||
<div class="table-cell table-cell-author">
|
||||
<div class="cell-author-wrapper">
|
||||
<img
|
||||
v-if="item.avatar"
|
||||
:src="item.avatar"
|
||||
class="cell-avatar"
|
||||
:alt="item.name"
|
||||
/>
|
||||
<div class="cell-author-main">
|
||||
<div class="cell-author-name">{{ item.name }}</div>
|
||||
<div class="cell-author-email">
|
||||
<span
|
||||
class="cell-email-text"
|
||||
@click="handleBlockEmail(item)"
|
||||
title="屏蔽该邮箱"
|
||||
>
|
||||
{{ item.email }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="cell-time">{{ formatDate(item.created) }}</span>
|
||||
<div v-if="item.ipAddress" class="cell-author-ip">
|
||||
<span class="cell-ip-text" @click="handleBlockIp(item)" title="屏蔽该 IP">{{ item.ipAddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-cell table-cell-content">
|
||||
<div class="cell-content-text">{{ item.contentText }}</div>
|
||||
</div>
|
||||
<div class="table-cell table-cell-path">
|
||||
<a
|
||||
:href="item.postSlug"
|
||||
target="_blank"
|
||||
class="cell-path"
|
||||
:title="item.postSlug"
|
||||
>{{ item.postSlug }}</a
|
||||
>
|
||||
</div>
|
||||
<div class="table-cell table-cell-status">
|
||||
<span class="cell-status" :class="`cell-status-${item.status}`">
|
||||
{{ formatStatus(item.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-cell table-cell-actions">
|
||||
<div class="table-actions">
|
||||
<button
|
||||
class="table-action"
|
||||
@click="changeStatus(item, 'approved')"
|
||||
:disabled="item.status === 'approved'"
|
||||
>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
class="table-action"
|
||||
@click="changeStatus(item, 'pending')"
|
||||
:disabled="item.status === 'pending'"
|
||||
>
|
||||
待审
|
||||
</button>
|
||||
<button
|
||||
class="table-action"
|
||||
@click="changeStatus(item, 'rejected')"
|
||||
:disabled="item.status === 'rejected'"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
class="table-action table-action-danger"
|
||||
@click="removeComment(item)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredComments.length === 0" class="table-empty">暂无数据</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 1" class="pagination">
|
||||
<button
|
||||
class="pagination-button"
|
||||
:disabled="pagination.page <= 1"
|
||||
@click="goPage(pagination.page - 1)"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
class="pagination-button"
|
||||
:class="{ 'pagination-button-active': pagination.page === 1 }"
|
||||
:disabled="pagination.page === 1"
|
||||
@click="goPage(1)"
|
||||
>
|
||||
1
|
||||
</button>
|
||||
<span
|
||||
v-if="visiblePages.length && visiblePages[0] > 2"
|
||||
class="pagination-ellipsis"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<button
|
||||
v-for="page in visiblePages"
|
||||
v-if="page !== 1 && page !== pagination.total"
|
||||
:key="page"
|
||||
class="pagination-button"
|
||||
:class="{ 'pagination-button-active': page === pagination.page }"
|
||||
:disabled="page === pagination.page"
|
||||
@click="goPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<span
|
||||
v-if="
|
||||
visiblePages.length &&
|
||||
visiblePages[visiblePages.length - 1] < pagination.total - 1
|
||||
"
|
||||
class="pagination-ellipsis"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<button
|
||||
v-if="pagination.total > 1"
|
||||
class="pagination-button"
|
||||
:class="{ 'pagination-button-active': pagination.page === pagination.total }"
|
||||
:disabled="pagination.page === pagination.total"
|
||||
@click="goPage(pagination.total)"
|
||||
>
|
||||
{{ pagination.total }}
|
||||
</button>
|
||||
<button
|
||||
class="pagination-button"
|
||||
:disabled="pagination.page >= pagination.total"
|
||||
@click="goPage(pagination.page + 1)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<div class="pagination-jump">
|
||||
<span>跳转到</span>
|
||||
<input
|
||||
v-model="jumpPageInput"
|
||||
class="pagination-input"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="pagination.total"
|
||||
@keyup.enter="handleJumpPage"
|
||||
/>
|
||||
<span>页</span>
|
||||
<button class="pagination-button" @click="handleJumpPage">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from "vue";
|
||||
import {
|
||||
CommentItem,
|
||||
CommentListResponse,
|
||||
fetchComments,
|
||||
deleteComment,
|
||||
updateCommentStatus,
|
||||
blockIp,
|
||||
blockEmail,
|
||||
} 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 jumpPageInput = ref("");
|
||||
|
||||
const filteredComments = computed(() => {
|
||||
if (!statusFilter.value) {
|
||||
return comments.value;
|
||||
}
|
||||
return comments.value.filter((item) => item.status === statusFilter.value);
|
||||
});
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = pagination.value.total;
|
||||
const current = pagination.value.page;
|
||||
const maxVisible = 5;
|
||||
if (total <= maxVisible) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1);
|
||||
}
|
||||
let start = current - Math.floor(maxVisible / 2);
|
||||
let end = current + Math.floor(maxVisible / 2);
|
||||
if (start < 1) {
|
||||
start = 1;
|
||||
end = maxVisible;
|
||||
} else if (end > total) {
|
||||
end = total;
|
||||
start = total - maxVisible + 1;
|
||||
}
|
||||
const pages: number[] = [];
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
|
||||
function formatDate(value: number) {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function formatStatus(status: string) {
|
||||
if (status === "approved") {
|
||||
return "已通过";
|
||||
}
|
||||
if (status === "pending") {
|
||||
return "待审核";
|
||||
}
|
||||
if (status === "rejected") {
|
||||
return "已拒绝";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
async function loadComments(page?: number) {
|
||||
const targetPage = typeof page === "number" ? page : 1;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetchComments(targetPage);
|
||||
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);
|
||||
}
|
||||
|
||||
function handleJumpPage() {
|
||||
const value = Number(jumpPageInput.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const page = Math.floor(value);
|
||||
if (page < 1 || page > pagination.value.total) {
|
||||
return;
|
||||
}
|
||||
jumpPageInput.value = "";
|
||||
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 || "删除失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlockIp(item: CommentItem) {
|
||||
if (!item.ipAddress) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认将 IP ${item.ipAddress} 加入黑名单吗?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await blockIp(item.ipAddress);
|
||||
window.alert(res.message || "已加入 IP 黑名单");
|
||||
} catch (e: any) {
|
||||
error.value = e.message || "屏蔽 IP 失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlockEmail(item: CommentItem) {
|
||||
if (!item.email) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认将邮箱 ${item.email} 加入黑名单吗?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await blockEmail(item.email);
|
||||
window.alert(res.message || "已加入邮箱黑名单");
|
||||
} 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: 0;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.comment-table {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
/* border-bottom: 1px solid #eaeae0; */
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row:hover .table-cell{
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: #24292f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #eaeae0;
|
||||
}
|
||||
|
||||
.table-cell-id {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-cell-author {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-cell-content {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
align-items: flex-start !important;
|
||||
justify-content: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.table-cell-path {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-cell-time {
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-cell-status {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-cell-actions {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.table-header .table-cell {
|
||||
font-weight: 500;
|
||||
color: #57606a;
|
||||
align-items: center;
|
||||
background-color: #f6f8fa;
|
||||
border-bottom: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
.cell-id {
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.cell-author-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cell-author-email {
|
||||
font-size: 11px;
|
||||
color: #57606a;
|
||||
word-break: break-all;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cell-email-text {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell-email-text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cell-content-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.cell-path {
|
||||
font-size: 12px;
|
||||
color: #2774cb;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cell-path:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cell-time {
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.cell-author-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-author-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cell-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cell-status {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cell-status-approved {
|
||||
color: #1a7f37;
|
||||
background-color: #e7f5eb;
|
||||
}
|
||||
|
||||
.cell-status-pending {
|
||||
color: #9a6700;
|
||||
background-color: #fff8c5;
|
||||
}
|
||||
|
||||
.cell-status-rejected {
|
||||
color: #d1242f;
|
||||
background-color: #ffebe9;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-action {
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d0d7de;
|
||||
background-color: #ffffff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-action:hover {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.table-action:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.table-action-danger {
|
||||
border-color: #d1242f;
|
||||
color: #d1242f;
|
||||
}
|
||||
|
||||
.table-action-danger:hover {
|
||||
background-color: #ffebe9;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #57606a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 30px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-button {
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d0d7de;
|
||||
background-color: #f6f8fa;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pagination-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-button-active {
|
||||
background-color: #0969da;
|
||||
color: #ffffff;
|
||||
border-color: #0969da;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.pagination-jump {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.pagination-input {
|
||||
width: 60px;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d0d7de;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cell-ip-text {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell-ip-text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
536
cwd-admin/src/views/DataView.vue
Normal file
536
cwd-admin/src/views/DataView.vue
Normal file
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">数据管理</h2>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="toast"
|
||||
:class="toastType === 'error' ? 'toast-error' : 'toast-success'"
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">数据导出</h3>
|
||||
<p class="card-desc">将所有评论数据导出为 JSON 格式。</p>
|
||||
<div class="card-actions">
|
||||
<button class="card-button" :disabled="exporting" @click="handleExport">
|
||||
<span v-if="exporting">导出中...</span>
|
||||
<span v-else>导出 JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">数据导入</h3>
|
||||
<p class="card-desc">从其他评论系统导入数据,请选择来源并上传 JSON 文件。</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">来源系统</label>
|
||||
<select v-model="importSource" class="form-select">
|
||||
<option value="twikoo">Twikoo (.json)</option>
|
||||
<option value="artalk">Artalk (.json)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
accept=".json"
|
||||
style="display: none"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<button class="card-button" :disabled="importing" @click="triggerFileInput">
|
||||
<span v-if="importing">导入中...</span>
|
||||
<span v-else>选择文件并导入</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="importLogs.length > 0" class="log-container">
|
||||
<div class="log-title">导入日志</div>
|
||||
<div class="log-list">
|
||||
<div v-for="(log, index) in importLogs" :key="index" class="log-item">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 前缀确认弹窗 -->
|
||||
<div v-if="showPrefixModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3 class="modal-title">检测到 URL 缺失前缀</h3>
|
||||
<p class="modal-desc">
|
||||
检测到 <strong>{{ missingPrefixCount }}</strong> 条评论的 URL
|
||||
不存在域名前缀(http/https)。<br />
|
||||
是否在导入时统一添加?
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">域名前缀 (例如 https://example.me)</label>
|
||||
<input
|
||||
v-model="urlPrefix"
|
||||
class="form-input"
|
||||
placeholder="请输入域名前缀"
|
||||
@keyup.enter="confirmPrefix"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn secondary" @click="cancelPrefix">
|
||||
直接导入(不添加)
|
||||
</button>
|
||||
<button class="modal-btn primary" @click="confirmPrefix">添加并导入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { exportComments, importComments } from "../api/admin";
|
||||
|
||||
const exporting = ref(false);
|
||||
const importing = ref(false);
|
||||
const importSource = ref("twikoo");
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const toastMessage = ref("");
|
||||
const toastType = ref<"success" | "error">("success");
|
||||
const toastVisible = ref(false);
|
||||
const importLogs = ref<string[]>([]);
|
||||
|
||||
// 前缀处理相关状态
|
||||
const showPrefixModal = ref(false);
|
||||
const urlPrefix = ref("");
|
||||
const missingPrefixCount = ref(0);
|
||||
const pendingJson = ref<any[]>([]);
|
||||
|
||||
function showToast(msg: string, type: "success" | "error" = "success") {
|
||||
toastMessage.value = msg;
|
||||
toastType.value = type;
|
||||
toastVisible.value = true;
|
||||
window.setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function addLog(msg: string) {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
const h = String(now.getHours()).padStart(2, "0");
|
||||
const min = String(now.getMinutes()).padStart(2, "0");
|
||||
const s = String(now.getSeconds()).padStart(2, "0");
|
||||
const timeStr = `${y}.${m}.${d} ${h}:${min}:${s}`;
|
||||
importLogs.value.push(`${timeStr} ${msg}`);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const data = await exportComments();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `comments-export-${new Date().toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
showToast("导出成功", "success");
|
||||
} catch (e: any) {
|
||||
showToast(e.message || "导出失败", "error");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 重置 input,允许重复选择同一文件
|
||||
target.value = "";
|
||||
|
||||
// 清空之前的日志
|
||||
importLogs.value = [];
|
||||
showPrefixModal.value = false;
|
||||
urlPrefix.value = "";
|
||||
pendingJson.value = [];
|
||||
|
||||
importing.value = true;
|
||||
addLog(`开始导入文件 ${file.name}`);
|
||||
addLog("正在读取文件...");
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
addLog("文件读取完成,正在解析数据...");
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (parseError) {
|
||||
throw new Error("JSON 解析失败,请检查文件格式");
|
||||
}
|
||||
|
||||
const comments = Array.isArray(json) ? json : [json];
|
||||
const count = comments.length;
|
||||
addLog(`解析成功,共 ${count} 条数据`);
|
||||
|
||||
// 检测前缀逻辑
|
||||
let missingCount = 0;
|
||||
for (const item of comments) {
|
||||
// 优先检查 Twikoo 字段 href,其次 Artalk 字段 page_key,最后 CWD 字段 post_slug
|
||||
const url = item.href || item.page_key || item.post_slug;
|
||||
if (url && typeof url === "string") {
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
missingCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingCount > 0) {
|
||||
addLog(`检测到 ${missingCount} 条评论 URL 缺少前缀,等待确认...`);
|
||||
missingPrefixCount.value = missingCount;
|
||||
pendingJson.value = comments;
|
||||
showPrefixModal.value = true;
|
||||
// 暂停在这里,等待用户操作 modal
|
||||
} else {
|
||||
// 无需处理,直接导入
|
||||
await executeImport(comments);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addLog(`导入失败: ${err.message || "未知错误"}`);
|
||||
showToast(err.message || "导入失败,文件格式错误", "error");
|
||||
importing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
addLog("读取文件失败");
|
||||
showToast("读取文件失败", "error");
|
||||
importing.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function confirmPrefix() {
|
||||
if (!urlPrefix.value) {
|
||||
showToast("请输入域名前缀", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理前缀,确保以 / 结尾或拼接正确
|
||||
let prefix = urlPrefix.value.trim();
|
||||
// 简单处理:如果 prefix 不以 / 结尾,且 url 不以 / 开头,可能需要补 /。
|
||||
// 但通常用户输入的域名可能带也可能不带。
|
||||
// 这里假设用户输入的 prefix 是 "https://example.com",而 href 是 "/posts/1" 或 "posts/1"
|
||||
// 为了安全,如果 prefix 结尾没有 /,且 target 不以 / 开头,中间加个 /。
|
||||
// 但更简单的策略是直接拼,让用户自己负责输入正确的。
|
||||
// 考虑到用户习惯,我们做个简单的优化:如果 prefix 没 / 且 url 没 /,加一个。
|
||||
|
||||
const comments = pendingJson.value.map((item) => {
|
||||
// 浅拷贝
|
||||
const newItem = { ...item };
|
||||
|
||||
// Twikoo
|
||||
if (newItem.href && typeof newItem.href === "string") {
|
||||
if (!newItem.href.startsWith("http://") && !newItem.href.startsWith("https://")) {
|
||||
newItem.href = joinUrl(prefix, newItem.href);
|
||||
}
|
||||
}
|
||||
|
||||
// Artalk
|
||||
if (newItem.page_key && typeof newItem.page_key === "string") {
|
||||
if (
|
||||
!newItem.page_key.startsWith("http://") &&
|
||||
!newItem.page_key.startsWith("https://")
|
||||
) {
|
||||
newItem.page_key = joinUrl(prefix, newItem.page_key);
|
||||
}
|
||||
}
|
||||
|
||||
// CWD
|
||||
if (newItem.post_slug && typeof newItem.post_slug === "string") {
|
||||
if (
|
||||
!newItem.post_slug.startsWith("http://") &&
|
||||
!newItem.post_slug.startsWith("https://")
|
||||
) {
|
||||
newItem.post_slug = joinUrl(prefix, newItem.post_slug);
|
||||
}
|
||||
}
|
||||
|
||||
return newItem;
|
||||
});
|
||||
|
||||
showPrefixModal.value = false;
|
||||
addLog(`已为 ${missingPrefixCount.value} 条数据添加前缀`);
|
||||
await executeImport(comments);
|
||||
}
|
||||
|
||||
function joinUrl(prefix: string, path: string): string {
|
||||
if (prefix.endsWith("/") && path.startsWith("/")) {
|
||||
return prefix + path.substring(1);
|
||||
}
|
||||
if (!prefix.endsWith("/") && !path.startsWith("/")) {
|
||||
return prefix + "/" + path;
|
||||
}
|
||||
return prefix + path;
|
||||
}
|
||||
|
||||
function cancelPrefix() {
|
||||
showPrefixModal.value = false;
|
||||
addLog("用户选择跳过添加前缀,直接导入");
|
||||
executeImport(pendingJson.value);
|
||||
}
|
||||
|
||||
async function executeImport(comments: any[]) {
|
||||
addLog("正在上传并导入数据库...");
|
||||
try {
|
||||
const res = await importComments(comments);
|
||||
addLog(`导入完成: ${res.message || "成功"}`);
|
||||
showToast(res.message || "导入成功", "success");
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addLog(`导入失败: ${err.message || "未知错误"}`);
|
||||
showToast(err.message || "导入失败", "error");
|
||||
} finally {
|
||||
importing.value = false;
|
||||
pendingJson.value = [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ... (existing styles) ... */
|
||||
.form-input {
|
||||
padding: 8px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #0969da;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.modal-btn.primary {
|
||||
background-color: #0969da;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-btn.secondary {
|
||||
background-color: #f6f8fa;
|
||||
border-color: #d0d7de;
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.modal-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.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-group {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: #24292f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #24292f;
|
||||
background-color: #f6f8fa;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
border-color: #0969da;
|
||||
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: #1a7f37;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: #d1242f;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #24292f;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
|
||||
monospace;
|
||||
color: #57606a;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
385
cwd-admin/src/views/LayoutView.vue
Normal file
385
cwd-admin/src/views/LayoutView.vue
Normal file
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<header class="layout-header">
|
||||
<button
|
||||
class="layout-menu-toggle"
|
||||
@click="toggleSider"
|
||||
aria-label="切换菜单"
|
||||
type="button"
|
||||
>
|
||||
<svg class="layout-menu-icon" viewBox="0 0 24 24" width="18" height="18">
|
||||
<path
|
||||
d="M4 7h16a1 1 0 0 0 0-2H4a1 1 0 0 0 0 2zm0 6h16a1 1 0 0 0 0-2H4a1 1 0 0 0 0 2zm0 6h16a1 1 0 0 0 0-2H4a1 1 0 0 0 0 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="layout-title">CWD 评论后台</div>
|
||||
<div class="layout-actions-wrapper">
|
||||
<div class="layout-actions">
|
||||
<a class="layout-button" href="https://cwd-docs.zishu.me" target="_blank">
|
||||
使用文档
|
||||
</a>
|
||||
<a class="layout-button" href="https://github.com/anghunk/cwd" target="_blank">
|
||||
Github
|
||||
</a>
|
||||
<button class="layout-button" @click="handleLogout">退出</button>
|
||||
</div>
|
||||
<button
|
||||
class="layout-actions-toggle"
|
||||
@click="toggleActions"
|
||||
aria-label="更多操作"
|
||||
type="button"
|
||||
>
|
||||
<svg class="layout-actions-icon" viewBox="0 0 24 24" width="18" height="18">
|
||||
<path
|
||||
d="M12 5.5a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zm0 8a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zm0 8a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="isActionsOpen" class="layout-actions-dropdown">
|
||||
<button class="layout-actions-item" type="button" @click="openDocs">
|
||||
使用文档
|
||||
</button>
|
||||
<button class="layout-actions-item" type="button" @click="openGithub">
|
||||
Github
|
||||
</button>
|
||||
<button
|
||||
class="layout-actions-item layout-actions-item-danger"
|
||||
type="button"
|
||||
@click="handleLogoutFromActions"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="layout-body">
|
||||
<nav
|
||||
class="layout-sider"
|
||||
:class="{ 'layout-sider-mobile-open': isMobileSiderOpen }"
|
||||
>
|
||||
<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>
|
||||
<li
|
||||
class="menu-item"
|
||||
:class="{ active: isRouteActive('data') }"
|
||||
@click="goData"
|
||||
>
|
||||
数据管理
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div v-if="isMobileSiderOpen" class="layout-sider-mask" @click="closeSider" />
|
||||
<main class="layout-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { logoutAdmin } from "../api/admin";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const isMobileSiderOpen = ref(false);
|
||||
const isActionsOpen = ref(false);
|
||||
|
||||
function isRouteActive(name: string) {
|
||||
return route.name === name;
|
||||
}
|
||||
|
||||
function closeSider() {
|
||||
isMobileSiderOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleSider() {
|
||||
isMobileSiderOpen.value = !isMobileSiderOpen.value;
|
||||
}
|
||||
|
||||
function toggleActions() {
|
||||
isActionsOpen.value = !isActionsOpen.value;
|
||||
}
|
||||
|
||||
function closeActions() {
|
||||
isActionsOpen.value = false;
|
||||
}
|
||||
|
||||
function goComments() {
|
||||
router.push({ name: "comments" });
|
||||
closeSider();
|
||||
}
|
||||
|
||||
function goData() {
|
||||
router.push({ name: "data" });
|
||||
closeSider();
|
||||
}
|
||||
|
||||
function goSettings() {
|
||||
router.push({ name: "settings" });
|
||||
closeSider();
|
||||
}
|
||||
|
||||
function openDocs() {
|
||||
window.open("https://cwd-docs.zishu.me", "_blank");
|
||||
closeActions();
|
||||
}
|
||||
|
||||
function openGithub() {
|
||||
window.open("https://github.com/anghunk/cwd", "_blank");
|
||||
closeActions();
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logoutAdmin();
|
||||
router.push({ name: "login" });
|
||||
closeSider();
|
||||
}
|
||||
|
||||
function handleLogoutFromActions() {
|
||||
closeActions();
|
||||
handleLogout();
|
||||
}
|
||||
</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-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layout-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layout-button {
|
||||
text-decoration: none;
|
||||
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-actions-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #57606a;
|
||||
background-color: #24292f;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layout-actions-dropdown {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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 40px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layout-menu-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #57606a;
|
||||
background-color: #24292f;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.layout-sider-mask {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layout-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.layout-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.layout-menu-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 220px;
|
||||
max-width: 80%;
|
||||
border-right: 1px solid #d0d7de;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.layout-sider-mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.layout-sider-mask {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 900;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.layout-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout-actions-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.layout-actions-dropdown {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
right: 12px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.3);
|
||||
padding: 6px 0;
|
||||
min-width: 160px;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-actions-item {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: #24292f;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-actions-item:hover {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.layout-actions-item-danger {
|
||||
color: #d1242f;
|
||||
}
|
||||
|
||||
.layout-actions-item-danger:hover {
|
||||
background-color: #ffebe9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
156
cwd-admin/src/views/LoginView.vue
Normal file
156
cwd-admin/src/views/LoginView.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<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">API 地址</label>
|
||||
<input v-model="apiBaseUrl" class="form-input" type="text" />
|
||||
</div>
|
||||
<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 { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { loginAdmin } from "../api/admin";
|
||||
|
||||
const router = useRouter();
|
||||
const defaultAdminName = (import.meta.env.VITE_ADMIN_NAME || "").trim();
|
||||
const defaultAdminPassword = (import.meta.env.VITE_ADMIN_PASSWORD || "").trim();
|
||||
const name = ref(defaultAdminName);
|
||||
const password = ref(defaultAdminPassword);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const apiBaseUrl = ref("");
|
||||
|
||||
const rawEnvApiBaseUrl = (import.meta.env.VITE_API_BASE_URL || "").trim();
|
||||
const defaultApiBaseUrl = rawEnvApiBaseUrl.replace(/\/+$/, "");
|
||||
|
||||
onMounted(() => {
|
||||
const stored = (localStorage.getItem("cwd_admin_api_base_url") || "").trim();
|
||||
if (stored) {
|
||||
apiBaseUrl.value = stored;
|
||||
return;
|
||||
}
|
||||
apiBaseUrl.value = defaultApiBaseUrl;
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const normalizedApiBaseUrl = apiBaseUrl.value.trim().replace(/\/+$/, "");
|
||||
if (!normalizedApiBaseUrl) {
|
||||
error.value = "请输入 API 地址";
|
||||
return;
|
||||
}
|
||||
if (!name.value || !password.value) {
|
||||
error.value = "请输入账号和密码";
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
submitting.value = true;
|
||||
try {
|
||||
localStorage.setItem("cwd_admin_api_base_url", normalizedApiBaseUrl);
|
||||
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>
|
||||
782
cwd-admin/src/views/SettingsView.vue
Normal file
782
cwd-admin/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,782 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">网站设置</h2>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="toast"
|
||||
:class="toastType === 'error' ? 'toast-error' : 'toast-success'"
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
<div v-if="loading" class="page-hint">加载中...</div>
|
||||
<div v-else>
|
||||
<div class="card">
|
||||
<h3 class="card-title">评论显示配置</h3>
|
||||
<div class="form-item">
|
||||
<label class="form-label">评论博主邮箱(用于前台标记)</label>
|
||||
<input v-model="commentAdminEmail" class="form-input" type="email" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">博主标签文字</label>
|
||||
<input v-model="commentAdminBadge" class="form-input" type="text" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">是否开启博主显示</label>
|
||||
<label class="switch">
|
||||
<input v-model="commentAdminEnabled" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">新评论是否审核后再显示</label>
|
||||
<label class="switch">
|
||||
<input v-model="requireReview" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">头像前缀(默认:https://gravatar.com/avatar)</label>
|
||||
<input v-model="avatarPrefix" class="form-input" type="text" />
|
||||
</div>
|
||||
<h3 class="card-title">安全设置</h3>
|
||||
<div class="form-item">
|
||||
<label class="form-label">管理员评论密钥</label>
|
||||
<div class="form-hint" style="margin-bottom: 4px">
|
||||
设置后前台使用管理员邮箱评论需输入此密钥。
|
||||
</div>
|
||||
<input
|
||||
v-model="commentAdminKey"
|
||||
class="form-input"
|
||||
placeholder="输入密钥以设置或修改"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label"
|
||||
>允许调用的域名(多个域名用逗号分隔,留空则不限制。设置后仅匹配域名可调用前台评论组件。)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="allowedDomains"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
placeholder="例如: example.com, test.com"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label"
|
||||
>IP 黑名单(多个 IP 用逗号或换行分隔,留空则不限制)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="blockedIps"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
placeholder="例如: 1.1.1.1, 2.2.2.2"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label"
|
||||
>邮箱黑名单(多个邮箱用逗号或换行分隔,留空则不限制)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="blockedEmails"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
placeholder="例如: spam@example.com, bot@test.com"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="card-button" :disabled="savingComment" @click="saveComment">
|
||||
<span v-if="savingComment">保存中...</span>
|
||||
<span v-else>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">通知邮箱设置</h3>
|
||||
<div class="form-item">
|
||||
<label class="form-label">开启邮件通知</label>
|
||||
<label class="switch">
|
||||
<input v-model="emailGlobalEnabled" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">管理员通知邮箱</label>
|
||||
<input
|
||||
v-model="email"
|
||||
class="form-input"
|
||||
type="email"
|
||||
placeholder="接收新评论提醒的邮箱"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h4 class="card-subtitle">1. SMTP 发件配置</h4>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">邮件服务商</label>
|
||||
<select v-model="smtpProvider" class="form-input" @change="onProviderChange">
|
||||
<option value="qq">QQ 邮箱</option>
|
||||
<option value="custom">自定义 SMTP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="smtpProvider === 'custom'">
|
||||
<div class="form-item">
|
||||
<label class="form-label">SMTP 服务器</label>
|
||||
<input v-model="smtpHost" class="form-input" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">SMTP 端口</label>
|
||||
<input
|
||||
v-model="smtpPort"
|
||||
class="form-input"
|
||||
type="number"
|
||||
placeholder="465"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">SSL 安全连接</label>
|
||||
<label class="switch">
|
||||
<input v-model="smtpSecure" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">发件邮箱账号</label>
|
||||
<input
|
||||
v-model="smtpUser"
|
||||
class="form-input"
|
||||
placeholder="例如: 123456@qq.com"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">授权码/密码</label>
|
||||
<input
|
||||
v-model="smtpPass"
|
||||
class="form-input"
|
||||
type="password"
|
||||
placeholder="QQ邮箱请使用授权码"
|
||||
/>
|
||||
<div v-if="smtpProvider === 'qq'" class="form-hint">
|
||||
注意:QQ 邮箱必须使用<a
|
||||
href="https://service.mail.qq.com/detail/0/75"
|
||||
target="_blank"
|
||||
>授权码</a
|
||||
>,而非 QQ 密码。<br />
|
||||
请登录 QQ 邮箱网页版,在【设置 - 账户】中开启 POP3/SMTP 服务并生成授权码。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h4 class="card-subtitle">2. 邮件模板设置</h4>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">管理员通知模板 (HTML)</label>
|
||||
<div class="form-hint">
|
||||
可用变量:${commentAuthor} (评论人昵称), ${postTitle} (文章标题), ${postUrl}
|
||||
(文章链接), ${commentContent} (评论内容)
|
||||
</div>
|
||||
<textarea
|
||||
v-model="templateAdmin"
|
||||
class="form-input"
|
||||
rows="6"
|
||||
placeholder="留空则使用默认模板"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">回复通知模板 (HTML)</label>
|
||||
<div class="form-hint">
|
||||
可用变量:${toName} (接收人昵称), ${replyAuthor} (回复人昵称), ${postTitle}
|
||||
(文章标题), ${postUrl} (文章链接), ${parentComment} (原评论), ${replyContent}
|
||||
(回复内容)
|
||||
</div>
|
||||
<textarea
|
||||
v-model="templateReply"
|
||||
class="form-input"
|
||||
rows="6"
|
||||
placeholder="留空则使用默认模板"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message && messageType === 'error'"
|
||||
class="form-message form-message-error"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
<div class="card-actions" style="justify-content: space-between">
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="testingEmail"
|
||||
@click="testEmail"
|
||||
>
|
||||
<span v-if="testingEmail">发送中...</span>
|
||||
<span v-else>发送测试邮件</span>
|
||||
</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
style="margin-left: auto"
|
||||
@click="resetTemplatesToDefault"
|
||||
>
|
||||
恢复默认模板
|
||||
</button>
|
||||
<button class="card-button" :disabled="savingEmail" @click="saveEmail">
|
||||
<span v-if="savingEmail">保存中...</span>
|
||||
<span v-else>保存配置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import {
|
||||
fetchAdminEmail,
|
||||
saveAdminEmail,
|
||||
fetchCommentSettings,
|
||||
saveCommentSettings,
|
||||
fetchEmailNotifySettings,
|
||||
saveEmailNotifySettings,
|
||||
sendTestEmail,
|
||||
} from "../api/admin";
|
||||
|
||||
const DEFAULT_REPLY_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
||||
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
|
||||
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#2563eb,#4f46e5);">
|
||||
<h1 style="margin:0;font-size:18px;line-height:1.4;color:#f9fafb;">评论回复 - \${postTitle}</h1>
|
||||
<p style="margin:4px 0 0;font-size:12px;color:#e5e7eb;">你在文章下的评论收到了新的回复</p>
|
||||
</div>
|
||||
<div style="padding:24px 28px;">
|
||||
<p style="margin:0 0 8px 0;font-size:14px;color:#374151;">Hi <span style="font-weight:600;">\${toName}</span>,</p>
|
||||
<p style="margin:0 0 16px 0;font-size:14px;color:#4b5563;">
|
||||
<span style="font-weight:600;">\${replyAuthor}</span> 回复了你在
|
||||
<span style="font-weight:600;">《\${postTitle}》</span>
|
||||
中的评论:
|
||||
</p>
|
||||
<div style="margin:0 0 18px 0;padding:14px 16px;border-radius:10px;background:#f3f4f6;border:1px solid #e5e7eb;">
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:6px;">你之前的评论</div>
|
||||
<div style="font-size:14px;color:#374151;">\${parentComment}</div>
|
||||
</div>
|
||||
<div style="margin:0 0 24px 0;padding:14px 16px;border-radius:10px;background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div style="font-size:12px;color:#1d4ed8;margin-bottom:6px;">最新回复</div>
|
||||
<div style="font-size:14px;color:#1f2937;">\${replyContent}</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-bottom:8px;">
|
||||
<a href="\${postUrl}" style="display:inline-block;padding:10px 22px;border-radius:999px;background:#2563eb;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none;">
|
||||
打开文章查看完整对话
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;">
|
||||
如果按钮无法点击,可以将链接复制到浏览器中打开:<br />
|
||||
<span style="word-break:break-all;color:#6b7280;">\${postUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding:14px 20px;border-top:1px solid #e5e7eb;background:#f9fafb;text-align:center;">
|
||||
<p style="margin:0;font-size:11px;line-height:1.6;color:#9ca3af;">
|
||||
此邮件由系统自动发送,请勿直接回复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const DEFAULT_ADMIN_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
||||
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
|
||||
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#0f766e,#059669);">
|
||||
<h1 style="margin:0;font-size:18px;line-height:1.4;color:#f9fafb;">新评论提醒</h1>
|
||||
<p style="margin:4px 0 0;font-size:12px;color:#d1fae5;">你的文章收到了新的评论</p>
|
||||
</div>
|
||||
<div style="padding:24px 28px;">
|
||||
<p style="margin:0 0 10px 0;font-size:14px;color:#374151;">
|
||||
<span style="font-weight:600;">\${commentAuthor}</span> 在文章
|
||||
<span style="font-weight:600;">《\${postTitle}》</span>
|
||||
下发表了新评论:
|
||||
</p>
|
||||
<div style="margin:0 0 18px 0;padding:14px 16px;border-radius:10px;background:#f9fafb;border:1px solid #e5e7eb;">
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:6px;">评论内容</div>
|
||||
<div style="font-size:14px;color:#374151;">\${commentContent}</div>
|
||||
</div>
|
||||
<div style="margin:0 0 8px 0;">
|
||||
<a href="\${postUrl}" style="display:inline-block;padding:10px 22px;border-radius:999px;background:#047857;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none;">
|
||||
打开后台查看并管理评论
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;">
|
||||
如果按钮无法点击,可以将链接复制到浏览器中打开:<br />
|
||||
<span style="word-break:break-all;color:#6b7280;">\${postUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding:14px 20px;border-top:1px solid #e5e7eb;background:#f9fafb;text-align:center;">
|
||||
<p style="margin:0;font-size:11px;line-height:1.6;color:#9ca3af;">
|
||||
此邮件由系统自动发送,如非本人操作可忽略本邮件。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const email = ref("");
|
||||
const emailGlobalEnabled = ref(true);
|
||||
const commentAdminEmail = ref("");
|
||||
const commentAdminBadge = ref("");
|
||||
const avatarPrefix = ref("");
|
||||
const commentAdminEnabled = ref(false);
|
||||
const allowedDomains = ref("");
|
||||
const blockedIps = ref("");
|
||||
const blockedEmails = ref("");
|
||||
const commentAdminKey = ref("");
|
||||
const adminKeySet = ref(false);
|
||||
const requireReview = ref(false);
|
||||
const savingEmail = ref(false);
|
||||
const testingEmail = ref(false);
|
||||
const savingComment = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref("");
|
||||
const messageType = ref<"success" | "error">("success");
|
||||
const toastMessage = ref("");
|
||||
const toastType = ref<"success" | "error">("success");
|
||||
const toastVisible = ref(false);
|
||||
|
||||
const smtpProvider = ref("qq");
|
||||
const smtpHost = ref("smtp.qq.com");
|
||||
const smtpPort = ref(465);
|
||||
const smtpUser = ref("");
|
||||
const smtpPass = ref("");
|
||||
const smtpSecure = ref(true);
|
||||
const templateAdmin = ref(DEFAULT_ADMIN_TEMPLATE);
|
||||
const templateReply = ref(DEFAULT_REPLY_TEMPLATE);
|
||||
|
||||
function onProviderChange() {
|
||||
if (smtpProvider.value === "qq") {
|
||||
smtpHost.value = "smtp.qq.com";
|
||||
smtpPort.value = 465;
|
||||
smtpSecure.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg: string, type: "success" | "error" = "success") {
|
||||
toastMessage.value = msg;
|
||||
toastType.value = type;
|
||||
toastVisible.value = true;
|
||||
window.setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function resetTemplatesToDefault() {
|
||||
templateAdmin.value = DEFAULT_ADMIN_TEMPLATE;
|
||||
templateReply.value = DEFAULT_REPLY_TEMPLATE;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [notifyRes, commentRes, emailNotifyRes] = await Promise.all([
|
||||
fetchAdminEmail(),
|
||||
fetchCommentSettings(),
|
||||
fetchEmailNotifySettings(),
|
||||
]);
|
||||
email.value = notifyRes.email || "";
|
||||
commentAdminEmail.value = commentRes.adminEmail || "";
|
||||
commentAdminBadge.value = commentRes.adminBadge || "博主";
|
||||
avatarPrefix.value = commentRes.avatarPrefix || "";
|
||||
commentAdminEnabled.value = !!commentRes.adminEnabled;
|
||||
allowedDomains.value = commentRes.allowedDomains
|
||||
? commentRes.allowedDomains.join(", ")
|
||||
: "";
|
||||
blockedIps.value = commentRes.blockedIps ? commentRes.blockedIps.join(", ") : "";
|
||||
blockedEmails.value = commentRes.blockedEmails
|
||||
? commentRes.blockedEmails.join(", ")
|
||||
: "";
|
||||
commentAdminKey.value = commentRes.adminKey || "";
|
||||
adminKeySet.value = !!commentRes.adminKeySet;
|
||||
requireReview.value = !!commentRes.requireReview;
|
||||
emailGlobalEnabled.value = !!emailNotifyRes.globalEnabled;
|
||||
|
||||
if (emailNotifyRes.templates) {
|
||||
templateAdmin.value = emailNotifyRes.templates.admin || DEFAULT_ADMIN_TEMPLATE;
|
||||
templateReply.value = emailNotifyRes.templates.reply || DEFAULT_REPLY_TEMPLATE;
|
||||
} else {
|
||||
templateAdmin.value = DEFAULT_ADMIN_TEMPLATE;
|
||||
templateReply.value = DEFAULT_REPLY_TEMPLATE;
|
||||
}
|
||||
|
||||
if (emailNotifyRes.smtp) {
|
||||
smtpHost.value = emailNotifyRes.smtp.host;
|
||||
smtpPort.value = emailNotifyRes.smtp.port;
|
||||
smtpUser.value = emailNotifyRes.smtp.user;
|
||||
smtpPass.value = emailNotifyRes.smtp.pass;
|
||||
smtpSecure.value = emailNotifyRes.smtp.secure;
|
||||
|
||||
if (emailNotifyRes.smtp.host === "smtp.qq.com") {
|
||||
smtpProvider.value = "qq";
|
||||
} else {
|
||||
smtpProvider.value = "custom";
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.value = e.message || "加载失败";
|
||||
messageType.value = "error";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEmail() {
|
||||
if (!email.value) {
|
||||
message.value = "请输入邮箱";
|
||||
messageType.value = "error";
|
||||
return;
|
||||
}
|
||||
savingEmail.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const [emailRes] = await Promise.all([
|
||||
saveAdminEmail(email.value),
|
||||
saveEmailNotifySettings({
|
||||
globalEnabled: emailGlobalEnabled.value,
|
||||
smtp: {
|
||||
host: smtpHost.value,
|
||||
port: smtpPort.value,
|
||||
user: smtpUser.value,
|
||||
pass: smtpPass.value,
|
||||
secure: smtpSecure.value,
|
||||
},
|
||||
templates: {
|
||||
reply: templateReply.value,
|
||||
admin: templateAdmin.value,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
showToast(emailRes.message || "保存成功", "success");
|
||||
} catch (e: any) {
|
||||
message.value = e.message || "保存失败";
|
||||
messageType.value = "error";
|
||||
} finally {
|
||||
savingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testEmail() {
|
||||
if (!email.value) {
|
||||
message.value = "请输入管理员通知邮箱作为测试接收邮箱";
|
||||
messageType.value = "error";
|
||||
return;
|
||||
}
|
||||
if (!smtpUser.value || !smtpPass.value) {
|
||||
message.value = "请先填写 SMTP 账号和密码";
|
||||
messageType.value = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
testingEmail.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const res = await sendTestEmail({
|
||||
toEmail: email.value,
|
||||
smtp: {
|
||||
host: smtpHost.value,
|
||||
port: smtpPort.value,
|
||||
user: smtpUser.value,
|
||||
pass: smtpPass.value,
|
||||
secure: smtpSecure.value,
|
||||
},
|
||||
});
|
||||
showToast(res.message || "发送成功,请查收邮件", "success");
|
||||
} catch (e: any) {
|
||||
// 显示详细错误信息
|
||||
console.error(e);
|
||||
let errorMsg = e.message || "发送失败";
|
||||
|
||||
// 针对 QQ 邮箱 535 错误的友好提示
|
||||
if (
|
||||
errorMsg.includes("535") &&
|
||||
(errorMsg.includes("Login fail") || errorMsg.includes("authentication failed"))
|
||||
) {
|
||||
errorMsg =
|
||||
"验证失败 (535):请检查 1. QQ 邮箱是否已开启 POP3/SMTP 服务;2. 密码栏是否填写了“授权码”(非 QQ 密码)。";
|
||||
}
|
||||
|
||||
message.value = errorMsg;
|
||||
messageType.value = "error";
|
||||
} finally {
|
||||
testingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComment() {
|
||||
savingComment.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const res = await saveCommentSettings({
|
||||
adminEmail: commentAdminEmail.value,
|
||||
adminBadge: commentAdminBadge.value,
|
||||
avatarPrefix: avatarPrefix.value,
|
||||
adminEnabled: commentAdminEnabled.value,
|
||||
allowedDomains: allowedDomains.value
|
||||
.split(/[,,\n]/)
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean),
|
||||
adminKey: commentAdminKey.value || undefined,
|
||||
requireReview: requireReview.value,
|
||||
blockedIps: blockedIps.value
|
||||
.split(/[,,\n]/)
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean),
|
||||
blockedEmails: blockedEmails.value
|
||||
.split(/[,,\n]/)
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
|
||||
showToast(res.message || "保存成功", "success");
|
||||
} catch (e: any) {
|
||||
message.value = e.message || "保存失败";
|
||||
messageType.value = "error";
|
||||
} finally {
|
||||
savingComment.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;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #d0d7de;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: #1a7f37;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: #d1242f;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #d0d7de;
|
||||
transition: 0.2s;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background-color: #ffffff;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(27, 31, 36, 0.15);
|
||||
}
|
||||
|
||||
.switch input:checked + .slider {
|
||||
background-color: #0969da;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider::before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
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.secondary {
|
||||
background-color: #f6f8fa;
|
||||
color: #24292f;
|
||||
border: 1px solid #d0d7de;
|
||||
}
|
||||
.card-button.secondary:hover {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d0d7de;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #f0f0f0;
|
||||
border-top-color: #0969da;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.page-hint {
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.form-hint a {
|
||||
color: #0969da;
|
||||
text-decoration: none;
|
||||
}
|
||||
.form-hint a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user