优化页面、修复BUG
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<!-- Conditional classes: Apply max-w-7xl only for frontend pages -->
|
<!-- Conditional classes: Apply max-w-7xl only for frontend pages -->
|
||||||
<main :class="[
|
<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 />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.dropdown-options {
|
.dropdown-options {
|
||||||
z-index: 99999999999999999999;
|
z-index: 99999999999999999999;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(12, 12, 14, 0.72);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(24px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(24px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 0.5rem;
|
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;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.option-item:hover {
|
.option-item:hover {
|
||||||
background: rgba(212, 179, 131, 0.1);
|
background: rgba(212, 179, 131, 0.15);
|
||||||
color: #d4b383;
|
color: #d4b383;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +1,166 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<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">
|
<Transition name="inquiry-fade">
|
||||||
<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
|
||||||
<div class="hidden md:flex w-1/3 bg-[#0c0c0c] border-r border-white/5 p-12 flex-col justify-between relative overflow-hidden">
|
v-if="isOpen"
|
||||||
<div class="absolute top-0 left-0 w-full h-full bg-noise opacity-10"></div>
|
class="fixed inset-0 z-[100] bg-[#050505]/95 backdrop-blur-xl flex items-center justify-center p-4"
|
||||||
<div class="absolute -top-20 -left-20 w-64 h-64 bg-art-accent/10 rounded-full blur-[80px]"></div>
|
@click.self="closeModal"
|
||||||
<div class="relative z-10">
|
>
|
||||||
<h3 class="font-serif text-3xl text-white italic mb-6 leading-tight">
|
<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]">
|
||||||
共创<br>无限<br>可能
|
<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">
|
||||||
</h3>
|
<div class="absolute top-0 left-0 w-full h-full bg-noise opacity-10"></div>
|
||||||
<p class="font-mono text-xs text-white/40 leading-relaxed">
|
<div class="absolute -top-20 -left-20 w-64 h-64 bg-art-accent/10 rounded-full blur-[80px]"></div>
|
||||||
告诉我你的构想。<br>
|
<div class="relative z-10">
|
||||||
我将提供架构与代码,<br>
|
<h3 class="font-serif text-3xl text-white italic mb-6 leading-tight">
|
||||||
将想象变为现实。
|
共创<br>无限<br>可能
|
||||||
</p>
|
</h3>
|
||||||
</div>
|
<p class="font-mono text-xs text-white/40 leading-relaxed">
|
||||||
<div class="relative z-10 font-mono text-[10px] text-white/20 tracking-widest">
|
告诉我你的构想。<br>
|
||||||
LAT: 30.2741° N<br>
|
我将提供架构与代码,<br>
|
||||||
LONG: 120.1551° E<br>
|
将想象变为现实。
|
||||||
HANGZHOU, CN
|
</p>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="relative z-10 font-mono text-[10px] text-white/20 tracking-widest">
|
||||||
<!-- Contact Method Selection -->
|
LAT: 30.2741° N<br>
|
||||||
<div class="space-y-4">
|
LONG: 120.1551° E<br>
|
||||||
<label class="text-xs font-mono text-white/30 block">联系方式类型</label>
|
HANGZHOU, CN
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full md:w-2/3 p-10 md:p-16 relative bg-[#080808]">
|
||||||
<div class="space-y-6">
|
<button
|
||||||
<p class="font-mono text-xs text-art-accent uppercase tracking-widest border-b border-white/10 pb-2 mb-6">02 项目细节</p>
|
type="button"
|
||||||
<div class="space-y-3">
|
@click="closeModal"
|
||||||
<label class="text-xs font-mono text-white/30 block mb-2">预估预算范围</label>
|
class="absolute top-6 right-6 p-2 text-white/30 hover:text-white hover:bg-white/5 rounded-full transition-colors z-20"
|
||||||
<div class="flex flex-wrap gap-3">
|
aria-label="关闭"
|
||||||
<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="absolute inset-0 w-0 bg-white transition-all duration-[250ms] ease-out group-hover:w-full"></div>
|
<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">
|
||||||
<span class="relative flex items-center justify-between z-10 px-8 py-4">
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
<span class="font-mono uppercase tracking-widest text-sm">{{ isSubmitting ? '发送中...' : '发送' }}</span>
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
<i v-if="!isSubmitting" data-lucide="arrow-right" class="w-4 h-4 transition-transform group-hover:translate-x-1"></i>
|
</svg>
|
||||||
<i v-else data-lucide="loader-2" class="w-4 h-4 animate-spin"></i>
|
|
||||||
</span>
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -173,6 +170,7 @@ import RadioGroup from './ui/RadioGroup.vue'
|
|||||||
import EmailAutocomplete from './ui/EmailAutocomplete.vue'
|
import EmailAutocomplete from './ui/EmailAutocomplete.vue'
|
||||||
import { submitInquiry } from '../services/api'
|
import { submitInquiry } from '../services/api'
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
@@ -199,27 +197,25 @@ const getContactLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const openModal = () => {
|
const openModal = () => {
|
||||||
const modal = document.getElementById('inquiry-modal')
|
isOpen.value = true
|
||||||
if (modal) {
|
document.body.style.overflow = 'hidden'
|
||||||
modal.classList.remove('hidden')
|
|
||||||
modal.classList.add('flex')
|
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
const modal = document.getElementById('inquiry-modal')
|
isOpen.value = false
|
||||||
if (modal) {
|
document.body.style.overflow = ''
|
||||||
modal.classList.remove('flex')
|
}
|
||||||
modal.classList.add('hidden')
|
|
||||||
document.body.style.overflow = ''
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && isOpen.value) {
|
||||||
|
closeModal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitInquiry({
|
await submitInquiry({
|
||||||
name: formData.value.name,
|
name: formData.value.name,
|
||||||
@@ -229,10 +225,9 @@ const handleSubmit = async () => {
|
|||||||
budget: formData.value.budget,
|
budget: formData.value.budget,
|
||||||
description: formData.value.description
|
description: formData.value.description
|
||||||
})
|
})
|
||||||
|
|
||||||
closeModal()
|
closeModal()
|
||||||
showToast('合作咨询已发送,我们会尽快与您联系!')
|
showToast('合作咨询已发送,我们会尽快与您联系!')
|
||||||
// Reset form
|
|
||||||
formData.value = {
|
formData.value = {
|
||||||
name: '',
|
name: '',
|
||||||
company: '',
|
company: '',
|
||||||
@@ -273,18 +268,42 @@ const showToast = (message: string, type: string = 'success') => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露方法给全局使用
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
(window as any).openInquiry = openModal;
|
;(window as any).openInquiry = openModal
|
||||||
(window as any).closeInquiry = closeModal;
|
;(window as any).closeInquiry = closeModal
|
||||||
}
|
}
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
delete (window as any).openInquiry;
|
delete (window as any).openInquiry
|
||||||
delete (window as any).closeInquiry;
|
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>
|
||||||
|
|||||||
@@ -2,42 +2,165 @@
|
|||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
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="fixed inset-0 z-[100] flex items-center justify-center transition-opacity duration-300 pointer-events-auto"
|
||||||
:class="{ 'opacity-0': isAnimating, 'opacity-100': !isAnimating }"
|
: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="absolute inset-0 bg-black/90 backdrop-blur-sm" @click="close"></div>
|
||||||
<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="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="{ 'scale-95': isAnimating, 'scale-100': !isAnimating }"
|
: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="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 items-center gap-3">
|
<div class="flex gap-2 shrink-0">
|
||||||
<i data-lucide="code-2" class="text-art-accent"></i>
|
<button
|
||||||
<span class="font-bold text-white">{{ title }}</span>
|
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>
|
</div>
|
||||||
<button @click="close" class="p-2 hover:bg-white/10 rounded-full text-white/50 hover:text-white transition-colors">
|
<span class="font-bold text-white truncate text-sm md:text-base min-w-0 flex-1">{{ title }}</span>
|
||||||
<i data-lucide="x" class="w-5 h-5"></i>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col md:flex-row h-full overflow-hidden">
|
<div class="flex-1 flex flex-col md:flex-row min-h-0 overflow-hidden">
|
||||||
<div class="w-full md:w-1/2 bg-[#0d0d0d] overflow-auto p-6 border-r border-white/10 relative group">
|
<!-- Left: code panel -->
|
||||||
<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">
|
<div
|
||||||
复制
|
class="flex flex-col min-h-0 bg-[#0d0d0d] md:border-r border-white/10"
|
||||||
</button>
|
:class="[
|
||||||
<pre class="font-mono text-sm leading-relaxed"><code ref="codeBlock" :class="languageClass">{{ code }}</code></pre>
|
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>
|
||||||
|
|
||||||
<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
|
<iframe
|
||||||
v-if="showHtmlPreview"
|
v-if="showHtmlPreview && activeRightTab === 'preview'"
|
||||||
class="w-full h-full border-0 bg-white"
|
class="flex-1 w-full border-0 bg-white min-h-0"
|
||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts"
|
||||||
:srcdoc="code"
|
:srcdoc="code"
|
||||||
/>
|
/>
|
||||||
<div v-else class="flex-1 overflow-auto p-8 text-white/80">
|
<div v-else class="flex-1 overflow-auto p-4 md:p-8 text-white/80 min-h-0">
|
||||||
<h4 class="text-sm text-art-muted mb-3">备注</h4>
|
<h4 v-if="!showHtmlPreview" class="text-sm text-art-muted mb-3">备注</h4>
|
||||||
<p v-if="description" class="text-sm leading-relaxed whitespace-pre-wrap">{{ description }}</p>
|
<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>
|
<p v-else class="text-sm text-white/30 font-mono">暂无备注</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,8 +173,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed, nextTick } from 'vue'
|
import { ref, watch, computed, nextTick } from 'vue'
|
||||||
import hljs from 'highlight.js'
|
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 { resolveHighlightLanguage, isHtmlFrontendType } from '../utils/codeHighlight'
|
||||||
|
import { renderMarkdown } from '../utils/markdownRenderer'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -60,12 +185,23 @@ const props = defineProps<{
|
|||||||
type?: string
|
type?: string
|
||||||
codeType?: CodeType | null
|
codeType?: CodeType | null
|
||||||
description?: string
|
description?: string
|
||||||
|
posts?: PostBrief[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
const isAnimating = ref(true)
|
const isAnimating = ref(true)
|
||||||
|
const isMaximized = ref(false)
|
||||||
|
const codePanelCollapsed = ref(false)
|
||||||
const codeBlock = ref<HTMLElement | null>(null)
|
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(() => {
|
const highlightLang = computed(() => {
|
||||||
if (props.codeType?.name) return props.codeType.name
|
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 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 = () => {
|
const highlightCode = () => {
|
||||||
if (codeBlock.value) {
|
if (codeBlock.value) {
|
||||||
delete codeBlock.value.dataset.highlighted
|
delete codeBlock.value.dataset.highlighted
|
||||||
@@ -83,11 +228,18 @@ const highlightCode = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleMaximize = () => {
|
||||||
|
isMaximized.value = !isMaximized.value
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.isOpen, (newVal) => {
|
watch(() => props.isOpen, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
|
isMaximized.value = false
|
||||||
|
codePanelCollapsed.value = false
|
||||||
|
activeRightTab.value = 'preview'
|
||||||
|
mobileTab.value = 'code'
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
setTimeout(() => { isAnimating.value = false }, 10)
|
setTimeout(() => { isAnimating.value = false }, 10)
|
||||||
setTimeout(() => { if (window.lucide) window.lucide.createIcons() }, 50)
|
|
||||||
nextTick(highlightCode)
|
nextTick(highlightCode)
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
@@ -97,6 +249,18 @@ watch(() => props.isOpen, (newVal) => {
|
|||||||
|
|
||||||
watch(() => props.code, () => nextTick(highlightCode))
|
watch(() => props.code, () => nextTick(highlightCode))
|
||||||
|
|
||||||
|
watch(activeRightTab, (tab) => {
|
||||||
|
if (tab === 'description') {
|
||||||
|
codePanelCollapsed.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(codePanelCollapsed, (collapsed) => {
|
||||||
|
if (!collapsed) {
|
||||||
|
nextTick(highlightCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
isAnimating.value = true
|
isAnimating.value = true
|
||||||
setTimeout(() => emit('close'), 300)
|
setTimeout(() => emit('close'), 300)
|
||||||
@@ -104,7 +268,7 @@ const close = () => {
|
|||||||
|
|
||||||
const copyCode = () => {
|
const copyCode = () => {
|
||||||
navigator.clipboard.writeText(props.code)
|
navigator.clipboard.writeText(props.code)
|
||||||
alert('代码已复制')
|
toast.success('代码已复制')
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewBackgroundStyle = {
|
const previewBackgroundStyle = {
|
||||||
@@ -117,3 +281,95 @@ const previewBackgroundStyle = {
|
|||||||
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px'
|
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px'
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -15,59 +15,15 @@
|
|||||||
|
|
||||||
<!-- Nav Menu -->
|
<!-- Nav Menu -->
|
||||||
<nav class="flex-1 overflow-y-auto py-6 px-3 space-y-1 custom-scrollbar">
|
<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">
|
<AdminMenuItem
|
||||||
<!-- Parent Menu Item -->
|
v-for="(item, index) in menuItems"
|
||||||
<div v-if="item.children">
|
:key="index"
|
||||||
<div
|
:item="item"
|
||||||
class="flex items-center justify-between px-3 py-2.5 rounded-md cursor-pointer transition-colors duration-200 group mb-1"
|
:depth="0"
|
||||||
:class="[isParentActive(item) ? 'text-white' : 'text-art-muted hover:text-white hover:bg-white/5']"
|
:is-sidebar-open="isSidebarOpen"
|
||||||
@click="toggleMenu(index)"
|
:is-active="isActive"
|
||||||
>
|
@request-open-sidebar="openSidebar"
|
||||||
<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>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User Footer -->
|
<!-- User Footer -->
|
||||||
@@ -154,11 +110,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRouter, useRoute } from 'vue-router'
|
||||||
import { useToast } from '../../composables/useToast'
|
import { useToast } from '../../composables/useToast'
|
||||||
import { useAuth } from '../../composables/useAuth'
|
import { useAuth } from '../../composables/useAuth'
|
||||||
import SessionExpiredModal from './SessionExpiredModal.vue'
|
import SessionExpiredModal from './SessionExpiredModal.vue'
|
||||||
|
import AdminMenuItem from './AdminMenuItem.vue'
|
||||||
|
import { openActiveMenuAncestors, findRouteTitle, type MenuItem } from './adminMenuTypes'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -191,15 +149,9 @@ const refreshCurrentPage = () => {
|
|||||||
router.go(0)
|
router.go(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItemWithState extends MenuItem {}
|
||||||
title: string
|
|
||||||
icon: string
|
|
||||||
path?: string
|
|
||||||
children?: { title: string; path: string; icon: string }[]
|
|
||||||
isOpen?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems = ref<MenuItem[]>([
|
const menuItems = ref<MenuItemWithState[]>([
|
||||||
{
|
{
|
||||||
title: '概览',
|
title: '概览',
|
||||||
icon: '📊',
|
icon: '📊',
|
||||||
@@ -219,8 +171,14 @@ const menuItems = ref<MenuItem[]>([
|
|||||||
{ title: '分类管理', path: '/admin/categories', icon: '📂' },
|
{ title: '分类管理', path: '/admin/categories', icon: '📂' },
|
||||||
{ title: '专栏管理', path: '/admin/columns', icon: '📚' },
|
{ title: '专栏管理', path: '/admin/columns', icon: '📚' },
|
||||||
{ title: '作品管理', path: '/admin/works', 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: '🏷️' }
|
{ title: '标签管理', path: '/admin/tags', icon: '🏷️' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -274,15 +232,9 @@ const menuItems = ref<MenuItem[]>([
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const toggleMenu = (index: number) => {
|
const openSidebar = () => {
|
||||||
if (!isSidebarOpen.value) {
|
if (!isSidebarOpen.value) {
|
||||||
isSidebarOpen.value = true
|
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 + '/')
|
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
|
// Get current route title for header
|
||||||
const currentRouteTitle = computed(() => {
|
const currentRouteTitle = computed(() => {
|
||||||
// Simple logic: find matching menu item title
|
return findRouteTitle(menuItems.value, route.path) || '管理控制台'
|
||||||
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 '管理控制台'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
@@ -329,13 +265,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
scheduleExpiryCheck()
|
scheduleExpiryCheck()
|
||||||
document.addEventListener('click', closeDropdown)
|
document.addEventListener('click', closeDropdown)
|
||||||
|
openActiveMenuAncestors(menuItems.value, isActive)
|
||||||
// Auto open menu based on current route
|
})
|
||||||
menuItems.value.forEach(item => {
|
|
||||||
if (item.children && item.children.some(child => isActive(child.path))) {
|
watch(() => route.path, () => {
|
||||||
item.isOpen = true
|
openActiveMenuAncestors(menuItems.value, isActive)
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
133
client/src/components/admin/AdminMenuItem.vue
Normal file
133
client/src/components/admin/AdminMenuItem.vue
Normal 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>
|
||||||
161
client/src/components/admin/InquiryDetailModal.vue
Normal file
161
client/src/components/admin/InquiryDetailModal.vue
Normal 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>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||||
@click.self="emit('close')"
|
@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 class="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-white">历史版本</h3>
|
<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>
|
<button type="button" class="text-art-muted hover:text-white" @click="emit('close')">✕</button>
|
||||||
</div>
|
</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="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 class="w-72 border-r border-white/10 overflow-y-auto custom-scrollbar shrink-0">
|
||||||
<div v-if="loading" class="p-6 text-center text-art-muted text-sm">加载中...</div>
|
<div v-if="loading" class="p-6 text-center text-art-muted text-sm">加载中...</div>
|
||||||
@@ -23,8 +36,8 @@
|
|||||||
:key="item.version"
|
:key="item.version"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full text-left px-4 py-3 border-b border-white/5 hover:bg-white/5 transition-colors"
|
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) }"
|
:class="{ 'bg-art-accent/10 border-l-2 border-l-art-accent': activeVersion === item.version }"
|
||||||
@click="toggleVersionSelect(item.version)"
|
@click="selectVersion(item.version)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="text-sm text-white font-medium">v{{ item.version }}</span>
|
<span class="text-sm text-white font-medium">v{{ item.version }}</span>
|
||||||
@@ -36,67 +49,70 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
|
<div class="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||||
<div v-if="viewMode === 'list'" class="text-sm text-art-muted">
|
<div v-if="!activeVersion" class="h-full flex items-center justify-center 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>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="viewMode === 'preview' && previewData">
|
<div v-else-if="detailLoading" class="h-full flex items-center justify-center text-sm text-art-muted">
|
||||||
<div class="flex items-center justify-between mb-4">
|
加载版本详情...
|
||||||
<h4 class="text-white font-medium">版本 v{{ previewData.version }}</h4>
|
</div>
|
||||||
<button type="button" class="text-xs text-art-accent" @click="viewMode = 'list'">返回列表</button>
|
|
||||||
</div>
|
<template v-else-if="previewData">
|
||||||
<div class="space-y-3 text-sm">
|
<section class="mb-8">
|
||||||
<div><span class="text-art-muted">标题:</span><span class="text-white">{{ previewData.title }}</span></div>
|
<h4 class="text-white font-medium mb-4">版本 v{{ previewData.version }} 内容</h4>
|
||||||
<div><span class="text-art-muted">分类:</span><span class="text-white">{{ previewData.categoryName || '—' }}</span></div>
|
<div class="space-y-3 text-sm">
|
||||||
<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.title }}</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.categoryName || '—' }}</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.columnName || '—' }}</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.tagNames?.join(', ') || '—' }}</span></div>
|
||||||
<div><span class="text-art-muted">摘要:</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
|
<div><span class="text-art-muted">状态:</span><span class="text-white">{{ previewData.isPublished === 1 ? '已发布' : '草稿' }}</span></div>
|
||||||
<div>
|
<div><span class="text-art-muted">修改人:</span><span class="text-white">{{ previewData.modifiedByName || previewData.modifiedBy }}</span></div>
|
||||||
<span class="text-art-muted block mb-2">正文:</span>
|
<div><span class="text-art-muted">摘要:</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
|
||||||
<div class="bg-black/30 rounded-lg p-4 max-h-96 overflow-y-auto prose prose-invert prose-sm max-w-none">
|
<div>
|
||||||
<MdPreview :model-value="previewData.content || ''" theme="dark" />
|
<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>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="viewMode === 'diff' && diffData">
|
<section>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<h4 class="text-white font-medium mb-4">
|
||||||
<h4 class="text-white font-medium">v{{ diffData.fromVersion }} → v{{ diffData.toVersion }}</h4>
|
<template v-if="activeVersion > 1">与上一版本对比(v{{ activeVersion - 1 }} → v{{ activeVersion }})</template>
|
||||||
<button type="button" class="text-xs text-art-accent" @click="viewMode = 'list'">返回列表</button>
|
<template v-else>版本对比</template>
|
||||||
</div>
|
</h4>
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div v-if="activeVersion === 1" class="text-sm text-art-muted rounded-lg border border-white/10 p-4 bg-white/[0.02]">
|
||||||
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>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,9 +156,9 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
const historyList = ref<PostHistory[]>([])
|
const historyList = ref<PostHistory[]>([])
|
||||||
const selectedVersions = ref<number[]>([])
|
const activeVersion = ref<number | null>(null)
|
||||||
const viewMode = ref<'list' | 'preview' | 'diff'>('list')
|
|
||||||
const previewData = ref<PostHistory | null>(null)
|
const previewData = ref<PostHistory | null>(null)
|
||||||
const diffData = ref<PostHistoryDiff | null>(null)
|
const diffData = ref<PostHistoryDiff | null>(null)
|
||||||
|
|
||||||
@@ -154,6 +170,9 @@ const loadHistory = async () => {
|
|||||||
try {
|
try {
|
||||||
historyList.value = await getPostHistory(props.postId)
|
historyList.value = await getPostHistory(props.postId)
|
||||||
historyList.value.sort((a, b) => b.version - a.version)
|
historyList.value.sort((a, b) => b.version - a.version)
|
||||||
|
if (historyList.value.length > 0) {
|
||||||
|
await selectVersion(historyList.value[0].version)
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showToast(error.message || '加载历史版本失败', 'error')
|
toast.showToast(error.message || '加载历史版本失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -161,41 +180,26 @@ const loadHistory = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleVersionSelect = (version: number) => {
|
const selectVersion = async (version: number) => {
|
||||||
const idx = selectedVersions.value.indexOf(version)
|
activeVersion.value = version
|
||||||
if (idx >= 0) {
|
detailLoading.value = true
|
||||||
selectedVersions.value.splice(idx, 1)
|
previewData.value = null
|
||||||
return
|
diffData.value = null
|
||||||
}
|
|
||||||
if (selectedVersions.value.length >= 2) {
|
|
||||||
selectedVersions.value.shift()
|
|
||||||
}
|
|
||||||
selectedVersions.value.push(version)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewVersion = async (version?: number) => {
|
|
||||||
if (!version) return
|
|
||||||
try {
|
try {
|
||||||
previewData.value = await getPostHistoryByVersion(props.postId, version)
|
const [detail, diff] = await Promise.all([
|
||||||
viewMode.value = 'preview'
|
getPostHistoryByVersion(props.postId, version),
|
||||||
|
version > 1 ? getPostHistoryDiff(props.postId, version - 1, version) : Promise.resolve(null)
|
||||||
|
])
|
||||||
|
previewData.value = detail
|
||||||
|
diffData.value = diff
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showToast(error.message || '加载版本详情失败', 'error')
|
toast.showToast(error.message || '加载版本详情失败', 'error')
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const compareVersions = async () => {
|
const restoreVersion = async (version: number) => {
|
||||||
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
|
|
||||||
if (!confirm(`确定将文章恢复为版本 v${version} 吗?当前内容会先保存为新版本。`)) return
|
if (!confirm(`确定将文章恢复为版本 v${version} 吗?当前内容会先保存为新版本。`)) return
|
||||||
try {
|
try {
|
||||||
await restorePostHistory(props.postId, version)
|
await restorePostHistory(props.postId, version)
|
||||||
@@ -211,8 +215,7 @@ watch(
|
|||||||
() => props.isOpen,
|
() => props.isOpen,
|
||||||
(open) => {
|
(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
selectedVersions.value = []
|
activeVersion.value = null
|
||||||
viewMode.value = 'list'
|
|
||||||
previewData.value = null
|
previewData.value = null
|
||||||
diffData.value = null
|
diffData.value = null
|
||||||
loadHistory()
|
loadHistory()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Selection -->
|
<!-- 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">
|
<div class="flex items-center justify-between">
|
||||||
<label class="block text-sm font-medium text-white">分类</label>
|
<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">
|
<button @click="showCreateCategory = true" class="text-xs text-art-accent hover:text-white transition-colors">
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Column Selection -->
|
<!-- 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">
|
<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>
|
<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">
|
<button @click="showCreateColumn = true" class="text-xs text-art-accent hover:text-white transition-colors">
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags Selection -->
|
<!-- 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">
|
<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>
|
<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">
|
<button @click="showCreateTag = true" class="text-xs text-art-accent hover:text-white transition-colors">
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, computed } from 'vue'
|
import { ref, watch, onMounted, computed, nextTick } from 'vue'
|
||||||
import {
|
import {
|
||||||
fetchCategories,
|
fetchCategories,
|
||||||
fetchColumns,
|
fetchColumns,
|
||||||
@@ -159,6 +159,7 @@ import ImageUpload from './ImageUpload.vue'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
post: Post | null
|
post: Post | null
|
||||||
|
focusSection?: 'category' | 'column' | 'tags' | 'all'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -186,6 +187,37 @@ const newTag = ref({ name: '', slug: '' })
|
|||||||
|
|
||||||
const saving = ref(false)
|
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(() => {
|
const categoryOptions = computed(() => {
|
||||||
return [
|
return [
|
||||||
@@ -344,6 +376,9 @@ watch(() => props.isOpen, (open) => {
|
|||||||
if (open) {
|
if (open) {
|
||||||
loadData()
|
loadData()
|
||||||
initForm()
|
initForm()
|
||||||
|
scrollToFocusSection()
|
||||||
|
} else {
|
||||||
|
activeHighlight.value = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
56
client/src/components/admin/adminMenuTypes.ts
Normal file
56
client/src/components/admin/adminMenuTypes.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -3,17 +3,17 @@
|
|||||||
<input
|
<input
|
||||||
:name="name"
|
:name="name"
|
||||||
type="text"
|
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"
|
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="placeholder"
|
:placeholder="inputPlaceholder"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
|
:required="required"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="showDropdown = true"
|
@focus="showDropdown = true"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
<label
|
<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="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"
|
||||||
:class="modelValue ? '-top-4 text-white/50' : ''"
|
|
||||||
>{{ label }}</label>
|
>{{ label }}</label>
|
||||||
|
|
||||||
<!-- Autocomplete Dropdown -->
|
<!-- Autocomplete Dropdown -->
|
||||||
@@ -39,10 +39,13 @@ const props = defineProps<{
|
|||||||
label: string
|
label: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
modelValue: string
|
modelValue: string
|
||||||
|
required?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const inputPlaceholder = computed(() => props.placeholder ?? ' ')
|
||||||
|
|
||||||
const showDropdown = ref(false)
|
const showDropdown = ref(false)
|
||||||
const emailSuffixes = ref<string[]>([])
|
const emailSuffixes = ref<string[]>([])
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
@@ -81,3 +84,15 @@ const selectSuggestion = (val: string) => {
|
|||||||
showDropdown.value = false
|
showDropdown.value = false
|
||||||
}
|
}
|
||||||
</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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
108
client/src/composables/useMdMediaUpload.ts
Normal file
108
client/src/composables/useMdMediaUpload.ts
Normal 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(``)
|
||||||
|
} else {
|
||||||
|
insertAtCursor(``)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<section id="blog-detail" class="page-section block">
|
<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 ">
|
<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
|
<button
|
||||||
@click="$router.push('/blog')"
|
@click="$router.push('/blog')"
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 2. 中间:文章主内容 -->
|
<!-- 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="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">
|
<div class="flex flex-wrap items-center gap-2 md:gap-3 mb-4 md:mb-6">
|
||||||
@@ -170,11 +170,40 @@
|
|||||||
<!-- 文章内容 -->
|
<!-- 文章内容 -->
|
||||||
<article
|
<article
|
||||||
ref="articleRef"
|
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>
|
<div v-html="renderedContent"></div>
|
||||||
</article>
|
</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 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">
|
<div v-if="post.tags && post.tags.length > 0">
|
||||||
@@ -334,15 +363,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, nextTick, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
|
import SnippetModal from '../components/SnippetModal.vue'
|
||||||
|
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
@@ -394,6 +435,23 @@ const columnPosts = ref<Post[]>([])
|
|||||||
const isColumnExpanded = ref(false)
|
const isColumnExpanded = ref(false)
|
||||||
const loadingColumnPosts = 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 { initObserver } = useScrollAnimation()
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
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>`
|
const copyIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: block;"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="ios-code-container 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="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 items-center gap-3 md:gap-4">
|
||||||
<div class="flex gap-1.5 md:gap-2">
|
<div class="flex gap-1.5 md:gap-2">
|
||||||
@@ -456,7 +514,7 @@ md.renderer.rules.fence = function (tokens, idx) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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>
|
<pre class="font-mono text-xs md:text-sm leading-relaxed" style="margin:0;">${lineNumbers}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -729,10 +787,23 @@ onBeforeUnmount(() => {
|
|||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
/* 确保这里也强制 pre */
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
white-space: pre !important;
|
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) {
|
:deep(.prose code) {
|
||||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: 'SF Mono', Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,12 +35,43 @@
|
|||||||
<div class="w-3 h-3 rounded-full bg-[#ffbd2e]"></div>
|
<div class="w-3 h-3 rounded-full bg-[#ffbd2e]"></div>
|
||||||
<div class="w-3 h-3 rounded-full bg-[#27c93f]"></div>
|
<div class="w-3 h-3 rounded-full bg-[#27c93f]"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs font-mono text-art-muted group-hover:text-white transition-colors">{{ snippet.title }}</span>
|
<span class="text-xs font-mono text-art-muted group-hover:text-white transition-colors truncate min-w-0 flex-1">{{ snippet.title }}</span>
|
||||||
<div class="text-xs font-mono text-art-accent opacity-0 group-hover:opacity-100 transition-opacity">点击运行 -></div>
|
<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>
|
</div>
|
||||||
<!-- Code Preview (Styled) -->
|
<!-- 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">
|
<div class="p-6 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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,6 +85,7 @@
|
|||||||
:type="currentSnippet.type"
|
:type="currentSnippet.type"
|
||||||
:code-type="currentSnippet.codeType"
|
:code-type="currentSnippet.codeType"
|
||||||
:description="currentSnippet.description"
|
:description="currentSnippet.description"
|
||||||
|
:posts="currentSnippet.posts"
|
||||||
@close="closeSnippet"
|
@close="closeSnippet"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -72,7 +104,7 @@ const loading = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
const modalOpen = ref(false)
|
const modalOpen = ref(false)
|
||||||
const currentSnippet = ref<Snippet>({ id: '', title: '', code: '', type: '' })
|
const currentSnippet = ref<Snippet>({ id: 0, title: '', code: '', type: '' })
|
||||||
|
|
||||||
const { initObserver } = useScrollAnimation()
|
const { initObserver } = useScrollAnimation()
|
||||||
|
|
||||||
@@ -90,7 +122,7 @@ const fetchSnippetsData = async () => {
|
|||||||
// 如果API调用失败,使用静态数据作为备选
|
// 如果API调用失败,使用静态数据作为备选
|
||||||
snippets.value = [
|
snippets.value = [
|
||||||
{
|
{
|
||||||
id: 'mouse',
|
id: 1,
|
||||||
title: 'React 鼠标追踪 Hook',
|
title: 'React 鼠标追踪 Hook',
|
||||||
code: `import { useState, useEffect } from 'react';
|
code: `import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
@@ -106,7 +138,7 @@ export const useMousePosition = () => {
|
|||||||
type: 'mouse'
|
type: 'mouse'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'glass',
|
id: 2,
|
||||||
title: 'CSS 极致毛玻璃效果',
|
title: 'CSS 极致毛玻璃效果',
|
||||||
code: `.glass-panel {
|
code: `.glass-panel {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
@@ -118,7 +150,7 @@ export const useMousePosition = () => {
|
|||||||
type: 'glass'
|
type: 'glass'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'noise',
|
id: 3,
|
||||||
title: 'SVG 噪点纹理滤镜',
|
title: 'SVG 噪点纹理滤镜',
|
||||||
code: `<filter id="noise">
|
code: `<filter id="noise">
|
||||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" />
|
<feTurbulence type="fractalNoise" baseFrequency="0.8" />
|
||||||
@@ -126,7 +158,7 @@ export const useMousePosition = () => {
|
|||||||
type: 'noise'
|
type: 'noise'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'animate',
|
id: 4,
|
||||||
title: 'CSS 流畅动画',
|
title: 'CSS 流畅动画',
|
||||||
code: `.animate-float {
|
code: `.animate-float {
|
||||||
animation: float 6s ease-in-out infinite;
|
animation: float 6s ease-in-out infinite;
|
||||||
@@ -150,6 +182,10 @@ const snippetLang = (snippet: Snippet) => {
|
|||||||
return resolveHighlightLanguage(snippet.codeType?.name || snippet.type)
|
return resolveHighlightLanguage(snippet.codeType?.name || snippet.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLongCode = (code: string) => {
|
||||||
|
return code.split('\n').length > 15 || code.length > 600
|
||||||
|
}
|
||||||
|
|
||||||
const openSnippet = (snippet: Snippet) => {
|
const openSnippet = (snippet: Snippet) => {
|
||||||
currentSnippet.value = snippet
|
currentSnippet.value = snippet
|
||||||
modalOpen.value = true
|
modalOpen.value = true
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<div class="inquiries-container">
|
<div class="inquiries-container">
|
||||||
<h1 class="page-title">合作咨询管理</h1>
|
<h1 class="page-title">合作咨询管理</h1>
|
||||||
|
|
||||||
<!-- Inquiries Table -->
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -41,15 +40,22 @@
|
|||||||
{{ getStatusLabel(item.status) }}
|
{{ getStatusLabel(item.status) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<td class="table-cell actions">
|
||||||
|
<button
|
||||||
|
class="btn-action btn-view"
|
||||||
|
@click="openDetail(item)"
|
||||||
|
title="查看详情"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="item.status === 0"
|
v-if="item.status === 0"
|
||||||
class="btn-action btn-read"
|
class="btn-action btn-read"
|
||||||
@click="updateStatus(item.id!, 1)"
|
@click="updateStatus(item.id!, 1)"
|
||||||
title="标记为已读"
|
title="标记为已读"
|
||||||
>
|
>
|
||||||
👁️
|
已读
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="item.status !== 2"
|
v-if="item.status !== 2"
|
||||||
@@ -57,18 +63,23 @@
|
|||||||
@click="updateStatus(item.id!, 2)"
|
@click="updateStatus(item.id!, 2)"
|
||||||
title="标记为已联系"
|
title="标记为已联系"
|
||||||
>
|
>
|
||||||
✅
|
已联系
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="inquiries.length === 0" class="empty-state">
|
<div v-if="inquiries.length === 0" class="empty-state">
|
||||||
<p>暂无咨询数据</p>
|
<p>暂无咨询数据</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InquiryDetailModal
|
||||||
|
v-model:is-open="isDetailModalOpen"
|
||||||
|
:inquiry="selectedInquiry"
|
||||||
|
@update-status="handleDetailStatusUpdate"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -76,9 +87,12 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { API_BASE, authFetchJson, fetchInquiries, getAuthHeaders, type Inquiry } from '../../services/api'
|
import { API_BASE, authFetchJson, fetchInquiries, getAuthHeaders, type Inquiry } from '../../services/api'
|
||||||
import { useToast } from '../../composables/useToast'
|
import { useToast } from '../../composables/useToast'
|
||||||
|
import InquiryDetailModal from '../../components/admin/InquiryDetailModal.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const inquiries = ref<Inquiry[]>([])
|
const inquiries = ref<Inquiry[]>([])
|
||||||
|
const isDetailModalOpen = ref(false)
|
||||||
|
const selectedInquiry = ref<Inquiry | null>(null)
|
||||||
|
|
||||||
const fetchInquiriesList = async () => {
|
const fetchInquiriesList = async () => {
|
||||||
try {
|
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) => {
|
const updateStatus = async (id: number, status: number) => {
|
||||||
try {
|
try {
|
||||||
await authFetchJson(`${API_BASE}/admin/inquiries/${id}/status`, {
|
await authFetchJson(`${API_BASE}/admin/inquiries/${id}/status`, {
|
||||||
@@ -97,13 +116,32 @@ const updateStatus = async (id: number, status: number) => {
|
|||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
})
|
})
|
||||||
toast.success('状态更新成功')
|
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) {
|
} catch (error: any) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error(error.message || '更新失败')
|
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 getContactMethodLabel = (method: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'wechat': '微信',
|
'wechat': '微信',
|
||||||
@@ -198,17 +236,26 @@ onMounted(() => {
|
|||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action {
|
.btn-action {
|
||||||
padding: 0.5rem;
|
padding: 0.35rem 0.65rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 0.75rem;
|
||||||
transition: all 0.3s;
|
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 {
|
.btn-read {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.1);
|
||||||
color: rgba(59, 130, 246, 0.8);
|
color: rgba(59, 130, 246, 0.8);
|
||||||
@@ -230,4 +277,4 @@ onMounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,25 +60,54 @@
|
|||||||
<tr v-for="post in posts" :key="post.id" class="group transition-colors duration-200">
|
<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-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 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>
|
<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 }}
|
{{ post.columnName }}
|
||||||
</span>
|
</button>
|
||||||
<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, 'column')"
|
||||||
|
>
|
||||||
|
无专栏
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-1">
|
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-1">
|
||||||
<span
|
<button
|
||||||
v-for="tag in post.tags.slice(0, 3)"
|
v-for="tag in post.tags.slice(0, 3)"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
class="px-2 py-0.5 rounded bg-white/5 border border-white/5 text-xs text-art-muted"
|
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 }}
|
#{{ tag.name }}
|
||||||
</span>
|
</button>
|
||||||
<span v-if="post.tags.length > 3" class="text-art-muted text-xs">+{{ post.tags.length - 3 }}</span>
|
<span v-if="post.tags.length > 3" class="text-art-muted text-xs self-center">+{{ post.tags.length - 3 }}</span>
|
||||||
</div>
|
</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>
|
||||||
<td class="text-art-muted text-xs">{{ post.date }}</td>
|
<td class="text-art-muted text-xs">{{ post.date }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -93,17 +122,20 @@
|
|||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
@click="openAccessLogModal(post)"
|
@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="查看访问记录"
|
title="查看访问记录"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<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 @click="openRelationModal(post)" class="admin-btn-secondary py-1 px-3 text-xs">
|
||||||
管理
|
管理
|
||||||
</button>
|
</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 :to="`/admin/posts/${post.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs">
|
||||||
编辑
|
编辑
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -169,9 +201,17 @@
|
|||||||
<PostRelationModal
|
<PostRelationModal
|
||||||
v-model:isOpen="isRelationModalOpen"
|
v-model:isOpen="isRelationModalOpen"
|
||||||
:post="editingPost"
|
:post="editingPost"
|
||||||
|
:focus-section="relationFocusSection"
|
||||||
@saved="fetchPosts"
|
@saved="fetchPosts"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PostHistoryModal
|
||||||
|
:is-open="isHistoryModalOpen"
|
||||||
|
:post-id="historyPostId"
|
||||||
|
@close="isHistoryModalOpen = false"
|
||||||
|
@restored="fetchPosts"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Access Log Modal -->
|
<!-- Access Log Modal -->
|
||||||
<AccessLogModal
|
<AccessLogModal
|
||||||
v-model:isOpen="isAccessLogModalOpen"
|
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 { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus } from '../../services/api'
|
||||||
import { useToast } from '../../composables/useToast'
|
import { useToast } from '../../composables/useToast'
|
||||||
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
|
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
|
||||||
|
import PostHistoryModal from '../../components/admin/PostHistoryModal.vue'
|
||||||
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
|
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
|
||||||
|
|
||||||
|
type RelationFocusSection = 'category' | 'column' | 'tags' | 'all'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const posts = ref<Post[]>([])
|
const posts = ref<Post[]>([])
|
||||||
const isRelationModalOpen = ref(false)
|
const isRelationModalOpen = ref(false)
|
||||||
const editingPost = ref<Post | null>(null)
|
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 isAccessLogModalOpen = ref(false)
|
||||||
const selectedPostId = ref<number | null>(null)
|
const selectedPostId = ref<number | null>(null)
|
||||||
const selectedPostTitle = ref<string>('')
|
const selectedPostTitle = ref<string>('')
|
||||||
@@ -226,11 +272,17 @@ const visiblePages = computed(() => {
|
|||||||
return pages
|
return pages
|
||||||
})
|
})
|
||||||
|
|
||||||
const openRelationModal = (post: Post) => {
|
const openRelationModal = (post: Post, section: RelationFocusSection = 'all') => {
|
||||||
editingPost.value = post
|
editingPost.value = post
|
||||||
|
relationFocusSection.value = section
|
||||||
isRelationModalOpen.value = true
|
isRelationModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openHistoryModal = (post: Post) => {
|
||||||
|
historyPostId.value = post.id
|
||||||
|
isHistoryModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const openAccessLogModal = (post: Post) => {
|
const openAccessLogModal = (post: Post) => {
|
||||||
selectedPostId.value = post.id
|
selectedPostId.value = post.id
|
||||||
selectedPostTitle.value = post.title
|
selectedPostTitle.value = post.title
|
||||||
|
|||||||
@@ -1,160 +1,222 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="snippet-form-container">
|
<div class="w-full h-[calc(100vh-8rem)] flex flex-col animate-reveal">
|
||||||
<h1 class="page-title">{{ isEditing ? '编辑代码片段' : '新建代码片段' }}</h1>
|
<!-- 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="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 overflow-hidden min-h-0">
|
||||||
<form @submit.prevent="handleSubmit" class="snippet-form">
|
<!-- Left: Code / Description tabs -->
|
||||||
<!-- Title Field -->
|
<div class="flex-1 flex flex-col min-w-0 min-h-[45vh] lg:min-h-0 h-full">
|
||||||
<div class="form-group">
|
<div class="admin-card flex-1 flex flex-col overflow-hidden border border-white/10 min-h-0">
|
||||||
<label for="title">标题</label>
|
<div class="flex border-b border-white/10 shrink-0">
|
||||||
<input
|
<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"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
|
class="admin-input"
|
||||||
placeholder="请输入代码片段标题"
|
placeholder="请输入代码片段标题"
|
||||||
required
|
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>
|
||||||
<div class="error-message" v-if="errors.code">
|
|
||||||
{{ errors.code }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description Field -->
|
<div class="space-y-2">
|
||||||
<div class="form-group">
|
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">类型</label>
|
||||||
<label for="description">描述</label>
|
<CustomSelect
|
||||||
<textarea
|
v-model.number="form.codeTypeId"
|
||||||
id="description"
|
:options="codeTypeOptions"
|
||||||
v-model="form.description"
|
placeholder="请选择代码类型"
|
||||||
placeholder="请输入代码片段描述"
|
/>
|
||||||
rows="3"
|
<div class="error-message" v-if="errors.codeTypeId">{{ errors.codeTypeId }}</div>
|
||||||
></textarea>
|
</div>
|
||||||
<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">
|
||||||
|
关联文章 <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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Submit Buttons -->
|
</form>
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
// Import MdEditor
|
|
||||||
import { MdEditor } from 'md-editor-v3'
|
import { MdEditor } from 'md-editor-v3'
|
||||||
|
import type { ExposeParam } from 'md-editor-v3'
|
||||||
import 'md-editor-v3/lib/style.css'
|
import 'md-editor-v3/lib/style.css'
|
||||||
|
|
||||||
import { useToast } from '../../composables/useToast'
|
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'
|
import CustomSelect from '../../components/CustomSelect.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
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 isSubmitting = ref(false)
|
||||||
const isEditing = computed(() => !!route.params.id)
|
const isEditing = computed(() => !!route.params.id)
|
||||||
const errors = reactive<Record<string, string>>({})
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
|
||||||
// Form data
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
code: '',
|
code: '',
|
||||||
codeTypeId: 0,
|
codeTypeId: 0,
|
||||||
description: ''
|
description: '',
|
||||||
|
postIds: [] as number[]
|
||||||
})
|
})
|
||||||
|
|
||||||
const codeTypeOptions = ref<{ value: number; label: string }[]>([])
|
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 => {
|
const validateForm = (): boolean => {
|
||||||
// Reset errors
|
|
||||||
Object.keys(errors).forEach(key => delete errors[key])
|
Object.keys(errors).forEach(key => delete errors[key])
|
||||||
|
|
||||||
let isValid = true
|
let isValid = true
|
||||||
|
|
||||||
// Validate title
|
|
||||||
if (!form.title.trim()) {
|
if (!form.title.trim()) {
|
||||||
errors.title = '代码片段标题不能为空'
|
errors.title = '代码片段标题不能为空'
|
||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate type
|
|
||||||
if (!form.codeTypeId) {
|
if (!form.codeTypeId) {
|
||||||
errors.codeTypeId = '请选择代码类型'
|
errors.codeTypeId = '请选择代码类型'
|
||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate code
|
|
||||||
if (!form.code.trim()) {
|
if (!form.code.trim()) {
|
||||||
errors.code = '代码内容不能为空'
|
errors.code = '代码内容不能为空'
|
||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid
|
return isValid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit handler
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!validateForm()) {
|
if (!validateForm()) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
// Update existing snippet
|
|
||||||
await updateSnippet(route.params.id as string, form)
|
await updateSnippet(route.params.id as string, form)
|
||||||
toast.success('代码片段更新成功')
|
toast.success('代码片段更新成功')
|
||||||
} else {
|
} else {
|
||||||
// Create new snippet
|
const result = await createSnippet(form)
|
||||||
await createSnippet(form)
|
|
||||||
toast.success('代码片段创建成功')
|
toast.success('代码片段创建成功')
|
||||||
|
if (result.id) {
|
||||||
|
router.push(`/admin/snippets/${result.id}/edit`)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to snippets list
|
|
||||||
router.push('/admin/snippets')
|
router.push('/admin/snippets')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error submitting form:', error)
|
console.error('Error submitting form:', error)
|
||||||
@@ -164,18 +226,20 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel handler
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
router.push('/admin/snippets')
|
router.push('/admin/snippets')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
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 ? '后端' : '其他'})` }))
|
codeTypeOptions.value = types.map(t => ({ value: t.id, label: `${t.name} (${t.category === 0 ? '前端' : t.category === 1 ? '后端' : '其他'})` }))
|
||||||
|
adminPosts.value = postsRes.list || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load code types:', error)
|
console.error('Failed to load form data:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
@@ -186,6 +250,7 @@ onMounted(async () => {
|
|||||||
form.code = snippet.code
|
form.code = snippet.code
|
||||||
form.codeTypeId = snippet.codeTypeId || snippet.codeType?.id || 0
|
form.codeTypeId = snippet.codeTypeId || snippet.codeType?.id || 0
|
||||||
form.description = snippet.description || ''
|
form.description = snippet.description || ''
|
||||||
|
form.postIds = snippet.posts?.map(p => p.id) || []
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to fetch snippet data:', error)
|
console.error('Failed to fetch snippet data:', error)
|
||||||
toast.error('加载代码片段数据失败: ' + (error.message || '未知错误'))
|
toast.error('加载代码片段数据失败: ' + (error.message || '未知错误'))
|
||||||
@@ -195,147 +260,66 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.error-message {
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
margin-top: 0.25rem;
|
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 {
|
.editor-wrapper {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
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) {
|
:deep(.custom-md-editor) {
|
||||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
background-color: transparent !important;
|
||||||
--md-bk-color: transparent !important;
|
--md-bk-color: transparent !important;
|
||||||
--md-color: white !important;
|
--md-color: white !important;
|
||||||
--md-border-color: transparent !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) {
|
:deep(.md-editor-toolbar-wrapper) {
|
||||||
@@ -351,20 +335,4 @@ onMounted(async () => {
|
|||||||
background-color: rgba(0, 0, 0, 0.2) !important;
|
background-color: rgba(0, 0, 0, 0.2) !important;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
|
border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
/* Responsive Design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.form-container {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn,
|
|
||||||
.submit-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const fetchSnippets = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSnippet = async (id: string) => {
|
const deleteSnippet = async (id: number) => {
|
||||||
if (confirm('确定要删除这个代码片段吗?')) {
|
if (confirm('确定要删除这个代码片段吗?')) {
|
||||||
try {
|
try {
|
||||||
await deleteSnippetApi(id)
|
await deleteSnippetApi(id)
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export interface Post {
|
|||||||
content?: string
|
content?: string
|
||||||
isPublished?: number
|
isPublished?: number
|
||||||
readCount?: number
|
readCount?: number
|
||||||
|
snippets?: SnippetBrief[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostHistory {
|
export interface PostHistory {
|
||||||
@@ -255,9 +256,22 @@ export const deleteCodeType = async (id: number): Promise<void> => {
|
|||||||
await parseApiResponse(response)
|
await parseApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文章/代码片段简要类型
|
||||||
|
export interface PostBrief {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnippetBrief {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
codeType?: CodeType
|
||||||
|
}
|
||||||
|
|
||||||
// 代码片段相关类型
|
// 代码片段相关类型
|
||||||
export interface Snippet {
|
export interface Snippet {
|
||||||
id: string
|
id: number
|
||||||
title: string
|
title: string
|
||||||
code: string
|
code: string
|
||||||
type: string
|
type: string
|
||||||
@@ -265,6 +279,7 @@ export interface Snippet {
|
|||||||
codeType?: CodeType
|
codeType?: CodeType
|
||||||
description?: string
|
description?: string
|
||||||
viewCount?: number
|
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 {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/snippets/${id}`)
|
const response = await fetch(`${API_BASE}/snippets/${id}`)
|
||||||
return await parseApiResponse(response)
|
return await parseApiResponse(response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching snippet ${id}:`, error)
|
console.error(`Error fetching snippet ${id}:`, error)
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: typeof id === 'number' ? id : 0,
|
||||||
title: '默认代码片段',
|
title: '默认代码片段',
|
||||||
code: 'console.log("Hello World");',
|
code: 'console.log("Hello World");',
|
||||||
type: 'js'
|
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 {
|
try {
|
||||||
const response = await authFetch(`${API_BASE}/admin/snippets`, {
|
const response = await authFetch(`${API_BASE}/admin/snippets`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify(snippetData)
|
body: JSON.stringify(snippetData)
|
||||||
})
|
})
|
||||||
await parseApiResponse(response)
|
const data = await parseApiResponse<{ id: number }>(response)
|
||||||
|
return data || { id: 0 }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create snippet error:', error)
|
console.error('Create snippet error:', error)
|
||||||
throw 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 {
|
try {
|
||||||
const response = await authFetch(`${API_BASE}/admin/snippets/${id}`, {
|
const response = await authFetch(`${API_BASE}/admin/snippets/${id}`, {
|
||||||
method: 'PUT',
|
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 {
|
try {
|
||||||
const response = await authFetch(`${API_BASE}/admin/snippets/${id}`, {
|
const response = await authFetch(`${API_BASE}/admin/snippets/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
74
client/src/utils/markdownRenderer.ts
Normal file
74
client/src/utils/markdownRenderer.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -11,6 +11,29 @@ import (
|
|||||||
"github.com/niangaodev/art-code/utils"
|
"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) {
|
func GetPosts(c *gin.Context) {
|
||||||
// 获取查询参数
|
// 获取查询参数
|
||||||
@@ -252,15 +275,9 @@ func AdminCreatePost(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存历史记录
|
historySaved := trySavePostHistory(c, post.ID)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存历史记录
|
historySaved := trySavePostHistory(c, postID)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, _ := c.Get("userID")
|
historySaved := trySavePostHistory(c, postID)
|
||||||
if saved, err := repositories.GetPostByIDAdmin(postID); err == nil && saved != nil {
|
|
||||||
_ = repositories.SavePostHistory(saved, userID.(uint))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, _ := c.Get("userID")
|
historySaved := trySavePostHistory(c, postID)
|
||||||
if saved, err := repositories.GetPostByIDAdmin(postID); err == nil && saved != nil {
|
|
||||||
_ = repositories.SavePostHistory(saved, userID.(uint))
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SuccessWithMsg(c, "Post status updated successfully", nil)
|
utils.SuccessWithMsg(c, "Post status updated successfully", gin.H{"historySaved": historySaved})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除文章
|
// 删除文章
|
||||||
|
|||||||
@@ -9,6 +9,36 @@ import (
|
|||||||
"github.com/niangaodev/art-code/utils"
|
"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 获取所有代码片段
|
// GetSnippets 获取所有代码片段
|
||||||
func GetSnippets(c *gin.Context) {
|
func GetSnippets(c *gin.Context) {
|
||||||
snippets, err := repositories.GetSnippets()
|
snippets, err := repositories.GetSnippets()
|
||||||
@@ -17,7 +47,6 @@ func GetSnippets(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建响应
|
|
||||||
responses := repositories.BuildSnippetsResponse(snippets)
|
responses := repositories.BuildSnippetsResponse(snippets)
|
||||||
if responses == nil {
|
if responses == nil {
|
||||||
utils.Success(c, []interface{}{})
|
utils.Success(c, []interface{}{})
|
||||||
@@ -27,8 +56,11 @@ func GetSnippets(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetSnippet(c *gin.Context) {
|
func GetSnippet(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id, ok := parseSnippetID(c)
|
||||||
// 从数据库获取代码片段
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
snippet, err := repositories.GetSnippetByID(id)
|
snippet, err := repositories.GetSnippetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ServerError(c, err)
|
utils.ServerError(c, err)
|
||||||
@@ -40,10 +72,7 @@ func GetSnippet(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建响应
|
utils.Success(c, repositories.BuildSnippetResponse(snippet))
|
||||||
response := repositories.BuildSnippetResponse(snippet)
|
|
||||||
|
|
||||||
utils.Success(c, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminGetSnippets 获取代码片段列表 (后台)
|
// AdminGetSnippets 获取代码片段列表 (后台)
|
||||||
@@ -73,12 +102,13 @@ func AdminGetSnippets(c *gin.Context) {
|
|||||||
|
|
||||||
// AdminCreateSnippet 创建代码片段
|
// AdminCreateSnippet 创建代码片段
|
||||||
func AdminCreateSnippet(c *gin.Context) {
|
func AdminCreateSnippet(c *gin.Context) {
|
||||||
var snippet models.Snippet
|
var payload snippetPayload
|
||||||
if err := c.ShouldBindJSON(&snippet); err != nil {
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
utils.Error(c, 400, "Invalid request")
|
utils.Error(c, 400, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snippet := payload.toSnippet(0)
|
||||||
syncSnippetTypeFromCodeType(&snippet)
|
syncSnippetTypeFromCodeType(&snippet)
|
||||||
|
|
||||||
if err := repositories.CreateSnippet(&snippet); err != nil {
|
if err := repositories.CreateSnippet(&snippet); err != nil {
|
||||||
@@ -86,20 +116,28 @@ func AdminCreateSnippet(c *gin.Context) {
|
|||||||
return
|
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})
|
utils.SuccessWithMsg(c, "Snippet created successfully", gin.H{"id": snippet.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUpdateSnippet 更新代码片段
|
// AdminUpdateSnippet 更新代码片段
|
||||||
func AdminUpdateSnippet(c *gin.Context) {
|
func AdminUpdateSnippet(c *gin.Context) {
|
||||||
idStr := c.Param("id")
|
id, ok := parseSnippetID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var snippet models.Snippet
|
var payload snippetPayload
|
||||||
if err := c.ShouldBindJSON(&snippet); err != nil {
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
utils.Error(c, 400, "Invalid request")
|
utils.Error(c, 400, "Invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
snippet.ID = idStr
|
snippet := payload.toSnippet(id)
|
||||||
syncSnippetTypeFromCodeType(&snippet)
|
syncSnippetTypeFromCodeType(&snippet)
|
||||||
|
|
||||||
if err := repositories.UpdateSnippet(&snippet); err != nil {
|
if err := repositories.UpdateSnippet(&snippet); err != nil {
|
||||||
@@ -107,6 +145,11 @@ func AdminUpdateSnippet(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := repositories.ReplaceSnippetPosts(snippet.ID, payload.PostIDs); err != nil {
|
||||||
|
utils.ServerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
utils.SuccessWithMsg(c, "Snippet updated successfully", nil)
|
utils.SuccessWithMsg(c, "Snippet updated successfully", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +165,12 @@ func syncSnippetTypeFromCodeType(snippet *models.Snippet) {
|
|||||||
|
|
||||||
// AdminDeleteSnippet 删除代码片段
|
// AdminDeleteSnippet 删除代码片段
|
||||||
func AdminDeleteSnippet(c *gin.Context) {
|
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)
|
utils.ServerError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/niangaodev/art-code/config"
|
"github.com/niangaodev/art-code/config"
|
||||||
"github.com/niangaodev/art-code/handlers"
|
"github.com/niangaodev/art-code/handlers"
|
||||||
"github.com/niangaodev/art-code/middleware"
|
"github.com/niangaodev/art-code/middleware"
|
||||||
|
"github.com/niangaodev/art-code/repositories"
|
||||||
"github.com/niangaodev/art-code/utils"
|
"github.com/niangaodev/art-code/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ func main() {
|
|||||||
|
|
||||||
// 运行数据库迁移 (Convert Datetime to BigInt)
|
// 运行数据库迁移 (Convert Datetime to BigInt)
|
||||||
// repositories.MigrateToBigInt() // 已禁用自动迁移
|
// repositories.MigrateToBigInt() // 已禁用自动迁移
|
||||||
|
repositories.MigratePostHistoryFields()
|
||||||
|
repositories.MigratePostSnippets()
|
||||||
|
|
||||||
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
||||||
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
|
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
|
||||||
|
|||||||
@@ -54,20 +54,21 @@ func (p *Post) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
|
|
||||||
// PostResponse 博客文章响应模型
|
// PostResponse 博客文章响应模型
|
||||||
type PostResponse struct {
|
type PostResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
CategoryID uint `json:"categoryId"`
|
CategoryID uint `json:"categoryId"`
|
||||||
CategoryName string `json:"categoryName"`
|
CategoryName string `json:"categoryName"`
|
||||||
CategorySlug string `json:"categorySlug"`
|
CategorySlug string `json:"categorySlug"`
|
||||||
ColumnID *uint `json:"columnId,omitempty"`
|
ColumnID *uint `json:"columnId,omitempty"`
|
||||||
ColumnName string `json:"columnName,omitempty"`
|
ColumnName string `json:"columnName,omitempty"`
|
||||||
ColumnSlug string `json:"columnSlug,omitempty"`
|
ColumnSlug string `json:"columnSlug,omitempty"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
Excerpt string `json:"excerpt,omitempty"`
|
Excerpt string `json:"excerpt,omitempty"`
|
||||||
Content *string `json:"content,omitempty"` // 使用指针类型,当 includeContent=true 时总是设置
|
Content *string `json:"content,omitempty"` // 使用指针类型,当 includeContent=true 时总是设置
|
||||||
Tags []Tag `json:"tags,omitempty"`
|
Tags []Tag `json:"tags,omitempty"`
|
||||||
IsPublished int `json:"isPublished,omitempty"`
|
IsPublished int `json:"isPublished,omitempty"`
|
||||||
ReadCount uint `json:"readCount,omitempty"`
|
ReadCount uint `json:"readCount,omitempty"`
|
||||||
|
Snippets []SnippetBrief `json:"snippets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostHistory 文章历史记录模型
|
// PostHistory 文章历史记录模型
|
||||||
|
|||||||
27
server/models/post_snippet.go
Normal file
27
server/models/post_snippet.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
// Snippet 代码片段模型
|
// Snippet 代码片段模型
|
||||||
type Snippet struct {
|
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"`
|
Title string `json:"title" gorm:"column:title"`
|
||||||
Code string `json:"code" gorm:"column:code;type:text"`
|
Code string `json:"code" gorm:"column:code;type:text"`
|
||||||
Type string `json:"type" gorm:"column:type"`
|
Type string `json:"type" gorm:"column:type"`
|
||||||
@@ -48,7 +48,7 @@ func (s *Snippet) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
|
|
||||||
// SnippetResponse 代码片段响应模型
|
// SnippetResponse 代码片段响应模型
|
||||||
type SnippetResponse struct {
|
type SnippetResponse struct {
|
||||||
ID string `json:"id"`
|
ID uint `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -56,4 +56,5 @@ type SnippetResponse struct {
|
|||||||
CodeType *CodeTypeResponse `json:"codeType,omitempty"`
|
CodeType *CodeTypeResponse `json:"codeType,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
ViewCount uint `json:"viewCount,omitempty"`
|
ViewCount uint `json:"viewCount,omitempty"`
|
||||||
|
Posts []PostBrief `json:"posts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ CREATE TABLE `about_profiles` (
|
|||||||
`wechat` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '',
|
`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',
|
`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',
|
`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,
|
`created_at` bigint NOT NULL DEFAULT 0,
|
||||||
`updated_at` bigint NOT NULL DEFAULT 0,
|
`updated_at` bigint NOT NULL DEFAULT 0,
|
||||||
`deleted_at` bigint NOT NULL DEFAULT 0,
|
`deleted_at` bigint NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (`id`) USING BTREE
|
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
|
-- Table structure for access_logs
|
||||||
@@ -310,8 +310,8 @@ CREATE TABLE `posts` (
|
|||||||
`column_id` int UNSIGNED NULL DEFAULT NULL COMMENT '专栏ID(可选)',
|
`column_id` int UNSIGNED NULL DEFAULT NULL COMMENT '专栏ID(可选)',
|
||||||
`excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '文章摘要',
|
`excerpt` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '文章摘要',
|
||||||
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文章内容',
|
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文章内容',
|
||||||
`read_count` int UNSIGNED NULL DEFAULT 0 COMMENT '阅读量',
|
`read_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读量',
|
||||||
`is_published` tinyint(1) NULL DEFAULT 1 COMMENT '是否已发布(0:草稿,1:已发布)',
|
`is_published` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否已发布(0:草稿,1:已发布)',
|
||||||
`deleted_at` bigint NOT NULL DEFAULT 0,
|
`deleted_at` bigint NOT NULL DEFAULT 0,
|
||||||
`created_at` bigint NOT NULL DEFAULT 0,
|
`created_at` bigint NOT NULL DEFAULT 0,
|
||||||
`updated_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`;
|
DROP TABLE IF EXISTS `code_types`;
|
||||||
CREATE TABLE `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 '类型名称',
|
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '类型名称',
|
||||||
`category` tinyint NOT NULL DEFAULT 0 COMMENT '0前端 1后端 2其他',
|
`category` tinyint NOT NULL DEFAULT 0 COMMENT '0前端 1后端 2其他',
|
||||||
`deleted_at` bigint NOT NULL DEFAULT 0,
|
`deleted_at` bigint NOT NULL DEFAULT 0,
|
||||||
@@ -403,13 +403,13 @@ CREATE TABLE `code_types` (
|
|||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
DROP TABLE IF EXISTS `snippets`;
|
DROP TABLE IF EXISTS `snippets`;
|
||||||
CREATE TABLE `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 '代码片段标题',
|
`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 '代码内容',
|
`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等)',
|
`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',
|
`code_type_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT '代码类型ID',
|
||||||
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '代码片段描述',
|
`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,
|
`deleted_at` bigint NOT NULL DEFAULT 0,
|
||||||
`created_at` bigint NOT NULL DEFAULT 0,
|
`created_at` bigint NOT NULL DEFAULT 0,
|
||||||
`updated_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 '按精选状态查询索引'
|
INDEX `idx_is_featured`(`is_featured` ASC) USING BTREE COMMENT '按精选状态查询索引'
|
||||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品表' ROW_FORMAT = DYNAMIC;
|
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品表' ROW_FORMAT = DYNAMIC;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- 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;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|||||||
@@ -112,3 +112,47 @@ func execSQL(query string) {
|
|||||||
log.Printf("SQL Error: %v | Query: %s", err, query)
|
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='文章代码片段关联表'`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -391,6 +391,10 @@ func BuildPostResponse(post *models.Post, includeContent bool) *models.PostRespo
|
|||||||
response.Content = &content
|
response.Content = &content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if snippets, err := GetSnippetsByPostID(post.ID); err == nil && len(snippets) > 0 {
|
||||||
|
response.Snippets = snippets
|
||||||
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
132
server/repositories/post_snippet_repository.go
Normal file
132
server/repositories/post_snippet_repository.go
Normal 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
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ func GetSnippets() ([]models.Snippet, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetSnippetByID 根据ID获取代码片段
|
// GetSnippetByID 根据ID获取代码片段
|
||||||
func GetSnippetByID(id string) (*models.Snippet, error) {
|
func GetSnippetByID(id uint) (*models.Snippet, error) {
|
||||||
var snippet models.Snippet
|
var snippet models.Snippet
|
||||||
err := config.DB.Model(&models.Snippet{}).
|
err := config.DB.Model(&models.Snippet{}).
|
||||||
Where("id = ? AND deleted_at = ?", id, 0).
|
Where("id = ? AND deleted_at = ?", id, 0).
|
||||||
@@ -47,6 +47,10 @@ func GetSnippetByID(id string) (*models.Snippet, error) {
|
|||||||
|
|
||||||
// BuildSnippetResponse 构建代码片段响应
|
// BuildSnippetResponse 构建代码片段响应
|
||||||
func BuildSnippetResponse(snippet *models.Snippet) *models.SnippetResponse {
|
func BuildSnippetResponse(snippet *models.Snippet) *models.SnippetResponse {
|
||||||
|
return BuildSnippetResponseWithPosts(snippet, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildSnippetResponseWithPosts(snippet *models.Snippet, posts []models.PostBrief) *models.SnippetResponse {
|
||||||
resp := &models.SnippetResponse{
|
resp := &models.SnippetResponse{
|
||||||
ID: snippet.ID,
|
ID: snippet.ID,
|
||||||
Title: snippet.Title,
|
Title: snippet.Title,
|
||||||
@@ -63,14 +67,27 @@ func BuildSnippetResponse(snippet *models.Snippet) *models.SnippetResponse {
|
|||||||
resp.Type = ct.Name
|
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
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSnippetsResponse 构建代码片段列表响应
|
// BuildSnippetsResponse 构建代码片段列表响应
|
||||||
func BuildSnippetsResponse(snippets []models.Snippet) []models.SnippetResponse {
|
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
|
var responses []models.SnippetResponse
|
||||||
for _, snippet := range snippets {
|
for _, snippet := range snippets {
|
||||||
responses = append(responses, *BuildSnippetResponse(&snippet))
|
responses = append(responses, *BuildSnippetResponseWithPosts(&snippet, postsMap[snippet.ID]))
|
||||||
}
|
}
|
||||||
return responses
|
return responses
|
||||||
}
|
}
|
||||||
@@ -105,7 +122,7 @@ func UpdateSnippet(snippet *models.Snippet) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSnippet 删除代码片段 (Soft Delete)
|
// DeleteSnippet 删除代码片段 (Soft Delete)
|
||||||
func DeleteSnippet(id string) error {
|
func DeleteSnippet(id uint) error {
|
||||||
err := config.DB.Model(&models.Snippet{}).
|
err := config.DB.Model(&models.Snippet{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Update("deleted_at", time.Now().Unix()).Error
|
Update("deleted_at", time.Now().Unix()).Error
|
||||||
|
|||||||
Reference in New Issue
Block a user