评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀 - 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑 - 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换 - 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
This commit is contained in:
@@ -60,6 +60,7 @@ const crumb = computed(() => {
|
||||
if (p.endsWith('/new')) return '写新的'
|
||||
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
|
||||
if (p.endsWith('/files')) return '文件'
|
||||
if (p.endsWith('/comments')) return '评论'
|
||||
if (p.endsWith('/tags')) return '标签'
|
||||
if (p.endsWith('/settings')) return '设置'
|
||||
if (p === '/admin' || p === '/admin/') return '总览'
|
||||
@@ -124,6 +125,10 @@ watch(() => route.path, () => loadCounts())
|
||||
<span class="ic">▣</span>
|
||||
<span>文件</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/comments" class="item">
|
||||
<span class="ic">❝</span>
|
||||
<span>评论</span>
|
||||
</RouterLink>
|
||||
<div class="group">操作</div>
|
||||
<RouterLink to="/admin/new" class="item">
|
||||
<span class="ic">✎</span>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { relativeDate, stripTags } from '../utils'
|
||||
|
||||
// 评论管理:待审队列优先,通过 / 删除 / 禁言都在行内完成。
|
||||
// 删除走软删(墓碑保楼层);禁言按读者维度,禁言中的行上有标记。
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const status = ref(route.query.status === 'any' || route.query.status === 'visible' ? route.query.status : 'pending')
|
||||
const page = computed(() => Number(route.query.page || 1))
|
||||
const size = 20
|
||||
|
||||
let seq = 0
|
||||
async function load() {
|
||||
const my = ++seq
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await adminApi.comments({ status: status.value, page: page.value, size })
|
||||
if (my !== seq) return
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
if (my !== seq) return
|
||||
error.value = e.message || '加载失败'
|
||||
items.value = []
|
||||
} finally {
|
||||
if (my === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
watch([status, page], load)
|
||||
|
||||
function setStatus(v) {
|
||||
status.value = v
|
||||
router.replace({ query: { ...route.query, status: v === 'pending' ? undefined : v, page: 1 } })
|
||||
}
|
||||
|
||||
async function approve(id) {
|
||||
await adminApi.approveComment(id)
|
||||
load()
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
|
||||
await adminApi.deleteComment(id)
|
||||
load()
|
||||
}
|
||||
|
||||
async function banToggle(c) {
|
||||
const banned = !c.user?.banned
|
||||
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
|
||||
await adminApi.setReaderBanned(c.user?.id, banned)
|
||||
c.user = { ...(c.user || {}), banned }
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ label: '待审', value: 'pending' },
|
||||
{ label: '已通过', value: 'visible' },
|
||||
{ label: '全部', value: 'any' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
|
||||
<h1 style="font-family: var(--serif); font-size: 22px;">
|
||||
评论 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="posts-toolbar" style="margin-bottom: 16px;">
|
||||
<button class="chip" :class="{ on: status === 'pending' }" @click="setStatus('pending')">待审</button>
|
||||
<button class="chip" :class="{ on: status === 'visible' }" @click="setStatus('visible')">已通过</button>
|
||||
<button class="chip" :class="{ on: status === 'any' }" @click="setStatus('any')">全部</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
{{ status === 'pending' ? '没有待审的评论。' : '还没有评论。' }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="c in items" :key="c.id" class="cm-row">
|
||||
<div class="cm-ava" aria-hidden="true">{{ (c.user?.name || '?').slice(0, 1) }}</div>
|
||||
<div class="cm-body-col">
|
||||
<div class="cm-row-head">
|
||||
<span class="cm-name" :title="c.user?.handle || ''">{{ c.user?.name || '匿名' }}</span>
|
||||
<span v-if="c.user?.banned" class="cm-flag">禁言中</span>
|
||||
<span v-if="c.status === 'pending'" class="cm-flag pend">待审</span>
|
||||
<span class="cm-time" :title="c.created_at">{{ relativeDate(c.created_at) }}</span>
|
||||
</div>
|
||||
<div class="cm-text">{{ stripTags(c.body_html) }}</div>
|
||||
<div class="cm-post">on {{ c.post_title || '无题' }}</div>
|
||||
</div>
|
||||
<div class="cm-acts">
|
||||
<a v-if="c.status === 'pending'" href="#" @click.prevent="approve(c.id)">通过</a>
|
||||
<a href="#" class="danger" @click.prevent="remove(c.id)">删除</a>
|
||||
<a href="#" @click.prevent="banToggle(c)">{{ c.user?.banned ? '解除禁言' : '禁言' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-if="pageCount > 1"
|
||||
aria-label="分页"
|
||||
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
|
||||
>
|
||||
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
|
||||
← 上一页
|
||||
</button>
|
||||
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
|
||||
下一页 →
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cm-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 14px 4px;
|
||||
border-bottom: 1px solid var(--admin-line-soft);
|
||||
}
|
||||
|
||||
.cm-ava {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--admin-paper-sunken);
|
||||
color: var(--admin-ink);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--serif);
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cm-row-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.cm-name {
|
||||
font-weight: 600;
|
||||
color: var(--admin-ink);
|
||||
}
|
||||
|
||||
.cm-flag {
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.cm-flag.pend {
|
||||
color: #9a5b45;
|
||||
border-color: #ddcdc2;
|
||||
}
|
||||
|
||||
.cm-time {
|
||||
color: var(--admin-faint);
|
||||
}
|
||||
|
||||
.cm-text {
|
||||
margin-top: 4px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.75;
|
||||
color: var(--admin-ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cm-post {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--admin-faint);
|
||||
}
|
||||
|
||||
.cm-acts {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.cm-acts a {
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.cm-acts a:hover {
|
||||
color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.cm-acts a.danger:hover {
|
||||
color: #b4553f;
|
||||
}
|
||||
</style>
|
||||
@@ -147,6 +147,8 @@ async function load() {
|
||||
settings.value.ui_id = UI_CONSTANTS.DEFAULT
|
||||
}
|
||||
socialText.value = socialToText(settings.value.social_links)
|
||||
settings.value.comments_enabled = !!settings.value.comments_enabled
|
||||
settings.value.comments_review = !!settings.value.comments_review
|
||||
// 服务端保证非 null,这里再兜一层,并预建分区键,
|
||||
// 这样 v-model 绑定的文本框一开始就有值可写。
|
||||
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
|
||||
@@ -382,6 +384,17 @@ async function save() {
|
||||
<label>ICP 备案号</label>
|
||||
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>评论</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer;">
|
||||
<input v-model="settings.comments_enabled" type="checkbox" />
|
||||
开放评论(关着时前台整个评论区不渲染)
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer; margin-top: 6px;">
|
||||
<input v-model="settings.comments_review" type="checkbox" />
|
||||
新评论需审核,通过后公开
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>社交链接(每行一条:名称 | 链接)</label>
|
||||
<textarea
|
||||
|
||||
@@ -83,6 +83,14 @@ export const adminApi = {
|
||||
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
|
||||
settings: () => request('/api/admin/settings'),
|
||||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
|
||||
// ---------- 评论管理 ----------
|
||||
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
|
||||
approveComment: (id) =>
|
||||
request('/api/admin/comments/' + id, { method: 'PUT', body: { status: 'visible' } }),
|
||||
deleteComment: (id) => request('/api/admin/comments/' + id, { method: 'DELETE' }),
|
||||
readers: (params = {}) => request('/api/admin/readers?' + new URLSearchParams(params)),
|
||||
setReaderBanned: (id, banned) =>
|
||||
request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }),
|
||||
// ---------- 文件上传 ----------
|
||||
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
|
||||
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }),
|
||||
|
||||
@@ -117,8 +117,12 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录卡 / 输入框 -->
|
||||
<!-- 登录卡 / 禁言 / 输入框 -->
|
||||
<div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" />
|
||||
<div v-else-if="reader.user.banned" class="cm-gate cm-gate-banned">
|
||||
<p class="cm-gate-t">你已被禁言,暂时无法参与评论。</p>
|
||||
<p class="cm-gate-hint">如有疑问请联系站主。</p>
|
||||
</div>
|
||||
<div v-else-if="!reader.user" class="cm-gate">
|
||||
<p class="cm-gate-t">使用社交账号登录</p>
|
||||
<ul v-if="reader.providers.length" class="cm-providers">
|
||||
|
||||
@@ -18,6 +18,7 @@ const routes = [
|
||||
{ path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
|
||||
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
|
||||
{ path: 'files', name: 'admin-files', component: () => import('./admin/FilesView.vue') },
|
||||
{ path: 'comments', name: 'admin-comments', component: () => import('./admin/CommentsView.vue') },
|
||||
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
|
||||
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
|
||||
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
|
||||
|
||||
Reference in New Issue
Block a user