优化页面、修复BUG

This commit is contained in:
李琦
2026-06-26 13:37:16 +08:00
parent 87fa638b24
commit 396272908b
19 changed files with 1105 additions and 410 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div class="admin-table-filter admin-card mb-6 p-4">
<div class="filter-row">
<div class="filter-main">
<div v-if="showKeyword" class="filter-keyword">
<div class="relative flex-1 min-w-[200px]">
<input
@@ -26,27 +26,46 @@
</div>
</div>
<div class="filter-extra">
<slot name="filters" />
<div v-if="hasPrimaryFilters" class="filter-primary">
<slot name="primaryFilters" />
</div>
<button
v-if="hasFilters"
type="button"
class="filter-toggle admin-btn-secondary text-xs"
@click="expanded = !expanded"
>
{{ expanded ? '收起筛选 ▲' : '展开筛选 ▼' }}
</button>
<div class="filter-actions">
<button type="button" class="admin-btn-secondary" @click="emit('search')">查询</button>
<button type="button" class="admin-btn-secondary opacity-70" @click="emit('reset')">重置</button>
</div>
</div>
<Transition name="filter-panel">
<div v-if="hasFilters && expanded" class="filter-panel">
<slot name="filters" />
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
import { computed, ref, useSlots, watch } from 'vue'
const props = withDefaults(defineProps<{
keyword?: string
keywordPlaceholder?: string
showKeyword?: boolean
defaultExpanded?: boolean
}>(), {
keyword: '',
keywordPlaceholder: '关键词搜索...',
showKeyword: true,
defaultExpanded: false,
})
const emit = defineEmits<{
@@ -55,6 +74,15 @@ const emit = defineEmits<{
reset: []
}>()
const slots = useSlots()
const hasFilters = computed(() => !!slots.filters)
const hasPrimaryFilters = computed(() => !!slots.primaryFilters)
const expanded = ref(props.defaultExpanded)
watch(() => props.defaultExpanded, (value) => {
expanded.value = value
})
const onKeywordInput = (e: Event) => {
emit('update:keyword', (e.target as HTMLInputElement).value)
}
@@ -66,7 +94,7 @@ const clearKeyword = () => {
</script>
<style scoped>
.filter-row {
.filter-main {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
@@ -74,21 +102,58 @@ const clearKeyword = () => {
}
.filter-keyword {
flex: 1 1 220px;
flex: 1 1 240px;
min-width: 200px;
max-width: 100%;
}
.filter-extra {
.filter-primary {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
flex: 0 0 auto;
}
.filter-toggle {
flex-shrink: 0;
white-space: nowrap;
}
.filter-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
margin-left: auto;
}
.filter-panel {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
width: 100%;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.filter-panel-enter-active,
.filter-panel-leave-active {
transition: opacity 0.2s ease, max-height 0.25s ease;
overflow: hidden;
}
.filter-panel-enter-from,
.filter-panel-leave-to {
opacity: 0;
max-height: 0;
}
.filter-panel-enter-to,
.filter-panel-leave-from {
opacity: 1;
max-height: 500px;
}
.search-icon {
@@ -125,6 +190,8 @@ const clearKeyword = () => {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 0 0 auto;
flex-shrink: 0;
min-width: 120px;
}

View File

@@ -5,7 +5,7 @@
class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@click.self="emit('close')"
>
<div class="w-full max-w-5xl max-h-[85vh] bg-[#121214] border border-white/10 rounded-xl shadow-2xl flex flex-col overflow-hidden">
<div class="w-full max-w-6xl max-h-[90vh] bg-[#121214] border border-white/10 rounded-xl shadow-2xl flex flex-col overflow-hidden">
<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>
@@ -28,6 +28,7 @@
</div>
<div class="flex-1 overflow-hidden flex min-h-0">
<!-- 左侧版本列表 -->
<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>
@@ -56,80 +57,222 @@
</button>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
<div v-if="!activeVersion" class="h-full flex items-center justify-center text-sm text-art-muted">
<!-- 右侧 Tab 面板 -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div v-if="!activeVersion" class="flex-1 flex items-center justify-center text-sm text-art-muted">
点击左侧版本查看内容与变更对比
</div>
<div v-else-if="detailLoading" class="h-full flex items-center justify-center text-sm text-art-muted">
<div v-else-if="detailLoading" class="flex-1 flex items-center justify-center text-sm text-art-muted">
加载版本详情...
</div>
<template v-else-if="previewData">
<section class="mb-8">
<h4 class="text-white font-medium mb-4">版本 v{{ previewData.version }} 内容</h4>
<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 class="flex items-start gap-2">
<span class="text-art-muted shrink-0">操作人</span>
<AuthorInfo
:name="previewData.userName || previewData.modifiedByName"
:email="previewData.userEmail"
:avatar="previewData.userAvatar"
:user-id="previewData.userId || previewData.modifiedBy"
variant="compact"
size="sm"
/>
<!-- Tab -->
<div class="flex border-b border-white/10 shrink-0">
<button
type="button"
class="px-5 py-2.5 text-sm transition-colors"
:class="activeMainTab === 'preview'
? 'text-art-accent bg-white/5 border-b-2 border-art-accent'
: 'text-white/40 hover:text-white/70'"
@click="activeMainTab = 'preview'"
>
版本内容
</button>
<button
type="button"
class="px-5 py-2.5 text-sm transition-colors flex items-center gap-2"
:class="activeMainTab === 'diff'
? 'text-art-accent bg-white/5 border-b-2 border-art-accent'
: 'text-white/40 hover:text-white/70'"
:disabled="activeVersion === 1"
@click="activeMainTab = 'diff'"
>
变更对比
<span
v-if="changedCount > 0"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-art-accent/20 text-art-accent font-medium"
>
{{ changedCount }}
</span>
</button>
</div>
<!-- Tab: 版本内容 -->
<div
v-show="activeMainTab === 'preview'"
class="flex-1 flex flex-col min-h-0 overflow-hidden"
>
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 flex flex-col min-h-0">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3 text-sm shrink-0 mb-4">
<div class="md:col-span-2">
<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 class="flex items-start gap-2 md:col-span-2">
<span class="text-art-muted shrink-0">操作人</span>
<AuthorInfo
:name="previewData.userName || previewData.modifiedByName"
:email="previewData.userEmail"
:avatar="previewData.userAvatar"
:user-id="previewData.userId || previewData.modifiedBy"
variant="compact"
size="sm"
/>
</div>
<div class="md:col-span-2">
<span class="text-art-muted">摘要</span>
<span class="text-white">{{ previewData.excerpt || '—' }}</span>
</div>
</div>
<div><span class="text-art-muted">摘要</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
<div>
<span class="text-art-muted block mb-2">正文</span>
<div class="bg-black/30 rounded-lg p-4 max-h-80 overflow-y-auto prose prose-invert prose-sm max-w-none">
<div class="flex-1 flex flex-col min-h-0">
<span class="text-art-muted text-sm mb-2 shrink-0">正文</span>
<div class="flex-1 min-h-[200px] bg-black/30 rounded-lg p-4 overflow-y-auto custom-scrollbar prose prose-invert prose-sm max-w-none">
<MdPreview :model-value="previewData.content || ''" theme="dark" />
</div>
</div>
</div>
</section>
</div>
<section>
<h4 class="text-white font-medium mb-4">
<template v-if="activeVersion > 1">与上一版本对比v{{ activeVersion - 1 }} v{{ activeVersion }}</template>
<template v-else>版本对比</template>
</h4>
<div v-if="activeVersion === 1" class="text-sm text-art-muted rounded-lg border border-white/10 p-4 bg-white/[0.02]">
这是首个版本无上一版本可对比
</div>
<div v-else-if="diffData" class="space-y-4">
<div
v-for="(field, key) in diffData.fields"
:key="key"
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">{{ fieldLabel(key) }}</div>
<template v-if="field.changed">
<div class="grid md:grid-cols-2 gap-3 text-xs">
<div>
<div class="text-art-muted mb-1">旧版</div>
<pre class="whitespace-pre-wrap bg-black/30 rounded p-2 text-white/70 max-h-40 overflow-y-auto">{{ field.from || '—' }}</pre>
</div>
<div>
<div class="text-art-muted mb-1">新版</div>
<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 && 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>
<!-- Tab: 变更对比 -->
<div
v-show="activeMainTab === 'diff'"
class="flex-1 flex flex-col min-h-0 overflow-hidden"
>
<!-- v1 empty state -->
<div
v-if="activeVersion === 1"
class="flex-1 flex items-center justify-center p-6"
>
<div class="text-sm text-art-muted rounded-lg border border-white/10 p-6 bg-white/[0.02] text-center max-w-md">
这是首个版本无上一版本可对比
</div>
</div>
</section>
<template v-else-if="diffData">
<!-- 摘要条 -->
<div class="px-6 py-3 border-b border-white/10 shrink-0 bg-white/[0.02] flex items-center gap-3">
<span class="text-sm text-white">
v{{ diffData.fromVersion }} v{{ diffData.toVersion }}
</span>
<span
v-if="changedCount > 0"
class="text-xs px-2 py-0.5 rounded-full bg-art-accent/15 text-art-accent"
>
{{ changedCount }} 项变更
</span>
<span v-else class="text-xs text-art-muted">无变更</span>
</div>
<!-- 无变更 empty state -->
<div
v-if="changedCount === 0"
class="flex-1 flex items-center justify-center p-6"
>
<div class="text-sm text-art-muted rounded-lg border border-white/10 p-6 bg-white/[0.02] text-center max-w-md">
与上一版本相比所有字段均无变化
</div>
</div>
<template v-else>
<!-- 字段子 Tab -->
<div class="flex gap-1 px-4 py-2 border-b border-white/10 shrink-0 overflow-x-auto custom-scrollbar">
<button
v-for="[key] in changedFields"
:key="key"
type="button"
class="shrink-0 px-3 py-1.5 text-xs rounded-md transition-colors"
:class="activeDiffField === key
? 'bg-art-accent/15 text-art-accent border border-art-accent/30'
: 'text-white/50 hover:text-white/80 hover:bg-white/5 border border-transparent'"
@click="activeDiffField = key"
>
{{ fieldLabel(key) }}
</button>
</div>
<!-- 双栏对比 -->
<div
v-if="activeDiffFieldData"
class="flex-1 flex flex-col md:flex-row min-h-0 overflow-hidden"
>
<!-- 旧版 -->
<div class="flex-1 flex flex-col min-h-0 md:border-r border-white/10">
<div class="px-4 py-2 text-xs text-art-muted border-b border-white/5 shrink-0 bg-black/20">
旧版 v{{ diffData.fromVersion }}
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-4 min-h-0">
<template v-if="activeDiffField === 'content'">
<div class="prose prose-invert prose-sm max-w-none">
<MdPreview :model-value="activeDiffFieldData.from || ''" theme="dark" />
</div>
</template>
<p v-else class="text-sm text-white/80 whitespace-pre-wrap break-words">
{{ activeDiffFieldData.from || '—' }}
</p>
</div>
</div>
<!-- 新版 -->
<div class="flex-1 flex flex-col min-h-0">
<div class="px-4 py-2 text-xs text-art-muted border-b border-white/5 shrink-0 bg-art-accent/5">
新版 v{{ diffData.toVersion }}
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-4 min-h-0">
<template v-if="activeDiffField === 'content'">
<div class="prose prose-invert prose-sm max-w-none">
<MdPreview :model-value="activeDiffFieldData.to || ''" theme="dark" />
</div>
</template>
<p v-else class="text-sm text-white/80 whitespace-pre-wrap break-words">
{{ activeDiffFieldData.to || '—' }}
</p>
</div>
</div>
</div>
<!-- 无变化字段折叠区 -->
<div v-if="unchangedCount > 0" class="shrink-0 border-t border-white/10">
<button
type="button"
class="w-full px-6 py-2.5 text-xs text-art-muted hover:text-white/70 hover:bg-white/[0.02] transition-colors flex items-center gap-2"
@click="showUnchanged = !showUnchanged"
>
<span>{{ showUnchanged ? '收起' : '展开' }}</span>
<span>{{ unchangedCount }} 项无变化</span>
<span class="ml-auto">{{ showUnchanged ? '▲' : '▼' }}</span>
</button>
<div v-if="showUnchanged" class="px-6 pb-4 flex flex-wrap gap-2">
<span
v-for="[key] in unchangedFields"
:key="key"
class="text-xs px-2 py-1 rounded bg-white/[0.03] border border-white/5 text-art-muted"
>
{{ fieldLabel(key) }}
</span>
</div>
</div>
</template>
</template>
</div>
</template>
</div>
</div>
@@ -139,7 +282,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import {
@@ -148,7 +291,8 @@ import {
getPostHistoryDiff,
restorePostHistory,
type PostHistory,
type PostHistoryDiff
type PostHistoryDiff,
type PostHistoryFieldDiff
} from '../../services/api'
import { useToast } from '../../composables/useToast'
import AuthorInfo from '../AuthorInfo.vue'
@@ -180,9 +324,40 @@ const historyList = ref<PostHistory[]>([])
const activeVersion = ref<number | null>(null)
const previewData = ref<PostHistory | null>(null)
const diffData = ref<PostHistoryDiff | null>(null)
const activeMainTab = ref<'preview' | 'diff'>('preview')
const activeDiffField = ref<string | null>(null)
const showUnchanged = ref(false)
const fieldLabel = (key: string | number) => FIELD_LABELS[String(key)] || String(key)
const changedFields = computed(() =>
Object.entries(diffData.value?.fields ?? {}).filter(([, f]) => f.changed)
)
const unchangedFields = computed(() =>
Object.entries(diffData.value?.fields ?? {}).filter(([, f]) => !f.changed)
)
const changedCount = computed(() => changedFields.value.length)
const unchangedCount = computed(() => unchangedFields.value.length)
const activeDiffFieldData = computed((): PostHistoryFieldDiff | null => {
if (!activeDiffField.value || !diffData.value) return null
return diffData.value.fields[activeDiffField.value] ?? null
})
const applyTabDefaults = (version: number) => {
if (version > 1 && changedCount.value > 0) {
activeMainTab.value = 'diff'
activeDiffField.value = changedFields.value[0]?.[0] ?? null
} else {
activeMainTab.value = 'preview'
activeDiffField.value = null
}
showUnchanged.value = false
}
const loadHistory = async () => {
if (!props.postId) return
loading.value = true
@@ -211,6 +386,7 @@ const selectVersion = async (version: number) => {
])
previewData.value = detail
diffData.value = diff
applyTabDefaults(version)
} catch (error: any) {
toast.showToast(error.message || '加载版本详情失败', 'error')
} finally {
@@ -237,6 +413,9 @@ watch(
activeVersion.value = null
previewData.value = null
diffData.value = null
activeMainTab.value = 'preview'
activeDiffField.value = null
showUnchanged.value = false
loadHistory()
}
}
@@ -244,6 +423,6 @@ watch(
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 2px; }
</style>

View File

@@ -1,23 +1,129 @@
/**
* 质感提升说明:
* 1. 请确保全局样式或 index.css 中包含以下动画定义:
* * #toast-container {
* position: fixed; top: 2rem; left: 50%; transform: translateX(-50%);
* display: flex; flex-direction: column; gap: 0.75rem; z-index: 9999;
* pointer-events: none;
* }
* .toast-item {
* pointer-events: auto;
* opacity: 0;
* transform: translateY(-16px) scale(0.92);
* transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); // 物理弹簧曲线
* will-change: transform, opacity;
* }
* .toast-item.show {
* opacity: 1;
* transform: translateY(0) scale(1);
* }
*/
export const useToast = () => {
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
// 高级感动画:成功的生长对勾
const getSuccessIcon = () => `
<svg class="w-5 h-5 flex-shrink-0 text-[green]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation: toast-icon-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;">
<style>
@keyframes toast-draw { to { stroke-dashoffset: 0; } }
@keyframes toast-icon-pop { 0% { transform: scale(0.6); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
.path-circle { stroke-dasharray: 63; stroke-dashoffset: 63; animation: toast-draw 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.path-check { stroke-dasharray: 15; stroke-dashoffset: 15; animation: toast-draw 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards 0.25s; }
</style>
<circle cx="12" cy="12" r="10" stroke-opacity="0.15"></circle>
<path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2z" class="path-circle" />
<path d="M8 12.5l3 3 5-6" class="path-check" />
</svg>
`
// 高级感动画:失败的错落叉号 (新增抖动效果)
const getErrorIcon = () => `
<svg class="w-5 h-5 flex-shrink-0 text-red-400 drop-shadow-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation: toast-icon-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards, toast-shake 0.4s ease-in-out 0.4s;">
<style>
@keyframes toast-draw { to { stroke-dashoffset: 0; } }
/* 复用弹跳动画,新增左右警示抖动动画 */
@keyframes toast-icon-pop { 0% { transform: scale(0.6); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
@keyframes toast-shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-2.5px); } 50% { transform: translateX(2.5px); } 75% { transform: translateX(-2.5px); } }
.path-circle { stroke-dasharray: 63; stroke-dashoffset: 63; animation: toast-draw 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.path-cross-1 { stroke-dasharray: 10; stroke-dashoffset: 10; animation: toast-draw 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards 0.2s; }
.path-cross-2 { stroke-dasharray: 10; stroke-dashoffset: 10; animation: toast-draw 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards 0.3s; }
</style>
<circle cx="12" cy="12" r="10" stroke-opacity="0.15"></circle>
<path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2z" class="path-circle" />
<path d="M9 9l6 6" class="path-cross-1" />
<path d="M15 9l-6 6" class="path-cross-2" />
</svg>
`
// 高级感动画:信息的优雅提示 (天蓝色丝滑延展)
const getInfoIcon = () => `
<svg class="w-5 h-5 flex-shrink-0 text-sky-400 drop-shadow-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation: toast-icon-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;">
<style>
@keyframes toast-draw { to { stroke-dashoffset: 0; } }
@keyframes toast-icon-pop { 0% { transform: scale(0.6); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
.path-circle { stroke-dasharray: 63; stroke-dashoffset: 63; animation: toast-draw 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.path-info-1 { stroke-dasharray: 2; stroke-dashoffset: 2; animation: toast-draw 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards 0.2s; }
.path-info-2 { stroke-dasharray: 8; stroke-dashoffset: 8; animation: toast-draw 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards 0.3s; }
</style>
<circle cx="12" cy="12" r="10" stroke-opacity="0.15"></circle>
<path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2z" class="path-circle" />
<path d="M12 8v.01" class="path-info-1" />
<path d="M12 12v4" class="path-info-2" />
</svg>
`
// 高级感动画:警告的重点提示 (琥珀色三角 + 抖动)
const getWarningIcon = () => `
<svg class="w-5 h-5 flex-shrink-0 text-amber-400 drop-shadow-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation: toast-icon-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards, toast-shake 0.4s ease-in-out 0.4s;">
<style>
@keyframes toast-draw { to { stroke-dashoffset: 0; } }
@keyframes toast-icon-pop { 0% { transform: scale(0.6); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
@keyframes toast-shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-2.5px); } 50% { transform: translateX(2.5px); } 75% { transform: translateX(-2.5px); } }
.path-triangle { stroke-dasharray: 72; stroke-dashoffset: 72; animation: toast-draw 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.path-warn-1 { stroke-dasharray: 6; stroke-dashoffset: 6; animation: toast-draw 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards 0.25s; }
.path-warn-2 { stroke-dasharray: 2; stroke-dashoffset: 2; animation: toast-draw 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards 0.45s; }
</style>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke-opacity="0.15" />
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" class="path-triangle" />
<path d="M12 9v4" class="path-warn-1" />
<path d="M12 17v.01" class="path-warn-2" />
</svg>
`
type ToastType = 'success' | 'error' | 'info' | 'warning'
const showToast = (message: string, type: ToastType = 'success') => {
const container = document.getElementById('toast-container')
if (!container) return
const toast = document.createElement('div')
toast.className = `toast ${type === 'success' ? 'toast-success' : 'toast-error'}`
// 质感核心:毛玻璃 (backdrop-blur) + 微调阴影 (shadow-2xl) + 细腻高光边框 (border-white/10)
const baseClasses = 'toast-item flex items-center gap-3.5 px-4 py-3.5 rounded-2xl border backdrop-blur-xl shadow-2xl transition-all'
// 状态配置表 (Map映射),消除啰嗦的 if-else
const typeConfig: Record<ToastType, { icon: string; classes: string }> = {
success: { icon: getSuccessIcon(), classes: 'bg-zinc-900/85 border-white/10 shadow-black/40 text-zinc-100' },
error: { icon: getErrorIcon(), classes: 'bg-red-950/85 border-red-500/20 shadow-red-900/20 text-red-50' },
info: { icon: getInfoIcon(), classes: 'bg-sky-950/85 border-sky-500/20 shadow-sky-900/20 text-sky-50' },
warning: { icon: getWarningIcon(), classes: 'bg-amber-950/85 border-amber-500/20 shadow-amber-900/20 text-amber-50' }
}
const currentConfig = typeConfig[type] || typeConfig.success
toast.className = `${baseClasses} ${currentConfig.classes}`
// 注入我们设计的原生 SVG 动画
toast.innerHTML = `
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}" class="w-5 h-5 ${type === 'success' ? 'text-[#d4b383]' : 'text-red-500'}"></i>
<span class="text-sm font-medium">${message}</span>
${currentConfig.icon}
<span class="text-sm font-medium tracking-wide leading-none pt-[1px]">${message}</span>
`
container.appendChild(toast)
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 显示动画
requestAnimationFrame(() => toast.classList.add('show'))
requestAnimationFrame(() => {
requestAnimationFrame(() => toast.classList.add('show'))
})
// 3秒后自动隐藏
setTimeout(() => {
@@ -26,7 +132,7 @@ export const useToast = () => {
}, 3000)
}
// 便捷方法
// 便捷方法保持不变
const success = (message: string) => {
showToast(message, 'success')
}
@@ -34,10 +140,18 @@ export const useToast = () => {
const error = (message: string) => {
showToast(message, 'error')
}
const info = (message: string) => {
showToast(message, 'info')
}
const warning = (message: string) => {
showToast(message, 'warning')
}
return {
showToast,
success,
error
error,
info,
warning
}
}

View File

@@ -1,10 +1,21 @@
<template>
<section id="blog-detail" class="page-section block">
<div class="max-w-[90rem] mx-auto pt-20 md:pt-32 px-4 md:px-6 pb-12 md:pb-20 relative">
<!-- 返回按钮 -->
<!-- 小屏fixed 返回 -->
<button
@click="$router.push('/blog')"
class="group flex items-center gap-2 text-art-muted hover:text-white transition-colors mb-6 md:mb-12"
class="xl:hidden fixed top-20 md:top-24 left-4 md:left-6 z-40 group flex items-center gap-2 rounded-full border border-white/10 bg-[#050505]/80 backdrop-blur-md px-3 py-2 text-art-muted hover:text-white transition-colors"
>
<Icon name="arrow-left" :size="16" class="transform group-hover:-translate-x-1 transition-transform" />
<span class="text-sm font-medium tracking-wide">返回列表</span>
</button>
<div class="h-10 mb-6 md:mb-12 xl:hidden" aria-hidden="true"></div>
<!-- 大屏 loading/error左侧栏未渲染时的返回 -->
<button
v-if="loading || error"
@click="$router.push('/blog')"
class="hidden xl:flex fixed top-32 left-[max(1.5rem,calc((100vw-90rem)/2+1.5rem))] z-40 group items-center gap-2 rounded-full border border-white/10 bg-[#050505]/80 backdrop-blur-md px-3 py-2 text-art-muted hover:text-white transition-colors"
>
<Icon name="arrow-left" :size="16" class="transform group-hover:-translate-x-1 transition-transform" />
<span class="text-sm font-medium tracking-wide">返回列表</span>
@@ -31,6 +42,13 @@
<!-- 1. 左侧边栏元信息 - 仅在大屏显示 -->
<aside class="hidden xl:block w-64 flex-shrink-0 order-1 sticky top-32 max-h-[calc(100vh-8rem)] overflow-y-auto hide-scrollbar">
<button
@click="$router.push('/blog')"
class="group flex items-center gap-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-md px-3 py-2 mb-6 text-art-muted hover:text-white hover:bg-white/10 transition-colors w-fit"
>
<Icon name="arrow-left" :size="16" class="transform group-hover:-translate-x-1 transition-transform" />
<span class="text-sm font-medium tracking-wide">返回列表</span>
</button>
<div class="space-y-6">
<!-- 专栏跳转卡片 -->
<div v-if="post.columnId && post.columnName" class="p-5 rounded-xl bg-white/5 border border-white/10 backdrop-blur-md shadow-lg transition-all duration-300 hover:bg-white/10">
@@ -378,16 +396,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, onBeforeUnmount } from 'vue'
import { ref, onMounted, computed, nextTick, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchPost, getRecommendedPosts, fetchColumnPosts, fetchSnippet, Post, Snippet, getPublicSettings } from '../services/api'
import { getCachedSiteTitle } from '../router'
import { fetchPost, getRecommendedPosts, fetchColumnPostTitles, fetchSnippet, Post, Snippet } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
import { renderMarkdown, preloadMarkdownRenderer } from '../utils/markdownRenderer'
import Icon from '../components/Icon.vue'
import AuthorInfo from '../components/AuthorInfo.vue'
import SnippetModal from '../components/SnippetModal.vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/atom-one-dark.css'
const route = useRoute()
@@ -431,8 +448,8 @@ const error = ref('')
const recommendedPosts = ref<Post[]>([])
const loadingRecommendations = ref(false)
// 专栏文章目录相关状态
const columnPosts = ref<Post[]>([])
// 专栏文章目录相关状态(仅需 id / title
const columnPosts = ref<Pick<Post, 'id' | 'title'>[]>([])
const isColumnExpanded = ref(false)
const loadingColumnPosts = ref(false)
@@ -455,84 +472,50 @@ const closeSnippetModal = () => {
const { initObserver } = useScrollAnimation()
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
const renderedContent = ref('')
let renderGeneration = 0
md.renderer.rules.heading_open = function (tokens, idx) {
const token = tokens[idx]
const contentToken = tokens[idx + 1]
const title = contentToken ? contentToken.content : ''
const slug = 'heading-' + title.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-') + '-' + idx
return `<${token.tag} id="${slug}">`
const scheduleArticleEnhancements = () => {
nextTick(() => {
initObserver()
addCopyListeners()
generateToc()
initScrollSpy()
})
}
// 核心修复部分:自定义 fence 渲染规则
md.renderer.rules.fence = function (tokens, idx) {
const token = tokens[idx]
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
let langName = ''
let highlighted
const renderArticleContent = (content: string) => {
const generation = ++renderGeneration
renderedContent.value = ''
if (info) {
langName = info.split(/\s+/g)[0]
}
if (langName && hljs.getLanguage(langName)) {
const run = async () => {
try {
highlighted = hljs.highlight(token.content, { language: langName, ignoreIllegals: true }).value
} catch (__) {
highlighted = md.utils.escapeHtml(token.content)
const html = await renderMarkdown(content)
if (generation !== renderGeneration) return
renderedContent.value = html
scheduleArticleEnhancements()
} catch (err) {
console.error('Failed to render markdown:', err)
}
} else {
highlighted = md.utils.escapeHtml(token.content)
}
const lines = token.content.trimEnd().split('\n')
const lineNumbers = lines.map((_, i) => i + 1).join('\n')
// 修复1: 显式定义的 SVG Icon颜色更亮 (text-white/70),去除复杂 opacity 类
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: block;"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`
return `
<div class="ios-code-container w-full max-w-full my-6 md:my-10 rounded-lg md:rounded-xl overflow-hidden shadow-2xl bg-[#1e1e1e] border border-white/5 relative group/code text-sm">
<div class="mac-window-header flex items-center justify-between px-3 md:px-4 py-2 md:py-3 bg-[#282828] border-b border-white/5">
<div class="flex items-center gap-3 md:gap-4">
<div class="flex gap-1.5 md:gap-2">
<div class="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-[#ff5f56]"></div>
<div class="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-[#ffbd2e]"></div>
<div class="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-[#27c93f]"></div>
</div>
<div class="text-[10px] md:text-xs text-white/30 font-mono select-none uppercase tracking-wider">${langName || 'TEXT'}</div>
</div>
<!-- 修复2: 复制按钮始终可见,增加点击热区,颜色加深 -->
<button class="copy-btn flex items-center gap-1.5 px-2 py-1 rounded hover:bg-white/10 transition-all cursor-pointer opacity-100" aria-label="Copy code">
<span class="text-white/70">${copyIconSvg}</span>
<span class="text-[10px] md:text-xs text-white/70 font-medium">复制</span>
</button>
</div>
<div class="code-content-wrapper flex min-w-0 max-w-full">
<div class="line-numbers-wrapper hidden md:block select-none text-right border-r border-white/5 bg-[#1e1e1e] py-3 md:py-4 px-2 md:px-3 min-w-[2.5rem] md:min-w-[3rem] text-white/20">
<pre class="font-mono text-xs md:text-sm leading-relaxed" style="margin:0;">${lineNumbers}</pre>
</div>
<!-- 修复3: 强制添加 style="white-space: pre; overflow-x: auto" 确保滑动 -->
<div class="code-block-wrapper flex-1 py-3 md:py-4 px-3 md:px-4 bg-[#1e1e1e] min-w-0 w-full" style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<pre class="hljs !bg-transparent !m-0 !p-0 !rounded-none !overflow-visible"><code class="!font-mono text-xs md:text-sm leading-relaxed block" style="white-space: pre;">${highlighted}</code></pre>
</div>
</div>
</div>
`
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => { void run() }, { timeout: 200 })
} else {
setTimeout(() => { void run() }, 0)
}
}
const renderedContent = computed(() => {
if (!post.value.content) return ''
return md.render(post.value.content)
})
watch(
() => post.value.content,
(content) => {
if (!content) {
renderedContent.value = ''
return
}
renderArticleContent(content)
}
)
const generateToc = () => {
if (!articleRef.value) return
@@ -679,15 +662,9 @@ const addCopyListeners = () => {
})
}
const updatePageTitle = async (articleTitle: string) => {
try {
const settings = await getPublicSettings()
const siteTitle = settings.site_title || '年糕崽崽.Dev'
document.title = `${siteTitle} | ${articleTitle}`
} catch (error) {
console.error('Failed to update page title:', error)
document.title = `年糕崽崽.Dev | ${articleTitle}`
}
const updatePageTitle = (articleTitle: string) => {
const siteTitle = getCachedSiteTitle()
document.title = `${siteTitle} | ${articleTitle}`
}
const fetchBlogDetail = async () => {
@@ -697,22 +674,14 @@ const fetchBlogDetail = async () => {
const postData = await fetchPost(postId)
if (postData) {
post.value = postData
loading.value = false
if (postData.title) {
await updatePageTitle(postData.title)
updatePageTitle(postData.title)
}
fetchRecommendations()
// 如果有专栏,加载专栏文章列表
if (postData.columnId) {
await fetchColumnPostsList(postData.columnId)
fetchColumnPostsList(postData.columnId)
}
nextTick(() => {
setTimeout(() => {
initObserver()
addCopyListeners()
generateToc()
initScrollSpy()
}, 100)
})
} else {
error.value = '未找到该文章'
}
@@ -747,8 +716,7 @@ const fetchColumnPostsList = async (columnId?: number) => {
loadingColumnPosts.value = true
try {
// 获取专栏的所有文章无limit限制
const posts = await fetchColumnPosts(id)
const posts = await fetchColumnPostTitles(id)
columnPosts.value = posts || []
} catch (err) {
console.error('Error fetching column posts:', err)
@@ -759,6 +727,7 @@ const fetchColumnPostsList = async (columnId?: number) => {
}
onMounted(() => {
preloadMarkdownRenderer()
fetchBlogDetail()
})

View File

@@ -97,23 +97,31 @@
<!-- Danmaku items will be rendered here -->
<div class="danmaku-row animate-marquee hover:[animation-play-state:paused]">
<div v-for="testimonial in testimonials" :key="testimonial.id" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-blue-500/20"></div>
<div class="w-6 h-6 rounded-full bg-blue-500/20">
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
</div>
<span>"{{ testimonial.content }}"</span>
</div>
<!-- 复制一份数据以实现无缝滚动 -->
<div v-for="testimonial in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-blue-500/20"></div>
<div class="w-6 h-6 rounded-full bg-blue-500/20">
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
</div>
<span>"{{ testimonial.content }}"</span>
</div>
</div>
<div class="danmaku-row animate-marquee-reverse hover:[animation-play-state:paused]">
<div v-for="testimonial in testimonials" :key="testimonial.id" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-purple-500/20"></div>
<div class="w-6 h-6 rounded-full bg-blue-500/20">
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
</div>
<span>"{{ testimonial.content }}"</span>
</div>
<!-- 复制一份数据以实现无缝滚动 -->
<div v-for="testimonial in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-purple-500/20"></div>
<div class="w-6 h-6 rounded-full bg-blue-500/20">
<image :src="testimonial.avatar ||'https://via.placeholder.com/50'" class="w-6 h-6" alt="Avatar" />
</div>
<span>"{{ testimonial.content }}"</span>
</div>
</div>

View File

@@ -4,30 +4,43 @@
<div class="toolbar">
<div class="filter-section">
<div class="filter-group">
<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 class="filter-main">
<button
type="button"
class="filter-toggle btn btn-sm btn-secondary"
@click="filterExpanded = !filterExpanded"
>
{{ filterExpanded ? '收起筛选 ▲' : '展开筛选 ▼' }}
</button>
<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>
<Transition name="filter-panel">
<div v-if="filterExpanded" class="filter-panel">
<div class="filter-group">
<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>
</Transition>
</div>
</div>
@@ -103,6 +116,7 @@ import CustomSelect from '../../components/CustomSelect.vue'
import { getDefaultMonthRange, toApiDateTime } from '../../utils/dateRange'
const toast = useToast()
const filterExpanded = ref(false)
const defaultRange = getDefaultMonthRange()
const logs = ref<PaginationResponse<AccessLog>>({
list: [],
@@ -205,19 +219,59 @@ onMounted(() => {
}
.filter-section {
display: flex;
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-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
}
.filter-toggle {
flex-shrink: 0;
white-space: nowrap;
}
.filter-panel {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
width: 100%;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.filter-panel-enter-active,
.filter-panel-leave-active {
transition: opacity 0.2s ease, max-height 0.25s ease;
overflow: hidden;
}
.filter-panel-enter-from,
.filter-panel-leave-to {
opacity: 0;
max-height: 0;
}
.filter-panel-enter-to,
.filter-panel-leave-from {
opacity: 1;
max-height: 500px;
}
.filter-actions {
display: flex;
gap: 0.5rem;
flex: 0 0 auto;
flex-shrink: 0;
margin-left: auto;
}
.filter-input {
@@ -244,6 +298,8 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 0 0 auto;
flex-shrink: 0;
}
.filter-group label {

View File

@@ -4,42 +4,55 @@
<div class="toolbar">
<div class="filter-section">
<div class="filter-group">
<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"
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 class="filter-main">
<button
type="button"
class="filter-toggle btn btn-sm btn-secondary"
@click="filterExpanded = !filterExpanded"
>
{{ filterExpanded ? '收起筛选 ▲' : '展开筛选 ▼' }}
</button>
<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>
<Transition name="filter-panel">
<div v-if="filterExpanded" class="filter-panel">
<div class="filter-group">
<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"
style="width: 80px;"
/>
</div>
</div>
</Transition>
</div>
</div>
@@ -120,6 +133,7 @@ import UserPickerPopover from '../../components/admin/UserPickerPopover.vue'
import { getDefaultMonthRange, toApiDateTime } from '../../utils/dateRange'
const toast = useToast()
const filterExpanded = ref(false)
const defaultRange = getDefaultMonthRange()
const logs = ref<PaginationResponse<OperationLog>>({
list: [],
@@ -248,19 +262,59 @@ onMounted(() => {
}
.filter-section {
display: flex;
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-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
}
.filter-toggle {
flex-shrink: 0;
white-space: nowrap;
}
.filter-panel {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
width: 100%;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.filter-panel-enter-active,
.filter-panel-leave-active {
transition: opacity 0.2s ease, max-height 0.25s ease;
overflow: hidden;
}
.filter-panel-enter-from,
.filter-panel-leave-to {
opacity: 0;
max-height: 0;
}
.filter-panel-enter-to,
.filter-panel-leave-from {
opacity: 1;
max-height: 500px;
}
.filter-actions {
display: flex;
gap: 0.5rem;
flex: 0 0 auto;
flex-shrink: 0;
margin-left: auto;
}
.filter-input {
@@ -287,6 +341,8 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 0 0 auto;
flex-shrink: 0;
}
.filter-group label {

View File

@@ -15,7 +15,7 @@
@search="handleSearch"
@reset="resetFilters"
>
<template #filters>
<template #primaryFilters>
<div class="filter-field">
<label>分类</label>
<CustomSelect v-model="filterCategoryId" :options="categoryOptions" style="width: 130px;" />
@@ -24,6 +24,8 @@
<label>专栏</label>
<CustomSelect v-model="filterColumnId" :options="columnOptions" style="width: 130px;" />
</div>
</template>
<template #filters>
<div class="filter-field">
<label>状态</label>
<CustomSelect v-model="filterPublished" :options="publishedOptions" style="width: 110px;" />
@@ -45,49 +47,73 @@
<thead>
<tr>
<th class="w-20">ID</th>
<th>标题</th>
<th>分类</th>
<th>专栏</th>
<th>文章</th>
<th>标签</th>
<th>作者</th>
<th>发布日期</th>
<th>状态</th>
<th>访问记录</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id" class="group transition-colors duration-200">
<td class="font-mono text-xs text-white/40">#{{ post.id }}</td>
<td class="font-medium text-white group-hover:text-art-accent transition-colors">{{ post.title }}</td>
<td>
<button
type="button"
class="px-2 py-1 rounded bg-white/5 border border-white/5 text-xs text-art-muted cursor-pointer hover:border-art-accent/40 hover:text-white transition-colors"
@click="openRelationModal(post, 'category')"
>
{{ post.categoryName || post.category?.name || '未分类' }}
</button>
<td class="font-mono text-xs text-white/40 align-top pt-4">#{{ post.id }}</td>
<td class="align-top py-3">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-white group-hover:text-art-accent transition-colors">{{ post.title }}</span>
<button
type="button"
@click="handleToggleStatus(post)"
:class="['px-2 py-0.5 rounded-full text-xs font-medium border transition-all hover:opacity-80 shrink-0', post.isPublished === 1 ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20']"
title="点击切换状态"
>
{{ post.isPublished === 1 ? '已发布' : '草稿' }}
</button>
<button
type="button"
@click="openAccessLogModal(post)"
class="admin-btn-secondary py-0.5 px-2 text-xs shrink-0"
title="查看访问记录"
>
查看
</button>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-art-muted mt-1.5">
<button
type="button"
class="hover:text-white transition-colors"
@click="openRelationModal(post, 'category')"
>
{{ post.categoryName || post.category?.name || '未分类' }}
</button>
<span class="text-white/20">·</span>
<button
v-if="post.columnName"
type="button"
class="text-art-accent/80 hover:text-art-accent transition-colors"
@click="openRelationModal(post, 'column')"
>
{{ post.columnName }}
</button>
<button
v-else
type="button"
class="text-art-muted/60 hover:text-art-muted transition-colors"
@click="openRelationModal(post, 'column')"
>
无专栏
</button>
<span class="text-white/20">·</span>
<AuthorInfo
:name="post.userName"
:email="post.userEmail"
:avatar="post.userAvatar"
:user-id="post.userId"
variant="compact"
size="sm"
/>
<span class="text-white/20">·</span>
<span>{{ post.date }}</span>
</div>
</td>
<td>
<button
v-if="post.columnName"
type="button"
class="px-2 py-1 rounded bg-art-accent/10 border border-art-accent/20 text-xs text-art-accent cursor-pointer hover:border-art-accent/40 transition-colors"
@click="openRelationModal(post, 'column')"
>
{{ post.columnName }}
</button>
<button
v-else
type="button"
class="px-2 py-0.5 rounded border border-dashed border-white/20 text-xs text-art-muted/60 cursor-pointer hover:border-art-accent/40 hover:text-art-muted transition-colors"
@click="openRelationModal(post, 'column')"
>
无专栏
</button>
</td>
<td>
<td class="align-top py-3">
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-1">
<button
v-for="tag in post.tags.slice(0, 3)"
@@ -109,36 +135,7 @@
无标签
</button>
</td>
<td>
<AuthorInfo
:name="post.userName"
:email="post.userEmail"
:avatar="post.userAvatar"
:user-id="post.userId"
variant="compact"
size="sm"
/>
</td>
<td class="text-art-muted text-xs">{{ post.date }}</td>
<td>
<button
@click="handleToggleStatus(post)"
:class="['px-2 py-1 rounded-full text-xs font-medium border transition-all hover:opacity-80', post.isPublished === 1 ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20']"
title="点击切换状态"
>
{{ post.isPublished === 1 ? '已发布' : '草稿' }}
</button>
</td>
<td>
<button
@click="openAccessLogModal(post)"
class="admin-btn-secondary py-1 px-3 text-xs"
title="查看访问记录"
>
查看
</button>
</td>
<td class="text-right">
<td class="text-right align-top py-3">
<div class="flex items-center justify-end gap-2 flex-wrap">
<button @click="openRelationModal(post)" class="admin-btn-secondary py-1 px-3 text-xs">
管理
@@ -383,10 +380,10 @@ const handleToggleStatus = async (post: Post) => {
try {
await togglePostStatus(post.id, newStatus)
post.isPublished = newStatus
toast.showToast(newStatus === 1 ? '文章已发布' : '文章已转为草稿', 'success')
toast.success(newStatus === 1 ? '文章已发布' : '文章已转为草稿')
} catch (error) {
console.error('Error toggling status:', error)
toast.showToast('更新状态失败', 'error')
toast.error('更新状态失败')
}
}

View File

@@ -26,6 +26,11 @@ const loadSiteTitle = async (): Promise<string> => {
return cachedSiteTitle || '年糕崽崽.Dev'
}
/** 供详情页等组件复用,避免重复请求 /settings */
export const getCachedSiteTitle = (): string => {
return cachedSiteTitle || document.title.split(' | ')[0] || '年糕崽崽.Dev'
}
// 设置页面标题
const setPageTitle = async (to: any) => {
const siteTitle = await loadSiteTitle()

View File

@@ -953,6 +953,21 @@ export const fetchColumnPosts = async (id: number): Promise<Post[]> => {
}
}
export interface PostTitleBrief {
id: number
title: string
}
export const fetchColumnPostTitles = async (id: number): Promise<PostTitleBrief[]> => {
try {
const response = await fetch(`${API_BASE}/columns/${id}/post-titles`)
return await parseApiResponse(response) || []
} catch (error) {
console.error('Fetch column post titles error:', error)
return []
}
}
export const addPostToColumn = async (columnId: number, postId: number): Promise<void> => {
try {
const response = await authFetch(`${API_BASE}/admin/columns/${columnId}/posts`, {

View File

@@ -1,46 +1,102 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import type { HLJSApi, LanguageFn } from 'highlight.js'
import hljsCore from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import python from 'highlight.js/lib/languages/python'
import go from 'highlight.js/lib/languages/go'
import java from 'highlight.js/lib/languages/java'
import css from 'highlight.js/lib/languages/css'
import xml from 'highlight.js/lib/languages/xml'
import json from 'highlight.js/lib/languages/json'
import bash from 'highlight.js/lib/languages/bash'
import sql from 'highlight.js/lib/languages/sql'
import yaml from 'highlight.js/lib/languages/yaml'
import markdown from 'highlight.js/lib/languages/markdown'
import rust from 'highlight.js/lib/languages/rust'
import cpp from 'highlight.js/lib/languages/cpp'
import c from 'highlight.js/lib/languages/c'
import php from 'highlight.js/lib/languages/php'
import shell from 'highlight.js/lib/languages/shell'
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
const LANGUAGE_REGISTRY: Array<[string, LanguageFn]> = [
['javascript', javascript],
['typescript', typescript],
['python', python],
['go', go],
['java', java],
['css', css],
['xml', xml],
['html', xml],
['json', json],
['bash', bash],
['sh', bash],
['sql', sql],
['yaml', yaml],
['yml', yaml],
['markdown', markdown],
['md', markdown],
['rust', rust],
['cpp', cpp],
['c', c],
['php', php],
['shell', shell],
]
md.renderer.rules.heading_open = function (tokens, idx) {
const token = tokens[idx]
const contentToken = tokens[idx + 1]
const title = contentToken ? contentToken.content : ''
const slug = 'heading-' + title.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-') + '-' + idx
return `<${token.tag} id="${slug}">`
let hljsInstance: HLJSApi | null = null
let mdInstance: MarkdownIt | null = null
function getHljs(): HLJSApi {
if (!hljsInstance) {
hljsInstance = hljsCore
for (const [name, register] of LANGUAGE_REGISTRY) {
hljsInstance.registerLanguage(name, register)
}
}
return hljsInstance
}
md.renderer.rules.fence = function (tokens, idx) {
const token = tokens[idx]
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
let langName = ''
let highlighted
function buildMarkdownRenderer(hljs: HLJSApi): MarkdownIt {
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
if (info) {
langName = info.split(/\s+/g)[0]
md.renderer.rules.heading_open = function (tokens, idx) {
const token = tokens[idx]
const contentToken = tokens[idx + 1]
const title = contentToken ? contentToken.content : ''
const slug = 'heading-' + title.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-') + '-' + idx
return `<${token.tag} id="${slug}">`
}
if (langName && hljs.getLanguage(langName)) {
try {
highlighted = hljs.highlight(token.content, { language: langName, ignoreIllegals: true }).value
} catch {
md.renderer.rules.fence = function (tokens, idx) {
const token = tokens[idx]
const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''
let langName = ''
let highlighted
if (info) {
langName = info.split(/\s+/g)[0]
}
if (langName && hljs.getLanguage(langName)) {
try {
highlighted = hljs.highlight(token.content, { language: langName, ignoreIllegals: true }).value
} catch {
highlighted = md.utils.escapeHtml(token.content)
}
} else {
highlighted = md.utils.escapeHtml(token.content)
}
} else {
highlighted = md.utils.escapeHtml(token.content)
}
const lines = token.content.trimEnd().split('\n')
const lineNumbers = lines.map((_, i) => i + 1).join('\n')
const lines = token.content.trimEnd().split('\n')
const lineNumbers = lines.map((_, i) => i + 1).join('\n')
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: block;"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: block;"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`
return `
return `
<div class="ios-code-container w-full max-w-full my-6 md:my-10 rounded-lg md:rounded-xl overflow-hidden shadow-2xl bg-[#1e1e1e] border border-white/5 relative group/code text-sm">
<div class="mac-window-header flex items-center justify-between px-3 md:px-4 py-2 md:py-3 bg-[#282828] border-b border-white/5">
<div class="flex items-center gap-3 md:gap-4">
@@ -66,9 +122,25 @@ md.renderer.rules.fence = function (tokens, idx) {
</div>
</div>
`
}
return md
}
export function renderMarkdown(content: string): string {
function getMarkdownRenderer(): MarkdownIt {
if (!mdInstance) {
mdInstance = buildMarkdownRenderer(getHljs())
}
return mdInstance
}
export async function renderMarkdown(content: string): Promise<string> {
if (!content) return ''
const md = getMarkdownRenderer()
return md.render(content)
}
/** 预加载 highlight.js可在路由进入详情页时调用 */
export function preloadMarkdownRenderer(): void {
getHljs()
}

View File

@@ -62,6 +62,24 @@ func GetColumnPosts(c *gin.Context) {
utils.Success(c, repositories.BuildPostsResponse(posts))
}
// GetColumnPostTitles 获取专栏文章目录(仅 id、title供详情页侧栏
func GetColumnPostTitles(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.Error(c, 400, "Invalid column ID")
return
}
briefs, err := repositories.GetPostBriefsByColumnID(uint(id))
if err != nil {
utils.ServerError(c, err)
return
}
utils.Success(c, briefs)
}
// AdminCreateColumn 创建专栏
func AdminCreateColumn(c *gin.Context) {
var col models.Column

View File

@@ -169,58 +169,59 @@ func GetPost(c *gin.Context) {
userID = uid.(uint)
}
alreadyVisited, err := repositories.HasVisitedThisHour(post.ID, userID, visitorKey)
if err != nil {
log.Printf("Failed to check visit dedup for post %d: %v", post.ID, err)
}
if !alreadyVisited {
if err := repositories.IncrementReadCount(post.ID); err != nil {
log.Printf("Failed to increment read count for post %d: %v", post.ID, err)
} else {
response.ReadCount++
}
// 记录用户访问日志 (异步执行,不阻塞响应)
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic in user access log goroutine for post %d: %v", post.ID, r)
}
}()
ip := c.ClientIP()
var location string
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic in GetRegion for IP %s (post %d): %v", ip, post.ID, r)
location = "Unknown"
}
}()
location = utils.GetRegion(ip)
}()
if location == "" {
location = "Unknown"
}
logEntry := &models.UserAccessLog{
UserID: userID,
UserIP: ip,
UserLocation: location,
ArticleID: post.ID,
VisitorKey: visitorKey,
}
if err := repositories.CreateUserAccessLog(logEntry); err != nil {
log.Printf("Failed to create user access log for PostID=%d, IP=%s: %v", post.ID, ip, err)
}
}()
}
postID := post.ID
clientIP := c.ClientIP()
utils.Success(c, response)
// 访问量统计与访问日志异步执行,不阻塞文章响应
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic in visit stats goroutine for post %d: %v", postID, r)
}
}()
alreadyVisited, err := repositories.HasVisitedThisHour(postID, userID, visitorKey)
if err != nil {
log.Printf("Failed to check visit dedup for post %d: %v", postID, err)
return
}
if alreadyVisited {
return
}
if err := repositories.IncrementReadCount(postID); err != nil {
log.Printf("Failed to increment read count for post %d: %v", postID, err)
}
var location string
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic in GetRegion for IP %s (post %d): %v", clientIP, postID, r)
location = "Unknown"
}
}()
location = utils.GetRegion(clientIP)
}()
if location == "" {
location = "Unknown"
}
logEntry := &models.UserAccessLog{
UserID: userID,
UserIP: clientIP,
UserLocation: location,
ArticleID: postID,
VisitorKey: visitorKey,
}
if err := repositories.CreateUserAccessLog(logEntry); err != nil {
log.Printf("Failed to create user access log for PostID=%d, IP=%s: %v", postID, clientIP, err)
}
}()
}
// 获取所有文章(包括未发布的,后台用)

View File

@@ -72,6 +72,7 @@ func main() {
api.GET("/columns", handlers.GetColumns)
api.GET("/columns/:id", handlers.GetColumnByID)
api.GET("/columns/:id/posts", handlers.GetColumnPosts)
api.GET("/columns/:id/post-titles", handlers.GetColumnPostTitles)
// 代码片段路由
api.GET("/snippets", handlers.GetSnippets)

View File

@@ -21,6 +21,7 @@ type Post struct {
ReadCount uint `json:"readCount" gorm:"column:read_count;default:0"`
IsPublished int `json:"isPublished" gorm:"column:is_published;default:0"` // 0: draft, 1: published
UserID uint `json:"userId" gorm:"column:user_id;default:0"`
Author *User `json:"-" gorm:"foreignKey:UserID;references:ID"`
Tags []Tag `json:"tags" gorm:"many2many:post_tags;joinForeignKey:post_id;joinReferences:tag_id"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`

View File

@@ -87,3 +87,37 @@ func GetCodeTypeForSnippet(snippet *models.Snippet) *models.CodeType {
}
return ct
}
// GetCodeTypesByIDs 批量获取代码类型
func GetCodeTypesByIDs(ids []uint) map[uint]*models.CodeType {
result := make(map[uint]*models.CodeType)
if len(ids) == 0 {
return result
}
unique := make([]uint, 0, len(ids))
seen := make(map[uint]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return result
}
var types []models.CodeType
if err := config.DB.Model(&models.CodeType{}).
Where("id IN ? AND deleted_at = ?", unique, 0).
Find(&types).Error; err != nil {
log.Printf("Error batch loading code types: %v", err)
return result
}
for i := range types {
result[types[i].ID] = &types[i]
}
return result
}

View File

@@ -146,10 +146,12 @@ func DeleteColumn(id uint) error {
return nil
}
// GetPostsByColumnID 获取专栏下的文章
// GetPostsByColumnID 获取专栏下的文章(不含正文 content
func GetPostsByColumnID(columnID uint) ([]models.Post, error) {
var posts []models.Post
err := config.DB.Model(&models.Post{}).
Select("posts.id", "posts.title", "posts.category_id", "posts.column_id", "posts.excerpt",
"posts.read_count", "posts.is_published", "posts.user_id", "posts.created_at", "posts.updated_at").
Preload("Category").
Preload("Tags").
Joins("JOIN column_posts cp ON posts.id = cp.post_id").
@@ -163,6 +165,25 @@ func GetPostsByColumnID(columnID uint) ([]models.Post, error) {
return posts, nil
}
// GetPostBriefsByColumnID 获取专栏文章目录(仅 id、title
func GetPostBriefsByColumnID(columnID uint) ([]models.PostBrief, error) {
var briefs []models.PostBrief
err := config.DB.Table("posts").
Select("posts.id, posts.title").
Joins("JOIN column_posts cp ON posts.id = cp.post_id").
Where("cp.column_id = ? AND posts.deleted_at = ? AND posts.is_published = ?", columnID, 0, 1).
Order("cp.sort_order ASC, posts.created_at DESC").
Scan(&briefs).Error
if err != nil {
log.Printf("Error querying post briefs by column ID: %v", err)
return nil, err
}
if briefs == nil {
briefs = []models.PostBrief{}
}
return briefs, nil
}
// AddPostToColumn 添加文章到专栏
func AddPostToColumn(columnID, postID, sortOrder uint) error {
columnPost := &models.ColumnPost{

View File

@@ -85,6 +85,9 @@ func GetPostByID(id uint) (*models.Post, error) {
Preload("Category").
Preload("Column").
Preload("Tags").
Preload("Author", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "username", "avatar", "email")
}).
Where("id = ? AND is_published = ? AND deleted_at = ?", id, 1, 0).
First(&post).Error
@@ -374,8 +377,73 @@ func GetPostCount() (int, error) {
return int(count), nil
}
type userBrief struct {
Username string
Avatar string
Email string
}
func resolveUserBrief(post *models.Post, userMap map[uint]userBrief) (username, avatar, email string) {
if post.Author != nil && post.Author.ID > 0 {
return post.Author.Username, post.Author.Avatar, post.Author.Email
}
if userMap != nil {
if u, ok := userMap[post.UserID]; ok {
return u.Username, u.Avatar, u.Email
}
}
if post.UserID > 0 {
return lookupUserBrief(post.UserID)
}
return "", "", ""
}
func collectPostUserIDs(posts []models.Post) []uint {
ids := make([]uint, 0, len(posts))
seen := make(map[uint]struct{})
for _, post := range posts {
if post.UserID == 0 {
continue
}
if _, ok := seen[post.UserID]; ok {
continue
}
seen[post.UserID] = struct{}{}
ids = append(ids, post.UserID)
}
return ids
}
func lookupUsersBrief(ids []uint) map[uint]userBrief {
result := make(map[uint]userBrief)
if len(ids) == 0 {
return result
}
var users []models.User
err := config.DB.Model(&models.User{}).
Select("id", "username", "avatar", "email").
Where("id IN ? AND deleted_at = ?", ids, 0).
Find(&users).Error
if err != nil {
log.Printf("Error batch loading users: %v", err)
return result
}
for _, user := range users {
result[user.ID] = userBrief{
Username: user.Username,
Avatar: user.Avatar,
Email: user.Email,
}
}
return result
}
// BuildPostResponse 构建博客文章响应
func BuildPostResponse(post *models.Post, includeContent bool) *models.PostResponse {
return buildPostResponse(post, includeContent, true, nil)
}
func buildPostResponse(post *models.Post, includeContent, includeSnippets bool, userMap map[uint]userBrief) *models.PostResponse {
// Format CreatedAt to Date string
dateStr := time.Unix(post.CreatedAt, 0).Format("2006-01-02")
@@ -392,7 +460,7 @@ func BuildPostResponse(post *models.Post, includeContent bool) *models.PostRespo
colName = post.Column.Name
}
userName, userAvatar, userEmail := lookupUserBrief(post.UserID)
userName, userAvatar, userEmail := resolveUserBrief(post, userMap)
response := &models.PostResponse{
ID: post.ID,
@@ -420,18 +488,24 @@ func BuildPostResponse(post *models.Post, includeContent bool) *models.PostRespo
response.Content = &content
}
if snippets, err := GetSnippetsByPostID(post.ID); err == nil && len(snippets) > 0 {
response.Snippets = snippets
if includeSnippets {
if snippets, err := GetSnippetsByPostID(post.ID); err == nil && len(snippets) > 0 {
response.Snippets = snippets
}
}
return response
}
// BuildPostsResponse 构建博客文章列表响应
// BuildPostsResponse 构建博客文章列表响应(不含正文与 snippets批量查作者
func BuildPostsResponse(posts []models.Post) []models.PostResponse {
var responses []models.PostResponse
for _, post := range posts {
responses = append(responses, *BuildPostResponse(&post, false))
if len(posts) == 0 {
return []models.PostResponse{}
}
userMap := lookupUsersBrief(collectPostUserIDs(posts))
responses := make([]models.PostResponse, 0, len(posts))
for i := range posts {
responses = append(responses, *buildPostResponse(&posts[i], false, false, userMap))
}
return responses
}

View File

@@ -70,13 +70,20 @@ func GetSnippetsByPostID(postID uint) ([]models.SnippetBrief, error) {
return nil, err
}
result := make([]models.SnippetBrief, 0, len(snippets))
typeIDs := make([]uint, 0, len(snippets))
for _, s := range snippets {
if s.CodeTypeID > 0 {
typeIDs = append(typeIDs, s.CodeTypeID)
}
}
codeTypeMap := GetCodeTypesByIDs(typeIDs)
for _, s := range snippets {
brief := models.SnippetBrief{
ID: s.ID,
Title: s.Title,
Type: s.Type,
}
if ct := GetCodeTypeForSnippet(&s); ct != nil {
if ct := codeTypeMap[s.CodeTypeID]; ct != nil {
r := BuildCodeTypeResponse(ct)
brief.CodeType = &r
if brief.Type == "" {