优化页面、修复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>
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<!-- Code Preview (Styled) -->
|
||||
<div class="p-6 overflow-hidden font-mono text-sm leading-relaxed pointer-events-none opacity-80 group-hover:opacity-100 transition-opacity">
|
||||
<pre class="whitespace-pre-wrap break-all line-clamp-4"><CodeBlock :code="snippet.code" :language="getSnippetLang(snippet.type)" /></pre>
|
||||
<pre class="whitespace-pre-wrap break-all line-clamp-4"><CodeBlock :code="snippet.code" :language="snippetLang(snippet)" /></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,6 +52,8 @@
|
||||
:title="currentSnippet.title"
|
||||
:code="currentSnippet.code"
|
||||
:type="currentSnippet.type"
|
||||
:code-type="currentSnippet.codeType"
|
||||
:description="currentSnippet.description"
|
||||
@close="closeSnippet"
|
||||
/>
|
||||
</section>
|
||||
@@ -63,6 +65,7 @@ import { fetchSnippets, Snippet } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import SnippetModal from '../components/SnippetModal.vue'
|
||||
import CodeBlock from '../components/CodeBlock.vue'
|
||||
import { resolveHighlightLanguage } from '../utils/codeHighlight'
|
||||
|
||||
const snippets = ref<Snippet[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -143,14 +146,8 @@ export const useMousePosition = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getSnippetLang = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'mouse': 'javascript',
|
||||
'glass': 'css',
|
||||
'noise': 'xml',
|
||||
'animate': 'css'
|
||||
}
|
||||
return map[type] || 'plaintext'
|
||||
const snippetLang = (snippet: Snippet) => {
|
||||
return resolveHighlightLanguage(snippet.codeType?.name || snippet.type)
|
||||
}
|
||||
|
||||
const openSnippet = (snippet: Snippet) => {
|
||||
|
||||
@@ -5,13 +5,28 @@
|
||||
<div class="toolbar">
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<label for="pageSize">每页显示:</label>
|
||||
<CustomSelect
|
||||
v-model.number="pageSize"
|
||||
:options="pageSizeOptions"
|
||||
@update:modelValue="fetchLogs"
|
||||
style="width: 80px;"
|
||||
/>
|
||||
<label>路由</label>
|
||||
<input v-model="filters.path" type="text" class="filter-input" placeholder="模糊搜索路径" @keyup.enter="handleSearch" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>归属地</label>
|
||||
<input v-model="filters.region" type="text" class="filter-input" placeholder="模糊搜索归属地" @keyup.enter="handleSearch" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>开始时间</label>
|
||||
<input v-model="startDateTime" type="datetime-local" class="filter-input datetime-input" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>结束时间</label>
|
||||
<input v-model="endDateTime" type="datetime-local" class="filter-input datetime-input" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>每页</label>
|
||||
<CustomSelect v-model.number="pageSize" :options="pageSizeOptions" style="width: 80px;" />
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="handleSearch">查询</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="resetFilters">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,12 +96,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, reactive } from 'vue'
|
||||
import { getAccessLogs, PaginationResponse, AccessLog } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
import { getDefaultMonthRange, toApiDateTime } from '../../utils/dateRange'
|
||||
|
||||
const toast = useToast()
|
||||
const defaultRange = getDefaultMonthRange()
|
||||
const logs = ref<PaginationResponse<AccessLog>>({
|
||||
list: [],
|
||||
total: 0,
|
||||
@@ -95,8 +112,14 @@ const logs = ref<PaginationResponse<AccessLog>>({
|
||||
})
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const startDateTime = ref(defaultRange.start)
|
||||
const endDateTime = ref(defaultRange.end)
|
||||
|
||||
const filters = reactive({
|
||||
path: '',
|
||||
region: ''
|
||||
})
|
||||
|
||||
// Select options
|
||||
const pageSizeOptions = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20' },
|
||||
@@ -104,19 +127,39 @@ const pageSizeOptions = [
|
||||
{ value: 100, label: '100' }
|
||||
]
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(logs.value.total / pageSize.value)
|
||||
const totalPages = computed(() => Math.ceil(logs.value.total / pageSize.value) || 1)
|
||||
|
||||
const buildFilters = () => ({
|
||||
path: filters.path || undefined,
|
||||
region: filters.region || undefined,
|
||||
startDate: toApiDateTime(startDateTime.value) || undefined,
|
||||
endDate: toApiDateTime(endDateTime.value) || undefined
|
||||
})
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
logs.value = await getAccessLogs(currentPage.value, pageSize.value)
|
||||
logs.value = await getAccessLogs(currentPage.value, pageSize.value, buildFilters())
|
||||
} catch (error) {
|
||||
console.error('Error fetching access logs:', error)
|
||||
toast.error('获取访问日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
const range = getDefaultMonthRange()
|
||||
filters.path = ''
|
||||
filters.region = ''
|
||||
startDateTime.value = range.start
|
||||
endDateTime.value = range.end
|
||||
currentPage.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const changePage = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchLogs()
|
||||
@@ -159,19 +202,48 @@ onMounted(() => {
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: flex-end;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.datetime-input {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
@@ -310,6 +382,17 @@ onMounted(() => {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #d4b383;
|
||||
color: #050505;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
82
client/src/pages/admin/CodeTypeForm.vue
Normal file
82
client/src/pages/admin/CodeTypeForm.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="code-type-form-container">
|
||||
<h1 class="page-title">{{ isEdit ? '编辑代码类型' : '新建代码类型' }}</h1>
|
||||
|
||||
<div class="form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">名称</label>
|
||||
<input v-model="form.name" type="text" class="form-input" placeholder="如 html、javascript" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">分类</label>
|
||||
<CustomSelect v-model.number="form.category" :options="categoryOptions" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" @click="router.back()">取消</button>
|
||||
<button type="submit" class="btn-primary">{{ isEdit ? '保存修改' : '创建类型' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { getAdminCodeTypes, createCodeType, updateCodeType } from '../../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const form = ref({ name: '', category: 0 })
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 0, label: '前端' },
|
||||
{ value: 1, label: '后端' },
|
||||
{ value: 2, label: '其他' }
|
||||
]
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!isEdit.value) return
|
||||
const id = parseInt(route.params.id as string, 10)
|
||||
const list = await getAdminCodeTypes()
|
||||
const item = list.find(ct => ct.id === id)
|
||||
if (item) {
|
||||
form.value = { name: item.name, category: item.category }
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateCodeType(parseInt(route.params.id as string, 10), form.value)
|
||||
toast.success('更新成功')
|
||||
} else {
|
||||
await createCodeType(form.value)
|
||||
toast.success('创建成功')
|
||||
}
|
||||
router.push('/admin/code-types')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '提交失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-type-form-container { width: 100%; max-width: 560px; }
|
||||
.page-title { font-size: 1.75rem; font-weight: 600; color: #d4b383; margin-bottom: 1.5rem; }
|
||||
.form-card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; padding: 1.5rem; }
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-label { display: block; margin-bottom: 0.5rem; color: rgba(255,255,255,0.8); font-size: 0.875rem; }
|
||||
.form-input { width: 100%; padding: 0.625rem 0.75rem; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.2); border-radius: 0.375rem; color: white; }
|
||||
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
|
||||
.btn-primary { background: #d4b383; color: #050505; border: none; padding: 0.5rem 1rem; border-radius: 0.375rem; cursor: pointer; }
|
||||
.btn-secondary { background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2); padding: 0.5rem 1rem; border-radius: 0.375rem; cursor: pointer; }
|
||||
</style>
|
||||
89
client/src/pages/admin/CodeTypes.vue
Normal file
89
client/src/pages/admin/CodeTypes.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="code-types-container">
|
||||
<h1 class="page-title">代码类型管理</h1>
|
||||
|
||||
<div class="action-bar">
|
||||
<button class="btn-primary" @click="router.push('/admin/code-types/create')">
|
||||
<span class="btn-icon">➕</span>
|
||||
新建类型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>分类</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in codeTypes" :key="item.id">
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>
|
||||
<span class="category-badge">{{ categoryLabel(item.category) }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-edit" @click="router.push(`/admin/code-types/${item.id}/edit`)">✏️</button>
|
||||
<button class="btn-delete" @click="handleDelete(item.id)">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="codeTypes.length === 0" class="empty-state">暂无代码类型</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminCodeTypes, deleteCodeType, CODE_TYPE_CATEGORY_LABELS, type CodeType } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const codeTypes = ref<CodeType[]>([])
|
||||
|
||||
const categoryLabel = (category: number) => CODE_TYPE_CATEGORY_LABELS[category] ?? '未知'
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
codeTypes.value = await getAdminCodeTypes()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('加载代码类型失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该代码类型吗?')) return
|
||||
try {
|
||||
await deleteCodeType(id)
|
||||
toast.success('删除成功')
|
||||
fetchList()
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-types-container { width: 100%; }
|
||||
.page-title { font-size: 1.75rem; font-weight: 600; color: #d4b383; margin-bottom: 1.5rem; }
|
||||
.action-bar { margin-bottom: 1rem; }
|
||||
.btn-primary { background: #d4b383; color: #050505; border: none; padding: 0.5rem 1rem; border-radius: 0.375rem; cursor: pointer; font-weight: 500; }
|
||||
.table-container { background: rgba(255,255,255,0.05); border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.1); overflow: hidden; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; color: white; }
|
||||
.admin-table th, .admin-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.admin-table th { color: #d4b383; background: rgba(255,255,255,0.08); }
|
||||
.category-badge { padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; background: rgba(212,179,131,0.15); color: #d4b383; }
|
||||
.actions { display: flex; gap: 0.5rem; }
|
||||
.btn-edit, .btn-delete { background: none; border: none; cursor: pointer; font-size: 1rem; }
|
||||
.empty-state { padding: 2rem; text-align: center; color: rgba(255,255,255,0.5); }
|
||||
</style>
|
||||
@@ -5,14 +5,41 @@
|
||||
<div class="toolbar">
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<label for="pageSize">每页显示:</label>
|
||||
<label>操作描述</label>
|
||||
<input v-model="filters.action" type="text" class="filter-input" placeholder="模糊搜索" @keyup.enter="handleSearch" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>方法</label>
|
||||
<CustomSelect v-model="filters.method" :options="methodOptions" style="width: 100px;" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>状态</label>
|
||||
<CustomSelect v-model.number="filters.status" :options="statusOptions" style="width: 100px;" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>操作人</label>
|
||||
<UserPickerPopover v-model="filters.userId" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>开始时间</label>
|
||||
<input v-model="startDateTime" type="datetime-local" class="filter-input datetime-input" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>结束时间</label>
|
||||
<input v-model="endDateTime" type="datetime-local" class="filter-input datetime-input" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>每页</label>
|
||||
<CustomSelect
|
||||
v-model.number="pageSize"
|
||||
:options="pageSizeOptions"
|
||||
@update:modelValue="fetchLogs"
|
||||
style="width: 80px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="handleSearch">查询</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="resetFilters">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,12 +112,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, reactive } from 'vue'
|
||||
import { getOperationLogs, PaginationResponse, OperationLog } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
import UserPickerPopover from '../../components/admin/UserPickerPopover.vue'
|
||||
import { getDefaultMonthRange, toApiDateTime } from '../../utils/dateRange'
|
||||
|
||||
const toast = useToast()
|
||||
const defaultRange = getDefaultMonthRange()
|
||||
const logs = ref<PaginationResponse<OperationLog>>({
|
||||
list: [],
|
||||
total: 0,
|
||||
@@ -99,8 +129,16 @@ const logs = ref<PaginationResponse<OperationLog>>({
|
||||
})
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const startDateTime = ref(defaultRange.start)
|
||||
const endDateTime = ref(defaultRange.end)
|
||||
|
||||
const filters = reactive({
|
||||
action: '',
|
||||
method: '',
|
||||
status: 0,
|
||||
userId: null as number | null
|
||||
})
|
||||
|
||||
// Select options
|
||||
const pageSizeOptions = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20' },
|
||||
@@ -108,19 +146,63 @@ const pageSizeOptions = [
|
||||
{ value: 100, label: '100' }
|
||||
]
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(logs.value.total / pageSize.value)
|
||||
const methodOptions = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'GET', label: 'GET' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'PATCH', label: 'PATCH' },
|
||||
{ value: 'DELETE', label: 'DELETE' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 0, label: '全部' },
|
||||
{ value: 200, label: '200' },
|
||||
{ value: 201, label: '201' },
|
||||
{ value: 400, label: '400' },
|
||||
{ value: 401, label: '401' },
|
||||
{ value: 403, label: '403' },
|
||||
{ value: 404, label: '404' },
|
||||
{ value: 500, label: '500' }
|
||||
]
|
||||
|
||||
const totalPages = computed(() => Math.ceil(logs.value.total / pageSize.value) || 1)
|
||||
|
||||
const buildFilters = () => ({
|
||||
action: filters.action || undefined,
|
||||
method: filters.method || undefined,
|
||||
status: filters.status || undefined,
|
||||
userId: filters.userId ?? undefined,
|
||||
startDate: toApiDateTime(startDateTime.value) || undefined,
|
||||
endDate: toApiDateTime(endDateTime.value) || undefined
|
||||
})
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
logs.value = await getOperationLogs(currentPage.value, pageSize.value)
|
||||
logs.value = await getOperationLogs(currentPage.value, pageSize.value, buildFilters())
|
||||
} catch (error) {
|
||||
console.error('Error fetching operation logs:', error)
|
||||
toast.error('获取操作日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
const range = getDefaultMonthRange()
|
||||
filters.action = ''
|
||||
filters.method = ''
|
||||
filters.status = 0
|
||||
filters.userId = null
|
||||
startDateTime.value = range.start
|
||||
endDateTime.value = range.end
|
||||
currentPage.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const changePage = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchLogs()
|
||||
@@ -163,19 +245,48 @@ onMounted(() => {
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: flex-end;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.datetime-input {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
|
||||
@@ -22,15 +22,13 @@
|
||||
<!-- Type Field -->
|
||||
<div class="form-group">
|
||||
<label for="type">类型</label>
|
||||
<input
|
||||
type="text"
|
||||
id="type"
|
||||
v-model="form.type"
|
||||
placeholder="请输入代码类型(如:js、css、html等)"
|
||||
required
|
||||
<CustomSelect
|
||||
v-model.number="form.codeTypeId"
|
||||
:options="codeTypeOptions"
|
||||
placeholder="请选择代码类型"
|
||||
/>
|
||||
<div class="error-message" v-if="errors.type">
|
||||
{{ errors.type }}
|
||||
<div class="error-message" v-if="errors.codeTypeId">
|
||||
{{ errors.codeTypeId }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +85,8 @@ import { MdEditor } from 'md-editor-v3'
|
||||
import 'md-editor-v3/lib/style.css'
|
||||
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createSnippet, updateSnippet, fetchSnippet } from '../../services/api'
|
||||
import { createSnippet, updateSnippet, fetchSnippet, getAdminCodeTypes } from '../../services/api'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -102,10 +101,12 @@ const errors = reactive<Record<string, string>>({})
|
||||
const form = reactive({
|
||||
title: '',
|
||||
code: '',
|
||||
type: 'js',
|
||||
codeTypeId: 0,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const codeTypeOptions = ref<{ value: number; label: string }[]>([])
|
||||
|
||||
// Validation function
|
||||
const validateForm = (): boolean => {
|
||||
// Reset errors
|
||||
@@ -120,8 +121,8 @@ const validateForm = (): boolean => {
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!form.type.trim()) {
|
||||
errors.type = '代码类型不能为空'
|
||||
if (!form.codeTypeId) {
|
||||
errors.codeTypeId = '请选择代码类型'
|
||||
isValid = false
|
||||
}
|
||||
|
||||
@@ -170,14 +171,20 @@ const handleCancel = () => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const types = await getAdminCodeTypes()
|
||||
codeTypeOptions.value = types.map(t => ({ value: t.id, label: `${t.name} (${t.category === 0 ? '前端' : t.category === 1 ? '后端' : '其他'})` }))
|
||||
} catch (error) {
|
||||
console.error('Failed to load code types:', error)
|
||||
}
|
||||
|
||||
if (isEditing.value) {
|
||||
try {
|
||||
const snippetId = route.params.id as string
|
||||
const snippet = await fetchSnippet(snippetId)
|
||||
// Populate form with snippet data
|
||||
form.title = snippet.title
|
||||
form.code = snippet.code
|
||||
form.type = snippet.type
|
||||
form.codeTypeId = snippet.codeTypeId || snippet.codeType?.id || 0
|
||||
form.description = snippet.description || ''
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch snippet data:', error)
|
||||
|
||||
@@ -110,6 +110,9 @@ const routes = [
|
||||
{ path: 'snippets', name: 'admin-snippets', component: () => import('./pages/admin/Snippets.vue') },
|
||||
{ path: 'snippets/create', name: 'admin-snippets-create', component: () => import('./pages/admin/SnippetForm.vue') },
|
||||
{ path: 'snippets/:id/edit', name: 'admin-snippets-edit', component: () => import('./pages/admin/SnippetForm.vue') },
|
||||
{ path: 'code-types', name: 'admin-code-types', component: () => import('./pages/admin/CodeTypes.vue') },
|
||||
{ path: 'code-types/create', name: 'admin-code-types-create', component: () => import('./pages/admin/CodeTypeForm.vue') },
|
||||
{ path: 'code-types/:id/edit', name: 'admin-code-types-edit', component: () => import('./pages/admin/CodeTypeForm.vue') },
|
||||
|
||||
// 标签管理
|
||||
{ path: 'tags', name: 'admin-tags', component: () => import('./pages/admin/Tags.vue') },
|
||||
|
||||
@@ -174,12 +174,16 @@ export interface PostHistory {
|
||||
version: number
|
||||
title: string
|
||||
categoryId: number
|
||||
categoryName?: string
|
||||
columnId?: number
|
||||
columnName?: string
|
||||
tagIds?: number[]
|
||||
tagNames?: string[]
|
||||
excerpt?: string
|
||||
content?: string
|
||||
isPublished: number
|
||||
modifiedBy: number
|
||||
modifiedByName?: string
|
||||
modifiedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
@@ -202,12 +206,63 @@ export interface HotSearchKeyword {
|
||||
count: number
|
||||
}
|
||||
|
||||
// 代码类型
|
||||
export interface CodeType {
|
||||
id: number
|
||||
name: string
|
||||
category: number // 0前端 1后端 2其他
|
||||
}
|
||||
|
||||
export const CODE_TYPE_CATEGORY_LABELS: Record<number, string> = {
|
||||
0: '前端',
|
||||
1: '后端',
|
||||
2: '其他'
|
||||
}
|
||||
|
||||
export const getCodeTypes = async (): Promise<CodeType[]> => {
|
||||
const response = await fetch(`${API_BASE}/code-types`)
|
||||
return await parseApiResponse(response)
|
||||
}
|
||||
|
||||
export const getAdminCodeTypes = async (): Promise<CodeType[]> => {
|
||||
const response = await authFetch(`${API_BASE}/admin/code-types`, { headers: getAuthHeaders() })
|
||||
return await parseApiResponse(response)
|
||||
}
|
||||
|
||||
export const createCodeType = async (data: Omit<CodeType, 'id'>): Promise<void> => {
|
||||
const response = await authFetch(`${API_BASE}/admin/code-types`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
await parseApiResponse(response)
|
||||
}
|
||||
|
||||
export const updateCodeType = async (id: number, data: Omit<CodeType, 'id'>): Promise<void> => {
|
||||
const response = await authFetch(`${API_BASE}/admin/code-types/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
await parseApiResponse(response)
|
||||
}
|
||||
|
||||
export const deleteCodeType = async (id: number): Promise<void> => {
|
||||
const response = await authFetch(`${API_BASE}/admin/code-types/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
await parseApiResponse(response)
|
||||
}
|
||||
|
||||
// 代码片段相关类型
|
||||
export interface Snippet {
|
||||
id: string
|
||||
title: string
|
||||
code: string
|
||||
type: string
|
||||
codeTypeId?: number
|
||||
codeType?: CodeType
|
||||
description?: string
|
||||
viewCount?: number
|
||||
}
|
||||
@@ -269,13 +324,27 @@ export const login = async (credentials: LoginRequest): Promise<LoginResponse> =
|
||||
}
|
||||
|
||||
// 用户管理API
|
||||
export interface GetUsersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export const getUsersPaginated = async (params: GetUsersParams = {}): Promise<PaginationResponse<User>> => {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 10
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
const response = await authFetch(`${API_BASE}/admin/users?${qs}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return await parseApiResponse(response)
|
||||
}
|
||||
|
||||
export const getUsers = async (): Promise<User[]> => {
|
||||
try {
|
||||
const response = await authFetch(`${API_BASE}/admin/users`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
const data = await parseApiResponse<any>(response)
|
||||
return (data?.list || []) as User[]
|
||||
const data = await getUsersPaginated({ page: 1, pageSize: 1000 })
|
||||
return data.list
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error)
|
||||
throw error
|
||||
@@ -1021,7 +1090,7 @@ export const fetchSnippet = async (id: string): Promise<Snippet> => {
|
||||
}
|
||||
}
|
||||
|
||||
export const createSnippet = async (snippetData: Omit<Snippet, 'id' | 'viewCount'>): Promise<void> => {
|
||||
export const createSnippet = async (snippetData: Omit<Snippet, 'id' | 'viewCount' | 'codeType' | 'type'> & { type?: string }): Promise<void> => {
|
||||
try {
|
||||
const response = await authFetch(`${API_BASE}/admin/snippets`, {
|
||||
method: 'POST',
|
||||
@@ -1035,7 +1104,7 @@ export const createSnippet = async (snippetData: Omit<Snippet, 'id' | 'viewCount
|
||||
}
|
||||
}
|
||||
|
||||
export const updateSnippet = async (id: string, snippetData: Omit<Snippet, 'id' | 'viewCount'>): Promise<void> => {
|
||||
export const updateSnippet = async (id: string, snippetData: Omit<Snippet, 'id' | 'viewCount' | 'codeType' | 'type'> & { type?: string }): Promise<void> => {
|
||||
try {
|
||||
const response = await authFetch(`${API_BASE}/admin/snippets/${id}`, {
|
||||
method: 'PUT',
|
||||
@@ -1141,10 +1210,31 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
}
|
||||
}
|
||||
|
||||
// 操作日志筛选参数
|
||||
export interface OperationLogFilters {
|
||||
action?: string
|
||||
method?: string
|
||||
status?: number
|
||||
userId?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 操作日志API
|
||||
export const getOperationLogs = async (page: number = 1, pageSize: number = 10): Promise<PaginationResponse<OperationLog>> => {
|
||||
export const getOperationLogs = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filters: OperationLogFilters = {}
|
||||
): Promise<PaginationResponse<OperationLog>> => {
|
||||
try {
|
||||
const response = await authFetch(`${API_BASE}/admin/operation-logs?page=${page}&pageSize=${pageSize}`, {
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (filters.action) qs.set('action', filters.action)
|
||||
if (filters.method) qs.set('method', filters.method)
|
||||
if (filters.status) qs.set('status', String(filters.status))
|
||||
if (filters.userId) qs.set('userId', String(filters.userId))
|
||||
if (filters.startDate) qs.set('startDate', filters.startDate)
|
||||
if (filters.endDate) qs.set('endDate', filters.endDate)
|
||||
const response = await authFetch(`${API_BASE}/admin/operation-logs?${qs}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return await parseApiResponse(response)
|
||||
@@ -1167,10 +1257,27 @@ export interface AccessLog {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 访问日志筛选参数
|
||||
export interface AccessLogFilters {
|
||||
path?: string
|
||||
region?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 访问日志API
|
||||
export const getAccessLogs = async (page: number = 1, pageSize: number = 10): Promise<PaginationResponse<AccessLog>> => {
|
||||
export const getAccessLogs = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filters: AccessLogFilters = {}
|
||||
): Promise<PaginationResponse<AccessLog>> => {
|
||||
try {
|
||||
const response = await authFetch(`${API_BASE}/admin/access-logs?page=${page}&pageSize=${pageSize}`, {
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (filters.path) qs.set('path', filters.path)
|
||||
if (filters.region) qs.set('region', filters.region)
|
||||
if (filters.startDate) qs.set('startDate', filters.startDate)
|
||||
if (filters.endDate) qs.set('endDate', filters.endDate)
|
||||
const response = await authFetch(`${API_BASE}/admin/access-logs?${qs}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return await parseApiResponse(response)
|
||||
|
||||
24
client/src/utils/codeHighlight.ts
Normal file
24
client/src/utils/codeHighlight.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
sh: 'bash',
|
||||
yml: 'yaml',
|
||||
md: 'markdown',
|
||||
xml: 'xml',
|
||||
svg: 'xml',
|
||||
noise: 'xml'
|
||||
}
|
||||
|
||||
export function resolveHighlightLanguage(typeName?: string): string {
|
||||
if (!typeName) return 'plaintext'
|
||||
const key = typeName.trim().toLowerCase()
|
||||
if (!key) return 'plaintext'
|
||||
return LANGUAGE_ALIASES[key] || key
|
||||
}
|
||||
|
||||
export function isHtmlFrontendType(codeType?: { name?: string; category?: number } | null): boolean {
|
||||
if (!codeType) return false
|
||||
return codeType.category === 0 && (codeType.name || '').toLowerCase() === 'html'
|
||||
}
|
||||
22
client/src/utils/dateRange.ts
Normal file
22
client/src/utils/dateRange.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** 格式化为 datetime-local 输入值 */
|
||||
export function formatDateTimeLocal(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
/** 默认范围:本月 1 日 00:00 ~ 今天 23:59 */
|
||||
export function getDefaultMonthRange(): { start: string; end: string } {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0)
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 0)
|
||||
return {
|
||||
start: formatDateTimeLocal(start),
|
||||
end: formatDateTimeLocal(end)
|
||||
}
|
||||
}
|
||||
|
||||
/** datetime-local -> API 查询参数 (YYYY-MM-DD HH:mm) */
|
||||
export function toApiDateTime(value: string): string {
|
||||
if (!value) return ''
|
||||
return value.replace('T', ' ')
|
||||
}
|
||||
Reference in New Issue
Block a user