231 lines
8.7 KiB
Vue
231 lines
8.7 KiB
Vue
<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>
|