feat(admin): 添加评论数据看板功能
- 新增数据看板页面,展示评论统计信息 - 添加 echarts 依赖用于数据可视化 - 实现后端统计接口,包括总数、按域名和最近7天数据 - 完善前端统计页面样式和交互 - 更新文档说明和版本更新指南
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5"
|
||||
"vue-router": "^4.4.5",
|
||||
"echarts": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
|
||||
@@ -62,6 +62,26 @@ export type EmailNotifySettingsResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CommentStatsResponse = {
|
||||
summary: {
|
||||
total: number;
|
||||
approved: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
};
|
||||
domains: {
|
||||
domain: string;
|
||||
total: number;
|
||||
approved: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
}[];
|
||||
last7Days: {
|
||||
date: string;
|
||||
total: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export async function loginAdmin(name: string, password: string): Promise<string> {
|
||||
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
|
||||
const key = res.data.key;
|
||||
@@ -160,3 +180,7 @@ export function exportComments(): Promise<any[]> {
|
||||
export function importComments(data: any[]): Promise<{ message: string }> {
|
||||
return post<{ message: string }>('/admin/comments/import', data);
|
||||
}
|
||||
|
||||
export function fetchCommentStats(): Promise<CommentStatsResponse> {
|
||||
return get<CommentStatsResponse>('/admin/stats/comments');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import LayoutView from '../views/LayoutView.vue';
|
||||
import CommentsView from '../views/CommentsView.vue';
|
||||
import SettingsView from '../views/SettingsView.vue';
|
||||
import DataView from '../views/DataView.vue';
|
||||
import StatsView from '../views/StatsView.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -24,6 +25,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'comments',
|
||||
component: CommentsView
|
||||
},
|
||||
{
|
||||
path: 'stats',
|
||||
name: 'stats',
|
||||
component: StatsView
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
@@ -55,4 +61,3 @@ router.beforeEach((to, from, next) => {
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
@@ -425,6 +425,72 @@ async function executeImport(comments: any[]) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background-color: #f6f8fa;
|
||||
border: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 12px;
|
||||
color: #57606a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.stats-value-approved {
|
||||
color: #1a7f37;
|
||||
}
|
||||
|
||||
.stats-value-pending {
|
||||
color: #9a6700;
|
||||
}
|
||||
|
||||
.stats-value-rejected {
|
||||
color: #d1242f;
|
||||
}
|
||||
|
||||
.domain-table {
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.domain-table-header {
|
||||
display: flex;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.domain-table-row {
|
||||
display: flex;
|
||||
border-top: 1px solid #eaeae0;
|
||||
}
|
||||
|
||||
.domain-cell {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: #24292f;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.domain-cell-domain {
|
||||
flex: 2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
|
||||
@@ -68,6 +68,13 @@
|
||||
>
|
||||
评论管理
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
:class="{ active: isRouteActive('stats') }"
|
||||
@click="goStats"
|
||||
>
|
||||
数据看板
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
:class="{ active: isRouteActive('settings') }"
|
||||
@@ -128,6 +135,11 @@ function goComments() {
|
||||
closeSider();
|
||||
}
|
||||
|
||||
function goStats() {
|
||||
router.push({ name: "stats" });
|
||||
closeSider();
|
||||
}
|
||||
|
||||
function goData() {
|
||||
router.push({ name: "data" });
|
||||
closeSider();
|
||||
|
||||
339
cwd-admin/src/views/StatsView.vue
Normal file
339
cwd-admin/src/views/StatsView.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<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">最近 7 天评论数趋势</h3>
|
||||
<div v-if="statsLoading" class="page-hint">加载中...</div>
|
||||
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
|
||||
<div v-else class="chart-wrapper">
|
||||
<div ref="chartEl" class="chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">整体概览</h3>
|
||||
<div v-if="statsLoading" class="page-hint">加载中...</div>
|
||||
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
|
||||
<div v-else>
|
||||
<div class="stats-grid">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">总评论数</div>
|
||||
<div class="stats-value">{{ statsSummary.total }}</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">已通过</div>
|
||||
<div class="stats-value stats-value-approved">{{ statsSummary.approved }}</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">待审核</div>
|
||||
<div class="stats-value stats-value-pending">{{ statsSummary.pending }}</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">已拒绝</div>
|
||||
<div class="stats-value stats-value-rejected">{{ statsSummary.rejected }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="card-title">按域名统计</h3>
|
||||
<div v-if="statsLoading" class="page-hint">加载中...</div>
|
||||
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
|
||||
<div v-else-if="domainStats.length === 0" class="page-hint">暂无评论数据</div>
|
||||
<div v-else class="domain-table">
|
||||
<div class="domain-table-header">
|
||||
<div class="domain-cell domain-cell-domain">域名</div>
|
||||
<div class="domain-cell">总数</div>
|
||||
<div class="domain-cell">已通过</div>
|
||||
<div class="domain-cell">待审核</div>
|
||||
<div class="domain-cell">已拒绝</div>
|
||||
</div>
|
||||
<div v-for="item in domainStats" :key="item.domain" class="domain-table-row">
|
||||
<div class="domain-cell domain-cell-domain">{{ item.domain }}</div>
|
||||
<div class="domain-cell">{{ item.total }}</div>
|
||||
<div class="domain-cell">{{ item.approved }}</div>
|
||||
<div class="domain-cell">{{ item.pending }}</div>
|
||||
<div class="domain-cell">{{ item.rejected }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref, nextTick } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
import { fetchCommentStats } from "../api/admin";
|
||||
|
||||
type DomainStat = {
|
||||
domain: string;
|
||||
total: number;
|
||||
approved: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
};
|
||||
|
||||
const statsLoading = ref(false);
|
||||
const statsError = ref("");
|
||||
const statsSummary = ref({
|
||||
total: 0,
|
||||
approved: 0,
|
||||
pending: 0,
|
||||
rejected: 0,
|
||||
});
|
||||
const domainStats = ref<DomainStat[]>([]);
|
||||
const last7Days = ref<{ date: string; total: number }[]>([]);
|
||||
|
||||
const toastMessage = ref("");
|
||||
const toastType = ref<"success" | "error">("success");
|
||||
const toastVisible = ref(false);
|
||||
|
||||
const chartEl = ref<HTMLDivElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
function showToast(msg: string, type: "success" | "error" = "success") {
|
||||
toastMessage.value = msg;
|
||||
toastType.value = type;
|
||||
toastVisible.value = true;
|
||||
window.setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
statsLoading.value = true;
|
||||
statsError.value = "";
|
||||
try {
|
||||
const res = await fetchCommentStats();
|
||||
statsSummary.value = {
|
||||
total: res.summary.total,
|
||||
approved: res.summary.approved,
|
||||
pending: res.summary.pending,
|
||||
rejected: res.summary.rejected,
|
||||
};
|
||||
domainStats.value = res.domains;
|
||||
last7Days.value = Array.isArray(res.last7Days) ? res.last7Days : [];
|
||||
} catch (e: any) {
|
||||
const msg = e.message || "加载统计数据失败";
|
||||
statsError.value = msg;
|
||||
showToast(msg, "error");
|
||||
} finally {
|
||||
statsLoading.value = false;
|
||||
await nextTick();
|
||||
if (!statsError.value && last7Days.value.length > 0) {
|
||||
renderChart();
|
||||
} else if (chartInstance) {
|
||||
chartInstance.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
const el = chartEl.value;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(el);
|
||||
}
|
||||
const dates = last7Days.value.map((item) => item.date.slice(5));
|
||||
const values = last7Days.value.map((item) => item.total);
|
||||
const option: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
},
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 16,
|
||||
top: 24,
|
||||
bottom: 32,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
minInterval: 1,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: values,
|
||||
areaStyle: {},
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
chartInstance.setOption(option);
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d0d7de;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.page-hint {
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
}
|
||||
|
||||
.page-error {
|
||||
font-size: 14px;
|
||||
color: #d1242f;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background-color: #f6f8fa;
|
||||
border: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 14px;
|
||||
color: #57606a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.stats-value-approved {
|
||||
color: #1a7f37;
|
||||
}
|
||||
|
||||
.stats-value-pending {
|
||||
color: #9a6700;
|
||||
}
|
||||
|
||||
.stats-value-rejected {
|
||||
color: #d1242f;
|
||||
}
|
||||
|
||||
.domain-table {
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.domain-table-header {
|
||||
display: flex;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.domain-table-row {
|
||||
display: flex;
|
||||
border-top: 1px solid #eaeae0;
|
||||
}
|
||||
|
||||
.domain-cell {
|
||||
flex: 1;
|
||||
padding: 10px 10px;
|
||||
font-size: 14px;
|
||||
color: #24292f;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.domain-cell-domain {
|
||||
flex: 2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
133
cwd-api/src/api/admin/getStats.ts
Normal file
133
cwd-api/src/api/admin/getStats.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Context } from 'hono';
|
||||
import { Bindings } from '../../bindings';
|
||||
|
||||
type StatusCounts = {
|
||||
total: number;
|
||||
approved: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
};
|
||||
|
||||
type DomainCounts = StatusCounts & {
|
||||
domain: string;
|
||||
};
|
||||
|
||||
function extractDomain(source: string | null | undefined): string | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
const value = source.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const summaryRow = await c.env.CWD_DB.prepare(
|
||||
"SELECT COUNT(*) as total, SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected FROM Comment"
|
||||
).first<{
|
||||
total: number | null;
|
||||
approved: number | null;
|
||||
pending: number | null;
|
||||
rejected: number | null;
|
||||
}>();
|
||||
|
||||
const summary: StatusCounts = {
|
||||
total: summaryRow?.total || 0,
|
||||
approved: summaryRow?.approved || 0,
|
||||
pending: summaryRow?.pending || 0,
|
||||
rejected: summaryRow?.rejected || 0
|
||||
};
|
||||
|
||||
const { results } = await c.env.CWD_DB.prepare(
|
||||
'SELECT post_slug, url, status FROM Comment'
|
||||
).all<{
|
||||
post_slug: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
}>();
|
||||
|
||||
const domainMap = new Map<string, StatusCounts>();
|
||||
|
||||
for (const row of results) {
|
||||
const domain =
|
||||
extractDomain(row.post_slug) || extractDomain(row.url) || 'unknown';
|
||||
|
||||
let counts = domainMap.get(domain);
|
||||
if (!counts) {
|
||||
counts = {
|
||||
total: 0,
|
||||
approved: 0,
|
||||
pending: 0,
|
||||
rejected: 0
|
||||
};
|
||||
domainMap.set(domain, counts);
|
||||
}
|
||||
counts.total += 1;
|
||||
if (row.status === 'approved') {
|
||||
counts.approved += 1;
|
||||
} else if (row.status === 'pending') {
|
||||
counts.pending += 1;
|
||||
} else if (row.status === 'rejected') {
|
||||
counts.rejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const domains: DomainCounts[] = Array.from(domainMap.entries())
|
||||
.map(([domain, counts]) => ({
|
||||
domain,
|
||||
total: counts.total,
|
||||
approved: counts.approved,
|
||||
pending: counts.pending,
|
||||
rejected: counts.rejected
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const now = Date.now();
|
||||
const sevenDaysAgo = now - 6 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const { results: dailyRows } = await c.env.CWD_DB.prepare(
|
||||
"SELECT date(created / 1000, 'unixepoch') as day, COUNT(*) as total FROM Comment WHERE created >= ? GROUP BY day ORDER BY day ASC"
|
||||
)
|
||||
.bind(sevenDaysAgo)
|
||||
.all<{ day: string; total: number }>();
|
||||
|
||||
const dailyMap = new Map<string, number>();
|
||||
for (const row of dailyRows) {
|
||||
if (row && row.day) {
|
||||
dailyMap.set(row.day, row.total || 0);
|
||||
}
|
||||
}
|
||||
|
||||
const last7Days: { date: string; total: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(now - i * 24 * 60 * 60 * 1000);
|
||||
const year = d.getUTCFullYear();
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
const key = `${year}-${month}-${day}`;
|
||||
last7Days.push({
|
||||
date: key,
|
||||
total: dailyMap.get(key) || 0
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
summary,
|
||||
domains,
|
||||
last7Days
|
||||
});
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '获取统计数据失败' }, 500);
|
||||
}
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { updateStatus } from './api/admin/updateStatus';
|
||||
import { getAdminEmail } from './api/admin/getAdminEmail';
|
||||
import { setAdminEmail } from './api/admin/setAdminEmail';
|
||||
import { testEmail } from './api/admin/testEmail';
|
||||
import { getStats } from './api/admin/getStats';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
const VERSION = `v${packageJson.version}`;
|
||||
@@ -236,6 +237,7 @@ app.get('/admin/comments/list', listComments);
|
||||
app.get('/admin/comments/export', exportComments);
|
||||
app.post('/admin/comments/import', importComments);
|
||||
app.put('/admin/comments/status', updateStatus);
|
||||
app.get('/admin/stats/comments', getStats);
|
||||
app.get('/admin/settings/email', getAdminEmail);
|
||||
app.put('/admin/settings/email', setAdminEmail);
|
||||
app.get('/admin/settings/email-notify', async (c) => {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
重新拉取 GitHub 项目代码
|
||||
|
||||
## 接口更新
|
||||
## 后端更新
|
||||
|
||||
接口逻辑相关的更新,需要重新部署到 Cloudflare Workers.
|
||||
|
||||
```
|
||||
cd cwd-api
|
||||
@@ -12,7 +14,9 @@ npm run deploy
|
||||
|
||||
重新部署到 Cloudflare Workers.
|
||||
|
||||
## 后台更新
|
||||
## 前端更新
|
||||
|
||||
### 管理后台
|
||||
|
||||
如果使用官方后台不需要更新。
|
||||
|
||||
@@ -24,7 +28,7 @@ npm run build
|
||||
|
||||
将打包后的代码更新到你托管的地方(例如 Cloudflare Pages、GitHub Pages、Netlify 等)。
|
||||
|
||||
## 评论端更新
|
||||
### 评论端
|
||||
|
||||
如果使用官方评论端 js `https://cwd.zishu.me/cwd.js`,不需要更新。
|
||||
|
||||
|
||||
@@ -37,5 +37,5 @@ features:
|
||||
---
|
||||
|
||||
<style>
|
||||
#comments{max-width:1200px;margin:2em auto 0;padding:0 24px;}
|
||||
#comments{max-width:1150px;margin:2em auto 0;}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user