Files
nl-blogs/client/src/components/admin/PostHistoryModal.vue
2026-06-24 16:50:04 +08:00

231 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@click.self="emit('close')"
>
<div class="w-full max-w-4xl max-h-[85vh] bg-[#121214] border border-white/10 rounded-xl shadow-2xl flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
<div>
<h3 class="text-lg font-medium text-white">历史版本</h3>
<p class="text-xs text-art-muted mt-1">每次保存后生成新版本可查看对比或恢复</p>
</div>
<button type="button" class="text-art-muted hover:text-white" @click="emit('close')"></button>
</div>
<div class="flex-1 overflow-hidden flex min-h-0">
<!-- Version list -->
<div class="w-72 border-r border-white/10 overflow-y-auto custom-scrollbar shrink-0">
<div v-if="loading" class="p-6 text-center text-art-muted text-sm">加载中...</div>
<div v-else-if="historyList.length === 0" class="p-6 text-center text-art-muted text-sm">暂无历史版本</div>
<button
v-for="item in historyList"
:key="item.version"
type="button"
class="w-full text-left px-4 py-3 border-b border-white/5 hover:bg-white/5 transition-colors"
:class="{ 'bg-art-accent/10 border-l-2 border-l-art-accent': selectedVersions.includes(item.version) }"
@click="toggleVersionSelect(item.version)"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-white font-medium">v{{ item.version }}</span>
<span class="text-xs text-art-muted">{{ item.modifiedAt }}</span>
</div>
<p class="text-xs text-art-muted mt-1 truncate">{{ item.title }}</p>
</button>
</div>
<!-- Detail / diff panel -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
<div v-if="viewMode === 'list'" class="text-sm text-art-muted">
<p>点击版本可勾选最多 2 进行对比或点击下方操作</p>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
class="admin-btn-secondary text-xs"
:disabled="selectedVersions.length !== 1"
@click="viewVersion(selectedVersions[0])"
>
查看选中版本
</button>
<button
type="button"
class="admin-btn-secondary text-xs"
:disabled="selectedVersions.length !== 2"
@click="compareVersions"
>
对比选中版本
</button>
<button
type="button"
class="admin-btn-primary text-xs"
:disabled="selectedVersions.length !== 1"
@click="restoreVersion(selectedVersions[0])"
>
恢复选中版本
</button>
</div>
</div>
<div v-else-if="viewMode === 'preview' && previewData">
<div class="flex items-center justify-between mb-4">
<h4 class="text-white font-medium">版本 v{{ previewData.version }}</h4>
<button type="button" class="text-xs text-art-accent" @click="viewMode = 'list'">返回列表</button>
</div>
<div class="space-y-3 text-sm">
<div><span class="text-art-muted">标题</span><span class="text-white">{{ previewData.title }}</span></div>
<div><span class="text-art-muted">摘要</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
<pre class="bg-black/30 rounded-lg p-4 text-xs text-white/80 whitespace-pre-wrap max-h-96 overflow-y-auto">{{ previewData.content || '—' }}</pre>
</div>
</div>
<div v-else-if="viewMode === 'diff' && diffData">
<div class="flex items-center justify-between mb-4">
<h4 class="text-white font-medium">v{{ diffData.fromVersion }} v{{ diffData.toVersion }}</h4>
<button type="button" class="text-xs text-art-accent" @click="viewMode = 'list'">返回列表</button>
</div>
<div class="space-y-4">
<div
v-for="(field, key) in diffData.fields"
:key="key"
class="rounded-lg border p-4"
:class="field.changed ? 'border-art-accent/30 bg-art-accent/5' : 'border-white/5 bg-white/[0.02]'"
>
<div class="text-xs uppercase tracking-wider text-art-muted mb-2">{{ key }}</div>
<template v-if="field.changed">
<div class="grid md:grid-cols-2 gap-3 text-xs">
<div>
<div class="text-art-muted mb-1">旧版</div>
<pre class="whitespace-pre-wrap bg-black/30 rounded p-2 text-white/70 max-h-40 overflow-y-auto">{{ field.from || '—' }}</pre>
</div>
<div>
<div class="text-art-muted mb-1">新版</div>
<pre class="whitespace-pre-wrap bg-black/30 rounded p-2 text-white/70 max-h-40 overflow-y-auto">{{ field.to || '—' }}</pre>
</div>
</div>
<pre v-if="field.diff" class="mt-2 text-xs text-art-accent/80 whitespace-pre-wrap">{{ field.diff }}</pre>
</template>
<div v-else class="text-xs text-art-muted">无变化</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import {
getPostHistory,
getPostHistoryByVersion,
getPostHistoryDiff,
restorePostHistory,
type PostHistory,
type PostHistoryDiff
} from '../../services/api'
import { useToast } from '../../composables/useToast'
const props = defineProps<{
isOpen: boolean
postId: number | string
}>()
const emit = defineEmits<{
close: []
restored: []
}>()
const toast = useToast()
const loading = ref(false)
const historyList = ref<PostHistory[]>([])
const selectedVersions = ref<number[]>([])
const viewMode = ref<'list' | 'preview' | 'diff'>('list')
const previewData = ref<PostHistory | null>(null)
const diffData = ref<PostHistoryDiff | null>(null)
const loadHistory = async () => {
if (!props.postId) return
loading.value = true
try {
historyList.value = await getPostHistory(props.postId)
historyList.value.sort((a, b) => b.version - a.version)
} catch (error: any) {
toast.showToast(error.message || '加载历史版本失败', 'error')
} finally {
loading.value = false
}
}
const toggleVersionSelect = (version: number) => {
const idx = selectedVersions.value.indexOf(version)
if (idx >= 0) {
selectedVersions.value.splice(idx, 1)
return
}
if (selectedVersions.value.length >= 2) {
selectedVersions.value.shift()
}
selectedVersions.value.push(version)
}
const viewVersion = async (version?: number) => {
if (!version) return
try {
previewData.value = await getPostHistoryByVersion(props.postId, version)
viewMode.value = 'preview'
} catch (error: any) {
toast.showToast(error.message || '加载版本详情失败', 'error')
}
}
const compareVersions = async () => {
if (selectedVersions.value.length !== 2) return
const [a, b] = [...selectedVersions.value].sort((x, y) => x - y)
try {
diffData.value = await getPostHistoryDiff(props.postId, a, b)
viewMode.value = 'diff'
} catch (error: any) {
toast.showToast(error.message || '对比失败', 'error')
}
}
const restoreVersion = async (version?: number) => {
if (!version) return
if (!confirm(`确定将文章恢复为版本 v${version} 吗?当前内容会先保存为新版本。`)) return
try {
await restorePostHistory(props.postId, version)
toast.showToast('版本恢复成功', 'success')
emit('restored')
emit('close')
} catch (error: any) {
toast.showToast(error.message || '恢复失败', 'error')
}
}
watch(
() => props.isOpen,
(open) => {
if (open) {
selectedVersions.value = []
viewMode.value = 'list'
previewData.value = null
diffData.value = null
loadHistory()
}
}
)
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
</style>