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">
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="admin">
|
||||
<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>
|
||||
</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">
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2>内容详情</h2>
|
||||
<p>详情页占位。</p>
|
||||
<div v-loading="loading" class="item-view">
|
||||
<el-alert
|
||||
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>
|
||||
</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