优化页面、修复BUG

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

4
.gitignore vendored
View File

@@ -6,3 +6,7 @@ client/dist
/client/.cursor/
/client/.idea/
/server/uploads/
/server/migrations/
/server/nl-blog
/client/dist.zip
/nl_blog线上.sql

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -9,13 +9,12 @@
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
<div>
<h3 class="text-lg font-medium text-white">历史版本</h3>
<p class="text-xs text-art-muted mt-1">每次保存后生成新版本可查看对比或恢复</p>
<p class="text-xs text-art-muted mt-1">每次保存后生成新版本vN 表示第 N 次保存后的内容</p>
</div>
<button type="button" class="text-art-muted hover:text-white" @click="emit('close')"></button>
</div>
<div class="flex-1 overflow-hidden flex min-h-0">
<!-- Version list -->
<div class="w-72 border-r border-white/10 overflow-y-auto custom-scrollbar shrink-0">
<div v-if="loading" class="p-6 text-center text-art-muted text-sm">加载中...</div>
<div v-else-if="historyList.length === 0" class="p-6 text-center text-art-muted text-sm">暂无历史版本</div>
@@ -31,39 +30,18 @@
<span class="text-sm text-white font-medium">v{{ item.version }}</span>
<span class="text-xs text-art-muted">{{ item.modifiedAt }}</span>
</div>
<p class="text-xs text-art-muted mt-1 truncate">{{ item.title }}</p>
<p class="text-xs text-art-muted mt-1 truncate">{{ item.modifiedByName || `用户#${item.modifiedBy}` }}</p>
<p class="text-xs text-white/70 mt-0.5 truncate">{{ item.title }}</p>
</button>
</div>
<!-- Detail / diff panel -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
<div v-if="viewMode === 'list'" class="text-sm text-art-muted">
<p>点击版本可勾选最多 2 进行对比或点击下方操作</p>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
class="admin-btn-secondary text-xs"
:disabled="selectedVersions.length !== 1"
@click="viewVersion(selectedVersions[0])"
>
查看选中版本
</button>
<button
type="button"
class="admin-btn-secondary text-xs"
:disabled="selectedVersions.length !== 2"
@click="compareVersions"
>
对比选中版本
</button>
<button
type="button"
class="admin-btn-primary text-xs"
:disabled="selectedVersions.length !== 1"
@click="restoreVersion(selectedVersions[0])"
>
恢复选中版本
</button>
<button type="button" class="admin-btn-secondary text-xs" :disabled="selectedVersions.length !== 1" @click="viewVersion(selectedVersions[0])">查看选中版本</button>
<button type="button" class="admin-btn-secondary text-xs" :disabled="selectedVersions.length !== 2" @click="compareVersions">对比选中版本</button>
<button type="button" class="admin-btn-primary text-xs" :disabled="selectedVersions.length !== 1" @click="restoreVersion(selectedVersions[0])">恢复选中版本</button>
</div>
</div>
@@ -74,8 +52,18 @@
</div>
<div class="space-y-3 text-sm">
<div><span class="text-art-muted">标题</span><span class="text-white">{{ previewData.title }}</span></div>
<div><span class="text-art-muted">分类</span><span class="text-white">{{ previewData.categoryName || '—' }}</span></div>
<div><span class="text-art-muted">专栏</span><span class="text-white">{{ previewData.columnName || '—' }}</span></div>
<div><span class="text-art-muted">标签</span><span class="text-white">{{ previewData.tagNames?.join(', ') || '—' }}</span></div>
<div><span class="text-art-muted">状态</span><span class="text-white">{{ previewData.isPublished === 1 ? '已发布' : '草稿' }}</span></div>
<div><span class="text-art-muted">修改人</span><span class="text-white">{{ previewData.modifiedByName || previewData.modifiedBy }}</span></div>
<div><span class="text-art-muted">摘要</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
<pre class="bg-black/30 rounded-lg p-4 text-xs text-white/80 whitespace-pre-wrap max-h-96 overflow-y-auto">{{ previewData.content || '—' }}</pre>
<div>
<span class="text-art-muted block mb-2">正文</span>
<div class="bg-black/30 rounded-lg p-4 max-h-96 overflow-y-auto prose prose-invert prose-sm max-w-none">
<MdPreview :model-value="previewData.content || ''" theme="dark" />
</div>
</div>
</div>
</div>
@@ -91,7 +79,7 @@
class="rounded-lg border p-4"
:class="field.changed ? 'border-art-accent/30 bg-art-accent/5' : 'border-white/5 bg-white/[0.02]'"
>
<div class="text-xs uppercase tracking-wider text-art-muted mb-2">{{ key }}</div>
<div class="text-xs uppercase tracking-wider text-art-muted mb-2">{{ fieldLabel(key) }}</div>
<template v-if="field.changed">
<div class="grid md:grid-cols-2 gap-3 text-xs">
<div>
@@ -103,7 +91,7 @@
<pre class="whitespace-pre-wrap bg-black/30 rounded p-2 text-white/70 max-h-40 overflow-y-auto">{{ field.to || '—' }}</pre>
</div>
</div>
<pre v-if="field.diff" class="mt-2 text-xs text-art-accent/80 whitespace-pre-wrap">{{ field.diff }}</pre>
<pre v-if="field.diff && key === 'content'" class="mt-2 text-xs text-art-accent/80 whitespace-pre-wrap max-h-48 overflow-y-auto">{{ field.diff }}</pre>
</template>
<div v-else class="text-xs text-art-muted">无变化</div>
</div>
@@ -118,6 +106,8 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import {
getPostHistory,
getPostHistoryByVersion,
@@ -128,6 +118,16 @@ import {
} from '../../services/api'
import { useToast } from '../../composables/useToast'
const FIELD_LABELS: Record<string, string> = {
title: '标题',
excerpt: '摘要',
content: '正文',
category: '分类',
column: '专栏',
tags: '标签',
isPublished: '发布状态'
}
const props = defineProps<{
isOpen: boolean
postId: number | string
@@ -146,6 +146,8 @@ const viewMode = ref<'list' | 'preview' | 'diff'>('list')
const previewData = ref<PostHistory | null>(null)
const diffData = ref<PostHistoryDiff | null>(null)
const fieldLabel = (key: string | number) => FIELD_LABELS[String(key)] || String(key)
const loadHistory = async () => {
if (!props.postId) return
loading.value = true
@@ -220,11 +222,6 @@ watch(
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 2px; }
</style>

View File

@@ -0,0 +1,274 @@
<template>
<div ref="rootRef" class="user-picker relative">
<button type="button" class="picker-trigger" @click.stop="toggleOpen">
<span v-if="selectedUser" class="selected-text">{{ selectedUser.username }}</span>
<span v-else class="placeholder">选择操作人</span>
<span v-if="selectedUser" class="clear-btn" @click.stop="clearSelection"></span>
</button>
<div v-if="isOpen" class="popover-card" @click.stop>
<input
v-model="searchKeyword"
type="text"
class="search-input"
placeholder="搜索账号或邮箱..."
@input="handleSearch"
/>
<div v-if="loading" class="popover-empty">加载中...</div>
<div v-else-if="users.length === 0" class="popover-empty">暂无用户</div>
<ul v-else class="user-list">
<li
v-for="user in users"
:key="user.id"
class="user-item"
:class="{ active: modelValue === user.id }"
@click="selectUser(user)"
>
<span class="user-name">{{ user.username }}</span>
<span class="user-email">{{ user.email }}</span>
</li>
</ul>
<div v-if="total > pageSize" class="popover-pagination">
<button type="button" :disabled="currentPage <= 1" @click="goPage(currentPage - 1)">上一页</button>
<span>{{ currentPage }} / {{ totalPages }}</span>
<button type="button" :disabled="currentPage >= totalPages" @click="goPage(currentPage + 1)">下一页</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { getUsersPaginated, type User } from '../../services/api'
const props = defineProps<{
modelValue?: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
const rootRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const loading = ref(false)
const users = ref<User[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 10
const searchKeyword = ref('')
const selectedUser = ref<User | null>(null)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const loadUsers = async () => {
loading.value = true
try {
const result = await getUsersPaginated({
page: currentPage.value,
pageSize,
keyword: searchKeyword.value || undefined
})
users.value = result.list
total.value = result.total
if (props.modelValue) {
const found = result.list.find(u => u.id === props.modelValue)
if (found) selectedUser.value = found
}
} finally {
loading.value = false
}
}
const toggleOpen = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
currentPage.value = 1
loadUsers()
}
}
const handleSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
currentPage.value = 1
loadUsers()
}, 300)
}
const goPage = (page: number) => {
currentPage.value = page
loadUsers()
}
const selectUser = (user: User) => {
selectedUser.value = user
emit('update:modelValue', user.id)
isOpen.value = false
}
const clearSelection = () => {
selectedUser.value = null
emit('update:modelValue', null)
}
const handleClickOutside = (e: MouseEvent) => {
if (rootRef.value && !rootRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
watch(() => props.modelValue, async (id) => {
if (!id) {
selectedUser.value = null
return
}
if (selectedUser.value?.id === id) return
const result = await getUsersPaginated({ page: 1, pageSize: 1, keyword: String(id) })
const found = result.list.find(u => u.id === id)
if (found) {
selectedUser.value = found
} else {
try {
const { fetchUser } = await import('../../services/api')
selectedUser.value = await fetchUser(id)
} catch {
selectedUser.value = null
}
}
}, { immediate: true })
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</script>
<style scoped>
.user-picker {
min-width: 160px;
}
.picker-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.375rem;
color: white;
font-size: 0.875rem;
cursor: pointer;
text-align: left;
}
.placeholder {
color: rgba(255, 255, 255, 0.4);
}
.selected-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clear-btn {
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
padding: 0 0.25rem;
}
.clear-btn:hover {
color: white;
}
.popover-card {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 100;
width: 280px;
background: #1a1a1e;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.search-input {
width: 100%;
padding: 0.625rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: white;
font-size: 0.875rem;
outline: none;
}
.user-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
}
.user-item {
padding: 0.625rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.user-item:hover,
.user-item.active {
background: rgba(212, 179, 131, 0.1);
}
.user-name {
font-size: 0.875rem;
color: white;
}
.user-email {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.45);
}
.popover-empty {
padding: 1.5rem;
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 0.875rem;
}
.popover-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.popover-pagination button {
background: none;
border: none;
color: #d4b383;
cursor: pointer;
font-size: 0.75rem;
padding: 0.25rem;
}
.popover-pagination button:disabled {
color: rgba(255, 255, 255, 0.2);
cursor: not-allowed;
}
</style>

