优化页面、修复BUG
This commit is contained in:
@@ -149,6 +149,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<SessionExpiredModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -156,12 +157,15 @@
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import SessionExpiredModal from './SessionExpiredModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const { logout, scheduleExpiryCheck, getUser, isAuthenticated } = useAuth()
|
||||
const isSidebarOpen = ref(true)
|
||||
const currentUser = ref(JSON.parse(localStorage.getItem('user') || '{}'))
|
||||
const currentUser = ref(getUser())
|
||||
|
||||
// Dropdown logic
|
||||
const isUserDropdownOpen = ref(false)
|
||||
@@ -286,10 +290,8 @@ const toggleSidebar = () => {
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
logout()
|
||||
toast.showToast('退出登录成功', 'success')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// Helper to determine active state
|
||||
@@ -320,10 +322,11 @@ const currentRouteTitle = computed(() => {
|
||||
|
||||
// Check if user is authenticated
|
||||
onMounted(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
scheduleExpiryCheck()
|
||||
document.addEventListener('click', closeDropdown)
|
||||
|
||||
// Auto open menu based on current route
|
||||
|
||||
@@ -1,86 +1,107 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="handleClose">
|
||||
<div class="admin-card max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-serif italic text-white">附件详情</h2>
|
||||
<button @click="handleClose" class="text-white/40 hover:text-white transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="attachment" class="space-y-6">
|
||||
<!-- 图片预览 -->
|
||||
<div v-if="attachment.fileType === 'image'" class="w-full rounded-lg overflow-hidden bg-white/5">
|
||||
<img :src="attachment.fileUrl" :alt="attachment.originalName" class="w-full h-auto max-h-96 object-contain" />
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="admin-card max-w-3xl w-full mx-4 max-h-[92vh] overflow-hidden flex flex-col border border-white/10 shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-white/10 shrink-0">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl font-serif italic text-white">附件详情</h2>
|
||||
<p v-if="attachment" class="text-sm text-art-muted mt-1 truncate" :title="attachment.originalName">
|
||||
{{ attachment.originalName }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="handleClose" class="text-white/40 hover:text-white transition-colors shrink-0 p-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 文件信息 -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件名</label>
|
||||
<p class="text-white">{{ attachment.originalName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件大小</label>
|
||||
<p class="text-white">{{ formatFileSize(attachment.fileSize) }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件类型</label>
|
||||
<p class="text-white">{{ getFileTypeLabel(attachment.fileType) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="attachment.mimeType">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">MIME类型</label>
|
||||
<p class="text-white font-mono text-sm">{{ attachment.mimeType }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">存储类型</label>
|
||||
<p class="text-white">{{ getStorageTypeLabel(attachment.storageType) }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">分类</label>
|
||||
<CustomSelect
|
||||
v-model="localCategoryId"
|
||||
:options="categoryOptions"
|
||||
placeholder="无分类"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件URL</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:value="attachment.fileUrl"
|
||||
readonly
|
||||
class="admin-input flex-1 font-mono text-sm"
|
||||
|
||||
<div v-if="attachment" class="overflow-y-auto custom-scrollbar flex-1">
|
||||
<!-- Media preview -->
|
||||
<div class="px-6 pt-6">
|
||||
<div class="rounded-xl overflow-hidden bg-black/40 border border-white/10 min-h-[200px] flex items-center justify-center">
|
||||
<img
|
||||
v-if="attachment.fileType === 'image'"
|
||||
:src="attachment.fileUrl"
|
||||
:alt="attachment.originalName"
|
||||
class="w-full max-h-[420px] object-contain"
|
||||
/>
|
||||
<button
|
||||
@click="handleCopyUrl"
|
||||
class="admin-btn-secondary px-4 whitespace-nowrap"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<video
|
||||
v-else-if="attachment.fileType === 'video'"
|
||||
:src="attachment.fileUrl"
|
||||
controls
|
||||
preload="metadata"
|
||||
class="w-full max-h-[420px] bg-black"
|
||||
/>
|
||||
<div v-else class="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<div class="w-20 h-20 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-art-muted">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-white font-medium mb-1">{{ attachment.originalName }}</p>
|
||||
<p class="text-sm text-art-muted">此文件类型暂不支持预览</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="attachment.createdAt" class="text-xs text-art-muted">
|
||||
创建时间: {{ formatDate(attachment.createdAt) }}
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="px-6 pt-4 flex flex-wrap gap-2">
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium border" :class="typeBadgeClass">
|
||||
{{ getFileTypeLabel(attachment.fileType) }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs bg-white/5 text-art-muted border border-white/10">
|
||||
{{ formatFileSize(attachment.fileSize) }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full text-xs bg-white/5 text-art-muted border border-white/10">
|
||||
{{ getStorageTypeLabel(attachment.storageType) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta grid -->
|
||||
<div class="px-6 py-5 grid sm:grid-cols-2 gap-4">
|
||||
<div class="rounded-lg bg-white/[0.03] border border-white/5 p-4">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">MIME 类型</label>
|
||||
<p class="text-white font-mono text-sm break-all">{{ attachment.mimeType || '—' }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white/[0.03] border border-white/5 p-4">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">创建时间</label>
|
||||
<p class="text-white text-sm">{{ attachment.createdAt ? formatDate(attachment.createdAt) : '—' }}</p>
|
||||
</div>
|
||||
<div class="sm:col-span-2 rounded-lg bg-white/[0.03] border border-white/5 p-4">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">分类</label>
|
||||
<CustomSelect
|
||||
v-model="localCategoryId"
|
||||
:options="categoryOptions"
|
||||
placeholder="无分类"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2 rounded-lg bg-white/[0.03] border border-white/5 p-4">
|
||||
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件 URL</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:value="attachment.fileUrl"
|
||||
readonly
|
||||
class="admin-input flex-1 font-mono text-sm"
|
||||
/>
|
||||
<button @click="handleCopyUrl" class="admin-btn-secondary px-4 whitespace-nowrap">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-3 pt-4 border-t border-white/10">
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex gap-3 px-6 py-4 border-t border-white/10 shrink-0">
|
||||
<button @click="handleClose" class="admin-btn-secondary flex-1">
|
||||
取消
|
||||
</button>
|
||||
@@ -90,7 +111,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -132,7 +152,6 @@ const emit = defineEmits<{
|
||||
const localCategoryId = ref<number>(0)
|
||||
const saving = ref(false)
|
||||
|
||||
// 计算属性:分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
return [
|
||||
{ value: 0, label: '无分类' },
|
||||
@@ -140,7 +159,16 @@ const categoryOptions = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
// 监听attachment变化,更新本地分类ID
|
||||
const typeBadgeClass = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
image: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/30',
|
||||
video: 'bg-violet-500/10 text-violet-300 border-violet-500/30',
|
||||
document: 'bg-blue-500/10 text-blue-300 border-blue-500/30',
|
||||
other: 'bg-white/5 text-art-muted border-white/10'
|
||||
}
|
||||
return map[props.attachment?.fileType || 'other'] || map.other
|
||||
})
|
||||
|
||||
watch(() => props.attachment, (newAttachment) => {
|
||||
if (newAttachment) {
|
||||
localCategoryId.value = newAttachment.categoryId || 0
|
||||
@@ -155,7 +183,6 @@ const handleSave = () => {
|
||||
saving.value = true
|
||||
const categoryId = localCategoryId.value === 0 ? null : localCategoryId.value
|
||||
emit('save', { categoryId })
|
||||
// saving状态由父组件控制
|
||||
}
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
@@ -183,8 +210,8 @@ const getFileTypeLabel = (fileType: string): string => {
|
||||
const getStorageTypeLabel = (storageType?: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
local: '本地存储',
|
||||
qcloud: '腾讯云COS',
|
||||
aliyun: '阿里云OSS',
|
||||
qcloud: '腾讯云 COS',
|
||||
aliyun: '阿里云 OSS',
|
||||
qiniu: '七牛云'
|
||||
}
|
||||
return labels[storageType || 'local'] || storageType || '本地存储'
|
||||
@@ -195,12 +222,15 @@ const formatDate = (timestamp: string | number): string => {
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 暴露saving状态给父组件
|
||||
defineExpose({
|
||||
saving
|
||||
})
|
||||
defineExpose({ saving })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局admin样式,无需额外样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { API_BASE } from '../../services/api'
|
||||
import { API_BASE, authFetch, parseApiResponse } from '../../services/api'
|
||||
import AttachmentLibraryModal from './AttachmentLibraryModal.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -365,22 +365,12 @@ const uploadSingleFile = async (file: File): Promise<string | null> => {
|
||||
}
|
||||
formData.append('storageType', 'local')
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${API_BASE}/admin/attachments/upload`, {
|
||||
const result = await parseApiResponse<{ fileUrl: string }>(await authFetch(`${API_BASE}/admin/attachments/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
headers: {},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || '上传失败')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.result.fileUrl
|
||||
}))
|
||||
return result.fileUrl
|
||||
} catch (err: any) {
|
||||
console.error('Upload error:', err)
|
||||
throw err
|
||||
|
||||
230
client/src/components/admin/PostHistoryModal.vue
Normal file
230
client/src/components/admin/PostHistoryModal.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<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>
|
||||
26
client/src/components/admin/SessionExpiredModal.vue
Normal file
26
client/src/components/admin/SessionExpiredModal.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="sessionExpired"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div class="w-full max-w-sm mx-4 bg-[#121214] border border-white/10 rounded-xl p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-medium text-white mb-2">登录已过期</h3>
|
||||
<p class="text-sm text-art-muted mb-6">您的登录状态已失效,请重新登录。</p>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full admin-btn-primary py-2.5"
|
||||
@click="confirmSessionExpired"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
|
||||
const { sessionExpired, confirmSessionExpired } = useAuth()
|
||||
</script>
|
||||
Reference in New Issue
Block a user