优化页面、修复BUG

This commit is contained in:
李琦
2026-06-25 16:57:06 +08:00
parent d5ff40e4d3
commit 20fb0e3203
31 changed files with 2032 additions and 754 deletions

View File

@@ -24,7 +24,7 @@
<!-- Main Content -->
<!-- Conditional classes: Apply max-w-7xl only for frontend pages -->
<main :class="[
isAdminOrLoginOrBlog ? 'w-full h-full' : 'pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
isAdminOrLoginOrBlog ? 'w-full h-full relative z-10' : 'pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10'
]">
<router-view />
</main>

View File

@@ -137,12 +137,12 @@ onUnmounted(() => {
.dropdown-options {
z-index: 99999999999999999999;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 12, 14, 0.72);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
max-height: 200px;
overflow-y: auto;
}
@@ -157,7 +157,7 @@ onUnmounted(() => {
}
.option-item:hover {
background: rgba(212, 179, 131, 0.1);
background: rgba(212, 179, 131, 0.15);
color: #d4b383;
}

View File

@@ -1,169 +1,166 @@
<template>
<Teleport to="body">
<div id="inquiry-modal" class="fixed inset-0 z-[100] bg-[#050505]/95 backdrop-blur-xl hidden items-center justify-center p-4 transition-opacity duration-300">
<div class="relative w-full max-w-4xl bg-[#080808] border border-white/10 overflow-hidden flex flex-col md:flex-row shadow-2xl">
<div class="hidden md:flex w-1/3 bg-[#0c0c0c] border-r border-white/5 p-12 flex-col justify-between relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-full bg-noise opacity-10"></div>
<div class="absolute -top-20 -left-20 w-64 h-64 bg-art-accent/10 rounded-full blur-[80px]"></div>
<div class="relative z-10">
<h3 class="font-serif text-3xl text-white italic mb-6 leading-tight">
共创<br>无限<br>可能
</h3>
<p class="font-mono text-xs text-white/40 leading-relaxed">
告诉我你的构想<br>
我将提供架构与代码<br>
将想象变为现实
</p>
</div>
<div class="relative z-10 font-mono text-[10px] text-white/20 tracking-widest">
LAT: 30.2741° N<br>
LONG: 120.1551° E<br>
HANGZHOU, CN
</div>
</div>
<div class="w-full md:w-2/3 p-10 md:p-16 relative bg-[#080808]">
<button @click="closeModal" class="absolute top-6 right-6 text-white/30 hover:text-white transition-colors z-20">
<i data-lucide="x" class="w-6 h-6"></i>
</button>
<form id="inquiry-form" class="space-y-10 relative z-10" @submit.prevent="handleSubmit">
<div class="space-y-8">
<p class="font-mono text-xs text-art-accent uppercase tracking-widest border-b border-white/10 pb-2 mb-6">01 身份识别</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="relative group/input">
<input
name="name"
type="text"
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
placeholder="Name"
v-model="formData.name"
required
>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="formData.name ? '-top-4 text-white/50' : ''"
>您的称呼</label>
</div>
<div class="relative group/input">
<input
name="company"
type="text"
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
placeholder="Company"
v-model="formData.company"
>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="formData.company ? '-top-4 text-white/50' : ''"
>公司 / 组织</label>
</div>
<Transition name="inquiry-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-[100] bg-[#050505]/95 backdrop-blur-xl flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="relative w-full max-w-4xl bg-[#080808] border border-white/10 overflow-y-auto overflow-x-hidden flex flex-col md:flex-row shadow-2xl max-h-[95vh]">
<div class="hidden md:flex w-1/3 bg-[#0c0c0c] border-r border-white/5 p-12 flex-col justify-between relative overflow-hidden shrink-0">
<div class="absolute top-0 left-0 w-full h-full bg-noise opacity-10"></div>
<div class="absolute -top-20 -left-20 w-64 h-64 bg-art-accent/10 rounded-full blur-[80px]"></div>
<div class="relative z-10">
<h3 class="font-serif text-3xl text-white italic mb-6 leading-tight">
共创<br>无限<br>可能
</h3>
<p class="font-mono text-xs text-white/40 leading-relaxed">
告诉我你的构想<br>
我将提供架构与代码<br>
将想象变为现实
</p>
</div>
<!-- Contact Method Selection -->
<div class="space-y-4">
<label class="text-xs font-mono text-white/30 block">联系方式类型</label>
<RadioGroup
name="contactMethod"
:options="contactOptions"
v-model="formData.contactMethod"
/>
</div>
<!-- Dynamic Contact Input -->
<div class="relative group/input">
<EmailAutocomplete
v-if="formData.contactMethod === 'email'"
name="contactValue"
label="邮箱地址"
v-model="formData.contactValue"
/>
<div v-else class="relative group/input">
<input
name="contactValue"
type="text"
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
placeholder="Contact"
v-model="formData.contactValue"
required
>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="formData.contactValue ? '-top-4 text-white/50' : ''"
>{{ getContactLabel }}</label>
</div>
<div class="relative z-10 font-mono text-[10px] text-white/20 tracking-widest">
LAT: 30.2741° N<br>
LONG: 120.1551° E<br>
HANGZHOU, CN
</div>
</div>
<div class="space-y-6">
<p class="font-mono text-xs text-art-accent uppercase tracking-widest border-b border-white/10 pb-2 mb-6">02 项目细节</p>
<div class="space-y-3">
<label class="text-xs font-mono text-white/30 block mb-2">预估预算范围</label>
<div class="flex flex-wrap gap-3">
<label class="cursor-pointer group">
<input
type="radio"
name="budget"
value="1w-5w"
class="peer hidden"
v-model="formData.budget"
>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">1w - 5w</span>
</label>
<label class="cursor-pointer group">
<input
type="radio"
name="budget"
value="5w-20w"
class="peer hidden"
v-model="formData.budget"
>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">5w - 20w</span>
</label>
<label class="cursor-pointer group">
<input
type="radio"
name="budget"
value="20w+"
class="peer hidden"
v-model="formData.budget"
>
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">20w +</span>
</label>
</div>
</div>
<div class="relative group/input mt-8">
<textarea
name="description"
rows="1"
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent resize-none min-h-[40px]"
placeholder="Brief"
v-model="formData.description"
@input="autoResizeTextarea"
></textarea>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="formData.description ? '-top-4 text-white/50' : ''"
>一句话描述需求</label>
</div>
</div>
<div class="pt-6">
<button
type="submit"
class="group relative w-full overflow-hidden bg-art-accent text-black rounded-full font-bold hover:scale-105 transition-transform"
:disabled="isSubmitting"
<div class="w-full md:w-2/3 p-10 md:p-16 relative bg-[#080808]">
<button
type="button"
@click="closeModal"
class="absolute top-6 right-6 p-2 text-white/30 hover:text-white hover:bg-white/5 rounded-full transition-colors z-20"
aria-label="关闭"
>
<div class="absolute inset-0 w-0 bg-white transition-all duration-[250ms] ease-out group-hover:w-full"></div>
<span class="relative flex items-center justify-between z-10 px-8 py-4">
<span class="font-mono uppercase tracking-widest text-sm">{{ isSubmitting ? '发送中...' : '发送' }}</span>
<i v-if="!isSubmitting" data-lucide="arrow-right" class="w-4 h-4 transition-transform group-hover:translate-x-1"></i>
<i v-else data-lucide="loader-2" class="w-4 h-4 animate-spin"></i>
</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<p class="mt-4 text-[10px] text-white/20 font-mono text-center tracking-widest">SECURED BY DIGITAL FINGERPRINTING</p>
<form id="inquiry-form" class="space-y-10 relative z-10" @submit.prevent="handleSubmit">
<div class="space-y-8">
<p class="font-mono text-xs text-art-accent uppercase tracking-widest border-b border-white/10 pb-2 mb-6">01 身份识别</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="relative group/input">
<input
name="name"
type="text"
class="inquiry-input peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
placeholder=" "
v-model="formData.name"
required
>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all pointer-events-none peer-focus:-top-4 peer-focus:text-art-accent peer-[:not(:placeholder-shown)]:-top-4 peer-[:not(:placeholder-shown)]:text-white/50 peer-autofill:-top-4 peer-autofill:text-white/50"
>您的称呼</label>
</div>
<div class="relative group/input">
<input
name="company"
type="text"
class="inquiry-input peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
placeholder=" "
v-model="formData.company"
>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all pointer-events-none peer-focus:-top-4 peer-focus:text-art-accent peer-[:not(:placeholder-shown)]:-top-4 peer-[:not(:placeholder-shown)]:text-white/50 peer-autofill:-top-4 peer-autofill:text-white/50"
>公司 / 组织</label>
</div>
</div>
<div class="space-y-4">
<label class="text-xs font-mono text-white/30 block">联系方式类型</label>
<RadioGroup
name="contactMethod"
:options="contactOptions"
v-model="formData.contactMethod"
/>
</div>
<div class="relative group/input">
<EmailAutocomplete
v-if="formData.contactMethod === 'email'"
name="contactValue"
label="邮箱地址"
v-model="formData.contactValue"
required
/>
<div v-else class="relative group/input">
<input
name="contactValue"
type="text"
class="inquiry-input peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
placeholder=" "
v-model="formData.contactValue"
required
>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all pointer-events-none peer-focus:-top-4 peer-focus:text-art-accent peer-[:not(:placeholder-shown)]:-top-4 peer-[:not(:placeholder-shown)]:text-white/50 peer-autofill:-top-4 peer-autofill:text-white/50"
>{{ getContactLabel }}</label>
</div>
</div>
</div>
<div class="space-y-6">
<p class="font-mono text-xs text-art-accent uppercase tracking-widest border-b border-white/10 pb-2 mb-6">02 项目细节</p>
<div class="space-y-3">
<label class="text-xs font-mono text-white/30 block mb-2">预估预算范围</label>
<div class="flex flex-wrap gap-3">
<label class="cursor-pointer group">
<input type="radio" name="budget" value="1w-5w" class="peer hidden" v-model="formData.budget">
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">1w - 5w</span>
</label>
<label class="cursor-pointer group">
<input type="radio" name="budget" value="5w-20w" class="peer hidden" v-model="formData.budget">
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">5w - 20w</span>
</label>
<label class="cursor-pointer group">
<input type="radio" name="budget" value="20w+" class="peer hidden" v-model="formData.budget">
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">20w +</span>
</label>
</div>
</div>
<br>
<div class="relative group/input mt-32">
<textarea
name="description"
:rows="3"
class="inquiry-input peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent resize-none min-h-[40px] max-h-[100px]"
placeholder=" "
v-model="formData.description"
@input="autoResizeTextarea"
></textarea>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all pointer-events-none peer-focus:-top-4 peer-focus:text-art-accent peer-[:not(:placeholder-shown)]:-top-4 peer-[:not(:placeholder-shown)]:text-white/50 peer-autofill:-top-4 peer-autofill:text-white/50"
>一句话描述需求</label>
</div>
</div>
<div class="pt-6">
<button
type="submit"
class="group relative w-full overflow-hidden bg-art-accent text-black rounded-full font-bold hover:scale-105 transition-transform"
:disabled="isSubmitting"
>
<div class="absolute inset-0 w-0 bg-white transition-all duration-[250ms] ease-out group-hover:w-full"></div>
<span class="relative flex items-center justify-between z-10 px-8 py-4">
<span class="font-mono uppercase tracking-widest text-sm">{{ isSubmitting ? '发送中...' : '发送' }}</span>
<svg v-if="!isSubmitting" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:translate-x-1">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
</span>
</button>
<p class="mt-4 text-[10px] text-white/20 font-mono text-center tracking-widest">SECURED BY DIGITAL FINGERPRINTING</p>
</div>
</form>
</div>
</form>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
@@ -173,6 +170,7 @@ import RadioGroup from './ui/RadioGroup.vue'
import EmailAutocomplete from './ui/EmailAutocomplete.vue'
import { submitInquiry } from '../services/api'
const isOpen = ref(false)
const isSubmitting = ref(false)
const formData = ref({
@@ -199,27 +197,25 @@ const getContactLabel = computed(() => {
})
const openModal = () => {
const modal = document.getElementById('inquiry-modal')
if (modal) {
modal.classList.remove('hidden')
modal.classList.add('flex')
document.body.style.overflow = 'hidden'
}
isOpen.value = true
document.body.style.overflow = 'hidden'
}
const closeModal = () => {
const modal = document.getElementById('inquiry-modal')
if (modal) {
modal.classList.remove('flex')
modal.classList.add('hidden')
document.body.style.overflow = ''
isOpen.value = false
document.body.style.overflow = ''
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen.value) {
closeModal()
}
}
const handleSubmit = async () => {
if (isSubmitting.value) return
isSubmitting.value = true
try {
await submitInquiry({
name: formData.value.name,
@@ -229,10 +225,9 @@ const handleSubmit = async () => {
budget: formData.value.budget,
description: formData.value.description
})
closeModal()
showToast('合作咨询已发送,我们会尽快与您联系!')
// Reset form
formData.value = {
name: '',
company: '',
@@ -273,18 +268,42 @@ const showToast = (message: string, type: string = 'success') => {
}
}
// 暴露方法给全局使用
onMounted(() => {
if (typeof window !== 'undefined') {
(window as any).openInquiry = openModal;
(window as any).closeInquiry = closeModal;
;(window as any).openInquiry = openModal
;(window as any).closeInquiry = closeModal
}
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
delete (window as any).openInquiry;
delete (window as any).closeInquiry;
delete (window as any).openInquiry
delete (window as any).closeInquiry
}
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
})
</script>
</script>
<style scoped>
.inquiry-input:-webkit-autofill,
.inquiry-input:-webkit-autofill:hover,
.inquiry-input:-webkit-autofill:focus,
.inquiry-input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 1000px #080808 inset !important;
-webkit-text-fill-color: white !important;
caret-color: white;
transition: background-color 5000s ease-in-out 0s;
}
.inquiry-fade-enter-active,
.inquiry-fade-leave-active {
transition: opacity 0.25s ease;
}
.inquiry-fade-enter-from,
.inquiry-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -2,42 +2,165 @@
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-10 transition-opacity duration-300 pointer-events-auto"
:class="{ 'opacity-0': isAnimating, 'opacity-100': !isAnimating }"
class="fixed inset-0 z-[100] flex items-center justify-center transition-opacity duration-300 pointer-events-auto"
:class="[
isMaximized ? 'p-0' : 'p-0 md:p-10',
isAnimating ? 'opacity-0' : 'opacity-100'
]"
>
<div class="absolute inset-0 bg-black/90 backdrop-blur-sm" @click="close"></div>
<div
class="relative z-[101] bg-[#0a0a0c] border border-white/10 w-full max-w-6xl h-[80vh] rounded-2xl flex flex-col overflow-hidden shadow-2xl transform transition-transform duration-300"
:class="{ 'scale-95': isAnimating, 'scale-100': !isAnimating }"
class="relative z-[101] bg-[#0a0a0c] border-0 md:border border-white/10 flex flex-col overflow-hidden shadow-2xl transform transition-all duration-300"
:class="[
isAnimating ? 'scale-95' : 'scale-100',
isMaximized
? 'fixed inset-0 w-full h-[100dvh] max-w-none rounded-none'
: 'w-full max-w-6xl h-[100dvh] max-h-[100dvh] md:h-[80vh] md:max-h-[80vh] rounded-none md:rounded-2xl'
]"
>
<div class="h-16 border-b border-white/10 flex items-center justify-between px-6 bg-white/5">
<div class="flex items-center gap-3">
<i data-lucide="code-2" class="text-art-accent"></i>
<span class="font-bold text-white">{{ title }}</span>
<div class="h-14 md:h-16 border-b border-white/10 flex items-center gap-3 px-4 md:px-6 bg-white/5 shrink-0 min-w-0">
<div class="flex gap-2 shrink-0">
<button
type="button"
class="w-3 h-3 rounded-full bg-[#ff5f56] hover:brightness-110 transition-all"
aria-label="关闭"
@click="close"
/>
<button
type="button"
class="w-3 h-3 rounded-full bg-[#ffbd2e] hover:brightness-110 transition-all"
aria-label="最小化"
@click="close"
/>
<button
type="button"
class="w-3 h-3 rounded-full bg-[#27c93f] hover:brightness-110 transition-all"
aria-label="最大化"
@click="toggleMaximize"
/>
</div>
<button @click="close" class="p-2 hover:bg-white/10 rounded-full text-white/50 hover:text-white transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
<span class="font-bold text-white truncate text-sm md:text-base min-w-0 flex-1">{{ title }}</span>
</div>
<div v-if="posts && posts.length > 0" class="px-4 md:px-6 py-2 bg-white/[0.02] border-b border-white/10 flex items-center gap-2 shrink-0 overflow-x-auto hide-scrollbar">
<span class="text-xs text-art-muted font-mono shrink-0">关联文章</span>
<a
v-for="p in posts"
:key="p.id"
:href="`/blog/${p.id}`"
target="_blank"
rel="noopener noreferrer"
class="text-xs px-2 py-1 rounded bg-white/5 border border-white/10 text-art-accent hover:text-white hover:border-art-accent/50 transition-colors truncate shrink-0 max-w-[70vw] md:max-w-none"
@click.stop
>
{{ p.title }}
</a>
</div>
<!-- Mobile tabs -->
<div class="md:hidden flex border-b border-white/10 shrink-0">
<button
type="button"
class="flex-1 px-4 py-2.5 text-xs font-mono transition-colors"
:class="mobileTab === 'code'
? 'text-art-accent bg-white/5 border-b-2 border-art-accent'
: 'text-white/40'"
@click="mobileTab = 'code'"
>
代码
</button>
<button
type="button"
class="flex-1 px-4 py-2.5 text-xs font-mono transition-colors"
:class="mobileTab === 'preview'
? 'text-art-accent bg-white/5 border-b-2 border-art-accent'
: 'text-white/40'"
@click="mobileTab = 'preview'"
>
{{ showHtmlPreview ? '预览 / 备注' : '备注' }}
</button>
</div>
<div class="flex-1 flex flex-col md:flex-row h-full overflow-hidden">
<div class="w-full md:w-1/2 bg-[#0d0d0d] overflow-auto p-6 border-r border-white/10 relative group">
<button class="absolute top-4 right-4 text-xs bg-white/10 px-2 py-1 rounded text-white/50 hover:text-white z-10" @click="copyCode">
复制
</button>
<pre class="font-mono text-sm leading-relaxed"><code ref="codeBlock" :class="languageClass">{{ code }}</code></pre>
<div class="flex-1 flex flex-col md:flex-row min-h-0 overflow-hidden">
<!-- Left: code panel -->
<div
class="flex flex-col min-h-0 bg-[#0d0d0d] md:border-r border-white/10"
:class="[
codePanelCollapsed ? 'hidden' : 'w-full md:w-1/2',
mobileTab === 'code' ? 'flex flex-1' : 'hidden md:flex'
]"
>
<div class="sticky top-0 z-10 flex justify-end px-3 md:px-4 py-2 bg-[#0d0d0d]/95 backdrop-blur-sm border-b border-white/5 shrink-0">
<button
class="text-xs bg-white/10 px-2 py-1 rounded text-white/50 hover:text-white transition-colors"
@click="copyCode"
>
复制
</button>
</div>
<div class="flex-1 overflow-auto p-4 md:p-6 min-h-0">
<pre class="font-mono text-xs md:text-sm leading-relaxed m-0"><code ref="codeBlock" :class="languageClass">{{ code }}</code></pre>
</div>
</div>
<div class="w-full md:w-1/2 relative flex flex-col overflow-hidden bg-[#1a1a1a]" :style="previewBackgroundStyle">
<!-- Right: preview / description -->
<div
class="relative flex flex-col min-h-0 overflow-hidden bg-[#1a1a1a]"
:class="[
codePanelCollapsed ? 'w-full md:w-full flex-1' : 'w-full md:w-1/2',
mobileTab === 'preview' ? 'flex flex-1' : 'hidden md:flex'
]"
:style="previewBackgroundStyle"
>
<div v-if="showHtmlPreview" class="hidden md:flex items-center border-b border-white/10 shrink-0">
<button
v-for="tab in rightTabs"
:key="tab.id"
class="flex-1 px-4 py-2.5 text-xs font-mono transition-colors"
:class="activeRightTab === tab.id
? 'text-art-accent bg-white/5 border-b-2 border-art-accent'
: 'text-white/40 hover:text-white/70'"
@click="activeRightTab = tab.id"
>
{{ tab.label }}
</button>
<button
v-if="showCollapseToggle"
type="button"
class="shrink-0 px-3 py-2 text-xs font-mono text-art-muted hover:text-white border-l border-white/10 transition-colors"
@click="codePanelCollapsed = !codePanelCollapsed"
>
{{ codePanelCollapsed ? '展开源码' : '收起源码' }}
</button>
</div>
<div v-if="showHtmlPreview" class="md:hidden flex border-b border-white/10 shrink-0">
<button
v-for="tab in rightTabs"
:key="tab.id"
class="flex-1 px-3 py-2 text-xs font-mono transition-colors"
:class="activeRightTab === tab.id
? 'text-art-accent bg-white/5 border-b-2 border-art-accent'
: 'text-white/40'"
@click="activeRightTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<iframe
v-if="showHtmlPreview"
class="w-full h-full border-0 bg-white"
v-if="showHtmlPreview && activeRightTab === 'preview'"
class="flex-1 w-full border-0 bg-white min-h-0"
sandbox="allow-scripts"
:srcdoc="code"
/>
<div v-else class="flex-1 overflow-auto p-8 text-white/80">
<h4 class="text-sm text-art-muted mb-3">备注</h4>
<p v-if="description" class="text-sm leading-relaxed whitespace-pre-wrap">{{ description }}</p>
<div v-else class="flex-1 overflow-auto p-4 md:p-8 text-white/80 min-h-0">
<h4 v-if="!showHtmlPreview" class="text-sm text-art-muted mb-3">备注</h4>
<article
v-if="description"
class="snippet-description prose prose-invert prose-sm max-w-none text-art-muted leading-relaxed"
v-html="renderedDescription"
/>
<p v-else class="text-sm text-white/30 font-mono">暂无备注</p>
</div>
</div>
@@ -50,8 +173,10 @@
<script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue'
import hljs from 'highlight.js'
import type { CodeType } from '../services/api'
import type { CodeType, PostBrief } from '../services/api'
import { resolveHighlightLanguage, isHtmlFrontendType } from '../utils/codeHighlight'
import { renderMarkdown } from '../utils/markdownRenderer'
import { useToast } from '../composables/useToast'
const props = defineProps<{
isOpen: boolean
@@ -60,12 +185,23 @@ const props = defineProps<{
type?: string
codeType?: CodeType | null
description?: string
posts?: PostBrief[]
}>()
const emit = defineEmits(['close'])
const toast = useToast()
const isAnimating = ref(true)
const isMaximized = ref(false)
const codePanelCollapsed = ref(false)
const codeBlock = ref<HTMLElement | null>(null)
const activeRightTab = ref<'preview' | 'description'>('preview')
const mobileTab = ref<'code' | 'preview'>('code')
const rightTabs = [
{ id: 'preview' as const, label: '效果预览' },
{ id: 'description' as const, label: '备注' },
]
const highlightLang = computed(() => {
if (props.codeType?.name) return props.codeType.name
@@ -76,6 +212,15 @@ const languageClass = computed(() => `language-${resolveHighlightLanguage(highli
const showHtmlPreview = computed(() => isHtmlFrontendType(props.codeType ?? null))
const showCollapseToggle = computed(
() => showHtmlPreview.value && activeRightTab.value === 'preview'
)
const renderedDescription = computed(() => {
if (!props.description) return ''
return renderMarkdown(props.description)
})
const highlightCode = () => {
if (codeBlock.value) {
delete codeBlock.value.dataset.highlighted
@@ -83,11 +228,18 @@ const highlightCode = () => {
}
}
const toggleMaximize = () => {
isMaximized.value = !isMaximized.value
}
watch(() => props.isOpen, (newVal) => {
if (newVal) {
isMaximized.value = false
codePanelCollapsed.value = false
activeRightTab.value = 'preview'
mobileTab.value = 'code'
document.body.style.overflow = 'hidden'
setTimeout(() => { isAnimating.value = false }, 10)
setTimeout(() => { if (window.lucide) window.lucide.createIcons() }, 50)
nextTick(highlightCode)
} else {
document.body.style.overflow = ''
@@ -97,6 +249,18 @@ watch(() => props.isOpen, (newVal) => {
watch(() => props.code, () => nextTick(highlightCode))
watch(activeRightTab, (tab) => {
if (tab === 'description') {
codePanelCollapsed.value = false
}
})
watch(codePanelCollapsed, (collapsed) => {
if (!collapsed) {
nextTick(highlightCode)
}
})
const close = () => {
isAnimating.value = true
setTimeout(() => emit('close'), 300)
@@ -104,7 +268,7 @@ const close = () => {
const copyCode = () => {
navigator.clipboard.writeText(props.code)
alert('代码已复制')
toast.success('代码已复制')
}
const previewBackgroundStyle = {
@@ -117,3 +281,95 @@ const previewBackgroundStyle = {
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px'
}
</script>
<style scoped>
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
:deep(.snippet-description.prose pre) {
background-color: transparent !important;
margin: 0 !important;
padding: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
border: none !important;
white-space: pre !important;
}
:deep(.snippet-description.prose code) {
font-family: 'JetBrains Mono', Menlo, Monaco, Consolas, monospace;
}
:deep(.snippet-description.prose :not(pre) > code) {
color: #d4b383;
background-color: rgba(212, 179, 131, 0.1);
padding: 0.2em 0.4em;
border-radius: 0.3em;
font-weight: 500;
word-break: break-word;
}
:deep(.snippet-description.prose h1),
:deep(.snippet-description.prose h2),
:deep(.snippet-description.prose h3) {
color: #fff;
font-family: serif;
margin-top: 1.5em;
margin-bottom: 0.6em;
line-height: 1.3;
}
:deep(.snippet-description.prose h2) {
font-size: 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 0.3rem;
}
:deep(.snippet-description.prose h3) {
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.9);
}
:deep(.snippet-description.prose a) {
color: #d4b383;
text-decoration: none;
border-bottom: 1px dashed rgba(212, 179, 131, 0.5);
transition: all 0.2s;
word-break: break-all;
}
:deep(.snippet-description.prose a:hover) {
color: #fff;
border-bottom-style: solid;
}
:deep(.snippet-description.prose blockquote) {
border-left: 3px solid #d4b383;
background: rgba(255, 255, 255, 0.02);
padding: 0.8rem 1rem;
border-radius: 0 0.5rem 0.5rem 0;
color: rgba(255, 255, 255, 0.7);
font-style: italic;
}
:deep(.snippet-description.prose ul li::marker) {
color: #d4b383;
}
:deep(.snippet-description.prose img) {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
:deep(.snippet-description.prose video) {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
</style>

View File

@@ -15,59 +15,15 @@
<!-- Nav Menu -->
<nav class="flex-1 overflow-y-auto py-6 px-3 space-y-1 custom-scrollbar">
<template v-for="(item, index) in menuItems" :key="index">
<!-- Parent Menu Item -->
<div v-if="item.children">
<div
class="flex items-center justify-between px-3 py-2.5 rounded-md cursor-pointer transition-colors duration-200 group mb-1"
:class="[isParentActive(item) ? 'text-white' : 'text-art-muted hover:text-white hover:bg-white/5']"
@click="toggleMenu(index)"
>
<div class="flex items-center gap-3 min-w-0">
<span class="text-lg shrink-0 group-hover:text-art-accent transition-colors" :class="isParentActive(item) ? 'text-art-accent' : ''">{{ item.icon }}</span>
<span class="text-sm font-medium truncate transition-opacity duration-300" :class="{ 'opacity-0 w-0': !isSidebarOpen }">{{ item.title }}</span>
</div>
<span
v-if="isSidebarOpen"
class="text-[10px] transition-transform duration-200 opacity-50 group-hover:opacity-100"
:class="{ 'rotate-180': item.isOpen }"
></span>
</div>
<!-- Submenu -->
<div
v-show="isSidebarOpen && item.isOpen"
class="ml-4 pl-4 border-l border-white/5 space-y-1 mb-2 overflow-hidden transition-all duration-300"
>
<router-link
v-for="(child, childIndex) in item.children"
:key="childIndex"
:to="child.path"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-all duration-200 relative group"
active-class="text-art-accent bg-art-accent/5 font-medium"
:class="!isActive(child.path) ? 'text-art-muted hover:text-white hover:bg-white/5' : ''"
>
<!-- Active Indicator Line -->
<div v-if="isActive(child.path)" class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-art-accent rounded-full"></div>
<span class="truncate">{{ child.title }}</span>
</router-link>
</div>
</div>
<!-- Single Menu Item -->
<router-link
v-else
:to="item.path || ''"
class="flex items-center gap-3 px-3 py-2.5 rounded-md mb-1 transition-all duration-200 relative group"
active-class="text-art-accent bg-art-accent/5"
:class="!isActive(item.path || '') ? 'text-art-muted hover:text-white hover:bg-white/5' : ''"
>
<div v-if="isActive(item.path || '')" class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-art-accent rounded-full"></div>
<span class="text-lg shrink-0 group-hover:text-art-accent transition-colors">{{ item.icon }}</span>
<span class="text-sm font-medium truncate transition-opacity duration-300" :class="{ 'opacity-0 w-0': !isSidebarOpen }">{{ item.title }}</span>
</router-link>
</template>
<AdminMenuItem
v-for="(item, index) in menuItems"
:key="index"
:item="item"
:depth="0"
:is-sidebar-open="isSidebarOpen"
:is-active="isActive"
@request-open-sidebar="openSidebar"
/>
</nav>
<!-- User Footer -->
@@ -154,11 +110,13 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { useAuth } from '../../composables/useAuth'
import SessionExpiredModal from './SessionExpiredModal.vue'
import AdminMenuItem from './AdminMenuItem.vue'
import { openActiveMenuAncestors, findRouteTitle, type MenuItem } from './adminMenuTypes'
const router = useRouter()
const route = useRoute()
@@ -191,15 +149,9 @@ const refreshCurrentPage = () => {
router.go(0)
}
interface MenuItem {
title: string
icon: string
path?: string
children?: { title: string; path: string; icon: string }[]
isOpen?: boolean
}
interface MenuItemWithState extends MenuItem {}
const menuItems = ref<MenuItem[]>([
const menuItems = ref<MenuItemWithState[]>([
{
title: '概览',
icon: '📊',
@@ -219,8 +171,14 @@ const menuItems = ref<MenuItem[]>([
{ title: '分类管理', path: '/admin/categories', icon: '📂' },
{ title: '专栏管理', path: '/admin/columns', icon: '📚' },
{ title: '作品管理', path: '/admin/works', icon: '🎨' },
{ title: '代码片段', path: '/admin/snippets', icon: '💻' },
{ title: '代码类型', path: '/admin/code-types', icon: '🏷️' },
{
title: '代码管理',
isOpen: false,
children: [
{ title: '代码片段', path: '/admin/snippets', icon: '💻' },
{ title: '代码分类', path: '/admin/code-types', icon: '🏷️' }
]
},
{ title: '标签管理', path: '/admin/tags', icon: '🏷️' }
]
},
@@ -274,15 +232,9 @@ const menuItems = ref<MenuItem[]>([
}
])
const toggleMenu = (index: number) => {
const openSidebar = () => {
if (!isSidebarOpen.value) {
isSidebarOpen.value = true
// Wait for transition
setTimeout(() => {
menuItems.value[index].isOpen = !menuItems.value[index].isOpen
}, 100)
} else {
menuItems.value[index].isOpen = !menuItems.value[index].isOpen
}
}
@@ -300,25 +252,9 @@ const isActive = (path: string) => {
return route.path === path || route.path.startsWith(path + '/')
}
const isParentActive = (item: MenuItem) => {
if (item.path && isActive(item.path)) return true
if (item.children) {
return item.children.some(child => isActive(child.path))
}
return false
}
// Get current route title for header
const currentRouteTitle = computed(() => {
// Simple logic: find matching menu item title
for (const item of menuItems.value) {
if (item.path === route.path) return item.title
if (item.children) {
const child = item.children.find(c => c.path === route.path)
if (child) return `${item.title} / ${child.title}`
}
}
return '管理控制台'
return findRouteTitle(menuItems.value, route.path) || '管理控制台'
})
// Check if user is authenticated
@@ -329,13 +265,11 @@ onMounted(() => {
}
scheduleExpiryCheck()
document.addEventListener('click', closeDropdown)
// Auto open menu based on current route
menuItems.value.forEach(item => {
if (item.children && item.children.some(child => isActive(child.path))) {
item.isOpen = true
}
})
openActiveMenuAncestors(menuItems.value, isActive)
})
watch(() => route.path, () => {
openActiveMenuAncestors(menuItems.value, isActive)
})
onUnmounted(() => {

View File

@@ -0,0 +1,133 @@
<template>
<div v-if="item.children?.length" class="mb-1">
<div
class="flex items-center justify-between rounded-md cursor-pointer transition-colors duration-200 group"
:class="[
depthPadding,
isItemActive ? 'text-white' : 'text-art-muted hover:text-white hover:bg-white/5'
]"
@click="handleToggle"
>
<div class="flex items-center gap-3 min-w-0">
<span
v-if="item.icon && depth === 0"
class="text-lg shrink-0 group-hover:text-art-accent transition-colors"
:class="isItemActive ? 'text-art-accent' : ''"
>{{ item.icon }}</span>
<span
class="text-sm truncate transition-opacity duration-300"
:class="[
depth === 0 ? 'font-medium' : '',
{ 'opacity-0 w-0': depth === 0 && !isSidebarOpen }
]"
>{{ item.title }}</span>
</div>
<span
v-if="isSidebarOpen"
class="text-[10px] transition-transform duration-200 opacity-50 group-hover:opacity-100 shrink-0"
:class="{ 'rotate-180': item.isOpen }"
></span>
</div>
<div
v-show="isSidebarOpen && item.isOpen"
class="border-l border-white/5 space-y-1 overflow-hidden transition-all duration-300"
:class="childContainerClass"
>
<AdminMenuItem
v-for="(child, childIndex) in item.children"
:key="`${item.title}-${childIndex}`"
:item="child"
:depth="depth + 1"
:is-sidebar-open="isSidebarOpen"
:is-active="isActive"
@toggle="(index) => $emit('toggle', index)"
@request-open-sidebar="$emit('request-open-sidebar')"
/>
</div>
</div>
<router-link
v-else-if="item.path"
:to="item.path"
class="flex items-center gap-3 rounded-md transition-all duration-200 relative group mb-1"
:class="[
depthPadding,
isActive(item.path) ? 'text-art-accent bg-art-accent/5 font-medium' : 'text-art-muted hover:text-white hover:bg-white/5'
]"
active-class="text-art-accent bg-art-accent/5"
>
<div
v-if="isActive(item.path)"
class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 rounded-full bg-art-accent"
:class="depth >= 2 ? 'h-3' : 'h-4'"
></div>
<span
v-if="item.icon && depth === 0"
class="text-lg shrink-0 group-hover:text-art-accent transition-colors"
>{{ item.icon }}</span>
<span
class="text-sm truncate transition-opacity duration-300"
:class="[
depth === 0 ? 'font-medium' : '',
{ 'opacity-0 w-0': depth === 0 && !isSidebarOpen }
]"
>{{ item.title }}</span>
</router-link>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AdminMenuItem from './AdminMenuItem.vue'
import type { MenuItem } from './adminMenuTypes'
const props = defineProps<{
item: MenuItem
depth: number
isSidebarOpen: boolean
isActive: (path: string) => boolean
}>()
const emit = defineEmits<{
toggle: [index: number]
'request-open-sidebar': []
}>()
const isItemActive = computed(() => {
if (props.item.path && props.isActive(props.item.path)) return true
if (props.item.children) {
return props.item.children.some(child => isMenuItemActive(child))
}
return false
})
const isMenuItemActive = (item: MenuItem): boolean => {
if (item.path && props.isActive(item.path)) return true
if (item.children) {
return item.children.some(child => isMenuItemActive(child))
}
return false
}
const depthPadding = computed(() => {
if (props.depth === 0) return 'px-3 py-2.5'
if (props.depth === 1) return 'px-3 py-2 ml-4 pl-4'
return 'px-3 py-1.5 ml-4 pl-3 text-xs'
})
const childContainerClass = computed(() => {
if (props.depth === 0) return 'ml-4 pl-4 mb-2'
return 'ml-2 pl-3 mb-1'
})
const handleToggle = () => {
if (!props.isSidebarOpen) {
emit('request-open-sidebar')
window.setTimeout(() => {
props.item.isOpen = true
}, 100)
return
}
props.item.isOpen = !props.item.isOpen
}
</script>

View File

@@ -0,0 +1,161 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="close"
>
<div class="bg-[#1a1a1a] border border-white/10 rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col animate-reveal">
<div class="p-4 border-b border-white/10 flex justify-between items-center shrink-0">
<h3 class="text-lg font-serif italic text-white">咨询详情</h3>
<button type="button" @click="close" class="text-white/50 hover:text-white p-1">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div v-if="inquiry" class="p-6 overflow-y-auto flex-1 space-y-4">
<div class="admin-card p-5 space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs text-art-muted mb-1">姓名</p>
<p class="text-white font-medium">{{ inquiry.name }}</p>
</div>
<span class="px-2 py-1 rounded text-xs shrink-0" :class="statusClass">{{ statusLabel }}</span>
</div>
<div>
<p class="text-xs text-art-muted mb-1">公司 / 组织</p>
<p class="text-white/90">{{ inquiry.company || '—' }}</p>
</div>
<div>
<p class="text-xs text-art-muted mb-1">联系方式</p>
<p class="text-xs text-art-muted mb-2">{{ contactMethodLabel }}</p>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-white font-mono break-all">{{ inquiry.contactValue }}</span>
<button
type="button"
class="admin-btn-secondary py-1 px-2 text-xs shrink-0"
@click="copyContact"
>
复制
</button>
</div>
</div>
<div>
<p class="text-xs text-art-muted mb-1">预估预算</p>
<p class="text-white/90">{{ inquiry.budget || '—' }}</p>
</div>
<div>
<p class="text-xs text-art-muted mb-1">需求描述</p>
<p class="text-white/90 text-sm leading-relaxed whitespace-pre-wrap">{{ inquiry.description || '—' }}</p>
</div>
<div>
<p class="text-xs text-art-muted mb-1">提交时间</p>
<p class="text-white/70 text-sm">{{ formattedCreatedAt }}</p>
</div>
</div>
</div>
<div class="p-4 border-t border-white/10 flex justify-end gap-2 shrink-0 bg-white/[0.02]">
<button type="button" class="admin-btn-secondary py-1.5 px-3 text-sm" @click="close">关闭</button>
<button
v-if="inquiry?.status === 0"
type="button"
class="admin-btn-secondary py-1.5 px-3 text-sm"
@click="emitStatus(1)"
>
标记已读
</button>
<button
v-if="inquiry?.status !== 2"
type="button"
class="admin-btn-primary py-1.5 px-3 text-sm"
@click="emitStatus(2)"
>
标记已联系
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Inquiry } from '../../services/api'
import { useToast } from '../../composables/useToast'
const props = defineProps<{
isOpen: boolean
inquiry: Inquiry | null
}>()
const emit = defineEmits<{
'update:isOpen': [value: boolean]
'update-status': [status: number]
}>()
const toast = useToast()
const close = () => {
emit('update:isOpen', false)
}
const contactMethodLabel = computed(() => {
const map: Record<string, string> = {
wechat: '微信',
email: '邮箱',
phone: '电话'
}
return map[props.inquiry?.contactMethod || ''] || props.inquiry?.contactMethod || '—'
})
const statusLabel = computed(() => {
switch (props.inquiry?.status) {
case 0: return '未读'
case 1: return '已读'
case 2: return '已联系'
default: return '未知'
}
})
const statusClass = computed(() => {
switch (props.inquiry?.status) {
case 0: return 'bg-red-500/20 text-red-400'
case 1: return 'bg-blue-500/20 text-blue-400'
case 2: return 'bg-green-500/20 text-green-400'
default: return 'bg-gray-500/20 text-gray-400'
}
})
const formattedCreatedAt = computed(() => {
const raw = props.inquiry?.createdAt
if (raw === undefined || raw === null || raw === '') return '—'
const timestamp = typeof raw === 'number' ? raw : Number(raw)
if (Number.isNaN(timestamp)) return '—'
const ms = timestamp > 1e12 ? timestamp : timestamp * 1000
return new Date(ms).toLocaleString()
})
const copyContact = async () => {
const value = props.inquiry?.contactValue
if (!value) return
try {
await navigator.clipboard.writeText(value)
toast.success('已复制到剪贴板')
} catch {
toast.error('复制失败,请手动复制')
}
}
const emitStatus = (status: number) => {
emit('update-status', status)
}
</script>

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-4xl max-h-[85vh] bg-[#121214] border border-white/10 rounded-xl shadow-2xl flex flex-col overflow-hidden">
<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="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>
@@ -14,6 +14,19 @@
<button type="button" class="text-art-muted hover:text-white" @click="emit('close')"></button>
</div>
<div class="flex items-center gap-3 px-6 py-3 border-b border-white/10 shrink-0 bg-white/[0.02]">
<button
type="button"
class="admin-btn-primary text-xs"
:disabled="!activeVersion"
@click="restoreVersion(activeVersion!)"
>
恢复此版本
</button>
<span v-if="activeVersion" class="text-xs text-art-muted">当前查看v{{ activeVersion }}</span>
<span v-else class="text-xs text-art-muted">请从左侧选择一个版本</span>
</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>
@@ -23,8 +36,8 @@
:key="item.version"
type="button"
class="w-full text-left px-4 py-3 border-b border-white/5 hover:bg-white/5 transition-colors"
:class="{ 'bg-art-accent/10 border-l-2 border-l-art-accent': selectedVersions.includes(item.version) }"
@click="toggleVersionSelect(item.version)"
:class="{ 'bg-art-accent/10 border-l-2 border-l-art-accent': activeVersion === item.version }"
@click="selectVersion(item.version)"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-white font-medium">v{{ item.version }}</span>
@@ -36,67 +49,70 @@
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
<div v-if="viewMode === 'list'" class="text-sm text-art-muted">
<p>点击版本可勾选最多 2 进行对比或点击下方操作</p>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="admin-btn-secondary text-xs" :disabled="selectedVersions.length !== 1" @click="viewVersion(selectedVersions[0])">查看选中版本</button>
<button type="button" class="admin-btn-secondary text-xs" :disabled="selectedVersions.length !== 2" @click="compareVersions">对比选中版本</button>
<button type="button" class="admin-btn-primary text-xs" :disabled="selectedVersions.length !== 1" @click="restoreVersion(selectedVersions[0])">恢复选中版本</button>
</div>
<div v-if="!activeVersion" class="h-full flex items-center justify-center text-sm text-art-muted">
点击左侧版本查看内容与变更对比
</div>
<div v-else-if="viewMode === 'preview' && previewData">
<div class="flex items-center justify-between mb-4">
<h4 class="text-white font-medium">版本 v{{ previewData.version }}</h4>
<button type="button" class="text-xs text-art-accent" @click="viewMode = 'list'">返回列表</button>
</div>
<div class="space-y-3 text-sm">
<div><span class="text-art-muted">标题</span><span class="text-white">{{ previewData.title }}</span></div>
<div><span class="text-art-muted">分类</span><span class="text-white">{{ previewData.categoryName || '—' }}</span></div>
<div><span class="text-art-muted">专栏</span><span class="text-white">{{ previewData.columnName || '—' }}</span></div>
<div><span class="text-art-muted">标签</span><span class="text-white">{{ previewData.tagNames?.join(', ') || '—' }}</span></div>
<div><span class="text-art-muted">状态</span><span class="text-white">{{ previewData.isPublished === 1 ? '已发布' : '草稿' }}</span></div>
<div><span class="text-art-muted">修改人</span><span class="text-white">{{ previewData.modifiedByName || previewData.modifiedBy }}</span></div>
<div><span class="text-art-muted">摘要</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
<div>
<span class="text-art-muted block mb-2">正文</span>
<div class="bg-black/30 rounded-lg p-4 max-h-96 overflow-y-auto prose prose-invert prose-sm max-w-none">
<MdPreview :model-value="previewData.content || ''" theme="dark" />
<div v-else-if="detailLoading" class="h-full 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><span class="text-art-muted">修改人</span><span class="text-white">{{ previewData.modifiedByName || previewData.modifiedBy }}</span></div>
<div><span class="text-art-muted">摘要</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
<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">
<MdPreview :model-value="previewData.content || ''" theme="dark" />
</div>
</div>
</div>
</div>
</div>
</section>
<div v-else-if="viewMode === 'diff' && diffData">
<div class="flex items-center justify-between mb-4">
<h4 class="text-white font-medium">v{{ diffData.fromVersion }} v{{ diffData.toVersion }}</h4>
<button type="button" class="text-xs text-art-accent" @click="viewMode = 'list'">返回列表</button>
</div>
<div 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>
<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>
</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>
</div>
</div>
</section>
</template>
</div>
</div>
</div>
@@ -140,9 +156,9 @@ const emit = defineEmits<{
const toast = useToast()
const loading = ref(false)
const detailLoading = ref(false)
const historyList = ref<PostHistory[]>([])
const selectedVersions = ref<number[]>([])
const viewMode = ref<'list' | 'preview' | 'diff'>('list')
const activeVersion = ref<number | null>(null)
const previewData = ref<PostHistory | null>(null)
const diffData = ref<PostHistoryDiff | null>(null)
@@ -154,6 +170,9 @@ const loadHistory = async () => {
try {
historyList.value = await getPostHistory(props.postId)
historyList.value.sort((a, b) => b.version - a.version)
if (historyList.value.length > 0) {
await selectVersion(historyList.value[0].version)
}
} catch (error: any) {
toast.showToast(error.message || '加载历史版本失败', 'error')
} finally {
@@ -161,41 +180,26 @@ const loadHistory = async () => {
}
}
const toggleVersionSelect = (version: number) => {
const idx = selectedVersions.value.indexOf(version)
if (idx >= 0) {
selectedVersions.value.splice(idx, 1)
return
}
if (selectedVersions.value.length >= 2) {
selectedVersions.value.shift()
}
selectedVersions.value.push(version)
}
const viewVersion = async (version?: number) => {
if (!version) return
const selectVersion = async (version: number) => {
activeVersion.value = version
detailLoading.value = true
previewData.value = null
diffData.value = null
try {
previewData.value = await getPostHistoryByVersion(props.postId, version)
viewMode.value = 'preview'
const [detail, diff] = await Promise.all([
getPostHistoryByVersion(props.postId, version),
version > 1 ? getPostHistoryDiff(props.postId, version - 1, version) : Promise.resolve(null)
])
previewData.value = detail
diffData.value = diff
} catch (error: any) {
toast.showToast(error.message || '加载版本详情失败', 'error')
} finally {
detailLoading.value = false
}
}
const compareVersions = async () => {
if (selectedVersions.value.length !== 2) return
const [a, b] = [...selectedVersions.value].sort((x, y) => x - y)
try {
diffData.value = await getPostHistoryDiff(props.postId, a, b)
viewMode.value = 'diff'
} catch (error: any) {
toast.showToast(error.message || '对比失败', 'error')
}
}
const restoreVersion = async (version?: number) => {
if (!version) return
const restoreVersion = async (version: number) => {
if (!confirm(`确定将文章恢复为版本 v${version} 吗?当前内容会先保存为新版本。`)) return
try {
await restorePostHistory(props.postId, version)
@@ -211,8 +215,7 @@ watch(
() => props.isOpen,
(open) => {
if (open) {
selectedVersions.value = []
viewMode.value = 'list'
activeVersion.value = null
previewData.value = null
diffData.value = null
loadHistory()

View File

@@ -15,7 +15,7 @@
</div>
<!-- Category Selection -->
<div class="space-y-3">
<div ref="categorySectionRef" class="space-y-3 rounded-lg transition-shadow duration-300" :class="highlightClass('category')">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">分类</label>
<button @click="showCreateCategory = true" class="text-xs text-art-accent hover:text-white transition-colors">
@@ -44,7 +44,7 @@
</div>
<!-- Column Selection -->
<div class="space-y-3">
<div ref="columnSectionRef" class="space-y-3 rounded-lg transition-shadow duration-300" :class="highlightClass('column')">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">专栏 <span class="text-art-muted text-xs">(可选)</span></label>
<button @click="showCreateColumn = true" class="text-xs text-art-accent hover:text-white transition-colors">
@@ -86,7 +86,7 @@
</div>
<!-- Tags Selection -->
<div class="space-y-3">
<div ref="tagsSectionRef" class="space-y-3 rounded-lg transition-shadow duration-300" :class="highlightClass('tags')">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">标签 <span class="text-art-muted text-xs">(可多选)</span></label>
<button @click="showCreateTag = true" class="text-xs text-art-accent hover:text-white transition-colors">
@@ -138,7 +138,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { ref, watch, onMounted, computed, nextTick } from 'vue'
import {
fetchCategories,
fetchColumns,
@@ -159,6 +159,7 @@ import ImageUpload from './ImageUpload.vue'
const props = defineProps<{
isOpen: boolean
post: Post | null
focusSection?: 'category' | 'column' | 'tags' | 'all'
}>()
const emit = defineEmits<{
@@ -186,6 +187,37 @@ const newTag = ref({ name: '', slug: '' })
const saving = ref(false)
const categorySectionRef = ref<HTMLElement | null>(null)
const columnSectionRef = ref<HTMLElement | null>(null)
const tagsSectionRef = ref<HTMLElement | null>(null)
const activeHighlight = ref<'category' | 'column' | 'tags' | null>(null)
const highlightClass = (section: 'category' | 'column' | 'tags') => {
return activeHighlight.value === section ? 'ring-2 ring-art-accent/50 ring-offset-2 ring-offset-[#1a1a1a]' : ''
}
const scrollToFocusSection = async () => {
const focus = props.focusSection || 'all'
if (focus === 'all') return
await nextTick()
const refMap = {
category: categorySectionRef,
column: columnSectionRef,
tags: tagsSectionRef
} as const
const target = refMap[focus]?.value
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
activeHighlight.value = focus
window.setTimeout(() => {
if (activeHighlight.value === focus) {
activeHighlight.value = null
}
}, 2000)
}
// 计算属性:分类选项
const categoryOptions = computed(() => {
return [
@@ -344,6 +376,9 @@ watch(() => props.isOpen, (open) => {
if (open) {
loadData()
initForm()
scrollToFocusSection()
} else {
activeHighlight.value = null
}
})

View File

@@ -0,0 +1,56 @@
export interface MenuItem {
title: string
icon?: string
path?: string
children?: MenuItem[]
isOpen?: boolean
}
export function isMenuItemRouteActive(item: MenuItem, isActive: (path: string) => boolean): boolean {
if (item.path && isActive(item.path)) return true
if (item.children) {
return item.children.some(child => isMenuItemRouteActive(child, isActive))
}
return false
}
export function openActiveMenuAncestors(items: MenuItem[], isActive: (path: string) => boolean) {
items.forEach(item => {
if (item.children && isMenuItemRouteActive(item, isActive)) {
item.isOpen = true
openActiveMenuAncestors(item.children, isActive)
}
})
}
export function findRouteTitle(items: MenuItem[], path: string, parentTitle?: string): string | null {
for (const item of items) {
if (item.path === path) {
return parentTitle ? `${parentTitle} / ${item.title}` : item.title
}
if (item.children) {
const childTitle = findRouteTitle(item.children, path, parentTitle ? `${parentTitle} / ${item.title}` : item.title)
if (childTitle) return childTitle
}
if (item.path && path.startsWith(item.path + '/')) {
return parentTitle ? `${parentTitle} / ${item.title}` : item.title
}
if (item.children) {
for (const child of item.children) {
if (child.path && path.startsWith(child.path + '/')) {
const prefix = parentTitle ? `${parentTitle} / ${item.title}` : item.title
return `${prefix} / ${child.title}`
}
if (child.children) {
for (const grand of child.children) {
if (grand.path && path.startsWith(grand.path + '/')) {
const prefix = parentTitle ? `${parentTitle} / ${item.title}` : item.title
return `${prefix} / ${child.title} / ${grand.title}`
}
}
}
}
}
}
return null
}

View File

@@ -3,17 +3,17 @@
<input
:name="name"
type="text"
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
:placeholder="placeholder"
class="inquiry-input peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
:placeholder="inputPlaceholder"
:value="modelValue"
:required="required"
@input="handleInput"
@focus="showDropdown = true"
@blur="handleBlur"
autocomplete="off"
>
<label
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-autofill:-top-4 peer-autofill:text-art-accent pointer-events-none"
:class="modelValue ? '-top-4 text-white/50' : ''"
class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all pointer-events-none peer-focus:-top-4 peer-focus:text-art-accent peer-[:not(:placeholder-shown)]:-top-4 peer-[:not(:placeholder-shown)]:text-white/50 peer-autofill:-top-4 peer-autofill:text-white/50"
>{{ label }}</label>
<!-- Autocomplete Dropdown -->
@@ -39,10 +39,13 @@ const props = defineProps<{
label: string
placeholder?: string
modelValue: string
required?: boolean
}>()
const emit = defineEmits(['update:modelValue'])
const inputPlaceholder = computed(() => props.placeholder ?? ' ')
const showDropdown = ref(false)
const emailSuffixes = ref<string[]>([])
const containerRef = ref<HTMLElement | null>(null)
@@ -81,3 +84,15 @@ const selectSuggestion = (val: string) => {
showDropdown.value = false
}
</script>
<style scoped>
.inquiry-input:-webkit-autofill,
.inquiry-input:-webkit-autofill:hover,
.inquiry-input:-webkit-autofill:focus,
.inquiry-input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 1000px #080808 inset !important;
-webkit-text-fill-color: white !important;
caret-color: white;
transition: background-color 5000s ease-in-out 0s;
}
</style>

View File

@@ -0,0 +1,108 @@
import type { Ref } from 'vue'
import type { ExposeParam, UploadImgCallBack } from 'md-editor-v3'
import { authFetch, parseApiResponse } from '../services/api'
import { useToast } from './useToast'
export function useMdMediaUpload(editorRef: Ref<ExposeParam | undefined>) {
const toast = useToast()
const insertAtCursor = (text: string) => {
editorRef.value?.insert?.(() => ({ targetValue: text }))
}
const uploadMediaFile = async (file: File): Promise<string | null> => {
const formData = new FormData()
formData.append('file', file)
formData.append('categoryId', '1')
formData.append('storageType', 'local')
const response = await authFetch('/api/admin/attachments/upload', {
method: 'POST',
headers: {},
body: formData
})
const result = await parseApiResponse<{ fileUrl: string }>(response)
return result?.fileUrl ?? null
}
const uploadAndInsertVideo = async (file: File) => {
try {
const url = await uploadMediaFile(file)
if (!url) return
insertAtCursor(`\n<video controls src="${url}" style="max-width:100%"></video>\n`)
toast.showToast('视频上传成功', 'success')
} catch (error: unknown) {
console.error('上传视频失败:', error)
const message = error instanceof Error ? error.message : '上传视频失败'
toast.showToast(message, 'error')
}
}
const handleUploadImg = async (files: File[], callback: UploadImgCallBack) => {
try {
const uploadResults = await Promise.all(
files.map(async (file) => {
const url = await uploadMediaFile(file)
return url ? { url, alt: file.name, title: file.name } : null
})
)
const urls = uploadResults.filter((item): item is { url: string; alt: string; title: string } => item !== null)
if (urls.length > 0) {
callback(urls)
toast.showToast('图片上传成功', 'success')
}
} catch (error: unknown) {
console.error('上传图片失败:', error)
const message = error instanceof Error ? error.message : '上传图片失败'
toast.showToast(message, 'error')
}
}
const handleEditorDrop = async (e: DragEvent) => {
const files = Array.from(e.dataTransfer?.files || [])
const imageFiles = files.filter((f) => f.type.startsWith('image/'))
const videoFiles = files.filter((f) => f.type.startsWith('video/'))
if (imageFiles.length === 0 && videoFiles.length === 0) return
e.preventDefault()
if (imageFiles.length > 0) {
await handleUploadImg(imageFiles, (urls) => {
if (!Array.isArray(urls) || urls.length === 0) return
urls.forEach((item) => {
if (typeof item === 'string') {
insertAtCursor(`![image](${item})`)
} else {
insertAtCursor(`![${item.alt || 'image'}](${item.url})`)
}
})
})
}
for (const file of videoFiles) {
await uploadAndInsertVideo(file)
}
}
const handleVideoPaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.startsWith('video/')) {
e.preventDefault()
const file = item.getAsFile()
if (file) await uploadAndInsertVideo(file)
break
}
}
}
return {
handleUploadImg,
handleEditorDrop,
handleVideoPaste,
}
}

View File

@@ -1,6 +1,6 @@
<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 ">
<section id="blog-detail" class="page-section block overflow-x-hidden">
<div class="max-w-[90rem] mx-auto pt-20 md:pt-32 px-4 md:px-6 pb-12 md:pb-20 relative overflow-x-hidden">
<!-- 返回按钮 -->
<button
@click="$router.push('/blog')"
@@ -144,7 +144,7 @@
</aside>
<!-- 2. 中间文章主内容 -->
<div class="flex-1 min-w-0 order-2">
<div class="flex-1 min-w-0 max-w-full order-2 overflow-x-hidden">
<!-- 文章头图与信息 -->
<div class="border-b border-white/5 pb-6 md:pb-8 mb-6 md:mb-10">
<div class="flex flex-wrap items-center gap-2 md:gap-3 mb-4 md:mb-6">
@@ -170,11 +170,40 @@
<!-- 文章内容 -->
<article
ref="articleRef"
class="blog-content prose prose-invert max-w-none text-art-muted leading-relaxed prose-base md:prose-lg"
class="blog-content prose prose-invert min-w-0 max-w-full overflow-x-hidden text-art-muted leading-relaxed prose-base md:prose-lg"
>
<div v-html="renderedContent"></div>
</article>
<!-- 相关代码片段 -->
<div v-if="post.snippets && post.snippets.length > 0" class="mt-8 md:mt-12 border-t border-white/10 pt-8 md:pt-10">
<h3 class="text-base md:text-lg font-bold text-white mb-4 md:mb-6 flex items-center gap-2">
<Icon name="code-2" :size="18" class="text-art-accent" />
相关代码
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
<button
v-for="snippet in post.snippets"
:key="snippet.id"
type="button"
class="w-full text-left rounded-xl overflow-hidden bg-[#0d0d0d] border border-white/10 hover:border-art-accent/50 transition-colors group"
@click="openRelatedSnippet(snippet.id)"
>
<div class="flex items-center justify-between px-3 py-2 bg-white/5 border-b border-white/5 gap-2">
<div class="flex gap-1.5 shrink-0">
<div class="w-2 h-2 rounded-full bg-[#ff5f56]"></div>
<div class="w-2 h-2 rounded-full bg-[#ffbd2e]"></div>
<div class="w-2 h-2 rounded-full bg-[#27c93f]"></div>
</div>
<span class="text-[10px] font-mono text-art-accent uppercase truncate">{{ snippet.codeType?.name || snippet.type }}</span>
</div>
<div class="p-4">
<div class="text-sm font-medium text-white group-hover:text-art-accent transition-colors line-clamp-2">{{ snippet.title }}</div>
</div>
</button>
</div>
</div>
<!-- 移动端专属信息补充区 -->
<div class="xl:hidden mt-8 md:mt-12 border-t border-white/10 pt-6 md:pt-8 space-y-4 md:space-y-6">
<div v-if="post.tags && post.tags.length > 0">
@@ -334,15 +363,27 @@
</div>
</div>
<SnippetModal
:is-open="snippetModalOpen"
:title="activeSnippet.title"
:code="activeSnippet.code"
:type="activeSnippet.type"
:code-type="activeSnippet.codeType"
:description="activeSnippet.description"
:posts="activeSnippet.posts"
@close="closeSnippetModal"
/>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchPost, getRecommendedPosts, fetchColumnPosts, Post, getPublicSettings } from '../services/api'
import { fetchPost, getRecommendedPosts, fetchColumnPosts, fetchSnippet, Post, Snippet, getPublicSettings } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
import Icon from '../components/Icon.vue'
import SnippetModal from '../components/SnippetModal.vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
@@ -394,6 +435,23 @@ const columnPosts = ref<Post[]>([])
const isColumnExpanded = ref(false)
const loadingColumnPosts = ref(false)
const snippetModalOpen = ref(false)
const activeSnippet = ref<Snippet>({ id: 0, title: '', code: '', type: '' })
const openRelatedSnippet = async (snippetId: number) => {
try {
const snippet = await fetchSnippet(snippetId)
activeSnippet.value = snippet
snippetModalOpen.value = true
} catch (err) {
console.error('Failed to load snippet:', err)
}
}
const closeSnippetModal = () => {
snippetModalOpen.value = false
}
const { initObserver } = useScrollAnimation()
const md = new MarkdownIt({
@@ -438,7 +496,7 @@ md.renderer.rules.fence = function (tokens, idx) {
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 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="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">
@@ -456,7 +514,7 @@ md.renderer.rules.fence = function (tokens, idx) {
</button>
</div>
<div class="code-content-wrapper flex">
<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>
@@ -729,10 +787,23 @@ onBeforeUnmount(() => {
border-radius: 0 !important;
box-shadow: none !important;
border: none !important;
/* 确保这里也强制 pre */
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
white-space: pre !important;
}
:deep(.ios-code-container) {
max-width: 100%;
}
:deep(.prose table) {
display: block;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
:deep(.prose code) {
font-family: 'SF Mono', Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}

View File

@@ -35,12 +35,43 @@
<div class="w-3 h-3 rounded-full bg-[#ffbd2e]"></div>
<div class="w-3 h-3 rounded-full bg-[#27c93f]"></div>
</div>
<span class="text-xs font-mono text-art-muted group-hover:text-white transition-colors">{{ snippet.title }}</span>
<div class="text-xs font-mono text-art-accent opacity-0 group-hover:opacity-100 transition-opacity">点击运行 -></div>
<span class="text-xs font-mono text-art-muted group-hover:text-white transition-colors truncate min-w-0 flex-1">{{ snippet.title }}</span>
<a
v-if="snippet.posts && snippet.posts.length > 0"
:href="`/blog/${snippet.posts[0].id}`"
target="_blank"
rel="noopener noreferrer"
class="hidden sm:inline text-[10px] font-mono text-art-accent/70 hover:text-art-accent truncate max-w-[28%] pointer-events-auto z-10 shrink-0"
@click.stop
>
{{ snippet.posts[0].title }}
</a>
<a
v-if="snippet.posts && snippet.posts.length > 0"
:href="`/blog/${snippet.posts[0].id}`"
target="_blank"
rel="noopener noreferrer"
class="sm:hidden text-[10px] font-mono text-art-accent/70 hover:text-art-accent pointer-events-auto z-10 shrink-0 px-1"
title="关联文章"
@click.stop
>
</a>
<div class="text-xs font-mono text-art-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0">点击查看 -></div>
</div>
<!-- Code Preview (Styled) -->
<div class="p-6 overflow-hidden font-mono text-sm leading-relaxed pointer-events-none opacity-80 group-hover:opacity-100 transition-opacity">
<pre class="whitespace-pre-wrap break-all line-clamp-4"><CodeBlock :code="snippet.code" :language="snippetLang(snippet)" /></pre>
<div class="p-6 pointer-events-none opacity-80 group-hover:opacity-100 transition-opacity">
<div
class="relative overflow-hidden font-mono text-sm leading-relaxed"
:style="{ maxHeight: 'calc(0.875rem * 1.625 * 15)' }"
>
<pre class="m-0 whitespace-pre-wrap break-words"><CodeBlock :code="snippet.code" :language="snippetLang(snippet)" /></pre>
<div
v-if="isLongCode(snippet.code)"
class="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0d0d0d] to-transparent"
aria-hidden="true"
/>
</div>
</div>
</div>
</div>
@@ -54,6 +85,7 @@
:type="currentSnippet.type"
:code-type="currentSnippet.codeType"
:description="currentSnippet.description"
:posts="currentSnippet.posts"
@close="closeSnippet"
/>
</section>
@@ -72,7 +104,7 @@ const loading = ref(false)
const error = ref('')
const modalOpen = ref(false)
const currentSnippet = ref<Snippet>({ id: '', title: '', code: '', type: '' })
const currentSnippet = ref<Snippet>({ id: 0, title: '', code: '', type: '' })
const { initObserver } = useScrollAnimation()
@@ -90,7 +122,7 @@ const fetchSnippetsData = async () => {
// 如果API调用失败使用静态数据作为备选
snippets.value = [
{
id: 'mouse',
id: 1,
title: 'React 鼠标追踪 Hook',
code: `import { useState, useEffect } from 'react';
@@ -106,7 +138,7 @@ export const useMousePosition = () => {
type: 'mouse'
},
{
id: 'glass',
id: 2,
title: 'CSS 极致毛玻璃效果',
code: `.glass-panel {
background: rgba(255, 255, 255, 0.1);
@@ -118,7 +150,7 @@ export const useMousePosition = () => {
type: 'glass'
},
{
id: 'noise',
id: 3,
title: 'SVG 噪点纹理滤镜',
code: `<filter id="noise">
<feTurbulence type="fractalNoise" baseFrequency="0.8" />
@@ -126,7 +158,7 @@ export const useMousePosition = () => {
type: 'noise'
},
{
id: 'animate',
id: 4,
title: 'CSS 流畅动画',
code: `.animate-float {
animation: float 6s ease-in-out infinite;
@@ -150,6 +182,10 @@ const snippetLang = (snippet: Snippet) => {
return resolveHighlightLanguage(snippet.codeType?.name || snippet.type)
}
const isLongCode = (code: string) => {
return code.split('\n').length > 15 || code.length > 600
}
const openSnippet = (snippet: Snippet) => {
currentSnippet.value = snippet
modalOpen.value = true

View File

@@ -2,7 +2,6 @@
<div class="inquiries-container">
<h1 class="page-title">合作咨询管理</h1>
<!-- Inquiries Table -->
<div class="table-container">
<table class="admin-table">
<thead>
@@ -41,15 +40,22 @@
{{ getStatusLabel(item.status) }}
</span>
</td>
<td class="table-cell">{{ item.createdAt ? new Date(item.createdAt).toLocaleString() : '-' }}</td>
<td class="table-cell">{{ formatCreatedAt(item.createdAt) }}</td>
<td class="table-cell actions">
<button
class="btn-action btn-view"
@click="openDetail(item)"
title="查看详情"
>
查看
</button>
<button
v-if="item.status === 0"
class="btn-action btn-read"
@click="updateStatus(item.id!, 1)"
title="标记为已读"
>
👁
已读
</button>
<button
v-if="item.status !== 2"
@@ -57,18 +63,23 @@
@click="updateStatus(item.id!, 2)"
title="标记为已联系"
>
已联系
</button>
</td>
</tr>
</tbody>
</table>
<!-- Empty State -->
<div v-if="inquiries.length === 0" class="empty-state">
<p>暂无咨询数据</p>
</div>
</div>
<InquiryDetailModal
v-model:is-open="isDetailModalOpen"
:inquiry="selectedInquiry"
@update-status="handleDetailStatusUpdate"
/>
</div>
</template>
@@ -76,9 +87,12 @@
import { ref, onMounted } from 'vue'
import { API_BASE, authFetchJson, fetchInquiries, getAuthHeaders, type Inquiry } from '../../services/api'
import { useToast } from '../../composables/useToast'
import InquiryDetailModal from '../../components/admin/InquiryDetailModal.vue'
const toast = useToast()
const inquiries = ref<Inquiry[]>([])
const isDetailModalOpen = ref(false)
const selectedInquiry = ref<Inquiry | null>(null)
const fetchInquiriesList = async () => {
try {
@@ -89,6 +103,11 @@ const fetchInquiriesList = async () => {
}
}
const openDetail = (item: Inquiry) => {
selectedInquiry.value = item
isDetailModalOpen.value = true
}
const updateStatus = async (id: number, status: number) => {
try {
await authFetchJson(`${API_BASE}/admin/inquiries/${id}/status`, {
@@ -97,13 +116,32 @@ const updateStatus = async (id: number, status: number) => {
body: JSON.stringify({ status })
})
toast.success('状态更新成功')
fetchInquiriesList()
await fetchInquiriesList()
if (selectedInquiry.value?.id === id) {
selectedInquiry.value = inquiries.value.find(item => item.id === id) || selectedInquiry.value
if (selectedInquiry.value) {
selectedInquiry.value.status = status
}
}
} catch (error: any) {
console.error(error)
toast.error(error.message || '更新失败')
}
}
const handleDetailStatusUpdate = async (status: number) => {
if (!selectedInquiry.value?.id) return
await updateStatus(selectedInquiry.value.id, status)
}
const formatCreatedAt = (createdAt?: string | number) => {
if (createdAt === undefined || createdAt === null || createdAt === '') return '-'
const timestamp = typeof createdAt === 'number' ? createdAt : Number(createdAt)
if (Number.isNaN(timestamp)) return '-'
const ms = timestamp > 1e12 ? timestamp : timestamp * 1000
return new Date(ms).toLocaleString()
}
const getContactMethodLabel = (method: string) => {
const map: Record<string, string> = {
'wechat': '微信',
@@ -198,17 +236,26 @@ onMounted(() => {
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-action {
padding: 0.5rem;
padding: 0.35rem 0.65rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 1rem;
font-size: 0.75rem;
transition: all 0.3s;
}
.btn-view {
background: rgba(212, 179, 131, 0.12);
color: rgba(212, 179, 131, 0.95);
}
.btn-view:hover {
background: rgba(212, 179, 131, 0.22);
}
.btn-read {
background: rgba(59, 130, 246, 0.1);
color: rgba(59, 130, 246, 0.8);
@@ -230,4 +277,4 @@ onMounted(() => {
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
</style>
</style>

View File

@@ -60,25 +60,54 @@
<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><span class="px-2 py-1 rounded bg-white/5 border border-white/5 text-xs text-art-muted">{{ post.categoryName || post.category?.name || '未分类' }}</span></td>
<td>
<span v-if="post.columnName" class="px-2 py-1 rounded bg-art-accent/10 border border-art-accent/20 text-xs text-art-accent">
<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>
<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 }}
</span>
<span v-else class="text-art-muted text-xs">-</span>
</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>
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-1">
<span
v-for="tag in post.tags.slice(0, 3)"
:key="tag.id"
class="px-2 py-0.5 rounded bg-white/5 border border-white/5 text-xs text-art-muted"
<button
v-for="tag in post.tags.slice(0, 3)"
:key="tag.id"
type="button"
class="px-2 py-0.5 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, 'tags')"
>
#{{ tag.name }}
</span>
<span v-if="post.tags.length > 3" class="text-art-muted text-xs">+{{ post.tags.length - 3 }}</span>
</button>
<span v-if="post.tags.length > 3" class="text-art-muted text-xs self-center">+{{ post.tags.length - 3 }}</span>
</div>
<span v-else class="text-art-muted text-xs">-</span>
<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, 'tags')"
>
无标签
</button>
</td>
<td class="text-art-muted text-xs">{{ post.date }}</td>
<td>
@@ -93,17 +122,20 @@
<td>
<button
@click="openAccessLogModal(post)"
class="admin-btn-secondary py-1 px-3 text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-200"
class="admin-btn-secondary py-1 px-3 text-xs"
title="查看访问记录"
>
查看
</button>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<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">
管理
</button>
<button @click="openHistoryModal(post)" class="admin-btn-secondary py-1 px-3 text-xs">
历史
</button>
<router-link :to="`/admin/posts/${post.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs">
编辑
</router-link>
@@ -169,9 +201,17 @@
<PostRelationModal
v-model:isOpen="isRelationModalOpen"
:post="editingPost"
:focus-section="relationFocusSection"
@saved="fetchPosts"
/>
<PostHistoryModal
:is-open="isHistoryModalOpen"
:post-id="historyPostId"
@close="isHistoryModalOpen = false"
@restored="fetchPosts"
/>
<!-- Access Log Modal -->
<AccessLogModal
v-model:isOpen="isAccessLogModalOpen"
@@ -186,12 +226,18 @@ import { ref, onMounted, computed } from 'vue'
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus } from '../../services/api'
import { useToast } from '../../composables/useToast'
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
import PostHistoryModal from '../../components/admin/PostHistoryModal.vue'
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
type RelationFocusSection = 'category' | 'column' | 'tags' | 'all'
const toast = useToast()
const posts = ref<Post[]>([])
const isRelationModalOpen = ref(false)
const editingPost = ref<Post | null>(null)
const relationFocusSection = ref<RelationFocusSection>('all')
const isHistoryModalOpen = ref(false)
const historyPostId = ref<number | string>('')
const isAccessLogModalOpen = ref(false)
const selectedPostId = ref<number | null>(null)
const selectedPostTitle = ref<string>('')
@@ -226,11 +272,17 @@ const visiblePages = computed(() => {
return pages
})
const openRelationModal = (post: Post) => {
const openRelationModal = (post: Post, section: RelationFocusSection = 'all') => {
editingPost.value = post
relationFocusSection.value = section
isRelationModalOpen.value = true
}
const openHistoryModal = (post: Post) => {
historyPostId.value = post.id
isHistoryModalOpen.value = true
}
const openAccessLogModal = (post: Post) => {
selectedPostId.value = post.id
selectedPostTitle.value = post.title

View File

@@ -1,160 +1,222 @@
<template>
<div class="snippet-form-container">
<h1 class="page-title">{{ isEditing ? '编辑代码片段' : '新建代码片段' }}</h1>
<div class="w-full h-[calc(100vh-8rem)] flex flex-col animate-reveal">
<!-- Top Bar -->
<div class="flex items-center justify-between mb-4 shrink-0 gap-3">
<h1 class="text-xl md:text-2xl font-serif italic text-white truncate">
{{ isEditing ? '编辑代码片段' : '新建代码片段' }}
</h1>
<div class="flex gap-2 md:gap-3 shrink-0">
<button type="button" class="admin-btn-secondary text-sm" @click="handleCancel">
取消
</button>
<button type="button" class="admin-btn-primary text-sm" @click="handleSubmit" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新' : '创建') }}
</button>
</div>
</div>
<div class="form-container">
<form @submit.prevent="handleSubmit" class="snippet-form">
<!-- Title Field -->
<div class="form-group">
<label for="title">标题</label>
<input
<form @submit.prevent="handleSubmit" class="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 overflow-hidden min-h-0">
<!-- Left: Code / Description tabs -->
<div class="flex-1 flex flex-col min-w-0 min-h-[45vh] lg:min-h-0 h-full">
<div class="admin-card flex-1 flex flex-col overflow-hidden border border-white/10 min-h-0">
<div class="flex border-b border-white/10 shrink-0">
<button
v-for="tab in editorTabs"
:key="tab.id"
type="button"
class="flex-1 px-4 py-2.5 text-xs font-mono transition-colors"
:class="editorTab === tab.id
? 'text-art-accent bg-white/5 border-b-2 border-art-accent'
: 'text-art-muted hover:text-white'"
@click="editorTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div v-show="editorTab === 'code'" class="flex-1 flex flex-col min-h-0 focus-within:border-art-accent/50">
<MdEditor
v-model="form.code"
theme="dark"
:preview="false"
placeholder="请输入代码..."
class="custom-md-editor flex-1"
/>
</div>
<div
v-show="editorTab === 'description'"
class="flex-1 flex flex-col min-h-0"
@paste="handleVideoPaste"
>
<MdEditor
ref="descEditorRef"
v-model="form.description"
theme="dark"
:preview="true"
placeholder="支持 Markdown可上传图片、粘贴或拖拽视频..."
class="custom-md-editor desc-md-editor flex-1"
@onUploadImg="handleUploadImg"
@onDrop="handleEditorDrop"
/>
</div>
</div>
<div class="error-message mt-1" v-if="errors.code && editorTab === 'code'">{{ errors.code }}</div>
</div>
<!-- Right: Meta & Settings -->
<div class="w-full lg:w-80 shrink-0 overflow-y-auto custom-scrollbar min-h-0 lg:max-h-full">
<div class="admin-card p-5 md:p-6 space-y-5">
<h3 class="text-sm font-medium text-white border-b border-white/5 pb-3 flex items-center gap-2">
<span class="text-art-accent"></span> 片段配置
</h3>
<div class="space-y-2">
<label for="title" class="block text-xs font-medium text-art-muted uppercase tracking-wider">标题</label>
<input
type="text"
id="title"
v-model="form.title"
class="admin-input"
placeholder="请输入代码片段标题"
required
/>
<div class="error-message" v-if="errors.title">
{{ errors.title }}
</div>
</div>
<!-- Type Field -->
<div class="form-group">
<label for="type">类型</label>
<CustomSelect
v-model.number="form.codeTypeId"
:options="codeTypeOptions"
placeholder="请选择代码类型"
/>
<div class="error-message" v-if="errors.codeTypeId">
{{ errors.codeTypeId }}
</div>
</div>
<!-- Code Field - Updated to MdEditor for better editing experience -->
<div class="form-group">
<label>代码内容</label>
<div class="editor-wrapper">
<MdEditor
v-model="form.code"
theme="dark"
:preview="false"
placeholder="请输入代码..."
class="custom-md-editor"
/>
<div class="error-message" v-if="errors.title">{{ errors.title }}</div>
</div>
<div class="error-message" v-if="errors.code">
{{ errors.code }}
</div>
</div>
<!-- Description Field -->
<div class="form-group">
<label for="description">描述</label>
<textarea
id="description"
v-model="form.description"
placeholder="请输入代码片段描述"
rows="3"
></textarea>
<div class="error-message" v-if="errors.description">
{{ errors.description }}
<div class="space-y-2">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">类型</label>
<CustomSelect
v-model.number="form.codeTypeId"
:options="codeTypeOptions"
placeholder="请选择代码类型"
/>
<div class="error-message" v-if="errors.codeTypeId">{{ errors.codeTypeId }}</div>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">
关联文章 <span class="normal-case text-art-muted/70">(可多选)</span>
</label>
<input
v-model="postSearch"
type="text"
placeholder="搜索文章标题..."
class="admin-input text-sm"
/>
<div class="post-chip-list">
<button
v-for="p in filteredPosts"
:key="p.id"
type="button"
class="post-chip"
:class="{ 'post-chip--active': form.postIds.includes(p.id) }"
@click="togglePost(p.id)"
>
{{ p.title }}
</button>
<div v-if="filteredPosts.length === 0" class="text-xs text-art-muted italic py-2">
暂无匹配文章
</div>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="form-actions">
<button type="button" class="cancel-btn" @click="handleCancel">
取消
</button>
<button type="submit" class="submit-btn" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新代码片段' : '创建代码片段') }}
</button>
</div>
</form>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
// Import MdEditor
import { MdEditor } from 'md-editor-v3'
import type { ExposeParam } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useToast } from '../../composables/useToast'
import { createSnippet, updateSnippet, fetchSnippet, getAdminCodeTypes } from '../../services/api'
import { useMdMediaUpload } from '../../composables/useMdMediaUpload'
import { createSnippet, updateSnippet, fetchSnippet, getAdminCodeTypes, getAdminPosts, Post } from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
const router = useRouter()
const route = useRoute()
const toast = useToast()
// Form state
const descEditorRef = ref<ExposeParam>()
const { handleUploadImg, handleEditorDrop, handleVideoPaste } = useMdMediaUpload(descEditorRef)
const editorTab = ref<'code' | 'description'>('code')
const editorTabs = [
{ id: 'code' as const, label: '代码' },
{ id: 'description' as const, label: '备注' },
]
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const errors = reactive<Record<string, string>>({})
// Form data
const form = reactive({
title: '',
code: '',
codeTypeId: 0,
description: ''
description: '',
postIds: [] as number[]
})
const codeTypeOptions = ref<{ value: number; label: string }[]>([])
const adminPosts = ref<Post[]>([])
const postSearch = ref('')
const filteredPosts = computed(() => {
const q = postSearch.value.trim().toLowerCase()
if (!q) return adminPosts.value
return adminPosts.value.filter(p => p.title.toLowerCase().includes(q))
})
const togglePost = (postId: number) => {
const idx = form.postIds.indexOf(postId)
if (idx === -1) {
form.postIds.push(postId)
} else {
form.postIds.splice(idx, 1)
}
}
// Validation function
const validateForm = (): boolean => {
// Reset errors
Object.keys(errors).forEach(key => delete errors[key])
let isValid = true
// Validate title
if (!form.title.trim()) {
errors.title = '代码片段标题不能为空'
isValid = false
}
// Validate type
if (!form.codeTypeId) {
errors.codeTypeId = '请选择代码类型'
isValid = false
}
// Validate code
if (!form.code.trim()) {
errors.code = '代码内容不能为空'
isValid = false
}
return isValid
}
// Submit handler
const handleSubmit = async () => {
if (!validateForm()) {
return
}
if (!validateForm()) return
isSubmitting.value = true
try {
if (isEditing.value) {
// Update existing snippet
await updateSnippet(route.params.id as string, form)
toast.success('代码片段更新成功')
} else {
// Create new snippet
await createSnippet(form)
const result = await createSnippet(form)
toast.success('代码片段创建成功')
if (result.id) {
router.push(`/admin/snippets/${result.id}/edit`)
return
}
}
// Redirect to snippets list
router.push('/admin/snippets')
} catch (error: any) {
console.error('Error submitting form:', error)
@@ -164,18 +226,20 @@ const handleSubmit = async () => {
}
}
// Cancel handler
const handleCancel = () => {
router.push('/admin/snippets')
}
// Lifecycle
onMounted(async () => {
try {
const types = await getAdminCodeTypes()
const [types, postsRes] = await Promise.all([
getAdminCodeTypes(),
getAdminPosts(1, 200)
])
codeTypeOptions.value = types.map(t => ({ value: t.id, label: `${t.name} (${t.category === 0 ? '前端' : t.category === 1 ? '后端' : '其他'})` }))
adminPosts.value = postsRes.list || []
} catch (error) {
console.error('Failed to load code types:', error)
console.error('Failed to load form data:', error)
}
if (isEditing.value) {
@@ -186,6 +250,7 @@ onMounted(async () => {
form.code = snippet.code
form.codeTypeId = snippet.codeTypeId || snippet.codeType?.id || 0
form.description = snippet.description || ''
form.postIds = snippet.posts?.map(p => p.id) || []
} catch (error: any) {
console.error('Failed to fetch snippet data:', error)
toast.error('加载代码片段数据失败: ' + (error.message || '未知错误'))
@@ -195,147 +260,66 @@ onMounted(async () => {
</script>
<style scoped>
.snippet-form-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.form-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.snippet-form {
max-width: 800px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0.5rem;
font-family: 'Inter', sans-serif;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
resize: vertical;
}
.form-group input::placeholder,
.form-group textarea::placeholder,
.form-group select::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
font-size: 0.75rem;
margin-top: 0.25rem;
font-family: 'Inter', sans-serif;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.cancel-btn {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.cancel-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.submit-btn {
padding: 0.75rem 1.5rem;
background-color: #d4b383;
color: #050505;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.submit-btn:hover:not(:disabled) {
background-color: transparent;
color: #d4b383;
transform: translateY(-1px);
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* MdEditor Customization (Consistent with PostForm) */
.editor-wrapper {
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
overflow: hidden;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.editor-wrapper:focus-within {
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
:deep(.custom-md-editor) {
background-color: rgba(255, 255, 255, 0.05) !important;
background-color: transparent !important;
--md-bk-color: transparent !important;
--md-color: white !important;
--md-border-color: transparent !important;
height: 500px;
height: 100% !important;
min-height: 280px;
}
:deep(.desc-md-editor) {
height: 100% !important;
min-height: 280px;
}
.post-chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
max-height: 160px;
overflow-y: auto;
}
.post-chip {
padding: 0.35rem 0.75rem;
font-size: 0.75rem;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.post-chip:hover {
border-color: rgba(255, 255, 255, 0.3);
color: white;
}
.post-chip--active {
background: rgba(212, 179, 131, 0.15);
border-color: #d4b383;
color: #d4b383;
}
:deep(.md-editor-toolbar-wrapper) {
@@ -351,20 +335,4 @@ onMounted(async () => {
background-color: rgba(0, 0, 0, 0.2) !important;
border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
}
/* Responsive Design */
@media (max-width: 768px) {
.form-container {
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.cancel-btn,
.submit-btn {
width: 100%;
}
}
</style>
</style>

View File

@@ -63,7 +63,7 @@ const fetchSnippets = async () => {
}
}
const deleteSnippet = async (id: string) => {
const deleteSnippet = async (id: number) => {
if (confirm('确定要删除这个代码片段吗?')) {
try {
await deleteSnippetApi(id)

View File

@@ -166,6 +166,7 @@ export interface Post {
content?: string
isPublished?: number
readCount?: number
snippets?: SnippetBrief[]
}
export interface PostHistory {
@@ -255,9 +256,22 @@ export const deleteCodeType = async (id: number): Promise<void> => {
await parseApiResponse(response)
}
// 文章/代码片段简要类型
export interface PostBrief {
id: number
title: string
}
export interface SnippetBrief {
id: number
title: string
type: string
codeType?: CodeType
}
// 代码片段相关类型
export interface Snippet {
id: string
id: number
title: string
code: string
type: string
@@ -265,6 +279,7 @@ export interface Snippet {
codeType?: CodeType
description?: string
viewCount?: number
posts?: PostBrief[]
}
// 系统配置相关类型
@@ -1075,14 +1090,14 @@ export const fetchSnippets = async (): Promise<Snippet[]> => {
}
}
export const fetchSnippet = async (id: string): Promise<Snippet> => {
export const fetchSnippet = async (id: number | string): Promise<Snippet> => {
try {
const response = await fetch(`${API_BASE}/snippets/${id}`)
return await parseApiResponse(response)
} catch (error) {
console.error(`Error fetching snippet ${id}:`, error)
return {
id: id,
id: typeof id === 'number' ? id : 0,
title: '默认代码片段',
code: 'console.log("Hello World");',
type: 'js'
@@ -1090,21 +1105,22 @@ export const fetchSnippet = async (id: string): Promise<Snippet> => {
}
}
export const createSnippet = async (snippetData: Omit<Snippet, 'id' | 'viewCount' | 'codeType' | 'type'> & { type?: string }): Promise<void> => {
export const createSnippet = async (snippetData: Omit<Snippet, 'id' | 'viewCount' | 'codeType' | 'type' | 'posts'> & { type?: string; postIds?: number[] }): Promise<{ id: number }> => {
try {
const response = await authFetch(`${API_BASE}/admin/snippets`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(snippetData)
})
await parseApiResponse(response)
const data = await parseApiResponse<{ id: number }>(response)
return data || { id: 0 }
} catch (error) {
console.error('Create snippet error:', error)
throw error
}
}
export const updateSnippet = async (id: string, snippetData: Omit<Snippet, 'id' | 'viewCount' | 'codeType' | 'type'> & { type?: string }): Promise<void> => {
export const updateSnippet = async (id: number | string, snippetData: Omit<Snippet, 'id' | 'viewCount' | 'codeType' | 'type' | 'posts'> & { type?: string; postIds?: number[] }): Promise<void> => {
try {
const response = await authFetch(`${API_BASE}/admin/snippets/${id}`, {
method: 'PUT',
@@ -1118,7 +1134,7 @@ export const updateSnippet = async (id: string, snippetData: Omit<Snippet, 'id'
}
}
export const deleteSnippet = async (id: string): Promise<void> => {
export const deleteSnippet = async (id: number | string): Promise<void> => {
try {
const response = await authFetch(`${API_BASE}/admin/snippets/${id}`, {
method: 'DELETE',

View File

@@ -0,0 +1,74 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
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}">`
}
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)
}
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>`
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>
<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>
<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>
`
}
export function renderMarkdown(content: string): string {
if (!content) return ''
return md.render(content)
}

View File

@@ -11,6 +11,29 @@ import (
"github.com/niangaodev/art-code/utils"
)
func trySavePostHistory(c *gin.Context, postID uint) bool {
userID, _ := c.Get("userID")
modifiedBy, ok := userID.(uint)
if !ok {
log.Printf("Error saving post history for post %d: invalid userID", postID)
return false
}
saved, err := repositories.GetPostByIDAdmin(postID)
if err != nil {
log.Printf("Error saving post history for post %d: reload failed: %v", postID, err)
return false
}
if saved == nil {
log.Printf("Error saving post history for post %d: post not found after save", postID)
return false
}
if err := repositories.SavePostHistory(saved, modifiedBy); err != nil {
log.Printf("Error saving post history for post %d: %v", postID, err)
return false
}
return true
}
// 获取博客文章列表 (前台)
func GetPosts(c *gin.Context) {
// 获取查询参数
@@ -252,15 +275,9 @@ func AdminCreatePost(c *gin.Context) {
return
}
// 保存历史记录
userID, _ := c.Get("userID")
if saved, err := repositories.GetPostByIDAdmin(post.ID); err == nil && saved != nil {
if err := repositories.SavePostHistory(saved, userID.(uint)); err != nil {
log.Printf("Error saving post history: %v", err)
}
}
historySaved := trySavePostHistory(c, post.ID)
utils.SuccessWithMsg(c, "Post created successfully", gin.H{"id": post.ID})
utils.SuccessWithMsg(c, "Post created successfully", gin.H{"id": post.ID, "historySaved": historySaved})
}
// 更新文章
@@ -287,15 +304,9 @@ func AdminUpdatePost(c *gin.Context) {
return
}
// 保存历史记录
userID, _ := c.Get("userID")
if saved, err := repositories.GetPostByIDAdmin(postID); err == nil && saved != nil {
if err := repositories.SavePostHistory(saved, userID.(uint)); err != nil {
log.Printf("Error saving post history: %v", err)
}
}
historySaved := trySavePostHistory(c, postID)
utils.SuccessWithMsg(c, "Post updated successfully", nil)
utils.SuccessWithMsg(c, "Post updated successfully", gin.H{"historySaved": historySaved})
}
// 更新文章关联关系(只更新分类、专栏、标签,不更新内容)
@@ -323,12 +334,9 @@ func AdminUpdatePostRelations(c *gin.Context) {
return
}
userID, _ := c.Get("userID")
if saved, err := repositories.GetPostByIDAdmin(postID); err == nil && saved != nil {
_ = repositories.SavePostHistory(saved, userID.(uint))
}
historySaved := trySavePostHistory(c, postID)
utils.SuccessWithMsg(c, "Post relations updated successfully", nil)
utils.SuccessWithMsg(c, "Post relations updated successfully", gin.H{"historySaved": historySaved})
}
// 切换文章发布状态
@@ -353,12 +361,9 @@ func AdminTogglePostStatus(c *gin.Context) {
return
}
userID, _ := c.Get("userID")
if saved, err := repositories.GetPostByIDAdmin(postID); err == nil && saved != nil {
_ = repositories.SavePostHistory(saved, userID.(uint))
}
historySaved := trySavePostHistory(c, postID)
utils.SuccessWithMsg(c, "Post status updated successfully", nil)
utils.SuccessWithMsg(c, "Post status updated successfully", gin.H{"historySaved": historySaved})
}
// 删除文章

View File

@@ -9,6 +9,36 @@ import (
"github.com/niangaodev/art-code/utils"
)
func parseSnippetID(c *gin.Context) (uint, bool) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil || id == 0 {
utils.Error(c, 400, "Invalid snippet ID")
return 0, false
}
return uint(id), true
}
type snippetPayload struct {
Title string `json:"title"`
Code string `json:"code"`
Type string `json:"type"`
CodeTypeID uint `json:"codeTypeId"`
Description string `json:"description"`
PostIDs []uint `json:"postIds"`
}
func (p *snippetPayload) toSnippet(id uint) models.Snippet {
return models.Snippet{
ID: id,
Title: p.Title,
Code: p.Code,
Type: p.Type,
CodeTypeID: p.CodeTypeID,
Description: p.Description,
}
}
// GetSnippets 获取所有代码片段
func GetSnippets(c *gin.Context) {
snippets, err := repositories.GetSnippets()
@@ -17,7 +47,6 @@ func GetSnippets(c *gin.Context) {
return
}
// 构建响应
responses := repositories.BuildSnippetsResponse(snippets)
if responses == nil {
utils.Success(c, []interface{}{})
@@ -27,8 +56,11 @@ func GetSnippets(c *gin.Context) {
}
func GetSnippet(c *gin.Context) {
id := c.Param("id")
// 从数据库获取代码片段
id, ok := parseSnippetID(c)
if !ok {
return
}
snippet, err := repositories.GetSnippetByID(id)
if err != nil {
utils.ServerError(c, err)
@@ -40,10 +72,7 @@ func GetSnippet(c *gin.Context) {
return
}
// 构建响应
response := repositories.BuildSnippetResponse(snippet)
utils.Success(c, response)
utils.Success(c, repositories.BuildSnippetResponse(snippet))
}
// AdminGetSnippets 获取代码片段列表 (后台)
@@ -73,12 +102,13 @@ func AdminGetSnippets(c *gin.Context) {
// AdminCreateSnippet 创建代码片段
func AdminCreateSnippet(c *gin.Context) {
var snippet models.Snippet
if err := c.ShouldBindJSON(&snippet); err != nil {
var payload snippetPayload
if err := c.ShouldBindJSON(&payload); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
snippet := payload.toSnippet(0)
syncSnippetTypeFromCodeType(&snippet)
if err := repositories.CreateSnippet(&snippet); err != nil {
@@ -86,20 +116,28 @@ func AdminCreateSnippet(c *gin.Context) {
return
}
if err := repositories.ReplaceSnippetPosts(snippet.ID, payload.PostIDs); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Snippet created successfully", gin.H{"id": snippet.ID})
}
// AdminUpdateSnippet 更新代码片段
func AdminUpdateSnippet(c *gin.Context) {
idStr := c.Param("id")
id, ok := parseSnippetID(c)
if !ok {
return
}
var snippet models.Snippet
if err := c.ShouldBindJSON(&snippet); err != nil {
var payload snippetPayload
if err := c.ShouldBindJSON(&payload); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
snippet.ID = idStr
snippet := payload.toSnippet(id)
syncSnippetTypeFromCodeType(&snippet)
if err := repositories.UpdateSnippet(&snippet); err != nil {
@@ -107,6 +145,11 @@ func AdminUpdateSnippet(c *gin.Context) {
return
}
if err := repositories.ReplaceSnippetPosts(snippet.ID, payload.PostIDs); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Snippet updated successfully", nil)
}
@@ -122,9 +165,12 @@ func syncSnippetTypeFromCodeType(snippet *models.Snippet) {
// AdminDeleteSnippet 删除代码片段
func AdminDeleteSnippet(c *gin.Context) {
idStr := c.Param("id")
id, ok := parseSnippetID(c)
if !ok {
return
}
if err := repositories.DeleteSnippet(idStr); err != nil {
if err := repositories.DeleteSnippet(id); err != nil {
utils.ServerError(c, err)
return
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/niangaodev/art-code/config"
"github.com/niangaodev/art-code/handlers"
"github.com/niangaodev/art-code/middleware"
"github.com/niangaodev/art-code/repositories"
"github.com/niangaodev/art-code/utils"
)
@@ -21,6 +22,8 @@ func main() {
// 运行数据库迁移 (Convert Datetime to BigInt)
// repositories.MigrateToBigInt() // 已禁用自动迁移
repositories.MigratePostHistoryFields()
repositories.MigratePostSnippets()
// 初始化ip2region (如果文件不存在将降级为普通IP记录)
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb

View File

@@ -54,20 +54,21 @@ func (p *Post) BeforeUpdate(tx *gorm.DB) error {
// PostResponse 博客文章响应模型
type PostResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName"`
CategorySlug string `json:"categorySlug"`
ColumnID *uint `json:"columnId,omitempty"`
ColumnName string `json:"columnName,omitempty"`
ColumnSlug string `json:"columnSlug,omitempty"`
Date string `json:"date"`
Excerpt string `json:"excerpt,omitempty"`
Content *string `json:"content,omitempty"` // 使用指针类型,当 includeContent=true 时总是设置
Tags []Tag `json:"tags,omitempty"`
IsPublished int `json:"isPublished,omitempty"`
ReadCount uint `json:"readCount,omitempty"`
ID uint `json:"id"`
Title string `json:"title"`
CategoryID uint `json:"categoryId"`
CategoryName string `json:"categoryName"`
CategorySlug string `json:"categorySlug"`
ColumnID *uint `json:"columnId,omitempty"`
ColumnName string `json:"columnName,omitempty"`
ColumnSlug string `json:"columnSlug,omitempty"`
Date string `json:"date"`
Excerpt string `json:"excerpt,omitempty"`
Content *string `json:"content,omitempty"` // 使用指针类型,当 includeContent=true 时总是设置
Tags []Tag `json:"tags,omitempty"`
IsPublished int `json:"isPublished,omitempty"`
ReadCount uint `json:"readCount,omitempty"`
Snippets []SnippetBrief `json:"snippets,omitempty"`
}
// PostHistory 文章历史记录模型

View File

@@ -0,0 +1,27 @@
package models
// PostSnippet 文章与代码片段关联
type PostSnippet struct {
PostID uint `json:"postId" gorm:"primaryKey;column:post_id"`
SnippetID uint `json:"snippetId" gorm:"primaryKey;column:snippet_id"`
SortOrder uint `json:"sortOrder" gorm:"column:sort_order;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at;default:0"`
}
func (PostSnippet) TableName() string {
return "post_snippets"
}
// PostBrief 文章简要信息
type PostBrief struct {
ID uint `json:"id"`
Title string `json:"title"`
}
// SnippetBrief 代码片段简要信息
type SnippetBrief struct {
ID uint `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
CodeType *CodeTypeResponse `json:"codeType,omitempty"`
}

View File

@@ -8,7 +8,7 @@ import (
// Snippet 代码片段模型
type Snippet struct {
ID string `json:"id" gorm:"primaryKey;column:id"`
ID uint `json:"id" gorm:"primaryKey;column:id;autoIncrement"`
Title string `json:"title" gorm:"column:title"`
Code string `json:"code" gorm:"column:code;type:text"`
Type string `json:"type" gorm:"column:type"`
@@ -48,7 +48,7 @@ func (s *Snippet) BeforeUpdate(tx *gorm.DB) error {
// SnippetResponse 代码片段响应模型
type SnippetResponse struct {
ID string `json:"id"`
ID uint `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Type string `json:"type"`
@@ -56,4 +56,5 @@ type SnippetResponse struct {
CodeType *CodeTypeResponse `json:"codeType,omitempty"`
Description string `json:"description,omitempty"`
ViewCount uint `json:"viewCount,omitempty"`
Posts []PostBrief `json:"posts,omitempty"`
}

View File

@@ -31,12 +31,12 @@ CREATE TABLE `about_profiles` (
`wechat` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '',
`tech_stack` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'JSON string or comma separated list',
`experiences` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'JSON string of experience list',
`is_primary` tinyint(1) NULL DEFAULT 0,
`is_primary` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否主展示资料0否 1是',
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
`deleted_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = DYNAMIC;
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '关于页个人资料表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for access_logs
@@ -310,8 +310,8 @@ CREATE TABLE `posts` (
`column_id` int UNSIGNED NULL DEFAULT NULL COMMENT '专栏ID可选',
`excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '文章摘要',
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文章内容',
`read_count` int UNSIGNED NULL DEFAULT 0 COMMENT '阅读量',
`is_published` tinyint(1) NULL DEFAULT 1 COMMENT '是否已发布0草稿1已发布',
`read_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读量',
`is_published` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否已发布0草稿1已发布',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
@@ -388,7 +388,7 @@ CREATE TABLE `settings` (
-- ----------------------------
DROP TABLE IF EXISTS `code_types`;
CREATE TABLE `code_types` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '类型名称',
`category` tinyint NOT NULL DEFAULT 0 COMMENT '0前端 1后端 2其他',
`deleted_at` bigint NOT NULL DEFAULT 0,
@@ -403,13 +403,13 @@ CREATE TABLE `code_types` (
-- ----------------------------
DROP TABLE IF EXISTS `snippets`;
CREATE TABLE `snippets` (
`id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '代码片段唯一标识',
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '代码片段ID',
`title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '代码片段标题',
`code` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '代码内容',
`type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '代码类型javascript、css、html等',
`code_type_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT '代码类型ID',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '代码片段描述',
`view_count` int UNSIGNED NULL DEFAULT 0 COMMENT '查看次数',
`view_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '查看次数',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
@@ -549,4 +549,18 @@ CREATE TABLE `works` (
INDEX `idx_is_featured`(`is_featured` ASC) USING BTREE COMMENT '按精选状态查询索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for post_snippets
-- ----------------------------
DROP TABLE IF EXISTS `post_snippets`;
CREATE TABLE `post_snippets` (
`post_id` int UNSIGNED NOT NULL COMMENT '文章ID',
`snippet_id` bigint UNSIGNED NOT NULL COMMENT '代码片段ID',
`sort_order` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '排序(越小越靠前)',
`created_at` bigint NOT NULL DEFAULT 0 COMMENT '创建时间Unix秒',
PRIMARY KEY (`post_id`, `snippet_id`) USING BTREE,
INDEX `idx_snippet_id`(`snippet_id` ASC) USING BTREE,
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章代码片段关联表' ROW_FORMAT = DYNAMIC;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -112,3 +112,47 @@ func execSQL(query string) {
log.Printf("SQL Error: %v | Query: %s", err, query)
}
}
// MigratePostHistoryFields adds column_id and tag_ids to post_history if missing.
func MigratePostHistoryFields() {
if !columnExists("post_history", "column_id") {
log.Printf("Adding post_history.column_id...")
execSQL("ALTER TABLE `post_history` ADD COLUMN `column_id` INT UNSIGNED NULL AFTER `category_id`")
}
if !columnExists("post_history", "tag_ids") {
log.Printf("Adding post_history.tag_ids...")
execSQL("ALTER TABLE `post_history` ADD COLUMN `tag_ids` JSON NULL COMMENT '标签ID快照' AFTER `column_id`")
}
}
func tableExists(tableName string) bool {
var count int64
err := config.DB.Raw(`
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = ?
`, tableName).Scan(&count).Error
if err != nil {
log.Printf("Error checking table %s: %v", tableName, err)
return false
}
return count > 0
}
// MigratePostSnippets creates post_snippets junction table if missing.
func MigratePostSnippets() {
if tableExists("post_snippets") {
return
}
log.Printf("Creating post_snippets table...")
execSQL(`CREATE TABLE post_snippets (
post_id INT UNSIGNED NOT NULL COMMENT '文章ID',
snippet_id BIGINT UNSIGNED NOT NULL COMMENT '代码片段ID',
sort_order INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '排序(越小越靠前)',
created_at BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间Unix秒',
PRIMARY KEY (post_id, snippet_id),
INDEX idx_snippet_id (snippet_id),
INDEX idx_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章代码片段关联表'`)
}

View File

@@ -391,6 +391,10 @@ 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
}
return response
}

View File

@@ -0,0 +1,132 @@
package repositories
import (
"log"
"time"
"github.com/niangaodev/art-code/config"
"github.com/niangaodev/art-code/models"
"gorm.io/gorm"
)
// ReplaceSnippetPosts 全量替换代码片段关联的文章
func ReplaceSnippetPosts(snippetID uint, postIDs []uint) error {
return config.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("snippet_id = ?", snippetID).Delete(&models.PostSnippet{}).Error; err != nil {
log.Printf("Error clearing snippet posts: %v", err)
return err
}
if len(postIDs) == 0 {
return nil
}
now := time.Now().Unix()
for i, postID := range postIDs {
row := models.PostSnippet{
PostID: postID,
SnippetID: snippetID,
SortOrder: uint(i),
CreatedAt: now,
}
if err := tx.Create(&row).Error; err != nil {
log.Printf("Error creating post_snippet: %v", err)
return err
}
}
return nil
})
}
// GetPostsBySnippetID 获取代码片段关联的文章
func GetPostsBySnippetID(snippetID uint) ([]models.PostBrief, error) {
var posts []models.Post
err := config.DB.Table("posts").
Select("posts.id, posts.title").
Joins("INNER JOIN post_snippets ps ON ps.post_id = posts.id").
Where("ps.snippet_id = ? AND posts.deleted_at = ?", snippetID, 0).
Order("ps.sort_order ASC, ps.created_at ASC").
Find(&posts).Error
if err != nil {
log.Printf("Error getting posts by snippet ID: %v", err)
return nil, err
}
result := make([]models.PostBrief, 0, len(posts))
for _, p := range posts {
result = append(result, models.PostBrief{ID: p.ID, Title: p.Title})
}
return result, nil
}
// GetSnippetsByPostID 获取文章关联的代码片段(不含完整 code
func GetSnippetsByPostID(postID uint) ([]models.SnippetBrief, error) {
var snippets []models.Snippet
err := config.DB.Table("snippets").
Select("snippets.id, snippets.title, snippets.type, snippets.code_type_id").
Joins("INNER JOIN post_snippets ps ON ps.snippet_id = snippets.id").
Where("ps.post_id = ? AND snippets.deleted_at = ?", postID, 0).
Order("ps.sort_order ASC, ps.created_at ASC").
Find(&snippets).Error
if err != nil {
log.Printf("Error getting snippets by post ID: %v", err)
return nil, err
}
result := make([]models.SnippetBrief, 0, len(snippets))
for _, s := range snippets {
brief := models.SnippetBrief{
ID: s.ID,
Title: s.Title,
Type: s.Type,
}
if ct := GetCodeTypeForSnippet(&s); ct != nil {
r := BuildCodeTypeResponse(ct)
brief.CodeType = &r
if brief.Type == "" {
brief.Type = ct.Name
}
}
result = append(result, brief)
}
return result, nil
}
// GetPostsBySnippetIDs 批量获取多个片段关联的文章
func GetPostsBySnippetIDs(snippetIDs []uint) map[uint][]models.PostBrief {
result := make(map[uint][]models.PostBrief)
if len(snippetIDs) == 0 {
return result
}
type row struct {
SnippetID uint
PostID uint
Title string
SortOrder uint
}
var rows []row
err := config.DB.Table("post_snippets ps").
Select("ps.snippet_id, ps.post_id, p.title, ps.sort_order").
Joins("INNER JOIN posts p ON p.id = ps.post_id").
Where("ps.snippet_id IN ? AND p.deleted_at = ?", snippetIDs, 0).
Order("ps.sort_order ASC, ps.created_at ASC").
Scan(&rows).Error
if err != nil {
log.Printf("Error batch getting posts by snippet IDs: %v", err)
return result
}
for _, r := range rows {
result[r.SnippetID] = append(result[r.SnippetID], models.PostBrief{ID: r.PostID, Title: r.Title})
}
return result
}
// GetPostIDsBySnippetID 获取片段关联的文章 ID 列表(后台编辑用)
func GetPostIDsBySnippetID(snippetID uint) ([]uint, error) {
var ids []uint
err := config.DB.Model(&models.PostSnippet{}).
Where("snippet_id = ?", snippetID).
Order("sort_order ASC, created_at ASC").
Pluck("post_id", &ids).Error
if err != nil {
log.Printf("Error getting post IDs by snippet ID: %v", err)
return nil, err
}
return ids, nil
}

View File

@@ -24,7 +24,7 @@ func GetSnippets() ([]models.Snippet, error) {
}
// GetSnippetByID 根据ID获取代码片段
func GetSnippetByID(id string) (*models.Snippet, error) {
func GetSnippetByID(id uint) (*models.Snippet, error) {
var snippet models.Snippet
err := config.DB.Model(&models.Snippet{}).
Where("id = ? AND deleted_at = ?", id, 0).
@@ -47,6 +47,10 @@ func GetSnippetByID(id string) (*models.Snippet, error) {
// BuildSnippetResponse 构建代码片段响应
func BuildSnippetResponse(snippet *models.Snippet) *models.SnippetResponse {
return BuildSnippetResponseWithPosts(snippet, nil)
}
func BuildSnippetResponseWithPosts(snippet *models.Snippet, posts []models.PostBrief) *models.SnippetResponse {
resp := &models.SnippetResponse{
ID: snippet.ID,
Title: snippet.Title,
@@ -63,14 +67,27 @@ func BuildSnippetResponse(snippet *models.Snippet) *models.SnippetResponse {
resp.Type = ct.Name
}
}
if posts != nil {
resp.Posts = posts
} else if posts, err := GetPostsBySnippetID(snippet.ID); err == nil && len(posts) > 0 {
resp.Posts = posts
}
return resp
}
// BuildSnippetsResponse 构建代码片段列表响应
func BuildSnippetsResponse(snippets []models.Snippet) []models.SnippetResponse {
if len(snippets) == 0 {
return []models.SnippetResponse{}
}
ids := make([]uint, len(snippets))
for i, s := range snippets {
ids[i] = s.ID
}
postsMap := GetPostsBySnippetIDs(ids)
var responses []models.SnippetResponse
for _, snippet := range snippets {
responses = append(responses, *BuildSnippetResponse(&snippet))
responses = append(responses, *BuildSnippetResponseWithPosts(&snippet, postsMap[snippet.ID]))
}
return responses
}
@@ -105,7 +122,7 @@ func UpdateSnippet(snippet *models.Snippet) error {
}
// DeleteSnippet 删除代码片段 (Soft Delete)
func DeleteSnippet(id string) error {
func DeleteSnippet(id uint) error {
err := config.DB.Model(&models.Snippet{}).
Where("id = ?", id).
Update("deleted_at", time.Now().Unix()).Error