feat: item detail and admin moderation UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user