优化页面、修复BUG
This commit is contained in:
@@ -3,8 +3,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted, watch, nextTick, computed } from 'vue'
|
||||
import hljs from 'highlight.js'
|
||||
import { resolveHighlightLanguage } from '../utils/codeHighlight'
|
||||
|
||||
const props = defineProps<{
|
||||
code: string
|
||||
@@ -13,11 +14,10 @@ const props = defineProps<{
|
||||
|
||||
const codeBlock = ref<HTMLElement | null>(null)
|
||||
|
||||
const languageClass = `language-${props.language || 'plaintext'}`
|
||||
const languageClass = computed(() => `language-${resolveHighlightLanguage(props.language)}`)
|
||||
|
||||
const highlight = () => {
|
||||
if (codeBlock.value) {
|
||||
// Reset internal state if needed, though highlighting overwrites innerHTML
|
||||
delete codeBlock.value.dataset.highlighted
|
||||
hljs.highlightElement(codeBlock.value)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ onMounted(() => {
|
||||
highlight()
|
||||
})
|
||||
|
||||
watch(() => props.code, () => {
|
||||
watch(() => [props.code, props.language], () => {
|
||||
nextTick(highlight)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
class="relative z-[101] bg-[#0a0a0c] border border-white/10 w-full max-w-6xl h-[80vh] rounded-2xl flex flex-col overflow-hidden shadow-2xl transform transition-transform duration-300"
|
||||
:class="{ 'scale-95': isAnimating, 'scale-100': !isAnimating }"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="h-16 border-b border-white/10 flex items-center justify-between px-6 bg-white/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="code-2" class="text-art-accent"></i>
|
||||
@@ -21,9 +20,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 flex flex-col md:flex-row h-full overflow-hidden">
|
||||
<!-- Code Section -->
|
||||
<div class="w-full md:w-1/2 bg-[#0d0d0d] overflow-auto p-6 border-r border-white/10 relative group">
|
||||
<button class="absolute top-4 right-4 text-xs bg-white/10 px-2 py-1 rounded text-white/50 hover:text-white z-10" @click="copyCode">
|
||||
复制
|
||||
@@ -31,42 +28,18 @@
|
||||
<pre class="font-mono text-sm leading-relaxed"><code ref="codeBlock" :class="languageClass">{{ code }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="w-full md:w-1/2 relative flex items-center justify-center overflow-hidden bg-[#1a1a1a]" :style="previewBackgroundStyle">
|
||||
|
||||
<!-- Mouse Preview -->
|
||||
<div v-if="type === 'mouse'" class="flex flex-col items-center justify-center p-8 border border-white/20 rounded-xl bg-black/50 backdrop-blur text-white transition-all duration-75" :style="mouseBoxStyle">
|
||||
<div class="text-4xl font-mono mb-2">{{ mouseX }}, {{ mouseY }}</div>
|
||||
<div class="text-xs text-white/50">在此区域移动鼠标</div>
|
||||
</div>
|
||||
|
||||
<!-- Glass Preview -->
|
||||
<div v-else-if="type === 'glass'" class="relative w-64 h-64 flex items-center justify-center">
|
||||
<div class="absolute top-0 left-0 w-32 h-32 bg-purple-500 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-float"></div>
|
||||
<div class="absolute bottom-0 right-0 w-32 h-32 bg-yellow-500 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-float" style="animation-delay: 2s"></div>
|
||||
<div class="glass-panel relative z-10 w-48 h-32 flex items-center justify-center text-white font-bold text-lg">
|
||||
Glass Card
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Noise Preview -->
|
||||
<div v-else-if="type === 'noise'" class="w-64 h-64 bg-[#222] rounded-xl relative overflow-hidden flex items-center justify-center border border-white/10">
|
||||
<svg class="absolute inset-0 w-full h-full opacity-50">
|
||||
<filter id="noiseFilter">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/>
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#noiseFilter)" opacity="1"/>
|
||||
</svg>
|
||||
<div class="relative z-10 text-white font-bold text-2xl tracking-widest">NOISE</div>
|
||||
</div>
|
||||
|
||||
<!-- Animation Preview -->
|
||||
<div v-else-if="type === 'animate'" class="flex flex-col items-center gap-4">
|
||||
<div class="w-16 h-16 bg-art-accent rounded-lg animate-float"></div>
|
||||
<div class="text-white/50 font-mono">Floating Animation</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-white/30 font-mono">预览不可用</div>
|
||||
<div class="w-full md:w-1/2 relative flex flex-col overflow-hidden bg-[#1a1a1a]" :style="previewBackgroundStyle">
|
||||
<iframe
|
||||
v-if="showHtmlPreview"
|
||||
class="w-full h-full border-0 bg-white"
|
||||
sandbox="allow-scripts"
|
||||
:srcdoc="code"
|
||||
/>
|
||||
<div v-else class="flex-1 overflow-auto p-8 text-white/80">
|
||||
<h4 class="text-sm text-art-muted mb-3">备注</h4>
|
||||
<p v-if="description" class="text-sm leading-relaxed whitespace-pre-wrap">{{ description }}</p>
|
||||
<p v-else class="text-sm text-white/30 font-mono">暂无备注</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,31 +50,32 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import hljs from 'highlight.js'
|
||||
import type { CodeType } from '../services/api'
|
||||
import { resolveHighlightLanguage, isHtmlFrontendType } from '../utils/codeHighlight'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
title: string
|
||||
code: string
|
||||
type: string
|
||||
type?: string
|
||||
codeType?: CodeType | null
|
||||
description?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const isAnimating = ref(true)
|
||||
const mouseX = ref(0)
|
||||
const mouseY = ref(0)
|
||||
const codeBlock = ref<HTMLElement | null>(null)
|
||||
|
||||
const languageClass = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'mouse': 'javascript',
|
||||
'glass': 'css',
|
||||
'noise': 'xml',
|
||||
'animate': 'css'
|
||||
}
|
||||
return `language-${map[props.type] || 'plaintext'}`
|
||||
const highlightLang = computed(() => {
|
||||
if (props.codeType?.name) return props.codeType.name
|
||||
return props.type || 'plaintext'
|
||||
})
|
||||
|
||||
const languageClass = computed(() => `language-${resolveHighlightLanguage(highlightLang.value)}`)
|
||||
|
||||
const showHtmlPreview = computed(() => isHtmlFrontendType(props.codeType ?? null))
|
||||
|
||||
const highlightCode = () => {
|
||||
if (codeBlock.value) {
|
||||
delete codeBlock.value.dataset.highlighted
|
||||
@@ -109,35 +83,23 @@ const highlightCode = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch isOpen to handle entry/exit animations
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
// Entry animation
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, 10)
|
||||
|
||||
// Initialize icons if needed
|
||||
setTimeout(() => {
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
}, 50)
|
||||
|
||||
// Highlight code
|
||||
nextTick(() => {
|
||||
highlightCode()
|
||||
})
|
||||
setTimeout(() => { isAnimating.value = false }, 10)
|
||||
setTimeout(() => { if (window.lucide) window.lucide.createIcons() }, 50)
|
||||
nextTick(highlightCode)
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
isAnimating.value = true
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.code, () => nextTick(highlightCode))
|
||||
|
||||
const close = () => {
|
||||
isAnimating.value = true
|
||||
setTimeout(() => {
|
||||
emit('close')
|
||||
}, 300)
|
||||
setTimeout(() => emit('close'), 300)
|
||||
}
|
||||
|
||||
const copyCode = () => {
|
||||
@@ -145,37 +107,6 @@ const copyCode = () => {
|
||||
alert('代码已复制')
|
||||
}
|
||||
|
||||
// Mouse movement logic for 'mouse' type
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (props.type !== 'mouse') return
|
||||
|
||||
// Find the preview container
|
||||
const container = (e.target as HTMLElement).closest('.preview-area') || (e.target as HTMLElement)
|
||||
if (!container) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
mouseX.value = Math.floor(e.clientX - rect.left)
|
||||
mouseY.value = Math.floor(e.clientY - rect.top)
|
||||
}
|
||||
|
||||
// Global mouse listener when modal is open and type is mouse
|
||||
watch(() => [props.isOpen, props.type], ([open, type]) => {
|
||||
if (open && type === 'mouse') {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
} else {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
})
|
||||
|
||||
const mouseBoxStyle = computed(() => {
|
||||
// Parallax effect
|
||||
const xOffset = (mouseX.value - 200) / 20 // Approx center offset
|
||||
const yOffset = (mouseY.value - 200) / 20
|
||||
return {
|
||||
transform: `translate(${xOffset}px, ${yOffset}px)`
|
||||
}
|
||||
})
|
||||
|
||||
const previewBackgroundStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
|
||||
@@ -186,23 +117,3 @@ const previewBackgroundStyle = {
|
||||
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
</style>
|
||||
@@ -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