460 lines
16 KiB
Vue
460 lines
16 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue"
|
|
import {
|
|
Calendar,
|
|
Tag,
|
|
DollarSign,
|
|
Award,
|
|
Layers,
|
|
Server,
|
|
Database,
|
|
Code,
|
|
Monitor,
|
|
CheckCircle,
|
|
User,
|
|
Image as ImageIcon,
|
|
Play,
|
|
X
|
|
} from "lucide-vue-next"
|
|
|
|
const props = defineProps({
|
|
visible: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
project: {
|
|
type: Object,
|
|
default: () => ({})
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['update:visible', 'close'])
|
|
|
|
const closeModal = () => {
|
|
emit('update:visible', false)
|
|
emit('close')
|
|
}
|
|
|
|
// 处理ESC键关闭模态框
|
|
const handleKeyDown = (e) => {
|
|
if (e.key === 'Escape' && props.visible) {
|
|
closeModal()
|
|
}
|
|
}
|
|
|
|
// 监听键盘事件
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('keydown', handleKeyDown)
|
|
})
|
|
|
|
// 防止模态框打开时页面滚动
|
|
watch(() => props.visible, (newVal) => {
|
|
if (newVal) {
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
}
|
|
})
|
|
|
|
// 图片预览相关
|
|
const previewVisible = ref(false)
|
|
const previewImage = ref("")
|
|
|
|
const handlePreview = (url) => {
|
|
previewImage.value = url
|
|
previewVisible.value = true
|
|
}
|
|
|
|
// 视频预览相关
|
|
const videoPreviewVisible = ref(false)
|
|
const videoPreviewUrl = ref("")
|
|
|
|
const handleVideoPreview = () => {
|
|
videoPreviewUrl.value = props.project.video_url
|
|
videoPreviewVisible.value = true
|
|
}
|
|
|
|
// 格式化价格
|
|
const formatPrice = (price) => {
|
|
return "¥ " + parseFloat(price).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
}
|
|
|
|
// 格式化日期
|
|
const formatDate = (dateString) => {
|
|
if (!dateString) return '-'
|
|
const date = new Date(dateString)
|
|
return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
|
|
}
|
|
|
|
// 计算优惠比例
|
|
const discountPercentage = computed(() => {
|
|
if (!props.project.price || !props.project.preferential_price) return 0
|
|
const originalPrice = parseFloat(props.project.price)
|
|
const discountPrice = parseFloat(props.project.preferential_price)
|
|
if (originalPrice <= 0 || discountPrice >= originalPrice) return 0
|
|
return Math.round((1 - discountPrice / originalPrice) * 100)
|
|
})
|
|
|
|
// 状态文本映射
|
|
const statusTextMap = {
|
|
0: '草稿',
|
|
1: '已发布',
|
|
2: '已下架'
|
|
}
|
|
|
|
// 状态颜色映射
|
|
const statusColorMap = {
|
|
0: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
|
1: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
|
2: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
|
}
|
|
|
|
// 阻止冒泡
|
|
const stopPropagation = (e) => {
|
|
e.stopPropagation()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!-- 主模态框 -->
|
|
<div v-if="props.visible" class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
|
<!-- 背景遮罩 -->
|
|
<div
|
|
class="fixed inset-0 bg-gray-500/80 dark:bg-gray-900/80 backdrop-blur-sm transition-opacity"
|
|
@click="closeModal"
|
|
></div>
|
|
|
|
<!-- 模态框内容容器 -->
|
|
<div class="flex items-center justify-center min-h-screen p-4">
|
|
<!-- 模态框内容 -->
|
|
<div
|
|
class="relative bg-gray-800 dark:bg-gray-800 rounded-2xl overflow-hidden shadow-xl transform transition-all max-w-5xl w-full animate-fadeIn"
|
|
@click="stopPropagation"
|
|
>
|
|
<!-- 项目头部信息 -->
|
|
<div class="relative">
|
|
<!-- 封面图 -->
|
|
<div class="h-64 w-full overflow-hidden relative">
|
|
<img
|
|
:src="project.cover"
|
|
:alt="project.title"
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
<div class="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent"></div>
|
|
|
|
<!-- 状态标签 -->
|
|
<div class="absolute top-4 right-4">
|
|
<span
|
|
:class="`px-3 py-1.5 rounded-full text-sm font-medium ${statusColorMap[project.status] || 'bg-gray-100 text-gray-800'}`"
|
|
>
|
|
{{ statusTextMap[project.status] || '未知状态' }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 标题和分类 -->
|
|
<div class="absolute bottom-0 left-0 right-0 p-6 text-white">
|
|
<div class="flex items-center mb-2">
|
|
<span class="px-3 py-1 bg-rose-600/90 rounded-full text-sm font-medium mr-2">
|
|
{{ project.category?.name || '未分类' }}
|
|
</span>
|
|
<span class="text-sm flex items-center">
|
|
<Calendar class="w-4 h-4 mr-1" />
|
|
{{ formatDate(project.created_at) }}
|
|
</span>
|
|
</div>
|
|
<h1 class="text-3xl font-bold">{{ project.title }}</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 关闭按钮 -->
|
|
<button
|
|
class="absolute top-4 left-4 p-2 rounded-full bg-black/30 text-white hover:bg-black/50 transition-colors"
|
|
@click="closeModal"
|
|
>
|
|
<X class="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 项目内容 -->
|
|
<div class="p-6 grid grid-cols-1 md:grid-cols-3 gap-8 bg-gray-800 dark:bg-gray-800 text-white">
|
|
<!-- 左侧信息 -->
|
|
<div class="md:col-span-2 space-y-8">
|
|
<!-- 标签 -->
|
|
<div class="flex flex-wrap gap-2">
|
|
<span
|
|
v-for="(tag, index) in project.labels"
|
|
:key="index"
|
|
class="px-3 py-1.5 bg-rose-900/20 text-rose-300 text-sm font-medium rounded-full flex items-center"
|
|
>
|
|
<Tag class="w-3.5 h-3.5 mr-1.5" />
|
|
{{ tag.name }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- 演示视频 -->
|
|
<div v-if="project.video_url" class="space-y-4">
|
|
<h2 class="text-xl font-semibold text-white flex items-center">
|
|
<Play class="w-5 h-5 mr-2 text-rose-500"/>
|
|
演示视频
|
|
</h2>
|
|
<div
|
|
class="relative rounded-xl overflow-hidden cursor-pointer h-64 bg-gray-700"
|
|
@click="handleVideoPreview" :style="{
|
|
backgroundImage: `url('${project.cover}')`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
}"
|
|
>
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<div class="w-16 h-16 rounded-full bg-rose-600 flex items-center justify-center shadow-lg">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white ml-1" fill="none"
|
|
viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="absolute bottom-4 left-4 right-4 text-white p-2 rounded-lg">
|
|
<p class="text-sm font-medium">点击播放项目演示视频</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 项目描述 -->
|
|
<div class="space-y-3">
|
|
<h2 class="text-xl font-semibold text-white flex items-center">
|
|
<Layers class="w-5 h-5 mr-2 text-rose-500" />
|
|
项目描述
|
|
</h2>
|
|
<p class="text-gray-300 leading-relaxed">
|
|
{{ project.description }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- 项目截图 -->
|
|
<div v-if="project.screenshots && project.screenshots.length > 0" class="space-y-4">
|
|
<h2 class="text-xl font-semibold text-white flex items-center">
|
|
<ImageIcon class="w-5 h-5 mr-2 text-rose-500" />
|
|
项目截图
|
|
</h2>
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
|
<div
|
|
v-for="(screenshot, index) in project.screenshots"
|
|
:key="index"
|
|
class="relative group rounded-xl overflow-hidden cursor-pointer h-40"
|
|
@click="handlePreview(screenshot.url)"
|
|
>
|
|
<img
|
|
:src="screenshot.url"
|
|
:alt="`Screenshot ${index + 1}`"
|
|
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
|
/>
|
|
<div
|
|
class="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center opacity-0 group-hover:opacity-100">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- 右侧信息 -->
|
|
<div class="space-y-6">
|
|
<!-- 价格卡片 -->
|
|
<div class="bg-gray-700 rounded-xl shadow-md p-6 border border-gray-600">
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-white flex items-center">
|
|
<DollarSign class="w-5 h-5 mr-1.5 text-rose-500"/>
|
|
项目价格
|
|
</h3>
|
|
<span v-if="discountPercentage > 0"
|
|
class="px-2 py-1 bg-rose-900/30 text-rose-300 text-xs font-bold rounded-full">
|
|
{{ discountPercentage }}% OFF
|
|
</span>
|
|
</div>
|
|
|
|
<div>
|
|
<div v-if="project.preferential_price" class="flex items-center">
|
|
<span class="text-2xl font-bold text-rose-400">
|
|
{{ formatPrice(project.preferential_price) }}
|
|
</span>
|
|
<span class="ml-2 text-sm text-gray-400 line-through">
|
|
{{ formatPrice(project.price) }}
|
|
</span>
|
|
</div>
|
|
<div v-else class="text-2xl font-bold text-white">
|
|
{{ formatPrice(project.price) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 框架信息 -->
|
|
<div v-if="project.frameworks && project.frameworks.length > 0"
|
|
class="bg-gray-700 rounded-xl shadow-md p-6 border border-gray-600">
|
|
<h3 class="text-lg font-semibold text-white mb-4">使用框架</h3>
|
|
<div class="flex flex-wrap gap-2">
|
|
<span
|
|
v-for="(framework, index) in project.frameworks"
|
|
:key="index"
|
|
class="px-3 py-1.5 bg-blue-900/20 text-blue-300 text-sm font-medium rounded-full"
|
|
>
|
|
{{ framework.name }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 作者信息 -->
|
|
<div v-if="project.author" class="bg-gray-700 rounded-xl shadow-md p-6 border border-gray-600">
|
|
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
|
|
<User class="w-5 h-5 mr-1.5 text-rose-500"/>
|
|
项目作者
|
|
</h3>
|
|
<div class="flex items-center">
|
|
<div class="w-12 h-12 rounded-full overflow-hidden mr-4">
|
|
<img
|
|
:src="project.author.avatar"
|
|
:alt="project.author.nick_name"
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<h4 class="font-medium text-white">{{ project.author.nick_name }}</h4>
|
|
<p class="text-sm text-gray-400">ID: {{ project.author.id }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 技术栈 -->
|
|
<div class="space-y-4">
|
|
<h2 class="text-xl font-semibold text-white flex items-center">
|
|
<Code class="w-5 h-5 mr-2 text-rose-500" />
|
|
技术栈
|
|
</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-1 gap-4">
|
|
<div class="bg-gray-700/50 p-4 rounded-xl">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2 flex items-center">
|
|
<Monitor class="w-4 h-4 mr-1.5 text-rose-500" />
|
|
前端技术
|
|
</h3>
|
|
<p class="text-gray-200 break-words">{{ project.front }}</p>
|
|
</div>
|
|
<div class="bg-gray-700/50 p-4 rounded-xl">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2 flex items-center">
|
|
<Server class="w-4 h-4 mr-1.5 text-rose-500" />
|
|
后端技术
|
|
</h3>
|
|
<p class="text-gray-200 break-words">{{ project.service }}</p>
|
|
</div>
|
|
<div class="bg-gray-700/50 p-4 rounded-xl">
|
|
<h3 class="text-sm font-medium text-gray-400 mb-2 flex items-center">
|
|
<Database class="w-4 h-4 mr-1.5 text-rose-500" />
|
|
部署架构
|
|
</h3>
|
|
<p class="text-gray-200 break-words">{{ project.deploy }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 核心功能 -->
|
|
<div class="space-y-4">
|
|
<h2 class="text-xl font-semibold text-white flex items-center">
|
|
<Award class="w-5 h-5 mr-2 text-rose-500" />
|
|
核心功能
|
|
</h2>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div
|
|
v-for="(feature, index) in project.features"
|
|
:key="index"
|
|
class="flex items-start"
|
|
>
|
|
<CheckCircle class="w-5 h-5 text-rose-500 mr-2 flex-shrink-0 mt-0.5" />
|
|
<span class="text-gray-300">{{ feature.name }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 图片预览模态框 -->
|
|
<div v-if="previewVisible" class="fixed inset-0 z-[60] overflow-y-auto" aria-labelledby="modal-title" role="dialog"
|
|
aria-modal="true">
|
|
<div
|
|
class="fixed inset-0 bg-gray-900/90 backdrop-blur-sm transition-opacity"
|
|
@click="previewVisible = false"
|
|
></div>
|
|
|
|
<div class="flex items-center justify-center min-h-screen p-4">
|
|
<div class="relative max-w-4xl w-full animate-fadeIn" @click="stopPropagation">
|
|
<button
|
|
class="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors z-10"
|
|
@click="previewVisible = false"
|
|
>
|
|
<X class="h-6 w-6"/>
|
|
</button>
|
|
<img :src="previewImage" class="max-h-[80vh] max-w-full mx-auto rounded-lg"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 视频预览模态框 -->
|
|
<div v-if="videoPreviewVisible" class="fixed inset-0 z-[60] overflow-y-auto" aria-labelledby="modal-title"
|
|
role="dialog" aria-modal="true">
|
|
<div
|
|
class="fixed inset-0 bg-gray-900/90 backdrop-blur-sm transition-opacity"
|
|
@click="videoPreviewVisible = false"
|
|
></div>
|
|
|
|
<div class="flex items-center justify-center min-h-screen p-4">
|
|
<div class="relative max-w-4xl w-full animate-fadeIn" @click="stopPropagation">
|
|
<button
|
|
class="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors z-10"
|
|
@click="videoPreviewVisible = false"
|
|
>
|
|
<X class="h-6 w-6"/>
|
|
</button>
|
|
<div class="relative pb-[56.25%] rounded-lg overflow-hidden">
|
|
<video
|
|
:src="videoPreviewUrl"
|
|
class="absolute inset-0 w-full h-full"
|
|
controls
|
|
autoplay
|
|
></video>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
/* 添加淡入动画 */
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.animate-fadeIn {
|
|
animation: fadeIn 0.3s ease-out;
|
|
}
|
|
</style>
|