优化页面、修复BUG

This commit is contained in:
李琦
2026-06-25 16:57:06 +08:00
parent d5ff40e4d3
commit 20fb0e3203
31 changed files with 2032 additions and 754 deletions

View File

@@ -15,59 +15,15 @@
<!-- Nav Menu -->
<nav class="flex-1 overflow-y-auto py-6 px-3 space-y-1 custom-scrollbar">
<template v-for="(item, index) in menuItems" :key="index">
<!-- Parent Menu Item -->
<div v-if="item.children">
<div
class="flex items-center justify-between px-3 py-2.5 rounded-md cursor-pointer transition-colors duration-200 group mb-1"
:class="[isParentActive(item) ? 'text-white' : 'text-art-muted hover:text-white hover:bg-white/5']"
@click="toggleMenu(index)"
>
<div class="flex items-center gap-3 min-w-0">
<span class="text-lg shrink-0 group-hover:text-art-accent transition-colors" :class="isParentActive(item) ? 'text-art-accent' : ''">{{ item.icon }}</span>
<span class="text-sm font-medium truncate transition-opacity duration-300" :class="{ 'opacity-0 w-0': !isSidebarOpen }">{{ item.title }}</span>
</div>
<span
v-if="isSidebarOpen"
class="text-[10px] transition-transform duration-200 opacity-50 group-hover:opacity-100"
:class="{ 'rotate-180': item.isOpen }"
></span>
</div>
<!-- Submenu -->
<div
v-show="isSidebarOpen && item.isOpen"
class="ml-4 pl-4 border-l border-white/5 space-y-1 mb-2 overflow-hidden transition-all duration-300"
>
<router-link
v-for="(child, childIndex) in item.children"
:key="childIndex"
:to="child.path"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-all duration-200 relative group"
active-class="text-art-accent bg-art-accent/5 font-medium"
:class="!isActive(child.path) ? 'text-art-muted hover:text-white hover:bg-white/5' : ''"
>
<!-- Active Indicator Line -->
<div v-if="isActive(child.path)" class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-art-accent rounded-full"></div>
<span class="truncate">{{ child.title }}</span>
</router-link>
</div>
</div>
<!-- Single Menu Item -->
<router-link
v-else
:to="item.path || ''"
class="flex items-center gap-3 px-3 py-2.5 rounded-md mb-1 transition-all duration-200 relative group"
active-class="text-art-accent bg-art-accent/5"
:class="!isActive(item.path || '') ? 'text-art-muted hover:text-white hover:bg-white/5' : ''"
>
<div v-if="isActive(item.path || '')" class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-art-accent rounded-full"></div>
<span class="text-lg shrink-0 group-hover:text-art-accent transition-colors">{{ item.icon }}</span>
<span class="text-sm font-medium truncate transition-opacity duration-300" :class="{ 'opacity-0 w-0': !isSidebarOpen }">{{ item.title }}</span>
</router-link>
</template>
<AdminMenuItem
v-for="(item, index) in menuItems"
:key="index"
:item="item"
:depth="0"
:is-sidebar-open="isSidebarOpen"
:is-active="isActive"
@request-open-sidebar="openSidebar"
/>
</nav>
<!-- User Footer -->
@@ -154,11 +110,13 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { useAuth } from '../../composables/useAuth'
import SessionExpiredModal from './SessionExpiredModal.vue'
import AdminMenuItem from './AdminMenuItem.vue'
import { openActiveMenuAncestors, findRouteTitle, type MenuItem } from './adminMenuTypes'
const router = useRouter()
const route = useRoute()
@@ -191,15 +149,9 @@ const refreshCurrentPage = () => {
router.go(0)
}
interface MenuItem {
title: string
icon: string
path?: string
children?: { title: string; path: string; icon: string }[]
isOpen?: boolean
}
interface MenuItemWithState extends MenuItem {}
const menuItems = ref<MenuItem[]>([
const menuItems = ref<MenuItemWithState[]>([
{
title: '概览',
icon: '📊',
@@ -219,8 +171,14 @@ const menuItems = ref<MenuItem[]>([
{ title: '分类管理', path: '/admin/categories', icon: '📂' },
{ title: '专栏管理', path: '/admin/columns', icon: '📚' },
{ title: '作品管理', path: '/admin/works', icon: '🎨' },
{ title: '代码片段', path: '/admin/snippets', icon: '💻' },
{ title: '代码类型', path: '/admin/code-types', icon: '🏷️' },
{
title: '代码管理',
isOpen: false,
children: [
{ title: '代码片段', path: '/admin/snippets', icon: '💻' },
{ title: '代码分类', path: '/admin/code-types', icon: '🏷️' }
]
},
{ title: '标签管理', path: '/admin/tags', icon: '🏷️' }
]
},
@@ -274,15 +232,9 @@ const menuItems = ref<MenuItem[]>([
}
])
const toggleMenu = (index: number) => {
const openSidebar = () => {
if (!isSidebarOpen.value) {
isSidebarOpen.value = true
// Wait for transition
setTimeout(() => {
menuItems.value[index].isOpen = !menuItems.value[index].isOpen
}, 100)
} else {
menuItems.value[index].isOpen = !menuItems.value[index].isOpen
}
}
@@ -300,25 +252,9 @@ const isActive = (path: string) => {
return route.path === path || route.path.startsWith(path + '/')
}
const isParentActive = (item: MenuItem) => {
if (item.path && isActive(item.path)) return true
if (item.children) {
return item.children.some(child => isActive(child.path))
}
return false
}
// Get current route title for header
const currentRouteTitle = computed(() => {
// Simple logic: find matching menu item title
for (const item of menuItems.value) {
if (item.path === route.path) return item.title
if (item.children) {
const child = item.children.find(c => c.path === route.path)
if (child) return `${item.title} / ${child.title}`
}
}
return '管理控制台'
return findRouteTitle(menuItems.value, route.path) || '管理控制台'
})
// Check if user is authenticated
@@ -329,13 +265,11 @@ onMounted(() => {
}
scheduleExpiryCheck()
document.addEventListener('click', closeDropdown)
// Auto open menu based on current route
menuItems.value.forEach(item => {
if (item.children && item.children.some(child => isActive(child.path))) {
item.isOpen = true
}
})
openActiveMenuAncestors(menuItems.value, isActive)
})
watch(() => route.path, () => {
openActiveMenuAncestors(menuItems.value, isActive)
})
onUnmounted(() => {

View File

@@ -0,0 +1,133 @@
<template>
<div v-if="item.children?.length" class="mb-1">
<div
class="flex items-center justify-between rounded-md cursor-pointer transition-colors duration-200 group"
:class="[
depthPadding,
isItemActive ? 'text-white' : 'text-art-muted hover:text-white hover:bg-white/5'
]"
@click="handleToggle"
>
<div class="flex items-center gap-3 min-w-0">
<span
v-if="item.icon && depth === 0"
class="text-lg shrink-0 group-hover:text-art-accent transition-colors"
:class="isItemActive ? 'text-art-accent' : ''"
>{{ item.icon }}</span>
<span
class="text-sm truncate transition-opacity duration-300"
:class="[
depth === 0 ? 'font-medium' : '',
{ 'opacity-0 w-0': depth === 0 && !isSidebarOpen }
]"
>{{ item.title }}</span>
</div>
<span
v-if="isSidebarOpen"
class="text-[10px] transition-transform duration-200 opacity-50 group-hover:opacity-100 shrink-0"
:class="{ 'rotate-180': item.isOpen }"
></span>
</div>
<div
v-show="isSidebarOpen && item.isOpen"
class="border-l border-white/5 space-y-1 overflow-hidden transition-all duration-300"
:class="childContainerClass"
>
<AdminMenuItem
v-for="(child, childIndex) in item.children"
:key="`${item.title}-${childIndex}`"
:item="child"
:depth="depth + 1"
:is-sidebar-open="isSidebarOpen"
:is-active="isActive"
@toggle="(index) => $emit('toggle', index)"
@request-open-sidebar="$emit('request-open-sidebar')"
/>
</div>
</div>
<router-link
v-else-if="item.path"
:to="item.path"
class="flex items-center gap-3 rounded-md transition-all duration-200 relative group mb-1"
:class="[
depthPadding,
isActive(item.path) ? 'text-art-accent bg-art-accent/5 font-medium' : 'text-art-muted hover:text-white hover:bg-white/5'
]"
active-class="text-art-accent bg-art-accent/5"
>
<div
v-if="isActive(item.path)"
class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 rounded-full bg-art-accent"
:class="depth >= 2 ? 'h-3' : 'h-4'"
></div>
<span
v-if="item.icon && depth === 0"
class="text-lg shrink-0 group-hover:text-art-accent transition-colors"
>{{ item.icon }}</span>
<span
class="text-sm truncate transition-opacity duration-300"
:class="[
depth === 0 ? 'font-medium' : '',
{ 'opacity-0 w-0': depth === 0 && !isSidebarOpen }
]"
>{{ item.title }}</span>
</router-link>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AdminMenuItem from './AdminMenuItem.vue'
import type { MenuItem } from './adminMenuTypes'
const props = defineProps<{
item: MenuItem
depth: number
isSidebarOpen: boolean
isActive: (path: string) => boolean
}>()
const emit = defineEmits<{
toggle: [index: number]
'request-open-sidebar': []
}>()
const isItemActive = computed(() => {
if (props.item.path && props.isActive(props.item.path)) return true
if (props.item.children) {
return props.item.children.some(child => isMenuItemActive(child))
}
return false
})
const isMenuItemActive = (item: MenuItem): boolean => {
if (item.path && props.isActive(item.path)) return true
if (item.children) {
return item.children.some(child => isMenuItemActive(child))
}
return false
}
const depthPadding = computed(() => {
if (props.depth === 0) return 'px-3 py-2.5'
if (props.depth === 1) return 'px-3 py-2 ml-4 pl-4'
return 'px-3 py-1.5 ml-4 pl-3 text-xs'
})
const childContainerClass = computed(() => {
if (props.depth === 0) return 'ml-4 pl-4 mb-2'
return 'ml-2 pl-3 mb-1'
})
const handleToggle = () => {
if (!props.isSidebarOpen) {
emit('request-open-sidebar')
window.setTimeout(() => {
props.item.isOpen = true
}, 100)
return
}
props.item.isOpen = !props.item.isOpen
}
</script>

View File

@@ -0,0 +1,161 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="close"
>
<div class="bg-[#1a1a1a] border border-white/10 rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col animate-reveal">
<div class="p-4 border-b border-white/10 flex justify-between items-center shrink-0">
<h3 class="text-lg font-serif italic text-white">咨询详情</h3>
<button type="button" @click="close" class="text-white/50 hover:text-white 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" 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="inquiry" class="p-6 overflow-y-auto flex-1 space-y-4">
<div class="admin-card p-5 space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs text-art-muted mb-1">姓名</p>
<p class="text-white font-medium">{{ inquiry.name }}</p>
</div>
<span class="px-2 py-1 rounded text-xs shrink-0" :class="statusClass">{{ statusLabel }}</span>
</div>
<div>
<p class="text-xs text-art-muted mb-1">公司 / 组织</p>
<p class="text-white/90">{{ inquiry.company || '—' }}</p>
</div>
<div>
<p class="text-xs text-art-muted mb-1">联系方式</p>
<p class="text-xs text-art-muted mb-2">{{ contactMethodLabel }}</p>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-white font-mono break-all">{{ inquiry.contactValue }}</span>
<button
type="button"
class="admin-btn-secondary py-1 px-2 text-xs shrink-0"
@click="copyContact"
>
复制
</button>
</div>
</div>
<div>
<p class="text-xs text-art-muted mb-1">预估预算</p>
<p class="text-white/90">{{ inquiry.budget || '—' }}</p>
</div>
<div>
<p class="text-xs text-art-muted mb-1">需求描述</p>
<p class="text-white/90 text-sm leading-relaxed whitespace-pre-wrap">{{ inquiry.description || '—' }}</p>
</div>
<div>
<p class="text-xs text-art-muted mb-1">提交时间</p>
<p class="text-white/70 text-sm">{{ formattedCreatedAt }}</p>
</div>
</div>
</div>
<div class="p-4 border-t border-white/10 flex justify-end gap-2 shrink-0 bg-white/[0.02]">
<button type="button" class="admin-btn-secondary py-1.5 px-3 text-sm" @click="close">关闭</button>
<button
v-if="inquiry?.status === 0"
type="button"
class="admin-btn-secondary py-1.5 px-3 text-sm"
@click="emitStatus(1)"
>
标记已读
</button>
<button
v-if="inquiry?.status !== 2"
type="button"
class="admin-btn-primary py-1.5 px-3 text-sm"
@click="emitStatus(2)"
>
标记已联系
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Inquiry } from '../../services/api'
import { useToast } from '../../composables/useToast'
const props = defineProps<{
isOpen: boolean
inquiry: Inquiry | null
}>()
const emit = defineEmits<{
'update:isOpen': [value: boolean]
'update-status': [status: number]
}>()
const toast = useToast()
const close = () => {
emit('update:isOpen', false)
}
const contactMethodLabel = computed(() => {
const map: Record<string, string> = {
wechat: '微信',
email: '邮箱',
phone: '电话'
}
return map[props.inquiry?.contactMethod || ''] || props.inquiry?.contactMethod || '—'
})
const statusLabel = computed(() => {
switch (props.inquiry?.status) {
case 0: return '未读'
case 1: return '已读'
case 2: return '已联系'
default: return '未知'
}
})
const statusClass = computed(() => {
switch (props.inquiry?.status) {
case 0: return 'bg-red-500/20 text-red-400'
case 1: return 'bg-blue-500/20 text-blue-400'
case 2: return 'bg-green-500/20 text-green-400'
default: return 'bg-gray-500/20 text-gray-400'
}
})
const formattedCreatedAt = computed(() => {
const raw = props.inquiry?.createdAt
if (raw === undefined || raw === null || raw === '') return '—'
const timestamp = typeof raw === 'number' ? raw : Number(raw)
if (Number.isNaN(timestamp)) return '—'
const ms = timestamp > 1e12 ? timestamp : timestamp * 1000
return new Date(ms).toLocaleString()
})
const copyContact = async () => {
const value = props.inquiry?.contactValue
if (!value) return
try {
await navigator.clipboard.writeText(value)
toast.success('已复制到剪贴板')
} catch {
toast.error('复制失败,请手动复制')
}
}
const emitStatus = (status: number) => {
emit('update-status', status)
}
</script>

View File

@@ -5,7 +5,7 @@
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="w-full max-w-5xl 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>
@@ -14,6 +14,19 @@
<button type="button" class="text-art-muted hover:text-white" @click="emit('close')"></button>
</div>
<div class="flex items-center gap-3 px-6 py-3 border-b border-white/10 shrink-0 bg-white/[0.02]">
<button
type="button"
class="admin-btn-primary text-xs"
:disabled="!activeVersion"
@click="restoreVersion(activeVersion!)"
>
恢复此版本
</button>
<span v-if="activeVersion" class="text-xs text-art-muted">当前查看v{{ activeVersion }}</span>
<span v-else class="text-xs text-art-muted">请从左侧选择一个版本</span>
</div>
<div class="flex-1 overflow-hidden flex min-h-0">
<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>
@@ -23,8 +36,8 @@
: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)"
:class="{ 'bg-art-accent/10 border-l-2 border-l-art-accent': activeVersion === item.version }"
@click="selectVersion(item.version)"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-white font-medium">v{{ item.version }}</span>
@@ -36,67 +49,70 @@
</div>
<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 v-if="!activeVersion" class="h-full flex items-center justify-center text-sm text-art-muted">
点击左侧版本查看内容与变更对比
</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.categoryName || '—' }}</span></div>
<div><span class="text-art-muted">专栏</span><span class="text-white">{{ previewData.columnName || '—' }}</span></div>
<div><span class="text-art-muted">标签</span><span class="text-white">{{ previewData.tagNames?.join(', ') || '—' }}</span></div>
<div><span class="text-art-muted">状态</span><span class="text-white">{{ previewData.isPublished === 1 ? '已发布' : '草稿' }}</span></div>
<div><span class="text-art-muted">修改人</span><span class="text-white">{{ previewData.modifiedByName || previewData.modifiedBy }}</span></div>
<div><span class="text-art-muted">摘要</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
<div>
<span class="text-art-muted block mb-2">正文</span>
<div class="bg-black/30 rounded-lg p-4 max-h-96 overflow-y-auto prose prose-invert prose-sm max-w-none">
<MdPreview :model-value="previewData.content || ''" theme="dark" />
<div v-else-if="detailLoading" class="h-full flex items-center justify-center text-sm text-art-muted">
加载版本详情...
</div>
<template v-else-if="previewData">
<section class="mb-8">
<h4 class="text-white font-medium mb-4">版本 v{{ previewData.version }} 内容</h4>
<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.categoryName || '—' }}</span></div>
<div><span class="text-art-muted">专栏</span><span class="text-white">{{ previewData.columnName || '—' }}</span></div>
<div><span class="text-art-muted">标签</span><span class="text-white">{{ previewData.tagNames?.join(', ') || '—' }}</span></div>
<div><span class="text-art-muted">状态</span><span class="text-white">{{ previewData.isPublished === 1 ? '已发布' : '草稿' }}</span></div>
<div><span class="text-art-muted">修改人</span><span class="text-white">{{ previewData.modifiedByName || previewData.modifiedBy }}</span></div>
<div><span class="text-art-muted">摘要</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
<div>
<span class="text-art-muted block mb-2">正文</span>
<div class="bg-black/30 rounded-lg p-4 max-h-80 overflow-y-auto prose prose-invert prose-sm max-w-none">
<MdPreview :model-value="previewData.content || ''" theme="dark" />
</div>
</div>
</div>
</div>
</div>
</section>
<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">{{ fieldLabel(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 && key === 'content'" class="mt-2 text-xs text-art-accent/80 whitespace-pre-wrap max-h-48 overflow-y-auto">{{ field.diff }}</pre>
</template>
<div v-else class="text-xs text-art-muted">无变化</div>
<section>
<h4 class="text-white font-medium mb-4">
<template v-if="activeVersion > 1">与上一版本对比v{{ activeVersion - 1 }} v{{ activeVersion }}</template>
<template v-else>版本对比</template>
</h4>
<div v-if="activeVersion === 1" class="text-sm text-art-muted rounded-lg border border-white/10 p-4 bg-white/[0.02]">
这是首个版本无上一版本可对比
</div>
</div>
</div>
<div v-else-if="diffData" 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">{{ fieldLabel(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 && key === 'content'" class="mt-2 text-xs text-art-accent/80 whitespace-pre-wrap max-h-48 overflow-y-auto">{{ field.diff }}</pre>
</template>
<div v-else class="text-xs text-art-muted">无变化</div>
</div>
</div>
</section>
</template>
</div>
</div>
</div>
@@ -140,9 +156,9 @@ const emit = defineEmits<{
const toast = useToast()
const loading = ref(false)
const detailLoading = ref(false)
const historyList = ref<PostHistory[]>([])
const selectedVersions = ref<number[]>([])
const viewMode = ref<'list' | 'preview' | 'diff'>('list')
const activeVersion = ref<number | null>(null)
const previewData = ref<PostHistory | null>(null)
const diffData = ref<PostHistoryDiff | null>(null)
@@ -154,6 +170,9 @@ const loadHistory = async () => {
try {
historyList.value = await getPostHistory(props.postId)
historyList.value.sort((a, b) => b.version - a.version)
if (historyList.value.length > 0) {
await selectVersion(historyList.value[0].version)
}
} catch (error: any) {
toast.showToast(error.message || '加载历史版本失败', 'error')
} finally {
@@ -161,41 +180,26 @@ const loadHistory = async () => {
}
}
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
const selectVersion = async (version: number) => {
activeVersion.value = version
detailLoading.value = true
previewData.value = null
diffData.value = null
try {
previewData.value = await getPostHistoryByVersion(props.postId, version)
viewMode.value = 'preview'
const [detail, diff] = await Promise.all([
getPostHistoryByVersion(props.postId, version),
version > 1 ? getPostHistoryDiff(props.postId, version - 1, version) : Promise.resolve(null)
])
previewData.value = detail
diffData.value = diff
} catch (error: any) {
toast.showToast(error.message || '加载版本详情失败', 'error')
} finally {
detailLoading.value = false
}
}
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
const restoreVersion = async (version: number) => {
if (!confirm(`确定将文章恢复为版本 v${version} 吗?当前内容会先保存为新版本。`)) return
try {
await restorePostHistory(props.postId, version)
@@ -211,8 +215,7 @@ watch(
() => props.isOpen,
(open) => {
if (open) {
selectedVersions.value = []
viewMode.value = 'list'
activeVersion.value = null
previewData.value = null
diffData.value = null
loadHistory()

View File

@@ -15,7 +15,7 @@
</div>
<!-- Category Selection -->
<div class="space-y-3">
<div ref="categorySectionRef" class="space-y-3 rounded-lg transition-shadow duration-300" :class="highlightClass('category')">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">分类</label>
<button @click="showCreateCategory = true" class="text-xs text-art-accent hover:text-white transition-colors">
@@ -44,7 +44,7 @@
</div>
<!-- Column Selection -->
<div class="space-y-3">
<div ref="columnSectionRef" class="space-y-3 rounded-lg transition-shadow duration-300" :class="highlightClass('column')">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">专栏 <span class="text-art-muted text-xs">(可选)</span></label>
<button @click="showCreateColumn = true" class="text-xs text-art-accent hover:text-white transition-colors">
@@ -86,7 +86,7 @@
</div>
<!-- Tags Selection -->
<div class="space-y-3">
<div ref="tagsSectionRef" class="space-y-3 rounded-lg transition-shadow duration-300" :class="highlightClass('tags')">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">标签 <span class="text-art-muted text-xs">(可多选)</span></label>
<button @click="showCreateTag = true" class="text-xs text-art-accent hover:text-white transition-colors">
@@ -138,7 +138,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { ref, watch, onMounted, computed, nextTick } from 'vue'
import {
fetchCategories,
fetchColumns,
@@ -159,6 +159,7 @@ import ImageUpload from './ImageUpload.vue'
const props = defineProps<{
isOpen: boolean
post: Post | null
focusSection?: 'category' | 'column' | 'tags' | 'all'
}>()
const emit = defineEmits<{
@@ -186,6 +187,37 @@ const newTag = ref({ name: '', slug: '' })
const saving = ref(false)
const categorySectionRef = ref<HTMLElement | null>(null)
const columnSectionRef = ref<HTMLElement | null>(null)
const tagsSectionRef = ref<HTMLElement | null>(null)
const activeHighlight = ref<'category' | 'column' | 'tags' | null>(null)
const highlightClass = (section: 'category' | 'column' | 'tags') => {
return activeHighlight.value === section ? 'ring-2 ring-art-accent/50 ring-offset-2 ring-offset-[#1a1a1a]' : ''
}
const scrollToFocusSection = async () => {
const focus = props.focusSection || 'all'
if (focus === 'all') return
await nextTick()
const refMap = {
category: categorySectionRef,
column: columnSectionRef,
tags: tagsSectionRef
} as const
const target = refMap[focus]?.value
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
activeHighlight.value = focus
window.setTimeout(() => {
if (activeHighlight.value === focus) {
activeHighlight.value = null
}
}, 2000)
}
// 计算属性:分类选项
const categoryOptions = computed(() => {
return [
@@ -344,6 +376,9 @@ watch(() => props.isOpen, (open) => {
if (open) {
loadData()
initForm()
scrollToFocusSection()
} else {
activeHighlight.value = null
}
})

View File

@@ -0,0 +1,56 @@
export interface MenuItem {
title: string
icon?: string
path?: string
children?: MenuItem[]
isOpen?: boolean
}
export function isMenuItemRouteActive(item: MenuItem, isActive: (path: string) => boolean): boolean {
if (item.path && isActive(item.path)) return true
if (item.children) {
return item.children.some(child => isMenuItemRouteActive(child, isActive))
}
return false
}
export function openActiveMenuAncestors(items: MenuItem[], isActive: (path: string) => boolean) {
items.forEach(item => {
if (item.children && isMenuItemRouteActive(item, isActive)) {
item.isOpen = true
openActiveMenuAncestors(item.children, isActive)
}
})
}
export function findRouteTitle(items: MenuItem[], path: string, parentTitle?: string): string | null {
for (const item of items) {
if (item.path === path) {
return parentTitle ? `${parentTitle} / ${item.title}` : item.title
}
if (item.children) {
const childTitle = findRouteTitle(item.children, path, parentTitle ? `${parentTitle} / ${item.title}` : item.title)
if (childTitle) return childTitle
}
if (item.path && path.startsWith(item.path + '/')) {
return parentTitle ? `${parentTitle} / ${item.title}` : item.title
}
if (item.children) {
for (const child of item.children) {
if (child.path && path.startsWith(child.path + '/')) {
const prefix = parentTitle ? `${parentTitle} / ${item.title}` : item.title
return `${prefix} / ${child.title}`
}
if (child.children) {
for (const grand of child.children) {
if (grand.path && path.startsWith(grand.path + '/')) {
const prefix = parentTitle ? `${parentTitle} / ${item.title}` : item.title
return `${prefix} / ${child.title} / ${grand.title}`
}
}
}
}
}
}
return null
}