优化页面、修复BUG

This commit is contained in:
李琦
2026-06-24 17:06:22 +08:00
parent 3f653bc336
commit d5ff40e4d3
38 changed files with 1507 additions and 354 deletions

View File

@@ -220,6 +220,7 @@ const menuItems = ref<MenuItem[]>([
{ title: '专栏管理', path: '/admin/columns', icon: '📚' },
{ title: '作品管理', path: '/admin/works', icon: '🎨' },
{ title: '代码片段', path: '/admin/snippets', icon: '💻' },
{ title: '代码类型', path: '/admin/code-types', icon: '🏷️' },
{ title: '标签管理', path: '/admin/tags', icon: '🏷️' }
]
},

View File

@@ -9,13 +9,12 @@
<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>
<p class="text-xs text-art-muted mt-1">每次保存后生成新版本vN 表示第 N 次保存后的内容</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>
@@ -31,39 +30,18 @@
<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>
<p class="text-xs text-art-muted mt-1 truncate">{{ item.modifiedByName || `用户#${item.modifiedBy}` }}</p>
<p class="text-xs text-white/70 mt-0.5 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>
<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>
@@ -74,8 +52,18 @@
</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>
<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>
<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>
</div>
</div>
</div>
@@ -91,7 +79,7 @@
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>
<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>
@@ -103,7 +91,7 @@
<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>
<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>
@@ -118,6 +106,8 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import {
getPostHistory,
getPostHistoryByVersion,
@@ -128,6 +118,16 @@ import {
} from '../../services/api'
import { useToast } from '../../composables/useToast'
const FIELD_LABELS: Record<string, string> = {
title: '标题',
excerpt: '摘要',
content: '正文',
category: '分类',
column: '专栏',
tags: '标签',
isPublished: '发布状态'
}
const props = defineProps<{
isOpen: boolean
postId: number | string
@@ -146,6 +146,8 @@ const viewMode = ref<'list' | 'preview' | 'diff'>('list')
const previewData = ref<PostHistory | null>(null)
const diffData = ref<PostHistoryDiff | null>(null)
const fieldLabel = (key: string | number) => FIELD_LABELS[String(key)] || String(key)
const loadHistory = async () => {
if (!props.postId) return
loading.value = true
@@ -220,11 +222,6 @@ watch(
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.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,274 @@
<template>
<div ref="rootRef" class="user-picker relative">
<button type="button" class="picker-trigger" @click.stop="toggleOpen">
<span v-if="selectedUser" class="selected-text">{{ selectedUser.username }}</span>
<span v-else class="placeholder">选择操作人</span>
<span v-if="selectedUser" class="clear-btn" @click.stop="clearSelection"></span>
</button>
<div v-if="isOpen" class="popover-card" @click.stop>
<input
v-model="searchKeyword"
type="text"
class="search-input"
placeholder="搜索账号或邮箱..."
@input="handleSearch"
/>
<div v-if="loading" class="popover-empty">加载中...</div>
<div v-else-if="users.length === 0" class="popover-empty">暂无用户</div>
<ul v-else class="user-list">
<li
v-for="user in users"
:key="user.id"
class="user-item"
:class="{ active: modelValue === user.id }"
@click="selectUser(user)"
>
<span class="user-name">{{ user.username }}</span>
<span class="user-email">{{ user.email }}</span>
</li>
</ul>
<div v-if="total > pageSize" class="popover-pagination">
<button type="button" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
<span>{{ currentPage }} / {{ totalPages }}</span>
<button type="button" :disabled="currentPage >= totalPages" @click="goPage(currentPage + 1)">下一页</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { getUsersPaginated, type User } from '../../services/api'
const props = defineProps<{
modelValue?: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
const rootRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const loading = ref(false)
const users = ref<User[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 10
const searchKeyword = ref('')
const selectedUser = ref<User | null>(null)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const loadUsers = async () => {
loading.value = true
try {
const result = await getUsersPaginated({
page: currentPage.value,
pageSize,
keyword: searchKeyword.value || undefined
})
users.value = result.list
total.value = result.total
if (props.modelValue) {
const found = result.list.find(u => u.id === props.modelValue)
if (found) selectedUser.value = found
}
} finally {
loading.value = false
}
}
const toggleOpen = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
currentPage.value = 1
loadUsers()
}
}
const handleSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
currentPage.value = 1
loadUsers()
}, 300)
}
const goPage = (page: number) => {
currentPage.value = page
loadUsers()
}
const selectUser = (user: User) => {
selectedUser.value = user
emit('update:modelValue', user.id)
isOpen.value = false
}
const clearSelection = () => {
selectedUser.value = null
emit('update:modelValue', null)
}
const handleClickOutside = (e: MouseEvent) => {
if (rootRef.value && !rootRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
watch(() => props.modelValue, async (id) => {
if (!id) {
selectedUser.value = null
return
}
if (selectedUser.value?.id === id) return
const result = await getUsersPaginated({ page: 1, pageSize: 1, keyword: String(id) })
const found = result.list.find(u => u.id === id)
if (found) {
selectedUser.value = found
} else {
try {
const { fetchUser } = await import('../../services/api')
selectedUser.value = await fetchUser(id)
} catch {
selectedUser.value = null
}
}
}, { immediate: true })
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</script>
<style scoped>
.user-picker {
min-width: 160px;
}
.picker-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.375rem;
color: white;
font-size: 0.875rem;
cursor: pointer;
text-align: left;
}
.placeholder {
color: rgba(255, 255, 255, 0.4);
}
.selected-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clear-btn {
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
padding: 0 0.25rem;
}
.clear-btn:hover {
color: white;
}
.popover-card {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 100;
width: 280px;
background: #1a1a1e;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.search-input {
width: 100%;
padding: 0.625rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: white;
font-size: 0.875rem;
outline: none;
}
.user-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
}
.user-item {
padding: 0.625rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.user-item:hover,
.user-item.active {
background: rgba(212, 179, 131, 0.1);
}
.user-name {
font-size: 0.875rem;
color: white;
}
.user-email {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.45);
}
.popover-empty {
padding: 1.5rem;
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 0.875rem;
}
.popover-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.popover-pagination button {
background: none;
border: none;
color: #d4b383;
cursor: pointer;
font-size: 0.75rem;
padding: 0.25rem;
}
.popover-pagination button:disabled {
color: rgba(255, 255, 255, 0.2);
cursor: not-allowed;
}
</style>