优化页面、修复BUG

This commit is contained in:
李琦
2026-06-24 16:50:04 +08:00
parent 88ba9be318
commit 3f653bc336
36 changed files with 1672 additions and 2401 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View 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>

View 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>