优化页面、修复BUG
This commit is contained in:
@@ -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: '🏷️' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
274
client/src/components/admin/UserPickerPopover.vue
Normal file
274
client/src/components/admin/UserPickerPopover.vue
Normal 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>
|
||||
Reference in New Issue
Block a user