优化页面、修复BUG
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
133
client/src/components/admin/AdminMenuItem.vue
Normal file
133
client/src/components/admin/AdminMenuItem.vue
Normal 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>
|
||||
161
client/src/components/admin/InquiryDetailModal.vue
Normal file
161
client/src/components/admin/InquiryDetailModal.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
56
client/src/components/admin/adminMenuTypes.ts
Normal file
56
client/src/components/admin/adminMenuTypes.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user