View File

@@ -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) => {

View File

@@ -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);

View 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>

View 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>

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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') },

View File

@@ -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)

View 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'
}

View 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', ' ')
}

View File

@@ -0,0 +1,83 @@
package handlers
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/niangaodev/art-code/models"
"github.com/niangaodev/art-code/repositories"
"github.com/niangaodev/art-code/utils"
)
func GetCodeTypes(c *gin.Context) {
list, err := repositories.GetCodeTypes()
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, repositories.BuildCodeTypesResponse(list))
}
func AdminGetCodeTypes(c *gin.Context) {
GetCodeTypes(c)
}
func AdminCreateCodeType(c *gin.Context) {
var ct models.CodeType
if err := c.ShouldBindJSON(&ct); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
if ct.Name == "" {
utils.Error(c, 400, "Name is required")
return
}
if err := repositories.CreateCodeType(&ct); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Code type created", repositories.BuildCodeTypeResponse(&ct))
}
func AdminUpdateCodeType(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
utils.Error(c, 400, "Invalid ID")
return
}
var ct models.CodeType
if err := c.ShouldBindJSON(&ct); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
ct.ID = id
if err := repositories.UpdateCodeType(&ct); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Code type updated", nil)
}
func AdminDeleteCodeType(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil {
utils.Error(c, 400, "Invalid ID")
return
}
count, err := repositories.CountSnippetsByCodeTypeID(id)
if err != nil {
utils.ServerError(c, err)
return
}
if count > 0 {
utils.Error(c, 400, "Cannot delete: snippets are using this code type")
return
}
if err := repositories.DeleteCodeType(id); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Code type deleted", nil)
}

View File

