feat: item detail and admin moderation UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
肉末
2026-07-14 13:02:11 +08:00
parent 445a940f7d
commit 600c536367
2 changed files with 461 additions and 5 deletions
+302 -2
View File
@@ -1,9 +1,309 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
adminCreateBan,
adminDeleteBan,
adminDeleteItem,
adminListBans,
adminListItems,
adminLogin,
type Ban,
type Item,
} from '../api/client'
const TOKEN_KEY = 'mincedpad_admin_token'
const password = ref('')
const token = ref('')
const loggingIn = ref(false)
const loading = ref(false)
const items = ref<Item[]>([])
const bans = ref<Ban[]>([])
const banIp = ref('')
const banReason = ref('')
const banning = ref(false)
const loggedIn = computed(() => Boolean(token.value))
function persistToken(value: string) {
token.value = value
if (value) {
sessionStorage.setItem(TOKEN_KEY, value)
} else {
sessionStorage.removeItem(TOKEN_KEY)
}
}
async function login() {
if (!password.value) {
ElMessage.warning('请输入管理密码')
return
}
loggingIn.value = true
try {
const data = await adminLogin(password.value)
persistToken(data.token)
password.value = ''
await refreshAll()
ElMessage.success('登录成功')
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '登录失败')
} finally {
loggingIn.value = false
}
}
function logout() {
persistToken('')
items.value = []
bans.value = []
}
async function refreshAll() {
if (!token.value) return
loading.value = true
try {
const [itemRes, banRes] = await Promise.all([
adminListItems(token.value),
adminListBans(token.value),
])
items.value = itemRes.items
bans.value = banRes.bans
} catch (err) {
const message = err instanceof Error ? err.message : '加载失败'
ElMessage.error(message)
if (message.toLowerCase().includes('unauthorized') || message.includes('401')) {
logout()
}
} finally {
loading.value = false
}
}
async function removeItem(row: Item) {
if (row.id == null) return
try {
await ElMessageBox.confirm(`确认删除「${row.title || row.slug}」?`, '删除内容', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await adminDeleteItem(token.value, row.id)
ElMessage.success('已删除')
await refreshAll()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '删除失败')
}
}
async function addBan() {
const ip = banIp.value.trim()
if (!ip) {
ElMessage.warning('请输入 IP')
return
}
banning.value = true
try {
await adminCreateBan(token.value, { ip, reason: banReason.value.trim() })
banIp.value = ''
banReason.value = ''
ElMessage.success('已封禁')
await refreshAll()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '封禁失败')
} finally {
banning.value = false
}
}
async function removeBan(row: Ban) {
try {
await ElMessageBox.confirm(`解除封禁 ${row.ip}`, '解除封禁', {
type: 'warning',
confirmButtonText: '解除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await adminDeleteBan(token.value, row.ip)
ElMessage.success('已解除')
await refreshAll()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '操作失败')
}
}
function formatTime(value?: string | null): string {
if (!value) return '-'
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return value
}
}
onMounted(() => {
const saved = sessionStorage.getItem(TOKEN_KEY)
if (saved) {
token.value = saved
void refreshAll()
}
})
</script> </script>
<template> <template>
<div> <div class="admin">
<h2>管理</h2> <h2>管理</h2>
<p>管理后台占位</p>
<section v-if="!loggedIn" class="login">
<el-input
v-model="password"
type="password"
show-password
placeholder="管理密码"
size="large"
@keyup.enter="login"
/>
<el-button
type="primary"
class="full-width-xs login-btn"
size="large"
:loading="loggingIn"
@click="login"
>
登录
</el-button>
</section>
<template v-else>
<div class="toolbar">
<el-button class="full-width-xs" :loading="loading" @click="refreshAll">刷新</el-button>
<el-button class="full-width-xs" @click="logout">退出</el-button>
</div>
<h3>内容列表</h3>
<el-table :data="items" v-loading="loading" style="width: 100%" empty-text="暂无内容">
<el-table-column prop="slug" label="标识" min-width="100" />
<el-table-column label="标题" min-width="120">
<template #default="{ row }">
{{ row.title || row.file_name || '-' }}
</template>
</el-table-column>
<el-table-column prop="kind" label="类型" width="70" />
<el-table-column label="IP" min-width="110">
<template #default="{ row }">
{{ row.created_ip || '-' }}
</template>
</el-table-column>
<el-table-column label="时间" min-width="150">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="large" @click="removeItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<h3 class="ban-title">IP 封禁</h3>
<div class="ban-form">
<el-input v-model="banIp" placeholder="IP 地址" size="large" />
<el-input v-model="banReason" placeholder="原因(可选)" size="large" />
<el-button
type="primary"
class="full-width-xs"
size="large"
:loading="banning"
@click="addBan"
>
封禁
</el-button>
</div>
<el-table :data="bans" v-loading="loading" style="width: 100%" empty-text="暂无封禁">
<el-table-column prop="ip" label="IP" min-width="120" />
<el-table-column prop="reason" label="原因" min-width="120" />
<el-table-column label="时间" min-width="150">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ row }">
<el-button size="large" @click="removeBan(row)">解除</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div> </div>
</template> </template>
<style scoped>
.admin h2 {
margin: 0 0 16px;
font-size: 1.2rem;
}
.admin h3 {
margin: 20px 0 10px;
font-size: 1.05rem;
}
.login {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 360px;
}
.login-btn {
margin-top: 4px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
}
.ban-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.ban-title {
margin-top: 28px;
}
:deep(.el-button) {
min-height: 40px;
}
@media (min-width: 769px) {
.ban-form {
flex-direction: row;
align-items: center;
}
.ban-form .el-input {
flex: 1;
}
.ban-form .el-button {
width: auto;
}
}
</style>
+159 -3
View File
@@ -1,9 +1,165 @@
<script setup lang="ts"> <script setup lang="ts">
import MarkdownIt from 'markdown-it'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { fetchItem, itemFileUrl, type Item } from '../api/client'
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
const route = useRoute()
const item = ref<Item | null>(null)
const loading = ref(true)
const error = ref('')
const showBurnAlert = ref(false)
const renderedBody = computed(() => {
if (!item.value?.body) return ''
return md.render(item.value.body)
})
const isImage = computed(() => {
const mime = item.value?.mime || ''
if (!mime.startsWith('image/')) return false
if (mime === 'image/svg+xml') return false
const name = (item.value?.file_name || '').toLowerCase()
return !name.endsWith('.svg')
})
const fileUrl = computed(() => {
if (!item.value || item.value.kind !== 'file') return ''
return itemFileUrl(item.value.slug)
})
async function load() {
const slug = String(route.params.slug || '')
loading.value = true
error.value = ''
item.value = null
showBurnAlert.value = false
try {
const data = await fetchItem(slug)
item.value = data
showBurnAlert.value = data.burn_after_read
} catch (err) {
error.value = err instanceof Error ? err.message : '加载失败'
ElMessage.error(error.value)
} finally {
loading.value = false
}
}
onMounted(load)
watch(
() => route.params.slug,
() => {
void load()
},
)
</script> </script>
<template> <template>
<div> <div v-loading="loading" class="item-view">
<h2>内容详情</h2> <el-alert
<p>详情页占位</p> v-if="showBurnAlert"
class="burn-alert"
type="warning"
title="阅后即焚"
description="此内容仅可查看一次,离开或刷新后将无法再次打开。"
show-icon
:closable="true"
@close="showBurnAlert = false"
/>
<el-result v-if="!loading && error" icon="warning" :title="error || '未找到'" />
<template v-else-if="item">
<h2 class="title">{{ item.title || item.file_name || item.slug }}</h2>
<div class="meta">
<span>{{ item.kind === 'file' ? '文件' : '文本' }}</span>
<span v-if="item.burn_after_read">阅后即焚</span>
<span v-if="!item.is_public">仅链接可见</span>
</div>
<div v-if="item.kind === 'text'" class="markdown" v-html="renderedBody" />
<div v-else class="file-block">
<template v-if="isImage">
<img class="preview" :src="fileUrl" :alt="item.file_name || item.title" />
</template>
<template v-else>
<p>文件{{ item.file_name || item.slug }}</p>
<el-button type="primary" class="full-width-xs" tag="a" :href="fileUrl" download>
下载文件
</el-button>
</template>
</div> </div>
</template> </template>
</div>
</template>
<style scoped>
.item-view {
min-height: 200px;
}
.burn-alert {
margin-bottom: 16px;
}
.title {
margin: 0 0 8px;
font-size: 1.25rem;
word-break: break-word;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-bottom: 16px;
color: #909399;
font-size: 0.9rem;
}
.markdown {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
line-height: 1.6;
word-break: break-word;
}
.markdown :deep(pre) {
overflow-x: auto;
padding: 10px;
background: #f5f7fa;
border-radius: 6px;
}
.markdown :deep(img) {
max-width: 100%;
height: auto;
}
.file-block {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 14px;
}
.preview {
display: block;
max-width: 100%;
height: auto;
border-radius: 4px;
}
</style>