优化页面、修复BUG

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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