@@ -12,7 +12,7 @@ import (
func AdminGetRecentActivities(c *gin.Context) {
// 获取最近10条操作日志
logs, _, err := repositories.GetOperationLogs(1, 10)
logs, _, err := repositories.GetOperationLogs(1, 10, repositories.OperationLogFilter{})
if err != nil {
utils.ServerError(c, err)
return
@@ -58,11 +58,9 @@ func AdminGetRecentActivities(c *gin.Context) {
// 获取操作日志列表
func AdminGetOperationLogs(c *gin.Context) {
// 获取分页参数
page := 1
pageSize := 10
// 从查询参数中获取分页信息
if c.Query("page") != "" {
if p, err := strconv.Atoi(c.Query("page")); err == nil {
page = p
@@ -75,8 +73,24 @@ func AdminGetOperationLogs(c *gin.Context) {
}
}
// 获取操作日志
logs, total, err := repositories.GetOperationLogs(page, pageSize)
filter := repositories.OperationLogFilter{
Action: c.Query("action"),
Method: c.Query("method"),
StartDate: c.Query("startDate"),
EndDate: c.Query("endDate"),
}
if statusStr := c.Query("status"); statusStr != "" {
if s, err := strconv.Atoi(statusStr); err == nil {
filter.Status = s
}
}
if userIDStr := c.Query("userId"); userIDStr != "" {
if uid, err := strconv.ParseUint(userIDStr, 10, 32); err == nil {
filter.UserID = uint(uid)
}
}
logs, total, err := repositories.GetOperationLogs(page, pageSize, filter)
if err != nil {
utils.ServerError(c, err)
return
@@ -145,8 +159,15 @@ func AdminGetAccessLogs(c *gin.Context) {
}
}
filter := repositories.AccessLogFilter{
Path: c.Query("path"),
Region: c.Query("region"),
StartDate: c.Query("startDate"),
EndDate: c.Query("endDate"),
}
// 获取访问日志
logs, total, err := repositories.GetAccessLogs(page, pageSize, nil)
logs, total, err := repositories.GetAccessLogs(page, pageSize, nil, filter)
if err != nil {
utils.ServerError(c, err)
return

View File

@@ -254,8 +254,10 @@ func AdminCreatePost(c *gin.Context) {
// 保存历史记录
userID, _ := c.Get("userID")
if err := repositories.SavePostHistory(&post, userID.(uint)); err != nil {
log.Printf("Error saving post history: %v", err)
if saved, err := repositories.GetPostByIDAdmin(post.ID); err == nil && saved != nil {
if err := repositories.SavePostHistory(saved, userID.(uint)); err != nil {
log.Printf("Error saving post history: %v", err)
}
}
utils.SuccessWithMsg(c, "Post created successfully", gin.H{"id": post.ID})
@@ -287,8 +289,10 @@ func AdminUpdatePost(c *gin.Context) {
// 保存历史记录
userID, _ := c.Get("userID")
if err := repositories.SavePostHistory(&post, userID.(uint)); err != nil {
log.Printf("Error saving post history: %v", err)
if saved, err := repositories.GetPostByIDAdmin(postID); err == nil && saved != nil {
if err := repositories.SavePostHistory(saved, userID.(uint)); err != nil {
log.Printf("Error saving post history: %v", err)
}
}
utils.SuccessWithMsg(c, "Post updated successfully", nil)
@@ -319,6 +323,11 @@ func AdminUpdatePostRelations(c *gin.Context) {
return
}
userID, _ := c.Get("userID")
if saved, err := repositories.GetPostByIDAdmin(postID); err == nil && saved != nil {
_ = repositories.SavePostHistory(saved, userID.(uint))
}
utils.SuccessWithMsg(c, "Post relations updated successfully", nil)
}
@@ -344,6 +353,11 @@ func AdminTogglePostStatus(c *gin.Context) {
return
}
userID, _ := c.Get("userID")
if saved, err := repositories.GetPostByIDAdmin(postID); err == nil && saved != nil {
_ = repositories.SavePostHistory(saved, userID.(uint))
}
utils.SuccessWithMsg(c, "Post status updated successfully", nil)
}

View File

@@ -79,6 +79,8 @@ func AdminCreateSnippet(c *gin.Context) {
return
}
syncSnippetTypeFromCodeType(&snippet)
if err := repositories.CreateSnippet(&snippet); err != nil {
utils.ServerError(c, err)
return
@@ -98,6 +100,7 @@ func AdminUpdateSnippet(c *gin.Context) {
}
snippet.ID = idStr
syncSnippetTypeFromCodeType(&snippet)
if err := repositories.UpdateSnippet(&snippet); err != nil {
utils.ServerError(c, err)
@@ -107,6 +110,16 @@ func AdminUpdateSnippet(c *gin.Context) {
utils.SuccessWithMsg(c, "Snippet updated successfully", nil)
}
func syncSnippetTypeFromCodeType(snippet *models.Snippet) {
if snippet.CodeTypeID == 0 {
return
}
ct, err := repositories.GetCodeTypeByID(snippet.CodeTypeID)
if err == nil && ct != nil {
snippet.Type = ct.Name
}
}
// AdminDeleteSnippet 删除代码片段
func AdminDeleteSnippet(c *gin.Context) {
idStr := c.Param("id")

View File

@@ -15,7 +15,12 @@ func AdminGetUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
users, total, err := repositories.GetUsers(page, pageSize)
keyword := c.Query("keyword")
if keyword == "" {
keyword = c.Query("q")
}
users, total, err := repositories.GetUsers(page, pageSize, keyword)
if err != nil {
utils.ServerError(c, err)
return

View File

@@ -71,6 +71,7 @@ func main() {
// 代码片段路由
api.GET("/snippets", handlers.GetSnippets)
api.GET("/snippets/:id", handlers.GetSnippet)
api.GET("/code-types", handlers.GetCodeTypes)
// 标签路由
api.GET("/tags", handlers.GetTags)
@@ -135,6 +136,12 @@ func main() {
authAdmin.PUT("/snippets/:id", middleware.PermissionMiddleware("snippets", "update"), handlers.AdminUpdateSnippet)
authAdmin.DELETE("/snippets/:id", middleware.PermissionMiddleware("snippets", "delete"), handlers.AdminDeleteSnippet)
// 代码类型管理
authAdmin.GET("/code-types", middleware.PermissionMiddleware("snippets", "read"), handlers.AdminGetCodeTypes)
authAdmin.POST("/code-types", middleware.PermissionMiddleware("snippets", "create"), handlers.AdminCreateCodeType)
authAdmin.PUT("/code-types/:id", middleware.PermissionMiddleware("snippets", "update"), handlers.AdminUpdateCodeType)
authAdmin.DELETE("/code-types/:id", middleware.PermissionMiddleware("snippets", "delete"), handlers.AdminDeleteCodeType)
// 系统配置管理
authAdmin.GET("/settings", middleware.PermissionMiddleware("settings", "read"), handlers.AdminGetSettings)
authAdmin.POST("/settings", middleware.PermissionMiddleware("settings", "create"), handlers.AdminCreateSetting)

View File

@@ -70,13 +70,16 @@ func OperationLogMiddleware() gin.HandlerFunc {
log.Printf("Creating operation log: UserID=%d, IP=%s, Region=%s, Path=%s", userID.(uint), ip, region, c.Request.URL.Path)
// 构建操作日志
path := c.Request.URL.Path
method := c.Request.Method
operationLog := &models.OperationLog{
UserID: userID.(uint),
Username: username.(string),
IP: ip,
Region: region,
Path: c.Request.URL.Path,
Method: c.Request.Method,
Path: path,
Method: method,
Action: utils.GetOperationAction(path, method),
Params: string(requestBody),
Status: c.Writer.Status(),
Duration: duration,

View File

@@ -0,0 +1,43 @@
package models
import (
"time"
"gorm.io/gorm"
)
// CodeType 代码类型
type CodeType struct {
ID uint `json:"id" gorm:"primaryKey;column:id"`
Name string `json:"name" gorm:"column:name"`
Category int `json:"category" gorm:"column:category"` // 0前端 1后端 2其他
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`
DeletedAt int64 `json:"deletedAt" gorm:"column:deleted_at;default:0"`
}
func (CodeType) TableName() string {
return "code_types"
}
func (c *CodeType) BeforeCreate(tx *gorm.DB) error {
now := time.Now().Unix()
if c.CreatedAt == 0 {
c.CreatedAt = now
}
if c.UpdatedAt == 0 {
c.UpdatedAt = now
}
return nil
}
func (c *CodeType) BeforeUpdate(tx *gorm.DB) error {
c.UpdatedAt = time.Now().Unix()
return nil
}
type CodeTypeResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Category int `json:"category"`
}

View File

@@ -15,6 +15,7 @@ type OperationLog struct {
Region string `json:"region" gorm:"column:region"` // IP归属地
Path string `json:"path" gorm:"column:path;index"`
Method string `json:"method" gorm:"column:method"`
Action string `json:"action" gorm:"column:action;index"`
Params string `json:"params" gorm:"column:params;type:text"`
Status int `json:"status" gorm:"column:status"`
Duration int `json:"duration" gorm:"column:duration"`

View File

@@ -118,21 +118,24 @@ func (ph *PostHistory) BeforeCreate(tx *gorm.DB) error {
// PostHistoryResponse 文章历史记录响应模型
type PostHistoryResponse struct {
ID uint `json:"id"`
PostID uint `json:"postId"`
Version int `json:"version"`
Title string `json:"title"`
CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName,omitempty"`
ColumnID *uint `json:"columnId,omitempty"`
TagIDs []uint `json:"tagIds,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Content string `json:"content,omitempty"`
Date string `json:"date"`
IsPublished int `json:"isPublished"`
ModifiedBy uint `json:"modifiedBy"`
ModifiedAt string `json:"modifiedAt"`
CreatedAt string `json:"createdAt"`
ID uint `json:"id"`
PostID uint `json:"postId"`
Version int `json:"version"`
Title string `json:"title"`
CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName,omitempty"`
ColumnID *uint `json:"columnId,omitempty"`
TagIDs []uint `json:"tagIds,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Content string `json:"content,omitempty"`
Date string `json:"date"`
IsPublished int `json:"isPublished"`
ModifiedBy uint `json:"modifiedBy"`
ModifiedByName string `json:"modifiedByName,omitempty"`
ColumnName string `json:"columnName,omitempty"`
TagNames []string `json:"tagNames,omitempty"`
ModifiedAt string `json:"modifiedAt"`
CreatedAt string `json:"createdAt"`
}
// PostHistoryFieldDiff 字段对比结果

View File

@@ -12,6 +12,7 @@ type Snippet struct {
Title string `json:"title" gorm:"column:title"`
Code string `json:"code" gorm:"column:code;type:text"`
Type string `json:"type" gorm:"column:type"`
CodeTypeID uint `json:"codeTypeId" gorm:"column:code_type_id"`
Description string `json:"description" gorm:"column:description;type:text"`
ViewCount uint `json:"viewCount" gorm:"column:view_count;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
@@ -47,8 +48,12 @@ func (s *Snippet) BeforeUpdate(tx *gorm.DB) error {
// SnippetResponse 代码片段响应模型
type SnippetResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Type string `json:"type"`
ID string `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Type string `json:"type"`
CodeTypeID uint `json:"codeTypeId,omitempty"`
CodeType *CodeTypeResponse `json:"codeType,omitempty"`
Description string `json:"description,omitempty"`
ViewCount uint `json:"viewCount,omitempty"`
}

View File

@@ -1,17 +1,17 @@
/*
Navicat Premium Dump SQL
Source Server : 我的mysql8
Source Server : 开发环境-本地
Source Server Type : MySQL
Source Server Version : 80407 (8.4.7)
Source Host : 101.43.12.11:3306
Source Server Version : 80408 (8.4.8)
Source Host : localhost:3306
Source Schema : nl_blog
Target Server Type : MySQL
Target Server Version : 80407 (8.4.7)
Target Server Version : 80408 (8.4.8)
File Encoding : 65001
Date: 24/06/2026 16:07:16
Date: 24/06/2026 16:52:32
*/
SET NAMES utf8mb4;
@@ -36,7 +36,7 @@ CREATE TABLE `about_profiles` (
`updated_at` bigint NOT NULL DEFAULT 0,
`deleted_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for access_logs
@@ -56,7 +56,7 @@ CREATE TABLE `access_logs` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_path`(`path` ASC) USING BTREE COMMENT '按访问路径查询索引',
INDEX `idx_status_code`(`status_code` ASC) USING BTREE COMMENT '按状态码查询索引'
) ENGINE = InnoDB AUTO_INCREMENT = 25100 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '访问日志表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 27003 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '访问日志表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for attachment_categories
@@ -72,7 +72,7 @@ CREATE TABLE `attachment_categories` (
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件分类表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件分类表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for attachments
@@ -98,7 +98,7 @@ CREATE TABLE `attachments` (
INDEX `idx_storage_type`(`storage_type` ASC) USING BTREE,
INDEX `idx_file_type`(`file_type` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 74 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 76 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for categories
@@ -116,7 +116,7 @@ CREATE TABLE `categories` (
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_slug`(`slug` ASC) USING BTREE,
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章分类表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章分类表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for column_posts
@@ -129,7 +129,7 @@ CREATE TABLE `column_posts` (
`created_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`column_id`, `post_id`) USING BTREE,
INDEX `idx_post_id`(`post_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '专栏文章关联表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '专栏文章关联表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for columns
@@ -147,7 +147,7 @@ CREATE TABLE `columns` (
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '专栏表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '专栏表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for email_suffixes
@@ -163,7 +163,7 @@ CREATE TABLE `email_suffixes` (
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_suffix`(`suffix` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '邮箱后缀配置表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '邮箱后缀配置表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for inquiries
@@ -182,7 +182,7 @@ CREATE TABLE `inquiries` (
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '合作咨询表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '合作咨询表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for operation_logs
@@ -196,14 +196,16 @@ CREATE TABLE `operation_logs` (
`region` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'IP归属地',
`path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作路径',
`method` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'HTTP方法',
`action` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '操作描述',
`params` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '请求参数',
`status` int NOT NULL COMMENT '响应状态码',
`duration` int NOT NULL COMMENT '响应时间(毫秒)',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id`(`user_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 487 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '操作日志表' ROW_FORMAT = Dynamic;
INDEX `idx_user_id`(`user_id` ASC) USING BTREE,
INDEX `idx_action`(`action` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 678 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '操作日志表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for oss_configs
@@ -225,7 +227,7 @@ CREATE TABLE `oss_configs` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_storage_type`(`storage_type` ASC) USING BTREE,
INDEX `idx_is_active`(`is_active` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'OSS配置表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'OSS配置表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for partners
@@ -242,7 +244,7 @@ CREATE TABLE `partners` (
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '合作伙伴表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '合作伙伴表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for permissions
@@ -258,7 +260,7 @@ CREATE TABLE `permissions` (
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `unique_resource_action`(`resource` ASC, `action` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 31 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '权限表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 31 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '权限表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for post_history
@@ -270,7 +272,7 @@ CREATE TABLE `post_history` (
`version` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '版本号',
`title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文章标题',
`category_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '文章分类ID',
`column_id` int UNSIGNED NULL DEFAULT NULL COMMENT '专栏ID',
`column_id` int UNSIGNED NULL DEFAULT NULL COMMENT '专栏ID快照',
`tag_ids` json NULL COMMENT '标签ID快照',
`excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '文章摘要',
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文章内容',
@@ -282,7 +284,7 @@ CREATE TABLE `post_history` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_post_id`(`post_id` ASC) USING BTREE,
INDEX `idx_version`(`version` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 60 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章历史记录表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 60 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章历史记录表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for post_tags
@@ -294,7 +296,7 @@ CREATE TABLE `post_tags` (
`created_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`post_id`, `tag_id`) USING BTREE,
INDEX `idx_tag_id`(`tag_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章标签关联表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章标签关联表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for posts
@@ -319,7 +321,7 @@ CREATE TABLE `posts` (
INDEX `idx_is_published`(`is_published` ASC) USING BTREE COMMENT '按发布状态查询索引',
INDEX `idx_original_id`(`original_id` ASC) USING BTREE,
FULLTEXT INDEX `idx_title_content`(`title`, `content`) COMMENT '标题和内容全文索引,用于搜索'
) ENGINE = InnoDB AUTO_INCREMENT = 36 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '博客文章表(新结构)' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 36 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '博客文章表(新结构)' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for role_permissions
@@ -330,7 +332,7 @@ CREATE TABLE `role_permissions` (
`permission_id` bigint UNSIGNED NOT NULL COMMENT '权限ID',
PRIMARY KEY (`role_id`, `permission_id`) USING BTREE,
INDEX `role_permissions_ibfk_2`(`permission_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '角色权限关联表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '角色权限关联表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for roles
@@ -345,7 +347,7 @@ CREATE TABLE `roles` (
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `name`(`name` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '角色表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '角色表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for search_logs
@@ -362,7 +364,7 @@ CREATE TABLE `search_logs` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_search_type`(`search_type` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 22 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '搜索记录表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 25 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '搜索记录表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for settings
@@ -379,7 +381,22 @@ CREATE TABLE `settings` (
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `key_name`(`key_name` ASC) USING BTREE,
INDEX `idx_key_name`(`key_name` ASC) USING BTREE COMMENT '按键名查询索引'
) ENGINE = InnoDB AUTO_INCREMENT = 16 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '网站配置表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 16 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '网站配置表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for code_types
-- ----------------------------
DROP TABLE IF EXISTS `code_types`;
CREATE TABLE `code_types` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '类型名称',
`category` tinyint NOT NULL DEFAULT 0 COMMENT '0前端 1后端 2其他',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_name`(`name` ASC, `deleted_at` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '代码类型表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for snippets
@@ -390,6 +407,7 @@ CREATE TABLE `snippets` (
`title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '代码片段标题',
`code` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '代码内容',
`type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '代码类型javascript、css、html等',
`code_type_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT '代码类型ID',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '代码片段描述',
`view_count` int UNSIGNED NULL DEFAULT 0 COMMENT '查看次数',
`deleted_at` bigint NOT NULL DEFAULT 0,
@@ -398,7 +416,7 @@ CREATE TABLE `snippets` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_type`(`type` ASC) USING BTREE COMMENT '按代码类型查询索引',
INDEX `idx_view_count`(`view_count` ASC) USING BTREE COMMENT '按查看次数查询索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '代码片段表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '代码片段表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for tags
@@ -415,7 +433,7 @@ CREATE TABLE `tags` (
UNIQUE INDEX `name`(`name` ASC) USING BTREE,
UNIQUE INDEX `slug`(`slug` ASC) USING BTREE,
INDEX `idx_slug`(`slug` ASC) USING BTREE COMMENT '按别名查询索引'
) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '标签表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '标签表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for testimonials
@@ -433,7 +451,7 @@ CREATE TABLE `testimonials` (
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '客户评价表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '客户评价表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for user_access_logs
@@ -445,15 +463,12 @@ CREATE TABLE `user_access_logs` (
`user_ip` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户IP地址',
`user_location` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户归属地',
`article_id` int NOT NULL COMMENT '访问的文章ID',
`visitor_key` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '匿名访客标识',
`deleted_at` bigint NOT NULL DEFAULT 0,
`access_time` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id`(`user_id` ASC) USING BTREE,
INDEX `idx_article_id`(`article_id` ASC) USING BTREE,
INDEX `idx_dedup_hour`(`article_id` ASC, `visitor_key` ASC, `access_time` ASC) USING BTREE,
INDEX `idx_dedup_user_hour`(`article_id` ASC, `user_id` ASC, `access_time` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 298 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户访问记录表' ROW_FORMAT = Dynamic;
INDEX `idx_article_id`(`article_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 298 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户访问记录表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for users
@@ -477,7 +492,7 @@ CREATE TABLE `users` (
INDEX `idx_email`(`email` ASC) USING BTREE COMMENT '按邮箱查询索引',
INDEX `idx_role`(`role` ASC) USING BTREE COMMENT '按角色查询索引',
INDEX `users_ibfk_1`(`role_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for work_gallery
@@ -494,7 +509,7 @@ CREATE TABLE `work_gallery` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_work_id`(`work_id` ASC) USING BTREE,
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 30 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品图库表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 30 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品图库表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for work_tech_stack
@@ -510,7 +525,7 @@ CREATE TABLE `work_tech_stack` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_work_id`(`work_id` ASC) USING BTREE,
INDEX `idx_category`(`category` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 28 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品技术栈表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 28 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品技术栈表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for works
@@ -532,6 +547,6 @@ CREATE TABLE `works` (
INDEX `idx_category`(`category` ASC) USING BTREE COMMENT '按分类查询索引',
INDEX `idx_year`(`year` ASC) USING BTREE COMMENT '按年份查询索引',
INDEX `idx_is_featured`(`is_featured` ASC) USING BTREE COMMENT '按精选状态查询索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品表' ROW_FORMAT = Dynamic;
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品表' ROW_FORMAT = DYNAMIC;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,9 @@
package repositories
// AccessLogFilter 访问日志筛选条件
type AccessLogFilter struct {
Path string
Region string
StartDate string
EndDate string
}

View File

@@ -0,0 +1,89 @@
package repositories
import (
"log"
"time"
"github.com/niangaodev/art-code/config"
"github.com/niangaodev/art-code/models"
"gorm.io/gorm"
)
func GetCodeTypes() ([]models.CodeType, error) {
var list []models.CodeType
err := config.DB.Model(&models.CodeType{}).
Where("deleted_at = ?", 0).
Order("category ASC, name ASC").
Find(&list).Error
return list, err
}
func GetCodeTypeByID(id uint) (*models.CodeType, error) {
var ct models.CodeType
err := config.DB.Model(&models.CodeType{}).
Where("id = ? AND deleted_at = ?", id, 0).
First(&ct).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &ct, nil
}
func CreateCodeType(ct *models.CodeType) error {
return config.DB.Create(ct).Error
}
func UpdateCodeType(ct *models.CodeType) error {
return config.DB.Model(&models.CodeType{}).
Where("id = ? AND deleted_at = ?", ct.ID, 0).
Updates(map[string]interface{}{
"name": ct.Name,
"category": ct.Category,
"updated_at": time.Now().Unix(),
}).Error
}
func DeleteCodeType(id uint) error {
return config.DB.Model(&models.CodeType{}).
Where("id = ?", id).
Update("deleted_at", time.Now().Unix()).Error
}
func CountSnippetsByCodeTypeID(id uint) (int64, error) {
var count int64
err := config.DB.Model(&models.Snippet{}).
Where("code_type_id = ? AND deleted_at = ?", id, 0).
Count(&count).Error
return count, err
}
func BuildCodeTypeResponse(ct *models.CodeType) models.CodeTypeResponse {
return models.CodeTypeResponse{
ID: ct.ID,
Name: ct.Name,
Category: ct.Category,
}
}
func BuildCodeTypesResponse(list []models.CodeType) []models.CodeTypeResponse {
res := make([]models.CodeTypeResponse, 0, len(list))
for _, ct := range list {
res = append(res, BuildCodeTypeResponse(&ct))
}
return res
}
func GetCodeTypeForSnippet(snippet *models.Snippet) *models.CodeType {
if snippet.CodeTypeID == 0 {
return nil
}
ct, err := GetCodeTypeByID(snippet.CodeTypeID)
if err != nil {
log.Printf("Error loading code type %d: %v", snippet.CodeTypeID, err)
return nil
}
return ct
}

View File

@@ -129,7 +129,7 @@ func GetDailyUV(startDate, endDate string) ([]UVTrendData, error) {
}
// GetAccessLogs 获取访问日志列表(支持分页和筛选)
func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64, error) {
func GetAccessLogs(page, pageSize int, postID *int, filter AccessLogFilter) ([]models.AccessLog, int64, error) {
query := config.DB.Model(&models.AccessLog{}).
Where("deleted_at = ?", 0)
@@ -168,6 +168,25 @@ func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64,
)
}
if filter.Path != "" {
query = query.Where("path LIKE ?", "%"+filter.Path+"%")
}
if filter.Region != "" {
query = query.Where("region LIKE ?", "%"+filter.Region+"%")
}
if filter.StartDate != "" {
startUnix := parseDateToUnix(filter.StartDate, false)
if startUnix > 0 {
query = query.Where("created_at >= ?", startUnix)
}
}
if filter.EndDate != "" {
endUnix := parseDateToUnix(filter.EndDate, true)
if endUnix > 0 {
query = query.Where("created_at <= ?", endUnix)
}
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
@@ -186,7 +205,7 @@ func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64,
// GetPostAccessLogsFromAccessLogs 从 access_logs 表获取指定文章的访问记录(已废弃,应使用 user_access_logs
// 保留此函数以保持向后兼容,但建议使用 user_access_log_repository.GetPostAccessLogs
func GetPostAccessLogsFromAccessLogs(postID int, page, pageSize int) ([]models.AccessLog, int64, error) {
return GetAccessLogs(page, pageSize, &postID)
return GetAccessLogs(page, pageSize, &postID, AccessLogFilter{})
}
// ExtractProvinceFromRegion 从归属地字符串中提取省份信息

View File

@@ -14,7 +14,7 @@ func MigrateToBigInt() {
"about_profiles", "partners", "testimonials", "inquiries",
"email_suffixes", "access_logs", "user_access_logs",
"operation_logs", "permissions", "roles",
"work_tech_stack", "work_gallery", "post_tags", "post_history",
"work_tech_stack", "work_gallery", "post_tags", "post_history", "code_types",
}
for _, table := range tables {

View File

@@ -0,0 +1,11 @@
package repositories
// OperationLogFilter 操作日志筛选条件
type OperationLogFilter struct {
Action string
Method string
Status int // 0 表示不筛选
UserID uint
StartDate string
EndDate string
}

View File

@@ -20,25 +20,45 @@ func CreateOperationLog(operationLog *models.OperationLog) error {
}
// GetOperationLogs 获取操作日志列表
func GetOperationLogs(page, pageSize int) ([]models.OperationLog, int64, error) {
func GetOperationLogs(page, pageSize int, filter OperationLogFilter) ([]models.OperationLog, int64, error) {
offset := (page - 1) * pageSize
var logs []models.OperationLog
var total int64
query := config.DB.Model(&models.OperationLog{}).
Where("deleted_at = ?", 0)
// 获取总记录数
err := config.DB.Model(&models.OperationLog{}).
Where("deleted_at = ?", 0).
Count(&total).Error
if err != nil {
if filter.Action != "" {
query = query.Where("action LIKE ?", "%"+filter.Action+"%")
}
if filter.Method != "" {
query = query.Where("method = ?", filter.Method)
}
if filter.Status > 0 {
query = query.Where("status = ?", filter.Status)
}
if filter.UserID > 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if filter.StartDate != "" {
startUnix := parseDateToUnix(filter.StartDate, false)
if startUnix > 0 {
query = query.Where("created_at >= ?", startUnix)
}
}
if filter.EndDate != "" {
endUnix := parseDateToUnix(filter.EndDate, true)
if endUnix > 0 {
query = query.Where("created_at <= ?", endUnix)
}
}
var total int64
if err := query.Count(&total).Error; err != nil {
log.Printf("Error counting operation logs: %v", err)
return nil, 0, err
}
// 获取分页数据
err = config.DB.Model(&models.OperationLog{}).
Where("deleted_at = ?", 0).
Order("created_at DESC").
var logs []models.OperationLog
err := query.Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&logs).Error
@@ -52,8 +72,10 @@ func GetOperationLogs(page, pageSize int) ([]models.OperationLog, int64, error)
// BuildOperationLogResponse 构建操作日志响应
func BuildOperationLogResponse(log *models.OperationLog) *models.OperationLogResponse {
// 生成操作描述
action := utils.GetOperationAction(log.Path, log.Method)
action := log.Action
if action == "" {
action = utils.GetOperationAction(log.Path, log.Method)
}
return &models.OperationLogResponse{
ID: log.ID,

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/niangaodev/art-code/config"
@@ -558,18 +559,22 @@ func GetPostHistoryByVersion(postID uint, version uint) (*models.PostHistory, er
// BuildPostHistoryResponse 构建历史记录响应
func BuildPostHistoryResponse(h *models.PostHistory, includeFull bool) *models.PostHistoryResponse {
resp := &models.PostHistoryResponse{
ID: h.ID,
PostID: h.PostID,
Version: h.Version,
Title: h.Title,
CategoryID: h.CategoryID,
ColumnID: h.ColumnID,
TagIDs: h.GetTagIDList(),
Date: time.Unix(h.CreatedAt, 0).Format("2006-01-02"),
IsPublished: h.IsPublished,
ModifiedBy: h.ModifiedBy,
ModifiedAt: time.Unix(h.ModifiedAt, 0).Format("2006-01-02 15:04:05"),
CreatedAt: time.Unix(h.CreatedAt, 0).Format("2006-01-02 15:04:05"),
ID: h.ID,
PostID: h.PostID,
Version: h.Version,
Title: h.Title,
CategoryID: h.CategoryID,
CategoryName: lookupCategoryName(h.CategoryID),
ColumnID: h.ColumnID,
ColumnName: lookupColumnName(h.ColumnID),
TagIDs: h.GetTagIDList(),
TagNames: lookupTagNames(h.GetTagIDList()),
Date: time.Unix(h.CreatedAt, 0).Format("2006-01-02"),
IsPublished: h.IsPublished,
ModifiedBy: h.ModifiedBy,
ModifiedByName: lookupUsername(h.ModifiedBy),
ModifiedAt: time.Unix(h.ModifiedAt, 0).Format("2006-01-02 15:04:05"),
CreatedAt: time.Unix(h.CreatedAt, 0).Format("2006-01-02 15:04:05"),
}
if includeFull {
@@ -580,6 +585,53 @@ func BuildPostHistoryResponse(h *models.PostHistory, includeFull bool) *models.P
return resp
}
func lookupCategoryName(id uint) string {
if id == 0 {
return ""
}
var name string
config.DB.Model(&models.Category{}).Where("id = ?", id).Pluck("name", &name)
return name
}
func lookupColumnName(id *uint) string {
if id == nil || *id == 0 {
return ""
}
var name string
config.DB.Model(&models.Column{}).Where("id = ?", *id).Pluck("name", &name)
return name
}
func lookupTagNames(ids []uint) []string {
if len(ids) == 0 {
return nil
}
var tags []models.Tag
config.DB.Model(&models.Tag{}).Where("id IN ?", ids).Find(&tags)
names := make([]string, 0, len(tags))
for _, t := range tags {
names = append(names, t.Name)
}
return names
}
func lookupUsername(id uint) string {
if id == 0 {
return ""
}
var username string
config.DB.Model(&models.User{}).Where("id = ?", id).Pluck("username", &username)
return username
}
func formatPublishedLabel(v int) string {
if v == 1 {
return "已发布"
}
return "草稿"
}
// BuildPostHistoryResponses 构建历史记录列表响应
func BuildPostHistoryResponses(history []models.PostHistory) []models.PostHistoryResponse {
var responses []models.PostHistoryResponse
@@ -621,17 +673,14 @@ func GetPostHistoryDiff(postID uint, fromVersion, toVersion uint) (*models.PostH
return nil, fmt.Errorf("to version not found")
}
fromTags, _ := json.Marshal(fromHistory.GetTagIDList())
toTags, _ := json.Marshal(toHistory.GetTagIDList())
fields := map[string]models.PostHistoryFieldDiff{
"title": buildFieldDiff(fromHistory.Title, toHistory.Title, false),
"excerpt": buildFieldDiff(fromHistory.Excerpt, toHistory.Excerpt, false),
"content": buildFieldDiff(fromHistory.Content, toHistory.Content, true),
"categoryId": buildFieldDiff(strconv.FormatUint(uint64(fromHistory.CategoryID), 10), strconv.FormatUint(uint64(toHistory.CategoryID), 10), false),
"columnId": buildFieldDiff(formatOptionalUint(fromHistory.ColumnID), formatOptionalUint(toHistory.ColumnID), false),
"tagIds": buildFieldDiff(string(fromTags), string(toTags), false),
"isPublished": buildFieldDiff(strconv.Itoa(fromHistory.IsPublished), strconv.Itoa(toHistory.IsPublished), false),
"category": buildFieldDiff(lookupCategoryName(fromHistory.CategoryID), lookupCategoryName(toHistory.CategoryID), false),
"column": buildFieldDiff(lookupColumnName(fromHistory.ColumnID), lookupColumnName(toHistory.ColumnID), false),
"tags": buildFieldDiff(strings.Join(lookupTagNames(fromHistory.GetTagIDList()), ", "), strings.Join(lookupTagNames(toHistory.GetTagIDList()), ", "), false),
"isPublished": buildFieldDiff(formatPublishedLabel(fromHistory.IsPublished), formatPublishedLabel(toHistory.IsPublished), false),
}
return &models.PostHistoryDiffResponse{

View File

@@ -47,12 +47,23 @@ func GetSnippetByID(id string) (*models.Snippet, error) {
// BuildSnippetResponse 构建代码片段响应
func BuildSnippetResponse(snippet *models.Snippet) *models.SnippetResponse {
return &models.SnippetResponse{
ID: snippet.ID,
Title: snippet.Title,
Code: snippet.Code,
Type: snippet.Type,
resp := &models.SnippetResponse{
ID: snippet.ID,
Title: snippet.Title,
Code: snippet.Code,
Type: snippet.Type,
CodeTypeID: snippet.CodeTypeID,
Description: snippet.Description,
ViewCount: snippet.ViewCount,
}
if ct := GetCodeTypeForSnippet(snippet); ct != nil {
r := BuildCodeTypeResponse(ct)
resp.CodeType = &r
if resp.Type == "" {
resp.Type = ct.Name
}
}
return resp
}
// BuildSnippetsResponse 构建代码片段列表响应
@@ -79,11 +90,12 @@ func UpdateSnippet(snippet *models.Snippet) error {
err := config.DB.Model(&models.Snippet{}).
Where("id = ? AND deleted_at = ?", snippet.ID, 0).
Updates(map[string]interface{}{
"title": snippet.Title,
"code": snippet.Code,
"type": snippet.Type,
"description": snippet.Description,
"updated_at": time.Now().Unix(),
"title": snippet.Title,
"code": snippet.Code,
"type": snippet.Type,
"code_type_id": snippet.CodeTypeID,
"description": snippet.Description,
"updated_at": time.Now().Unix(),
}).Error
if err != nil {
log.Printf("Error updating snippet: %v", err)

View File

@@ -49,27 +49,28 @@ func GetUserByID(id uint) (*models.User, error) {
return &user, nil
}
// GetUsers 获取所有用户 (分页)
func GetUsers(page, pageSize int) ([]models.User, int, error) {
// GetUsers 获取所有用户 (分页,支持 keyword 搜索)
func GetUsers(page, pageSize int, keyword string) ([]models.User, int, error) {
offset := (page - 1) * pageSize
var users []models.User
var total int64
query := config.DB.Model(&models.User{}).
Where("users.deleted_at = ?", 0)
// 获取总数
err := config.DB.Model(&models.User{}).
Where("deleted_at = ?", 0).
Count(&total).Error
if err != nil {
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("users.username LIKE ? OR users.email LIKE ?", like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
log.Printf("Error getting user count: %v", err)
return nil, 0, err
}
// 获取用户列表
err = config.DB.Model(&models.User{}).
var users []models.User
err := query.
Select("users.*, COALESCE(roles.name, users.role) as role").
Joins("LEFT JOIN roles ON users.role_id = roles.id").
Where("users.deleted_at = ?", 0).
Order("users.created_at DESC").
Limit(pageSize).
Offset(offset).

View File

@@ -133,6 +133,22 @@ func GetOperationAction(path, method string) string {
}
}
// 代码类型管理
if strings.Contains(pathLower, "/api/admin/code-types") {
if methodUpper == "GET" {
return "查看代码类型列表"
}
if methodUpper == "POST" {
return "创建代码类型"
}
if methodUpper == "PUT" {
return "更新代码类型"
}
if methodUpper == "DELETE" {
return "删除代码类型"
}
}
// 代码片段管理
if strings.Contains(pathLower, "/api/admin/snippets") {
if methodUpper == "GET" {