优化页面、修复BUG
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('更新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 获取所有文章(包括未发布的,后台用)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user