正在显示
25 个修改的文件
包含
975 行增加
和
239 行删除
不能预览此文件类型
| @@ -9,11 +9,42 @@ import { getWebSite } from "~/api/webSite"; | @@ -9,11 +9,42 @@ import { getWebSite } from "~/api/webSite"; | ||
| 9 | import { getClassifyList } from "~/api/classify"; | 9 | import { getClassifyList } from "~/api/classify"; |
| 10 | import type { webSiteType } from "~/api/types/webSite"; | 10 | import type { webSiteType } from "~/api/types/webSite"; |
| 11 | import type { classifyType } from "~/api/types/classify"; | 11 | import type { classifyType } from "~/api/types/classify"; |
| 12 | + | ||
| 12 | const webSite = useState<webSiteType>("webSite"); | 13 | const webSite = useState<webSiteType>("webSite"); |
| 13 | const sortList = useState<classifyType[]>("sortTree"); | 14 | const sortList = useState<classifyType[]>("sortTree"); |
| 14 | 15 | ||
| 15 | -webSite.value = await getWebSite(); | ||
| 16 | -sortList.value = await getClassifyList(); | 16 | +const { data: webSiteData } = await useAsyncData( |
| 17 | + "webSite", | ||
| 18 | + async () => { | ||
| 19 | + const res = await getWebSite(); | ||
| 20 | + return res; | ||
| 21 | + }, | ||
| 22 | + { | ||
| 23 | + server: true, | ||
| 24 | + lazy: false, | ||
| 25 | + getCachedData: () => null, | ||
| 26 | + } | ||
| 27 | +); | ||
| 28 | + | ||
| 29 | +const { data: sortListData } = await useAsyncData( | ||
| 30 | + "sortList", | ||
| 31 | + async () => { | ||
| 32 | + const res = await getClassifyList(); | ||
| 33 | + return res; | ||
| 34 | + }, | ||
| 35 | + { | ||
| 36 | + server: true, | ||
| 37 | + lazy: false, | ||
| 38 | + getCachedData: () => null, | ||
| 39 | + } | ||
| 40 | +); | ||
| 41 | + | ||
| 42 | +if (webSiteData.value) { | ||
| 43 | + webSite.value = webSiteData.value; | ||
| 44 | +} | ||
| 45 | +if (sortListData.value) { | ||
| 46 | + sortList.value = sortListData.value; | ||
| 47 | +} | ||
| 17 | </script> | 48 | </script> |
| 18 | 49 | ||
| 19 | <style> | 50 | <style> |
assets/css/tailwind.css
0 → 100644
| 1 | @font-face { | 1 | @font-face { |
| 2 | font-family: "iconfont"; /* Project id 5094593 */ | 2 | font-family: "iconfont"; /* Project id 5094593 */ |
| 3 | - src: url('iconfont.woff2?t=1770621719751') format('woff2'), | ||
| 4 | - url('iconfont.woff?t=1770621719751') format('woff'), | ||
| 5 | - url('iconfont.ttf?t=1770621719751') format('truetype'); | 3 | + src: url('iconfont.woff2?t=1773713724403') format('woff2'), |
| 4 | + url('iconfont.woff?t=1773713724403') format('woff'), | ||
| 5 | + url('iconfont.ttf?t=1773713724403') format('truetype'); | ||
| 6 | } | 6 | } |
| 7 | 7 | ||
| 8 | .iconfont { | 8 | .iconfont { |
| @@ -13,6 +13,10 @@ | @@ -13,6 +13,10 @@ | ||
| 13 | -moz-osx-font-smoothing: grayscale; | 13 | -moz-osx-font-smoothing: grayscale; |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | +.icon-up_top:before { | ||
| 17 | + content: "\e888"; | ||
| 18 | +} | ||
| 19 | + | ||
| 16 | .icon-international:before { | 20 | .icon-international:before { |
| 17 | content: "\e638"; | 21 | content: "\e638"; |
| 18 | } | 22 | } |
此 diff 太大无法显示。
不能预览此文件类型
不能预览此文件类型
不能预览此文件类型
components/App/FloatingButtons.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="fixed bottom-8 right-8 flex flex-col gap-3 z-50"> | ||
| 3 | + <Transition name="fade"> | ||
| 4 | + <button | ||
| 5 | + v-show="showBackTop" | ||
| 6 | + @click="scrollToTop" | ||
| 7 | + class="w-12 h-12 bg-[#5961f9] hover:bg-[#4751e8] dark:bg-gray-700 dark:hover:bg-gray-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-300 hover:scale-110" | ||
| 8 | + aria-label="回到顶部" | ||
| 9 | + > | ||
| 10 | + <i class="iconfont icon-up_top text-xl"></i> | ||
| 11 | + </button> | ||
| 12 | + </Transition> | ||
| 13 | + | ||
| 14 | + <button | ||
| 15 | + @click="toggleDark" | ||
| 16 | + class="w-12 h-12 bg-[#5961f9] hover:bg-[#4751e8] dark:bg-gray-700 dark:hover:bg-gray-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-300 hover:scale-110" | ||
| 17 | + :aria-label="isDark ? '切换到日间模式' : '切换到夜间模式'" | ||
| 18 | + > | ||
| 19 | + <i v-if="isDark" class="iconfont icon-sunny text-xl"></i> | ||
| 20 | + <i v-else class="iconfont icon-moon text-xl"></i> | ||
| 21 | + </button> | ||
| 22 | + </div> | ||
| 23 | +</template> | ||
| 24 | + | ||
| 25 | +<script lang="ts" setup> | ||
| 26 | +const { isDark, toggleDark } = useDarkMode(); | ||
| 27 | + | ||
| 28 | +const showBackTop = ref(false); | ||
| 29 | + | ||
| 30 | +const handleScroll = () => { | ||
| 31 | + showBackTop.value = window.scrollY > 300; | ||
| 32 | +}; | ||
| 33 | + | ||
| 34 | +const scrollToTop = () => { | ||
| 35 | + window.scrollTo({ | ||
| 36 | + top: 0, | ||
| 37 | + behavior: "smooth", | ||
| 38 | + }); | ||
| 39 | +}; | ||
| 40 | + | ||
| 41 | +onMounted(() => { | ||
| 42 | + window.addEventListener("scroll", handleScroll); | ||
| 43 | +}); | ||
| 44 | + | ||
| 45 | +onUnmounted(() => { | ||
| 46 | + window.removeEventListener("scroll", handleScroll); | ||
| 47 | +}); | ||
| 48 | +</script> | ||
| 49 | + | ||
| 50 | +<style scoped> | ||
| 51 | +.fade-enter-active, | ||
| 52 | +.fade-leave-active { | ||
| 53 | + transition: opacity 0.3s ease; | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +.fade-enter-from, | ||
| 57 | +.fade-leave-to { | ||
| 58 | + opacity: 0; | ||
| 59 | +} | ||
| 60 | +</style> |
| 1 | <template> | 1 | <template> |
| 2 | - <!-- Footer --> | ||
| 3 | - <footer class="bg-gray-800 text-white py-12 px-8 mt-auto"> | 2 | + <footer class="bg-gray-800 dark:bg-gray-950 text-white py-12 px-8 mt-auto transition-colors duration-300"> |
| 4 | <div class="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-8"> | 3 | <div class="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-8"> |
| 5 | <div> | 4 | <div> |
| 6 | <h3 class="text-xl font-bold mb-4"> | 5 | <h3 class="text-xl font-bold mb-4"> |
| @@ -23,25 +22,25 @@ | @@ -23,25 +22,25 @@ | ||
| 23 | <div class="flex space-x-4"> | 22 | <div class="flex space-x-4"> |
| 24 | <a | 23 | <a |
| 25 | href="#" | 24 | href="#" |
| 26 | - class="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center hover:bg-blue-500 transition-colors" | 25 | + class="w-10 h-10 rounded-full bg-gray-700 dark:bg-gray-800 flex items-center justify-center hover:bg-blue-500 transition-colors" |
| 27 | > | 26 | > |
| 28 | <el-icon :size="20"><Star /></el-icon> | 27 | <el-icon :size="20"><Star /></el-icon> |
| 29 | </a> | 28 | </a> |
| 30 | <a | 29 | <a |
| 31 | href="#" | 30 | href="#" |
| 32 | - class="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center hover:bg-blue-400 transition-colors" | 31 | + class="w-10 h-10 rounded-full bg-gray-700 dark:bg-gray-800 flex items-center justify-center hover:bg-blue-400 transition-colors" |
| 33 | > | 32 | > |
| 34 | <el-icon :size="20"><Link /></el-icon> | 33 | <el-icon :size="20"><Link /></el-icon> |
| 35 | </a> | 34 | </a> |
| 36 | <a | 35 | <a |
| 37 | href="#" | 36 | href="#" |
| 38 | - class="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center hover:bg-pink-500 transition-colors" | 37 | + class="w-10 h-10 rounded-full bg-gray-700 dark:bg-gray-800 flex items-center justify-center hover:bg-pink-500 transition-colors" |
| 39 | > | 38 | > |
| 40 | <el-icon :size="20"><Star /></el-icon> | 39 | <el-icon :size="20"><Star /></el-icon> |
| 41 | </a> | 40 | </a> |
| 42 | <a | 41 | <a |
| 43 | href="#" | 42 | href="#" |
| 44 | - class="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center hover:bg-red-500 transition-colors" | 43 | + class="w-10 h-10 rounded-full bg-gray-700 dark:bg-gray-800 flex items-center justify-center hover:bg-red-500 transition-colors" |
| 45 | > | 44 | > |
| 46 | <el-icon :size="20"><Search /></el-icon> | 45 | <el-icon :size="20"><Search /></el-icon> |
| 47 | </a> | 46 | </a> |
| @@ -49,7 +48,7 @@ | @@ -49,7 +48,7 @@ | ||
| 49 | </div> | 48 | </div> |
| 50 | </div> | 49 | </div> |
| 51 | <div | 50 | <div |
| 52 | - class="max-w-6xl mx-auto mt-8 pt-8 border-t border-gray-700 text-center text-gray-400" | 51 | + class="max-w-6xl mx-auto mt-8 pt-8 border-t border-gray-700 dark:border-gray-800 text-center text-gray-400" |
| 53 | > | 52 | > |
| 54 | <p>{{ webSite.bottomAnnouncement }}</p> | 53 | <p>{{ webSite.bottomAnnouncement }}</p> |
| 55 | </div> | 54 | </div> |
| 1 | <template> | 1 | <template> |
| 2 | - <!-- 顶部导航栏 --> | ||
| 3 | <header | 2 | <header |
| 4 | - class="fixed top-0 left-0 right-0 z-50 bg-gray-900 text-white shadow-md" | 3 | + class="fixed top-0 left-0 right-0 z-50 bg-gray-900 dark:bg-[#272929] text-white shadow-md transition-colors duration-300" |
| 5 | > | 4 | > |
| 6 | <div class="mx-auto md:px-6 px-3 py-3 flex items-center justify-between"> | 5 | <div class="mx-auto md:px-6 px-3 py-3 flex items-center justify-between"> |
| 7 | <NuxtLink to="/" class="flex items-center space-x-2"> | 6 | <NuxtLink to="/" class="flex items-center space-x-2"> |
| 1 | <template> | 1 | <template> |
| 2 | <nav | 2 | <nav |
| 3 | - class="max-[768px]:flex-[0] flex-shrink-0 scroll-container w-56 bg-white shadow-lg h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto" | 3 | + class="max-[768px]:flex-[0] flex-shrink-0 scroll-container w-56 bg-white dark:bg-[#272929] shadow-lg h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto transition-colors duration-300" |
| 4 | > | 4 | > |
| 5 | <div class="md:p-4 p-2"> | 5 | <div class="md:p-4 p-2"> |
| 6 | - <h2 class="text-lg font-semibold mb-4 text-gray-700">工具分类</h2> | 6 | + <h2 class="text-lg font-semibold mb-4 text-gray-700 dark:text-[#c6c9cf]"> |
| 7 | + 工具分类 | ||
| 8 | + </h2> | ||
| 7 | <ul id="menu" class="space-y-1"> | 9 | <ul id="menu" class="space-y-1"> |
| 8 | <li | 10 | <li |
| 9 | :id="`menu-${category.id}`" | 11 | :id="`menu-${category.id}`" |
| @@ -11,35 +13,35 @@ | @@ -11,35 +13,35 @@ | ||
| 11 | :key="index" | 13 | :key="index" |
| 12 | class="menu-item" | 14 | class="menu-item" |
| 13 | > | 15 | > |
| 14 | - <a | ||
| 15 | - :href="`#term-${category.id}`" | ||
| 16 | - @click.stop="toggleCategory($event, category.id, index)" | ||
| 17 | - class="w-full flex items-center justify-between p-3 text-[#515c6b] hover:text-[#5961f9] rounded-lg hover:bg-gray-100 transition-colors" | 16 | + <div |
| 17 | + class="w-full flex items-center justify-between p-3 text-[#515c6b] dark:text-[#c6c9cf] hover:text-[#5961f9] dark:hover:text-white rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer" | ||
| 18 | > | 18 | > |
| 19 | - <div class="flex items-center space-x-2"> | 19 | + <a |
| 20 | + :href="`#term-${category.id}`" | ||
| 21 | + @click="handleCategoryClick($event, category.id, category)" | ||
| 22 | + class="flex items-center space-x-2 flex-1" | ||
| 23 | + > | ||
| 20 | <i | 24 | <i |
| 21 | class="iconfont text-sm" | 25 | class="iconfont text-sm" |
| 22 | :class="[`icon-${category.icon}`]" | 26 | :class="[`icon-${category.icon}`]" |
| 23 | ></i> | 27 | ></i> |
| 24 | <span class="text-sm">{{ category.label }}</span> | 28 | <span class="text-sm">{{ category.label }}</span> |
| 25 | - </div> | ||
| 26 | - <div v-if="category.children"> | 29 | + </a> |
| 30 | + <button | ||
| 31 | + v-if="category.children && category.children.length > 0" | ||
| 32 | + @click.stop="toggleExpand(index)" | ||
| 33 | + class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors" | ||
| 34 | + :aria-label="activeCategory === index ? '收起分类' : '展开分类'" | ||
| 35 | + > | ||
| 27 | <el-icon | 36 | <el-icon |
| 28 | size="14px" | 37 | size="14px" |
| 29 | - color="#515c6b" | ||
| 30 | - v-show="activeCategory !== index" | 38 | + class="text-[#515c6b] dark:text-[#c6d9df] transition-transform duration-300" |
| 39 | + :class="{ 'rotate-90': activeCategory === index }" | ||
| 31 | > | 40 | > |
| 32 | <ArrowRightBold /> | 41 | <ArrowRightBold /> |
| 33 | </el-icon> | 42 | </el-icon> |
| 34 | - <el-icon | ||
| 35 | - size="14px" | ||
| 36 | - color="#515c6b" | ||
| 37 | - v-show="activeCategory === index" | ||
| 38 | - > | ||
| 39 | - <ArrowDownBold /> | ||
| 40 | - </el-icon> | ||
| 41 | - </div> | ||
| 42 | - </a> | 43 | + </button> |
| 44 | + </div> | ||
| 43 | 45 | ||
| 44 | <transition name="slide"> | 46 | <transition name="slide"> |
| 45 | <ul v-show="activeCategory === index" class="ml-4 space-y-0.5"> | 47 | <ul v-show="activeCategory === index" class="ml-4 space-y-0.5"> |
| @@ -50,8 +52,10 @@ | @@ -50,8 +52,10 @@ | ||
| 50 | > | 52 | > |
| 51 | <a | 53 | <a |
| 52 | :href="`#term-${category.id}-${subItem.id}`" | 54 | :href="`#term-${category.id}-${subItem.id}`" |
| 53 | - class="block text-sm py-2 px-3 rounded hover:bg-gray-100 text-[#515c6b] hover:text-[#5961f9] transition-colors" | ||
| 54 | - @click.stop="" | 55 | + class="block text-sm py-2 px-3 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-[#515c6b] dark:text-[#c6c9cf] hover:text-[#5961f9] dark:hover:text-white transition-colors" |
| 56 | + @click.stop=" | ||
| 57 | + handleSubCategoryClick($event, category.id, subItem.id) | ||
| 58 | + " | ||
| 55 | > | 59 | > |
| 56 | {{ subItem.label }} | 60 | {{ subItem.label }} |
| 57 | </a> | 61 | </a> |
| @@ -66,35 +70,68 @@ | @@ -66,35 +70,68 @@ | ||
| 66 | 70 | ||
| 67 | <script setup lang="ts"> | 71 | <script setup lang="ts"> |
| 68 | import type { classifyType } from "~/api/types/classify"; | 72 | import type { classifyType } from "~/api/types/classify"; |
| 69 | -import { ArrowRightBold, ArrowDownBold } from "@element-plus/icons-vue"; | 73 | +import { ArrowRightBold } from "@element-plus/icons-vue"; |
| 70 | const sortList = useState<classifyType[]>("sortTree"); | 74 | const sortList = useState<classifyType[]>("sortTree"); |
| 71 | -// 激活的分类索引 | ||
| 72 | const activeCategory = ref<number | null>(0); | 75 | const activeCategory = ref<number | null>(0); |
| 73 | const route = useRoute(); | 76 | const route = useRoute(); |
| 74 | const router = useRouter(); | 77 | const router = useRouter(); |
| 78 | +const config = useRuntimeConfig(); | ||
| 79 | +const { setActiveSubCategory } = useActiveSubCategory(); | ||
| 75 | 80 | ||
| 76 | -// 切换分类展开状态 | ||
| 77 | -const toggleCategory = (event: any, id: number, index: number) => { | 81 | +const toggleExpand = (index: number) => { |
| 78 | if (activeCategory.value === index) { | 82 | if (activeCategory.value === index) { |
| 79 | activeCategory.value = null; | 83 | activeCategory.value = null; |
| 80 | } else { | 84 | } else { |
| 81 | activeCategory.value = index; | 85 | activeCategory.value = index; |
| 82 | } | 86 | } |
| 83 | - event?.preventDefault(); | ||
| 84 | - if (route.path === "/") { | ||
| 85 | - document.getElementById(`term-${id}`)?.scrollIntoView({ | 87 | +}; |
| 88 | + | ||
| 89 | +const scrollToElement = (elementId: string) => { | ||
| 90 | + const element = document.getElementById(elementId); | ||
| 91 | + if (element) { | ||
| 92 | + element.scrollIntoView({ | ||
| 86 | behavior: "smooth", | 93 | behavior: "smooth", |
| 87 | block: "center", | 94 | block: "center", |
| 88 | }); | 95 | }); |
| 96 | + return true; | ||
| 97 | + } | ||
| 98 | + return false; | ||
| 99 | +}; | ||
| 100 | + | ||
| 101 | +const handleCategoryClick = ( | ||
| 102 | + event: Event, | ||
| 103 | + id: number, | ||
| 104 | + SubCategory: classifyType | ||
| 105 | +) => { | ||
| 106 | + event.preventDefault(); | ||
| 107 | + if (SubCategory.children && SubCategory.children.length > 0) { | ||
| 108 | + const targetChildId = `term-${id}-${SubCategory.children[0].id}`; | ||
| 109 | + setActiveSubCategory(targetChildId); | ||
| 110 | + } else { | ||
| 111 | + setActiveSubCategory(null); | ||
| 112 | + } | ||
| 113 | + const targetId = `term-${id}`; | ||
| 114 | + | ||
| 115 | + if (route.path === "/") { | ||
| 116 | + scrollToElement(targetId); | ||
| 117 | + } else { | ||
| 118 | + router.push(`/#${targetId}`); | ||
| 119 | + } | ||
| 120 | +}; | ||
| 121 | + | ||
| 122 | +const handleSubCategoryClick = ( | ||
| 123 | + event: Event, | ||
| 124 | + parentId: number, | ||
| 125 | + subId: number | ||
| 126 | +) => { | ||
| 127 | + event.preventDefault(); | ||
| 128 | + const targetId = `term-${parentId}-${subId}`; | ||
| 129 | + setActiveSubCategory(targetId); | ||
| 130 | + | ||
| 131 | + if (route.path === "/") { | ||
| 132 | + scrollToElement(targetId); | ||
| 89 | } else { | 133 | } else { |
| 90 | - router.push("/"); | ||
| 91 | - let timer = setTimeout(() => { | ||
| 92 | - document.getElementById(`term-${id}`)?.scrollIntoView({ | ||
| 93 | - behavior: "smooth", | ||
| 94 | - block: "center", | ||
| 95 | - }); | ||
| 96 | - clearTimeout(timer); | ||
| 97 | - }, 500); | 134 | + router.push(`/#${targetId}`); |
| 98 | } | 135 | } |
| 99 | }; | 136 | }; |
| 100 | </script> | 137 | </script> |
components/Common/ParticleBackground.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <canvas ref="canvasRef" class="absolute inset-0 w-full h-full"></canvas> | ||
| 3 | +</template> | ||
| 4 | + | ||
| 5 | +<script lang="ts" setup> | ||
| 6 | +const props = defineProps<{ | ||
| 7 | + particleCount?: number; | ||
| 8 | + particleColor?: string; | ||
| 9 | + lineColor?: string; | ||
| 10 | + particleSize?: number; | ||
| 11 | + lineDistance?: number; | ||
| 12 | + speed?: number; | ||
| 13 | +}>(); | ||
| 14 | + | ||
| 15 | +const canvasRef = ref<HTMLCanvasElement | null>(null); | ||
| 16 | +const { isDark } = useDarkMode(); | ||
| 17 | + | ||
| 18 | +const config = { | ||
| 19 | + particleCount: props.particleCount || 80, | ||
| 20 | + particleColor: props.particleColor || "#5961f9", | ||
| 21 | + lineColor: props.lineColor || "#5961f9", | ||
| 22 | + particleSize: props.particleSize || 2, | ||
| 23 | + lineDistance: props.lineDistance || 120, | ||
| 24 | + speed: props.speed || 0.5, | ||
| 25 | +}; | ||
| 26 | + | ||
| 27 | +interface Particle { | ||
| 28 | + x: number; | ||
| 29 | + y: number; | ||
| 30 | + vx: number; | ||
| 31 | + vy: number; | ||
| 32 | + size: number; | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +let particles: Particle[] = []; | ||
| 36 | +let animationId: number | null = null; | ||
| 37 | +let ctx: CanvasRenderingContext2D | null = null; | ||
| 38 | +let canvas: HTMLCanvasElement | null = null; | ||
| 39 | + | ||
| 40 | +const initParticles = () => { | ||
| 41 | + particles = []; | ||
| 42 | + if (!canvas) return; | ||
| 43 | + | ||
| 44 | + for (let i = 0; i < config.particleCount; i++) { | ||
| 45 | + particles.push({ | ||
| 46 | + x: Math.random() * canvas.width, | ||
| 47 | + y: Math.random() * canvas.height, | ||
| 48 | + vx: (Math.random() - 0.5) * config.speed, | ||
| 49 | + vy: (Math.random() - 0.5) * config.speed, | ||
| 50 | + size: Math.random() * config.particleSize + 1, | ||
| 51 | + }); | ||
| 52 | + } | ||
| 53 | +}; | ||
| 54 | + | ||
| 55 | +const drawParticles = () => { | ||
| 56 | + if (!ctx || !canvas) return; | ||
| 57 | + | ||
| 58 | + ctx.clearRect(0, 0, canvas.width, canvas.height); | ||
| 59 | + | ||
| 60 | + const particleAlpha = isDark.value ? 0.6 : 0.8; | ||
| 61 | + const lineAlpha = isDark.value ? 0.15 : 0.2; | ||
| 62 | + | ||
| 63 | + particles.forEach((particle, i) => { | ||
| 64 | + particle.x += particle.vx; | ||
| 65 | + particle.y += particle.vy; | ||
| 66 | + | ||
| 67 | + if (particle.x < 0 || particle.x > canvas!.width) particle.vx *= -1; | ||
| 68 | + if (particle.y < 0 || particle.y > canvas!.height) particle.vy *= -1; | ||
| 69 | + | ||
| 70 | + ctx!.beginPath(); | ||
| 71 | + ctx!.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); | ||
| 72 | + ctx!.fillStyle = `${config.particleColor}${Math.round(particleAlpha * 255) | ||
| 73 | + .toString(16) | ||
| 74 | + .padStart(2, "0")}`; | ||
| 75 | + ctx!.fill(); | ||
| 76 | + | ||
| 77 | + for (let j = i + 1; j < particles.length; j++) { | ||
| 78 | + const dx = particles[j].x - particle.x; | ||
| 79 | + const dy = particles[j].y - particle.y; | ||
| 80 | + const distance = Math.sqrt(dx * dx + dy * dy); | ||
| 81 | + | ||
| 82 | + if (distance < config.lineDistance) { | ||
| 83 | + const opacity = (1 - distance / config.lineDistance) * lineAlpha; | ||
| 84 | + ctx!.beginPath(); | ||
| 85 | + ctx!.moveTo(particle.x, particle.y); | ||
| 86 | + ctx!.lineTo(particles[j].x, particles[j].y); | ||
| 87 | + ctx!.strokeStyle = `${config.lineColor}${Math.round(opacity * 255) | ||
| 88 | + .toString(16) | ||
| 89 | + .padStart(2, "0")}`; | ||
| 90 | + ctx!.lineWidth = 1; | ||
| 91 | + ctx!.stroke(); | ||
| 92 | + } | ||
| 93 | + } | ||
| 94 | + }); | ||
| 95 | + | ||
| 96 | + animationId = requestAnimationFrame(drawParticles); | ||
| 97 | +}; | ||
| 98 | + | ||
| 99 | +const handleResize = () => { | ||
| 100 | + if (!canvas || !ctx) return; | ||
| 101 | + | ||
| 102 | + const parent = canvas.parentElement; | ||
| 103 | + if (parent) { | ||
| 104 | + canvas.width = parent.offsetWidth; | ||
| 105 | + canvas.height = parent.offsetHeight; | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + initParticles(); | ||
| 109 | +}; | ||
| 110 | + | ||
| 111 | +const handleMouse = (e: MouseEvent) => { | ||
| 112 | + if (!canvas) return; | ||
| 113 | + | ||
| 114 | + const rect = canvas.getBoundingClientRect(); | ||
| 115 | + const mouseX = e.clientX - rect.left; | ||
| 116 | + const mouseY = e.clientY - rect.top; | ||
| 117 | + | ||
| 118 | + particles.forEach((particle) => { | ||
| 119 | + const dx = mouseX - particle.x; | ||
| 120 | + const dy = mouseY - particle.y; | ||
| 121 | + const distance = Math.sqrt(dx * dx + dy * dy); | ||
| 122 | + | ||
| 123 | + if (distance < 100) { | ||
| 124 | + const force = (100 - distance) / 100; | ||
| 125 | + particle.vx -= (dx / distance) * force * 0.5; | ||
| 126 | + particle.vy -= (dy / distance) * force * 0.5; | ||
| 127 | + } | ||
| 128 | + }); | ||
| 129 | +}; | ||
| 130 | + | ||
| 131 | +onMounted(() => { | ||
| 132 | + canvas = canvasRef.value; | ||
| 133 | + if (!canvas) return; | ||
| 134 | + | ||
| 135 | + ctx = canvas.getContext("2d"); | ||
| 136 | + if (!ctx) return; | ||
| 137 | + | ||
| 138 | + handleResize(); | ||
| 139 | + drawParticles(); | ||
| 140 | + | ||
| 141 | + window.addEventListener("resize", handleResize); | ||
| 142 | + canvas.addEventListener("mousemove", handleMouse); | ||
| 143 | +}); | ||
| 144 | + | ||
| 145 | +onUnmounted(() => { | ||
| 146 | + if (animationId) { | ||
| 147 | + cancelAnimationFrame(animationId); | ||
| 148 | + } | ||
| 149 | + window.removeEventListener("resize", handleResize); | ||
| 150 | + if (canvas) { | ||
| 151 | + canvas.removeEventListener("mousemove", handleMouse); | ||
| 152 | + } | ||
| 153 | +}); | ||
| 154 | + | ||
| 155 | +watch(isDark, () => { | ||
| 156 | + drawParticles(); | ||
| 157 | +}); | ||
| 158 | +</script> |
components/CommonCard/index.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div | ||
| 3 | + v-for="(item, index) in cardList" | ||
| 4 | + :key="index" | ||
| 5 | + class="bg-white dark:bg-[#272929] rounded-xl shadow-md overflow-hidden hover:shadow-lg hover:translate-y-[-6px] transition-all duration-300" | ||
| 6 | + > | ||
| 7 | + <el-popconfirm | ||
| 8 | + v-if="item.isPopup == '1'" | ||
| 9 | + class="box-item" | ||
| 10 | + :title="item.popupContent" | ||
| 11 | + placement="top-start" | ||
| 12 | + icon-color="#5961f9" | ||
| 13 | + width="280" | ||
| 14 | + :icon="Promotion" | ||
| 15 | + confirm-button-text="确认前往" | ||
| 16 | + cancel-button-text="取消" | ||
| 17 | + @confirm="onConfirm(item.id)" | ||
| 18 | + > | ||
| 19 | + <template #reference> | ||
| 20 | + <a | ||
| 21 | + :href="config.public.baseUrl + '/site-details/' + item.id" | ||
| 22 | + target="_blank" | ||
| 23 | + @click.stop="onNuxtLink" | ||
| 24 | + > | ||
| 25 | + <div class="group p-3"> | ||
| 26 | + <div class="flex items-start space-x-4"> | ||
| 27 | + <img | ||
| 28 | + loading="lazy" | ||
| 29 | + :src="config.public.apiUrl + item.image" | ||
| 30 | + :alt="item.title" | ||
| 31 | + class="w-10 h-10 md:w-14 md:h-14 object-cover rounded-lg" | ||
| 32 | + /> | ||
| 33 | + <div> | ||
| 34 | + <h3 | ||
| 35 | + class="font-bold dark:text-[#c6c9cf] md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9] dark:group-hover:text-white" | ||
| 36 | + > | ||
| 37 | + {{ item.title }} | ||
| 38 | + </h3> | ||
| 39 | + <p | ||
| 40 | + class="text-gray-600 dark:text-[#6c757d] text-xs mt-1 md:line-clamp-2 line-clamp-1" | ||
| 41 | + > | ||
| 42 | + {{ item.description }} | ||
| 43 | + </p> | ||
| 44 | + </div> | ||
| 45 | + </div> | ||
| 46 | + </div> | ||
| 47 | + </a> | ||
| 48 | + </template> | ||
| 49 | + </el-popconfirm> | ||
| 50 | + <a | ||
| 51 | + v-else | ||
| 52 | + :href="config.public.baseUrl + '/site-details/' + item.id" | ||
| 53 | + target="_blank" | ||
| 54 | + > | ||
| 55 | + <div class="group p-3"> | ||
| 56 | + <div class="flex items-start space-x-4"> | ||
| 57 | + <img | ||
| 58 | + loading="lazy" | ||
| 59 | + :src="config.public.apiUrl + item.image" | ||
| 60 | + :alt="item.title" | ||
| 61 | + class="w-10 h-10 md:w-14 md:h-14 object-cover rounded-lg" | ||
| 62 | + /> | ||
| 63 | + <div> | ||
| 64 | + <h3 | ||
| 65 | + class="font-bold dark:text-[#c6c9cf] md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9] dark:group-hover:text-white" | ||
| 66 | + > | ||
| 67 | + {{ item.title }} | ||
| 68 | + </h3> | ||
| 69 | + <p | ||
| 70 | + class="text-gray-600 dark:text-[#6c757d] text-xs mt-1 md:line-clamp-2 line-clamp-1" | ||
| 71 | + > | ||
| 72 | + {{ item.description }} | ||
| 73 | + </p> | ||
| 74 | + </div> | ||
| 75 | + </div> | ||
| 76 | + </div> | ||
| 77 | + </a> | ||
| 78 | + </div> | ||
| 79 | +</template> | ||
| 80 | + | ||
| 81 | +<script lang="ts" setup> | ||
| 82 | +import { Promotion } from "@element-plus/icons-vue"; | ||
| 83 | +defineProps<{ | ||
| 84 | + cardList: any[]; | ||
| 85 | +}>(); | ||
| 86 | + | ||
| 87 | +const config = useRuntimeConfig(); | ||
| 88 | +// 阻止默认行为 | ||
| 89 | +function onNuxtLink(event: any) { | ||
| 90 | + event.preventDefault(); | ||
| 91 | +} | ||
| 92 | +// 点击确认跳转 | ||
| 93 | +function onConfirm(id: number) { | ||
| 94 | + window.open(`/site-details/${id}`); | ||
| 95 | +} | ||
| 96 | +</script> |
| @@ -6,13 +6,13 @@ | @@ -6,13 +6,13 @@ | ||
| 6 | class="iconfont text-lg mr-2" | 6 | class="iconfont text-lg mr-2" |
| 7 | :class="[`icon-${childData.icon}`]" | 7 | :class="[`icon-${childData.icon}`]" |
| 8 | ></i> | 8 | ></i> |
| 9 | - <h4 class="text-xl text-[#555]"> | 9 | + <h4 class="text-xl text-[#555] dark:text-[#888]"> |
| 10 | {{ childData.label }} | 10 | {{ childData.label }} |
| 11 | </h4> | 11 | </h4> |
| 12 | </div> | 12 | </div> |
| 13 | <div class="flex items-center flex-auto"> | 13 | <div class="flex items-center flex-auto"> |
| 14 | <div | 14 | <div |
| 15 | - class="scroll-container relative bg-black/10 rounded-[50px] md:overflow-hidden p-[3px] overflow-y-auto" | 15 | + class="scroll-container relative bg-black/10 dark:bg-[#17181a] rounded-[50px] md:overflow-hidden p-[3px] overflow-y-auto" |
| 16 | slidertab="sliderTab" | 16 | slidertab="sliderTab" |
| 17 | > | 17 | > |
| 18 | <ul | 18 | <ul |
| @@ -22,13 +22,24 @@ | @@ -22,13 +22,24 @@ | ||
| 22 | > | 22 | > |
| 23 | <li | 23 | <li |
| 24 | v-for="(child, index) in childData.children" | 24 | v-for="(child, index) in childData.children" |
| 25 | + :key="child.id" | ||
| 25 | class="h-auto w-auto cursor-pointer rounded-[100px] transition-all duration-350" | 26 | class="h-auto w-auto cursor-pointer rounded-[100px] transition-all duration-350" |
| 26 | - :class="[index === currentFilter ? 'bg-[#5961f9]' : '']" | 27 | + :class="[ |
| 28 | + activeSubCategoryId === `term-${childData.id}-${child.id}` || | ||
| 29 | + index === currentFilter | ||
| 30 | + ? 'bg-[#5961f9]' | ||
| 31 | + : '', | ||
| 32 | + ]" | ||
| 27 | > | 33 | > |
| 28 | <a | 34 | <a |
| 29 | - :id="`#term-${childData.id}-${child.id}`" | 35 | + :id="`term-${childData.id}-${child.id}`" |
| 30 | class="h-7 leading-7 px-3 block relative text-[#888] text-center md:text-sm text-xs md:leading-7" | 36 | class="h-7 leading-7 px-3 block relative text-[#888] text-center md:text-sm text-xs md:leading-7" |
| 31 | - :class="[index === currentFilter ? 'text-white' : '']" | 37 | + :class="[ |
| 38 | + activeSubCategoryId === `term-${childData.id}-${child.id}` || | ||
| 39 | + index === currentFilter | ||
| 40 | + ? 'text-white' | ||
| 41 | + : '', | ||
| 42 | + ]" | ||
| 32 | style="transition: 0.25s" | 43 | style="transition: 0.25s" |
| 33 | :href="`#tab-${childData.id}-${child.id}`" | 44 | :href="`#tab-${childData.id}-${child.id}`" |
| 34 | @click.stop="onClick($event, child.alias, index)" | 45 | @click.stop="onClick($event, child.alias, index)" |
| @@ -55,13 +66,17 @@ | @@ -55,13 +66,17 @@ | ||
| 55 | <div | 66 | <div |
| 56 | v-for="(childContentItem, childContentIndex) in childData.children" | 67 | v-for="(childContentItem, childContentIndex) in childData.children" |
| 57 | :key="childContentItem.id" | 68 | :key="childContentItem.id" |
| 58 | - v-show="currentFilter === childContentIndex" | 69 | + v-show=" |
| 70 | + activeSubCategoryId === `term-${childData.id}-${childContentItem.id}` || | ||
| 71 | + childContentIndex === currentFilter | ||
| 72 | + " | ||
| 73 | + :id="`tab-${childData.id}-${childContentItem.id}`" | ||
| 59 | class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 md:gap-6 gap-4" | 74 | class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 md:gap-6 gap-4" |
| 60 | > | 75 | > |
| 61 | <div | 76 | <div |
| 62 | v-for="appItem in childContentItem.appVos" | 77 | v-for="appItem in childContentItem.appVos" |
| 63 | :key="appItem.id" | 78 | :key="appItem.id" |
| 64 | - class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300" | 79 | + class="bg-white dark:bg-[#272929] rounded-xl shadow-md overflow-hidden hover:shadow-lg hover:translate-y-[-6px] transition-all duration-300" |
| 65 | > | 80 | > |
| 66 | <el-popconfirm | 81 | <el-popconfirm |
| 67 | v-if="appItem.isPopup == '1'" | 82 | v-if="appItem.isPopup == '1'" |
| @@ -90,12 +105,12 @@ | @@ -90,12 +105,12 @@ | ||
| 90 | /> | 105 | /> |
| 91 | <div> | 106 | <div> |
| 92 | <h3 | 107 | <h3 |
| 93 | - class="font-bold md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9]" | 108 | + class="font-bold dark:text-[#c6c9cf] md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9] dark:group-hover:text-white" |
| 94 | > | 109 | > |
| 95 | {{ appItem.title }} | 110 | {{ appItem.title }} |
| 96 | </h3> | 111 | </h3> |
| 97 | <p | 112 | <p |
| 98 | - class="text-gray-600 text-xs mt-1 md:line-clamp-2 line-clamp-1" | 113 | + class="text-gray-600 dark:text-[#6c757d] text-xs mt-1 md:line-clamp-2 line-clamp-1" |
| 99 | > | 114 | > |
| 100 | {{ appItem.description }} | 115 | {{ appItem.description }} |
| 101 | </p> | 116 | </p> |
| @@ -120,12 +135,12 @@ | @@ -120,12 +135,12 @@ | ||
| 120 | /> | 135 | /> |
| 121 | <div> | 136 | <div> |
| 122 | <h3 | 137 | <h3 |
| 123 | - class="font-bold md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9]" | 138 | + class="font-bold dark:text-[#c6c9cf] md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9] dark:group-hover:text-white" |
| 124 | > | 139 | > |
| 125 | {{ appItem.title }} | 140 | {{ appItem.title }} |
| 126 | </h3> | 141 | </h3> |
| 127 | <p | 142 | <p |
| 128 | - class="text-gray-600 text-xs mt-1 md:line-clamp-2 line-clamp-1" | 143 | + class="text-gray-600 dark:text-[#6c757d] text-xs mt-1 md:line-clamp-2 line-clamp-1" |
| 129 | > | 144 | > |
| 130 | {{ appItem.description }} | 145 | {{ appItem.description }} |
| 131 | </p> | 146 | </p> |
| @@ -143,24 +158,90 @@ import { Promotion } from "@element-plus/icons-vue"; | @@ -143,24 +158,90 @@ import { Promotion } from "@element-plus/icons-vue"; | ||
| 143 | const props = defineProps<{ | 158 | const props = defineProps<{ |
| 144 | childData: any; | 159 | childData: any; |
| 145 | }>(); | 160 | }>(); |
| 146 | - | 161 | +const route = useRoute(); |
| 147 | const childAlias = ref(props.childData.children[0].alias); | 162 | const childAlias = ref(props.childData.children[0].alias); |
| 148 | const config = useRuntimeConfig(); | 163 | const config = useRuntimeConfig(); |
| 149 | -// 阻止默认行为 | 164 | +const { activeSubCategoryId, setActiveSubCategory } = useActiveSubCategory(); |
| 165 | + | ||
| 166 | +// 监听activeSubCategoryId的变化,当变化是把currentFilter更新为-1 | ||
| 167 | +watch(activeSubCategoryId, (newValue, oldValue) => { | ||
| 168 | + if (newValue !== oldValue) { | ||
| 169 | + currentFilter.value = -1; | ||
| 170 | + } | ||
| 171 | +}); | ||
| 172 | + | ||
| 150 | function onNuxtLink(event: any) { | 173 | function onNuxtLink(event: any) { |
| 151 | event.preventDefault(); | 174 | event.preventDefault(); |
| 152 | } | 175 | } |
| 153 | -// 点击确认跳转 | 176 | + |
| 154 | function onConfirm(id: number) { | 177 | function onConfirm(id: number) { |
| 155 | window.open(`/site-details/${id}`); | 178 | window.open(`/site-details/${id}`); |
| 156 | } | 179 | } |
| 157 | -// 导航样式内容 | 180 | + |
| 158 | const currentFilter = ref(0); | 181 | const currentFilter = ref(0); |
| 159 | 182 | ||
| 160 | -// 切换分类内容 | ||
| 161 | function onClick(event: any, alias: string, index: number) { | 183 | function onClick(event: any, alias: string, index: number) { |
| 162 | event?.preventDefault(); | 184 | event?.preventDefault(); |
| 163 | childAlias.value = alias; | 185 | childAlias.value = alias; |
| 164 | currentFilter.value = index; | 186 | currentFilter.value = index; |
| 187 | + const targetId = `term-${props.childData.id}-${props.childData.children[index].id}`; | ||
| 188 | + setActiveSubCategory(targetId); | ||
| 165 | } | 189 | } |
| 190 | + | ||
| 191 | +const scrollToSubCategory = () => { | ||
| 192 | + if (import.meta.client) { | ||
| 193 | + const hash = window.location.hash; | ||
| 194 | + if (hash && hash.startsWith("#term-")) { | ||
| 195 | + const parts = hash.replace("#term-", "").split("-"); | ||
| 196 | + if (parts.length === 2) { | ||
| 197 | + const parentId = parts[0]; | ||
| 198 | + const childId = parts[1]; | ||
| 199 | + | ||
| 200 | + if (parentId === String(props.childData.id)) { | ||
| 201 | + const index = props.childData.children.findIndex( | ||
| 202 | + (child: any) => String(child.id) === childId | ||
| 203 | + ); | ||
| 204 | + if (index !== -1) { | ||
| 205 | + currentFilter.value = index; | ||
| 206 | + childAlias.value = props.childData.children[index].alias; | ||
| 207 | + const targetId = `term-${parentId}-${childId}`; | ||
| 208 | + setActiveSubCategory(targetId); | ||
| 209 | + | ||
| 210 | + nextTick(() => { | ||
| 211 | + const element = document.getElementById(targetId); | ||
| 212 | + if (element) { | ||
| 213 | + element.scrollIntoView({ | ||
| 214 | + behavior: "smooth", | ||
| 215 | + block: "center", | ||
| 216 | + }); | ||
| 217 | + } | ||
| 218 | + }); | ||
| 219 | + } | ||
| 220 | + } | ||
| 221 | + } else if (parts.length === 1) { | ||
| 222 | + const parentId = parts[0]; | ||
| 223 | + if (parentId === String(props.childData.id)) { | ||
| 224 | + nextTick(() => { | ||
| 225 | + const element = document.getElementById(`term-${parentId}`); | ||
| 226 | + if (element) { | ||
| 227 | + element.scrollIntoView({ | ||
| 228 | + behavior: "smooth", | ||
| 229 | + block: "center", | ||
| 230 | + }); | ||
| 231 | + } | ||
| 232 | + }); | ||
| 233 | + } | ||
| 234 | + } | ||
| 235 | + } | ||
| 236 | + } | ||
| 237 | +}; | ||
| 238 | + | ||
| 239 | +onMounted(() => { | ||
| 240 | + scrollToSubCategory(); | ||
| 241 | + window.addEventListener("hashchange", scrollToSubCategory); | ||
| 242 | +}); | ||
| 243 | + | ||
| 244 | +onUnmounted(() => { | ||
| 245 | + window.removeEventListener("hashchange", scrollToSubCategory); | ||
| 246 | +}); | ||
| 166 | </script> | 247 | </script> |
| @@ -2,7 +2,7 @@ | @@ -2,7 +2,7 @@ | ||
| 2 | <!-- 分类导航 --> | 2 | <!-- 分类导航 --> |
| 3 | <div class="mb-6"> | 3 | <div class="mb-6"> |
| 4 | <div class="flex items-center mb-2"> | 4 | <div class="flex items-center mb-2"> |
| 5 | - <h4 class="text-gray text-lg m-0"> | 5 | + <h4 class="text-gray dark:text-[#888] text-lg m-0"> |
| 6 | <i | 6 | <i |
| 7 | :id="`term-${childData.id}`" | 7 | :id="`term-${childData.id}`" |
| 8 | class="iconfont text-lg mr-2" | 8 | class="iconfont text-lg mr-2" |
| @@ -12,7 +12,7 @@ | @@ -12,7 +12,7 @@ | ||
| 12 | </h4> | 12 | </h4> |
| 13 | <div class="flex-auto"></div> | 13 | <div class="flex-auto"></div> |
| 14 | <a | 14 | <a |
| 15 | - class="hidden md:block text-xs ml-2 text-[#282a2d] hover:text-[#5961f9]" | 15 | + class="hidden md:block text-xs ml-2 text-[#282a2d] hover:text-[#5961f9] dark:text-[#989da1]" |
| 16 | :href="`${config.public.baseUrl}/category/${childData.alias}`" | 16 | :href="`${config.public.baseUrl}/category/${childData.alias}`" |
| 17 | >查看更多 >></a | 17 | >查看更多 >></a |
| 18 | > | 18 | > |
| @@ -30,7 +30,7 @@ | @@ -30,7 +30,7 @@ | ||
| 30 | <div | 30 | <div |
| 31 | v-for="(item, index) in childData.appVos" | 31 | v-for="(item, index) in childData.appVos" |
| 32 | :key="index" | 32 | :key="index" |
| 33 | - class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300" | 33 | + class="bg-white dark:bg-[#272929] rounded-xl shadow-md overflow-hidden hover:shadow-lg hover:translate-y-[-6px] transition-all duration-300" |
| 34 | > | 34 | > |
| 35 | <el-popconfirm | 35 | <el-popconfirm |
| 36 | v-if="item.isPopup == '1'" | 36 | v-if="item.isPopup == '1'" |
| @@ -60,12 +60,12 @@ | @@ -60,12 +60,12 @@ | ||
| 60 | /> | 60 | /> |
| 61 | <div> | 61 | <div> |
| 62 | <h3 | 62 | <h3 |
| 63 | - class="font-bold md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9]" | 63 | + class="font-bold dark:text-[#c6c9cf] md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9] dark:group-hover:text-white" |
| 64 | > | 64 | > |
| 65 | {{ item.title }} | 65 | {{ item.title }} |
| 66 | </h3> | 66 | </h3> |
| 67 | <p | 67 | <p |
| 68 | - class="text-gray-600 text-xs mt-1 md:line-clamp-2 line-clamp-1" | 68 | + class="text-gray-600 dark:text-[#6c757d] text-xs mt-1 md:line-clamp-2 line-clamp-1" |
| 69 | > | 69 | > |
| 70 | {{ item.description }} | 70 | {{ item.description }} |
| 71 | </p> | 71 | </p> |
| @@ -86,12 +86,12 @@ | @@ -86,12 +86,12 @@ | ||
| 86 | /> | 86 | /> |
| 87 | <div> | 87 | <div> |
| 88 | <h3 | 88 | <h3 |
| 89 | - class="font-bold md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9]" | 89 | + class="font-bold dark:text-[#c6c9cf] md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9] dark:group-hover:text-white" |
| 90 | > | 90 | > |
| 91 | {{ item.title }} | 91 | {{ item.title }} |
| 92 | </h3> | 92 | </h3> |
| 93 | <p | 93 | <p |
| 94 | - class="text-gray-600 text-xs mt-1 md:line-clamp-2 line-clamp-1" | 94 | + class="text-gray-600 dark:text-[#6c757d] text-xs mt-1 md:line-clamp-2 line-clamp-1" |
| 95 | > | 95 | > |
| 96 | {{ item.description }} | 96 | {{ item.description }} |
| 97 | </p> | 97 | </p> |
| @@ -36,7 +36,7 @@ | @@ -36,7 +36,7 @@ | ||
| 36 | <div | 36 | <div |
| 37 | v-for="(item, index) in recommendList" | 37 | v-for="(item, index) in recommendList" |
| 38 | :key="index" | 38 | :key="index" |
| 39 | - class="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300" | 39 | + class="bg-white dark:bg-[#272929] rounded-xl shadow-md overflow-hidden hover:shadow-lg hover:translate-y-[-6px] transition-all duration-300" |
| 40 | > | 40 | > |
| 41 | <el-popconfirm | 41 | <el-popconfirm |
| 42 | v-if="item.isPopup == '1'" | 42 | v-if="item.isPopup == '1'" |
| @@ -66,12 +66,12 @@ | @@ -66,12 +66,12 @@ | ||
| 66 | /> | 66 | /> |
| 67 | <div> | 67 | <div> |
| 68 | <h3 | 68 | <h3 |
| 69 | - class="font-bold md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9]" | 69 | + class="font-bold dark:text-[#c6c9cf] md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9] dark:group-hover:text-white" |
| 70 | > | 70 | > |
| 71 | {{ item.title }} | 71 | {{ item.title }} |
| 72 | </h3> | 72 | </h3> |
| 73 | <p | 73 | <p |
| 74 | - class="text-gray-600 text-xs mt-1 md:line-clamp-2 line-clamp-1" | 74 | + class="text-gray-600 dark:text-[#6c757d] text-xs mt-1 md:line-clamp-2 line-clamp-1" |
| 75 | > | 75 | > |
| 76 | {{ item.description }} | 76 | {{ item.description }} |
| 77 | </p> | 77 | </p> |
| @@ -96,12 +96,12 @@ | @@ -96,12 +96,12 @@ | ||
| 96 | /> | 96 | /> |
| 97 | <div> | 97 | <div> |
| 98 | <h3 | 98 | <h3 |
| 99 | - class="font-bold md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9]" | 99 | + class="font-bold dark:text-[#c6c9cf] md:text-base text-sm line-clamp-1 text-gray-800 transition group-hover:text-[#5961f9] dark:group-hover:text-white" |
| 100 | > | 100 | > |
| 101 | {{ item.title }} | 101 | {{ item.title }} |
| 102 | </h3> | 102 | </h3> |
| 103 | <p | 103 | <p |
| 104 | - class="text-gray-600 text-xs mt-1 md:line-clamp-2 line-clamp-1" | 104 | + class="text-gray-600 dark:text-[#6c757d] text-xs mt-1 md:line-clamp-2 line-clamp-1" |
| 105 | > | 105 | > |
| 106 | {{ item.description }} | 106 | {{ item.description }} |
| 107 | </p> | 107 | </p> |
composables/useActiveSubCategory.ts
0 → 100644
| 1 | +export const useActiveSubCategory = () => { | ||
| 2 | + const activeSubCategoryId = useState<string | null>("activeSubCategoryId", () => null); | ||
| 3 | + | ||
| 4 | + const setActiveSubCategory = (id: string | null) => { | ||
| 5 | + activeSubCategoryId.value = id; | ||
| 6 | + }; | ||
| 7 | + | ||
| 8 | + const clearActiveSubCategory = () => { | ||
| 9 | + activeSubCategoryId.value = null; | ||
| 10 | + }; | ||
| 11 | + | ||
| 12 | + return { | ||
| 13 | + activeSubCategoryId, | ||
| 14 | + setActiveSubCategory, | ||
| 15 | + clearActiveSubCategory, | ||
| 16 | + }; | ||
| 17 | +}; |
composables/useDarkMode.ts
0 → 100644
| 1 | +export const useDarkMode = () => { | ||
| 2 | + const isDark = useState<boolean>("darkMode", () => false); | ||
| 3 | + | ||
| 4 | + const toggleDark = () => { | ||
| 5 | + isDark.value = !isDark.value; | ||
| 6 | + updateTheme(); | ||
| 7 | + }; | ||
| 8 | + | ||
| 9 | + const updateTheme = () => { | ||
| 10 | + if (import.meta.client) { | ||
| 11 | + if (isDark.value) { | ||
| 12 | + document.documentElement.classList.add("dark"); | ||
| 13 | + localStorage.setItem("theme", "dark"); | ||
| 14 | + } else { | ||
| 15 | + document.documentElement.classList.remove("dark"); | ||
| 16 | + localStorage.setItem("theme", "light"); | ||
| 17 | + } | ||
| 18 | + } | ||
| 19 | + }; | ||
| 20 | + | ||
| 21 | + const initTheme = () => { | ||
| 22 | + if (import.meta.client) { | ||
| 23 | + const savedTheme = localStorage.getItem("theme"); | ||
| 24 | + const prefersDark = window.matchMedia( | ||
| 25 | + "(prefers-color-scheme: dark)" | ||
| 26 | + ).matches; | ||
| 27 | + | ||
| 28 | + if (savedTheme === "dark" || (!savedTheme && prefersDark)) { | ||
| 29 | + isDark.value = true; | ||
| 30 | + document.documentElement.classList.add("dark"); | ||
| 31 | + } else { | ||
| 32 | + isDark.value = false; | ||
| 33 | + document.documentElement.classList.remove("dark"); | ||
| 34 | + } | ||
| 35 | + } | ||
| 36 | + }; | ||
| 37 | + | ||
| 38 | + return { | ||
| 39 | + isDark, | ||
| 40 | + toggleDark, | ||
| 41 | + initTheme, | ||
| 42 | + }; | ||
| 43 | +}; |
| @@ -10,8 +10,15 @@ | @@ -10,8 +10,15 @@ | ||
| 10 | <AppFooter /> | 10 | <AppFooter /> |
| 11 | </div> | 11 | </div> |
| 12 | </div> | 12 | </div> |
| 13 | + <AppFloatingButtons /> | ||
| 13 | </template> | 14 | </template> |
| 14 | 15 | ||
| 15 | -<script setup></script> | 16 | +<script lang="ts" setup> |
| 17 | +const { initTheme } = useDarkMode(); | ||
| 18 | + | ||
| 19 | +onMounted(() => { | ||
| 20 | + initTheme(); | ||
| 21 | +}); | ||
| 22 | +</script> | ||
| 16 | 23 | ||
| 17 | <style></style> | 24 | <style></style> |
| @@ -12,6 +12,22 @@ export default defineNuxtConfig({ | @@ -12,6 +12,22 @@ export default defineNuxtConfig({ | ||
| 12 | '@nuxtjs/tailwindcss', | 12 | '@nuxtjs/tailwindcss', |
| 13 | '@element-plus/nuxt' | 13 | '@element-plus/nuxt' |
| 14 | ], | 14 | ], |
| 15 | + tailwindcss: { | ||
| 16 | + cssPath: '~/assets/css/tailwind.css', | ||
| 17 | + config: { | ||
| 18 | + darkMode: 'class', | ||
| 19 | + content: [ | ||
| 20 | + './components/**/*.{js,vue,ts}', | ||
| 21 | + './layouts/**/*.vue', | ||
| 22 | + './pages/**/*.vue', | ||
| 23 | + './plugins/**/*.{js,ts}', | ||
| 24 | + './app.vue', | ||
| 25 | + ], | ||
| 26 | + theme: { | ||
| 27 | + extend: {}, | ||
| 28 | + }, | ||
| 29 | + } | ||
| 30 | + }, | ||
| 15 | features: { | 31 | features: { |
| 16 | inlineStyles: false, | 32 | inlineStyles: false, |
| 17 | devLogs: false, | 33 | devLogs: false, |
| 1 | <script lang="ts" setup> | 1 | <script lang="ts" setup> |
| 2 | -import type { appListType, appType } from "~/api/types/app"; | ||
| 3 | -import type { adListType } from "~/api/types/ad"; | ||
| 4 | import { getAppList, getAllApp } from "~/api/app"; | 2 | import { getAppList, getAllApp } from "~/api/app"; |
| 5 | import type { webSiteType } from "~/api/types/webSite"; | 3 | import type { webSiteType } from "~/api/types/webSite"; |
| 6 | import { getAdList } from "~/api/ad"; | 4 | import { getAdList } from "~/api/ad"; |
| 7 | -const recommendList = ref<appType[]>([]); | ||
| 8 | -const appList = ref<appListType[]>([]); | ||
| 9 | -const adList = ref<adListType | null>(null); | ||
| 10 | -// 获取轮播广告 | ||
| 11 | -const adRes = await getAdList(); | ||
| 12 | -adList.value = adRes[0]; | ||
| 13 | -// 获取推荐应用 | ||
| 14 | -const recommendRes = await getAppList({ | ||
| 15 | - pageSize: 10, | ||
| 16 | - pageNum: 1, | ||
| 17 | - isRecommend: "1", | ||
| 18 | -}); | ||
| 19 | -recommendList.value = recommendRes.rows; | ||
| 20 | -// 获取全部应用 | ||
| 21 | -const allRes = await getAllApp(); | ||
| 22 | -appList.value = allRes.data; | 5 | + |
| 23 | const webSite = useState<webSiteType>("webSite"); | 6 | const webSite = useState<webSiteType>("webSite"); |
| 7 | +const route = useRoute(); | ||
| 8 | +console.log(route); | ||
| 9 | +const { data: adList } = await useAsyncData( | ||
| 10 | + "adList", | ||
| 11 | + async () => { | ||
| 12 | + const res = await getAdList(); | ||
| 13 | + return res[0] || null; | ||
| 14 | + }, | ||
| 15 | + { | ||
| 16 | + server: true, | ||
| 17 | + lazy: false, | ||
| 18 | + } | ||
| 19 | +); | ||
| 20 | + | ||
| 21 | +const { data: recommendList } = await useAsyncData( | ||
| 22 | + "recommendList", | ||
| 23 | + async () => { | ||
| 24 | + const res = await getAppList({ | ||
| 25 | + pageSize: 10, | ||
| 26 | + pageNum: 1, | ||
| 27 | + isRecommend: "1", | ||
| 28 | + }); | ||
| 29 | + return res.rows || []; | ||
| 30 | + }, | ||
| 31 | + { | ||
| 32 | + server: true, | ||
| 33 | + lazy: false, | ||
| 34 | + } | ||
| 35 | +); | ||
| 36 | + | ||
| 37 | +const { data: appList } = await useAsyncData( | ||
| 38 | + "appList", | ||
| 39 | + async () => { | ||
| 40 | + const res = await getAllApp(); | ||
| 41 | + return res.data || []; | ||
| 42 | + }, | ||
| 43 | + { | ||
| 44 | + server: true, | ||
| 45 | + lazy: false, | ||
| 46 | + } | ||
| 47 | +); | ||
| 24 | 48 | ||
| 25 | useHead({ | 49 | useHead({ |
| 26 | title: webSite.value.webname, | 50 | title: webSite.value.webname, |
| @@ -67,69 +91,73 @@ useHead({ | @@ -67,69 +91,73 @@ useHead({ | ||
| 67 | { | 91 | { |
| 68 | "@type": "WebSite", | 92 | "@type": "WebSite", |
| 69 | "@id": "https://aiboxgo.com/#website", | 93 | "@id": "https://aiboxgo.com/#website", |
| 70 | - "url": "https://aiboxgo.com/", | ||
| 71 | - "name": webSite.value.webname, | ||
| 72 | - "description": webSite.value.webdescription, | ||
| 73 | - "inLanguage": "zh-CN", | ||
| 74 | - "potentialAction": { | 94 | + url: "https://aiboxgo.com/", |
| 95 | + name: webSite.value.webname, | ||
| 96 | + description: webSite.value.webdescription, | ||
| 97 | + inLanguage: "zh-CN", | ||
| 98 | + potentialAction: { | ||
| 75 | "@type": "SearchAction", | 99 | "@type": "SearchAction", |
| 76 | - "target": { | 100 | + target: { |
| 77 | "@type": "EntryPoint", | 101 | "@type": "EntryPoint", |
| 78 | - "urlTemplate": "https://aiboxgo.com/search?keyword={search_term_string}" | 102 | + urlTemplate: |
| 103 | + "https://aiboxgo.com/search?keyword={search_term_string}", | ||
| 79 | }, | 104 | }, |
| 80 | - "query-input": "required name=search_term_string" | ||
| 81 | - } | 105 | + "query-input": "required name=search_term_string", |
| 106 | + }, | ||
| 82 | }, | 107 | }, |
| 83 | { | 108 | { |
| 84 | "@type": "Organization", | 109 | "@type": "Organization", |
| 85 | "@id": "https://aiboxgo.com/#organization", | 110 | "@id": "https://aiboxgo.com/#organization", |
| 86 | - "name": webSite.value.webname, | ||
| 87 | - "url": "https://aiboxgo.com/", | ||
| 88 | - "logo": { | 111 | + name: webSite.value.webname, |
| 112 | + url: "https://aiboxgo.com/", | ||
| 113 | + logo: { | ||
| 89 | "@type": "ImageObject", | 114 | "@type": "ImageObject", |
| 90 | - "url": "https://aiboxgo.com/favicon.ico" | 115 | + url: "https://aiboxgo.com/favicon.ico", |
| 91 | }, | 116 | }, |
| 92 | - "sameAs": [] | 117 | + sameAs: [], |
| 93 | }, | 118 | }, |
| 94 | { | 119 | { |
| 95 | "@type": "CollectionPage", | 120 | "@type": "CollectionPage", |
| 96 | "@id": "https://aiboxgo.com/", | 121 | "@id": "https://aiboxgo.com/", |
| 97 | - "url": "https://aiboxgo.com/", | ||
| 98 | - "name": webSite.value.webname, | ||
| 99 | - "description": webSite.value.webdescription, | ||
| 100 | - "isPartOf": { | ||
| 101 | - "@id": "https://aiboxgo.com/#website" | 122 | + url: "https://aiboxgo.com/", |
| 123 | + name: webSite.value.webname, | ||
| 124 | + description: webSite.value.webdescription, | ||
| 125 | + isPartOf: { | ||
| 126 | + "@id": "https://aiboxgo.com/#website", | ||
| 102 | }, | 127 | }, |
| 103 | - "about": { | ||
| 104 | - "@id": "https://aiboxgo.com/#organization" | ||
| 105 | - } | ||
| 106 | - } | ||
| 107 | - ] | ||
| 108 | - }) | ||
| 109 | - } | ||
| 110 | - ] | 128 | + about: { |
| 129 | + "@id": "https://aiboxgo.com/#organization", | ||
| 130 | + }, | ||
| 131 | + }, | ||
| 132 | + ], | ||
| 133 | + }), | ||
| 134 | + }, | ||
| 135 | + ], | ||
| 111 | }); | 136 | }); |
| 112 | </script> | 137 | </script> |
| 113 | 138 | ||
| 114 | <template> | 139 | <template> |
| 115 | - <div class="flex flex-col min-h-screen bg-white"> | ||
| 116 | - <main class="flex-grow md:p-6 p-2 bg-white"> | ||
| 117 | - <!-- 轮播广告区域 --> | ||
| 118 | - <ADSwiperCarousel :adSwiperList="adList" /> | ||
| 119 | - <!-- Banner 区域 --> | 140 | + <div |
| 141 | + class="flex flex-col min-h-screen bg-white dark:bg-[#1a1b1d] transition-colors duration-300" | ||
| 142 | + > | ||
| 143 | + <main | ||
| 144 | + class="flex-grow md:p-6 p-2 bg-white dark:bg-[#1a1b1d] transition-colors duration-300" | ||
| 145 | + > | ||
| 146 | + <ADSwiperCarousel v-if="adList" :adSwiperList="adList" /> | ||
| 120 | <HomeBanner /> | 147 | <HomeBanner /> |
| 121 | - <!-- 广告区域 --> | ||
| 122 | - <!-- 工具展示区 --> | ||
| 123 | <section class="md:mb-12 mb-6"> | 148 | <section class="md:mb-12 mb-6"> |
| 124 | - <!-- 推荐工具区 --> | ||
| 125 | <HomeRecommend | 149 | <HomeRecommend |
| 150 | + v-if="recommendList && recommendList.length > 0" | ||
| 126 | :recommendList="recommendList" | 151 | :recommendList="recommendList" |
| 127 | navTitle="推荐工具" | 152 | navTitle="推荐工具" |
| 128 | navIcon="star" | 153 | navIcon="star" |
| 129 | /> | 154 | /> |
| 130 | 155 | ||
| 131 | - <div v-for="appItem in appList" class="md:mb-12 mb-6"> | ||
| 132 | - <!-- 分类导航及内容 --> | 156 | + <div |
| 157 | + v-for="appItem in appList" | ||
| 158 | + :key="appItem?.id" | ||
| 159 | + class="md:mb-12 mb-6" | ||
| 160 | + > | ||
| 133 | <HomeContent :appData="appItem" /> | 161 | <HomeContent :appData="appItem" /> |
| 134 | </div> | 162 | </div> |
| 135 | </section> | 163 | </section> |
| 1 | <script lang="ts" setup> | 1 | <script lang="ts" setup> |
| 2 | -import { getAppDetail } from "~/api/app"; | ||
| 3 | -import type { appDetail, Types } from "~/api/types/app"; | 2 | +import { getAppDetail, getAppList } from "~/api/app"; |
| 3 | +import type { appDetail, Types, appType } from "~/api/types/app"; | ||
| 4 | import type { webSiteType } from "~/api/types/webSite"; | 4 | import type { webSiteType } from "~/api/types/webSite"; |
| 5 | const route = useRoute(); | 5 | const route = useRoute(); |
| 6 | const config = useRuntimeConfig(); | 6 | const config = useRuntimeConfig(); |
| @@ -14,12 +14,12 @@ const DetailData = ref<appDetail>({ | @@ -14,12 +14,12 @@ const DetailData = ref<appDetail>({ | ||
| 14 | types: [], | 14 | types: [], |
| 15 | }); | 15 | }); |
| 16 | const webSite = useState<webSiteType>("webSite"); | 16 | const webSite = useState<webSiteType>("webSite"); |
| 17 | +const relatedApps = ref<appType[]>([]); | ||
| 18 | + | ||
| 17 | function mergeDuplicates(data: Types[]) { | 19 | function mergeDuplicates(data: Types[]) { |
| 18 | const map = new Map(); | 20 | const map = new Map(); |
| 19 | - | ||
| 20 | data.forEach((item) => { | 21 | data.forEach((item) => { |
| 21 | if (!map.has(item.id)) { | 22 | if (!map.has(item.id)) { |
| 22 | - // 如果是第一次遇到这个id,创建新对象 | ||
| 23 | map.set(item.id, { | 23 | map.set(item.id, { |
| 24 | id: item.id, | 24 | id: item.id, |
| 25 | label: item.label, | 25 | label: item.label, |
| @@ -27,9 +27,7 @@ function mergeDuplicates(data: Types[]) { | @@ -27,9 +27,7 @@ function mergeDuplicates(data: Types[]) { | ||
| 27 | children: [...(item.children || [])], | 27 | children: [...(item.children || [])], |
| 28 | }); | 28 | }); |
| 29 | } else { | 29 | } else { |
| 30 | - // 如果已经存在,合并children | ||
| 31 | const existing = map.get(item.id); | 30 | const existing = map.get(item.id); |
| 32 | - // 避免重复的子项(基于子项id) | ||
| 33 | const existingChildIds = new Set( | 31 | const existingChildIds = new Set( |
| 34 | existing.children.map((child: any) => child.id) | 32 | existing.children.map((child: any) => child.id) |
| 35 | ); | 33 | ); |
| @@ -40,16 +38,26 @@ function mergeDuplicates(data: Types[]) { | @@ -40,16 +38,26 @@ function mergeDuplicates(data: Types[]) { | ||
| 40 | }); | 38 | }); |
| 41 | } | 39 | } |
| 42 | }); | 40 | }); |
| 43 | - | ||
| 44 | return Array.from(map.values()); | 41 | return Array.from(map.values()); |
| 45 | } | 42 | } |
| 46 | 43 | ||
| 47 | -// 获取详情数据 | ||
| 48 | const detailRes = await getAppDetail(Number(route.params.id)); | 44 | const detailRes = await getAppDetail(Number(route.params.id)); |
| 49 | DetailData.value = detailRes.data; | 45 | DetailData.value = detailRes.data; |
| 50 | DetailData.value.types = mergeDuplicates(detailRes.data.types); | 46 | DetailData.value.types = mergeDuplicates(detailRes.data.types); |
| 51 | - | ||
| 52 | -console.log("详情数据", DetailData.value); | 47 | +if (DetailData.value.types?.length > 0) { |
| 48 | + const firstType = DetailData.value.types[0]; | ||
| 49 | + const typeAlias = firstType.alias || firstType.children?.[0]?.alias; | ||
| 50 | + if (typeAlias) { | ||
| 51 | + const relatedRes = await getAppList({ | ||
| 52 | + pageNum: 1, | ||
| 53 | + pageSize: 8, | ||
| 54 | + typeAlias: typeAlias, | ||
| 55 | + }); | ||
| 56 | + relatedApps.value = relatedRes.rows | ||
| 57 | + .filter((app: appType) => app.id !== DetailData.value.id) | ||
| 58 | + .slice(0, 6); | ||
| 59 | + } | ||
| 60 | +} | ||
| 53 | 61 | ||
| 54 | useHead({ | 62 | useHead({ |
| 55 | title: DetailData.value.popupContent | 63 | title: DetailData.value.popupContent |
| @@ -177,95 +185,219 @@ useHead({ | @@ -177,95 +185,219 @@ useHead({ | ||
| 177 | </script> | 185 | </script> |
| 178 | 186 | ||
| 179 | <template> | 187 | <template> |
| 180 | - <div class="flex flex-col min-h-screen bg-white"> | ||
| 181 | - <main class="flex-grow md:p-6 bg-white p-1"> | ||
| 182 | - <!-- Top Application Info Bar --> | ||
| 183 | - <header | ||
| 184 | - v-show="DetailData.types.length > 0" | ||
| 185 | - class="bg-white shadow-sm md:py-4 md:px-8 py-2 px-4 flex md:items-center md:justify-between flex-col md:flex-row" | ||
| 186 | - > | ||
| 187 | - <div class="flex items-center space-x-4"> | ||
| 188 | - <img | ||
| 189 | - :src="config.public.apiUrl + DetailData.image" | ||
| 190 | - :alt="DetailData.title" | ||
| 191 | - class="w-16 h-16 object-contain" | ||
| 192 | - loading="lazy" | ||
| 193 | - /> | ||
| 194 | - <div> | ||
| 195 | - <h1 class="text-2xl font-bold text-[#5961f9]"> | ||
| 196 | - {{ DetailData.title }} | ||
| 197 | - </h1> | ||
| 198 | - <p class="text-sm text-gray-600 mt-1"> | ||
| 199 | - {{ DetailData.description }} | ||
| 200 | - </p> | ||
| 201 | - <div class="mt-2 flex items-center space-x-2"> | 188 | + <div |
| 189 | + class="flex flex-col min-h-screen bg-white dark:bg-[#1a1b1d] transition-colors duration-300" | ||
| 190 | + > | ||
| 191 | + <main class="flex-grow bg-white dark:bg-[#1a1b1d]"> | ||
| 192 | + <div class="relative overflow-hidden"> | ||
| 193 | + <CommonParticleBackground | ||
| 194 | + :particleCount="60" | ||
| 195 | + particleColor="#5961f9" | ||
| 196 | + lineColor="#7c3aed" | ||
| 197 | + :particleSize="2" | ||
| 198 | + :lineDistance="100" | ||
| 199 | + :speed="0.3" | ||
| 200 | + /> | ||
| 201 | + <div | ||
| 202 | + class="absolute inset-0 bg-gradient-to-r from-[#5961f9]/10 via-[#7c3aed]/10 to-[#a855f7]/10 dark:from-[#5961f9]/5 dark:via-[#7c3aed]/5 dark:to-[#a855f7]/5" | ||
| 203 | + ></div> | ||
| 204 | + | ||
| 205 | + <div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | ||
| 206 | + <div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start"> | ||
| 207 | + <div class="lg:col-span-2 flex justify-center lg:justify-end"> | ||
| 208 | + <div class="relative group"> | ||
| 209 | + <div | ||
| 210 | + class="absolute -inset-1 bg-gradient-to-r from-[#5961f9] to-[#a855f7] rounded-2xl blur opacity-30 group-hover:opacity-50 transition duration-300" | ||
| 211 | + ></div> | ||
| 212 | + <img | ||
| 213 | + :src="config.public.apiUrl + DetailData.image" | ||
| 214 | + :alt="DetailData.title" | ||
| 215 | + class="relative w-24 h-24 md:w-32 md:h-32 object-contain bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-3" | ||
| 216 | + loading="lazy" | ||
| 217 | + /> | ||
| 218 | + </div> | ||
| 219 | + </div> | ||
| 220 | + | ||
| 221 | + <div class="lg:col-span-7 space-y-4"> | ||
| 222 | + <div class="flex items-center gap-3 flex-wrap"> | ||
| 223 | + <h1 | ||
| 224 | + class="text-2xl md:text-3xl font-bold text-[#282a2d] dark:text-[#c6c9cf]" | ||
| 225 | + > | ||
| 226 | + {{ DetailData.title }} | ||
| 227 | + </h1> | ||
| 228 | + <span | ||
| 229 | + v-if="DetailData.popupContent" | ||
| 230 | + class="px-3 py-1 bg-gradient-to-r from-[#5961f9] to-[#a855f7] text-white text-xs rounded-full" | ||
| 231 | + > | ||
| 232 | + {{ DetailData.popupContent }} | ||
| 233 | + </span> | ||
| 234 | + </div> | ||
| 235 | + | ||
| 236 | + <div class="flex flex-wrap gap-2"> | ||
| 237 | + <template v-for="tag in DetailData.types" :key="tag.id"> | ||
| 238 | + <template v-if="tag.children && tag.children.length > 0"> | ||
| 239 | + <NuxtLink | ||
| 240 | + v-for="child in tag.children" | ||
| 241 | + :key="child.id" | ||
| 242 | + :to="'/category/' + child.alias" | ||
| 243 | + class="px-3 py-1.5 bg-white/80 dark:bg-gray-800/80 text-[#5961f9] dark:text-[#8b92f9] rounded-full text-sm hover:bg-[#5961f9] hover:text-white dark:hover:bg-[#5961f9] transition-all duration-300 shadow-sm" | ||
| 244 | + > | ||
| 245 | + {{ child.label }} | ||
| 246 | + </NuxtLink> | ||
| 247 | + </template> | ||
| 248 | + <template v-else> | ||
| 249 | + <NuxtLink | ||
| 250 | + :to="'/category/' + tag.alias" | ||
| 251 | + class="px-3 py-1.5 bg-white/80 dark:bg-gray-800/80 text-[#5961f9] dark:text-[#8b92f9] rounded-full text-sm hover:bg-[#5961f9] hover:text-white dark:hover:bg-[#5961f9] transition-all duration-300 shadow-sm" | ||
| 252 | + > | ||
| 253 | + {{ tag.label }} | ||
| 254 | + </NuxtLink> | ||
| 255 | + </template> | ||
| 256 | + </template> | ||
| 257 | + </div> | ||
| 258 | + | ||
| 259 | + <p | ||
| 260 | + class="text-gray-600 dark:text-gray-300 text-base leading-relaxed line-clamp-3" | ||
| 261 | + > | ||
| 262 | + {{ DetailData.description }} | ||
| 263 | + </p> | ||
| 264 | + | ||
| 265 | + <div class="flex flex-wrap gap-3 pt-2"> | ||
| 266 | + <a | ||
| 267 | + :href="DetailData.link" | ||
| 268 | + target="_blank" | ||
| 269 | + rel="noopener noreferrer" | ||
| 270 | + class="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-[#5961f9] to-[#7c3aed] hover:from-[#4751e8] hover:to-[#6d28d9] text-white font-medium rounded-xl shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-300" | ||
| 271 | + > | ||
| 272 | + <i class="iconfont icon-guide text-lg"></i> | ||
| 273 | + <span>立即访问</span> | ||
| 274 | + </a> | ||
| 275 | + </div> | ||
| 276 | + </div> | ||
| 277 | + | ||
| 278 | + <div class="lg:col-span-3"> | ||
| 202 | <div | 279 | <div |
| 203 | - v-for="tag in DetailData.types" | ||
| 204 | - class="flex items-center space-x-2" | 280 | + class="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl shadow-lg p-4 border border-gray-100 dark:border-gray-700" |
| 205 | > | 281 | > |
| 206 | - <template v-if="tag.children.length > 0"> | ||
| 207 | - <NuxtLink | ||
| 208 | - v-for="child in tag.children" | ||
| 209 | - :to="'/category/' + child.alias" | ||
| 210 | - class="px-2 py-1 bg-blue-100 text-[#5961f9] rounded-full text-xs" | ||
| 211 | - > | ||
| 212 | - {{ child.label }} | ||
| 213 | - </NuxtLink> | ||
| 214 | - </template> | ||
| 215 | - <template v-else> | ||
| 216 | - <NuxtLink | ||
| 217 | - :to="'/category/' + tag.alias" | ||
| 218 | - class="px-2 py-1 bg-blue-100 text-[#5961f9] rounded-full text-xs" | ||
| 219 | - > | ||
| 220 | - {{ tag.label }} | ||
| 221 | - </NuxtLink> | ||
| 222 | - </template> | 282 | + <div |
| 283 | + class="text-center text-gray-400 dark:text-gray-500 text-sm" | ||
| 284 | + > | ||
| 285 | + <a | ||
| 286 | + no_cache="" | ||
| 287 | + href="https://www.coze.cn/?utm_medium=daohang&utm_source=aikit&utm_content=&utm_id=&utm_campaign=&utm_term=hw_coze_aikit&utm_source_platform=" | ||
| 288 | + rel="external nofollow" | ||
| 289 | + target="_blank" | ||
| 290 | + ><img | ||
| 291 | + src="https://ai-kit.cn/wp-content/uploads/2026/01/kouzi_gd.jpg" | ||
| 292 | + alt="扣子" | ||
| 293 | + /></a> | ||
| 294 | + </div> | ||
| 223 | </div> | 295 | </div> |
| 224 | </div> | 296 | </div> |
| 225 | </div> | 297 | </div> |
| 226 | </div> | 298 | </div> |
| 227 | - <div class="flex md:space-x-3 md:mt-0 mt-4"> | ||
| 228 | - <a | ||
| 229 | - :href="DetailData.link" | ||
| 230 | - target="_blank" | ||
| 231 | - class="!rounded-button whitespace-nowrap px-4 py-2 bg-[#5961f9] max-[768px]:text-xs text-white hover:bg-blue-600 transition-colors" | ||
| 232 | - > | ||
| 233 | - <i class="iconfont icon-guide"></i>访问官网 | ||
| 234 | - </a> | ||
| 235 | - </div> | ||
| 236 | - </header> | 299 | + </div> |
| 237 | 300 | ||
| 238 | - <main class="relative w-full"> | ||
| 239 | - <!-- 悬浮广告弹窗 --> | ||
| 240 | - <!-- <div | ||
| 241 | - class="md:absolute top-0 right-0 md:m-4 z-50 relative max-[768px]:m-auto" | ||
| 242 | - v-show="showAd" | ||
| 243 | - :style="{ | ||
| 244 | - width: `${detailAd.width}px`, | ||
| 245 | - height: `${detailAd.height}px`, | ||
| 246 | - }" | ||
| 247 | - > | ||
| 248 | - <div | ||
| 249 | - class="w-full h-full relative" | ||
| 250 | - v-for="item in detailAd.frontAdVos" | 301 | + <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> |
| 302 | + <article class="prose prose-lg dark:prose-invert max-w-none p-6 md:p-8"> | ||
| 303 | + <div v-html="DetailData.content" class="detail-content"></div> | ||
| 304 | + </article> | ||
| 305 | + </div> | ||
| 306 | + | ||
| 307 | + <div | ||
| 308 | + v-if="relatedApps.length > 0" | ||
| 309 | + class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" | ||
| 310 | + > | ||
| 311 | + <div class="rounded-2xl p-6"> | ||
| 312 | + <h2 | ||
| 313 | + class="text-xl font-bold text-[#555] dark:text-[#888] mb-6 flex items-center gap-2" | ||
| 251 | > | 314 | > |
| 252 | - <img | ||
| 253 | - class="w-full h-full object-contain" | ||
| 254 | - :src="config.public.baseUrl + item.image" | ||
| 255 | - :alt="item.title" | ||
| 256 | - /> | ||
| 257 | - <div | ||
| 258 | - class="absolute top-1 right-1 cursor-pointer bg-white w-4 h-4 text-center rounded-[50%] text-xs" | ||
| 259 | - @click="showAd = false" | ||
| 260 | - > | ||
| 261 | - X | ||
| 262 | - </div> | 315 | + <i class="iconfont icon-tag" style="font-size: 1.2rem"></i> |
| 316 | + 相关推荐 | ||
| 317 | + </h2> | ||
| 318 | + <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-3 gap-4"> | ||
| 319 | + <CommonCard :cardList="relatedApps" /> | ||
| 263 | </div> | 320 | </div> |
| 264 | - </div> --> | ||
| 265 | - <div class="md:max-w-5xl mx-auto md:p-8 p-2 w-full"> | ||
| 266 | - <article v-html="DetailData.content"></article> | ||
| 267 | </div> | 321 | </div> |
| 268 | - </main> | 322 | + </div> |
| 269 | </main> | 323 | </main> |
| 270 | </div> | 324 | </div> |
| 271 | </template> | 325 | </template> |
| 326 | + | ||
| 327 | +<style scoped lang="less"> | ||
| 328 | +.line-clamp-3 { | ||
| 329 | + display: -webkit-box; | ||
| 330 | + -webkit-line-clamp: 3; | ||
| 331 | + -webkit-box-orient: vertical; | ||
| 332 | + overflow: hidden; | ||
| 333 | +} | ||
| 334 | + | ||
| 335 | +.detail-content { | ||
| 336 | + @apply text-[#282a2d] dark:text-[#c6c9cf]; | ||
| 337 | +} | ||
| 338 | + | ||
| 339 | +.detail-content :deep(h1), | ||
| 340 | +.detail-content :deep(h2), | ||
| 341 | +.detail-content :deep(h3), | ||
| 342 | +.detail-content :deep(h4) { | ||
| 343 | + @apply text-[#282a2d] my-5 py-1 pl-5 border-l-4 border-[#5961f9] dark:text-[#c6c9cf]; | ||
| 344 | +} | ||
| 345 | + | ||
| 346 | +.detail-content :deep(h1) { | ||
| 347 | + @apply text-2xl; | ||
| 348 | +} | ||
| 349 | + | ||
| 350 | +.detail-content :deep(h2) { | ||
| 351 | + @apply text-xl; | ||
| 352 | +} | ||
| 353 | + | ||
| 354 | +.detail-content :deep(h3) { | ||
| 355 | + @apply text-2xl; | ||
| 356 | +} | ||
| 357 | + | ||
| 358 | +.detail-content :deep(p) { | ||
| 359 | + @apply mb-4 leading-relaxed; | ||
| 360 | +} | ||
| 361 | + | ||
| 362 | +.detail-content :deep(img) { | ||
| 363 | + @apply rounded-lg max-w-full h-auto my-4; | ||
| 364 | +} | ||
| 365 | + | ||
| 366 | +.detail-content :deep(a) { | ||
| 367 | + @apply text-[#5961f9] hover:underline; | ||
| 368 | +} | ||
| 369 | + | ||
| 370 | +.detail-content :deep(ul), | ||
| 371 | +.detail-content :deep(ol) { | ||
| 372 | + @apply pl-6 mb-4 text-sm list-disc; | ||
| 373 | +} | ||
| 374 | + | ||
| 375 | +.detail-content :deep(li) { | ||
| 376 | + @apply mb-2; | ||
| 377 | +} | ||
| 378 | + | ||
| 379 | +.detail-content :deep(blockquote) { | ||
| 380 | + @apply border-l-4 border-[#5961f9] pl-4 italic my-4; | ||
| 381 | +} | ||
| 382 | + | ||
| 383 | +.detail-content :deep(code) { | ||
| 384 | + @apply bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded text-sm; | ||
| 385 | +} | ||
| 386 | + | ||
| 387 | +.detail-content :deep(pre) { | ||
| 388 | + @apply bg-gray-100 dark:bg-gray-700 p-4 rounded-lg overflow-x-auto my-4; | ||
| 389 | +} | ||
| 390 | + | ||
| 391 | +.detail-content :deep(table) { | ||
| 392 | + @apply w-full border-collapse my-4; | ||
| 393 | +} | ||
| 394 | + | ||
| 395 | +.detail-content :deep(th), | ||
| 396 | +.detail-content :deep(td) { | ||
| 397 | + @apply border border-gray-200 dark:border-gray-600 px-4 py-2; | ||
| 398 | +} | ||
| 399 | + | ||
| 400 | +.detail-content :deep(th) { | ||
| 401 | + @apply bg-gray-100 dark:bg-gray-700 font-semibold; | ||
| 402 | +} | ||
| 403 | +</style> |
| 1 | import { ElLoading } from 'element-plus' | 1 | import { ElLoading } from 'element-plus' |
| 2 | 2 | ||
| 3 | -const useMyfetch = async (url: any, options?: any, headers?: any) => { | ||
| 4 | - let loadingInstance | 3 | +let loadingInstance: ReturnType<typeof ElLoading.service> | null = null |
| 4 | +let requestCount = 0 | ||
| 5 | + | ||
| 6 | +const showLoading = () => { | ||
| 7 | + if (requestCount === 0) { | ||
| 8 | + loadingInstance = ElLoading.service({ | ||
| 9 | + lock: true, | ||
| 10 | + text: '加载中...', | ||
| 11 | + background: 'rgba(255, 255, 255, 0.7)', | ||
| 12 | + }) | ||
| 13 | + } | ||
| 14 | + requestCount++ | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +const hideLoading = () => { | ||
| 18 | + requestCount-- | ||
| 19 | + if (requestCount <= 0) { | ||
| 20 | + requestCount = 0 | ||
| 21 | + if (loadingInstance) { | ||
| 22 | + loadingInstance.close() | ||
| 23 | + loadingInstance = null | ||
| 24 | + } | ||
| 25 | + } | ||
| 26 | +} | ||
| 5 | 27 | ||
| 28 | +const useMyfetch = async (url: any, options?: any, headers?: any) => { | ||
| 6 | try { | 29 | try { |
| 7 | - loadingInstance = ElLoading.service() | ||
| 8 | - const config = useRuntimeConfig() // 3.0正式版环境变量要从useRuntimeConfig里的public拿 | ||
| 9 | - const reqUrl = config.public.apiUrl + url // 你的接口地址 | ||
| 10 | - // 不设置key,始终拿到的都是第一个请求的值,参数一样则不会进行第二次请求 | 30 | + showLoading() |
| 31 | + const config = useRuntimeConfig() | ||
| 32 | + const reqUrl = config.public.apiUrl + url | ||
| 11 | 33 | ||
| 12 | - // 可以设置默认headers例如 | ||
| 13 | const customHeaders = { | 34 | const customHeaders = { |
| 14 | Authorization: useCookie('accessToken').value, | 35 | Authorization: useCookie('accessToken').value, |
| 15 | ...headers | 36 | ...headers |
| @@ -23,17 +44,11 @@ const useMyfetch = async (url: any, options?: any, headers?: any) => { | @@ -23,17 +44,11 @@ const useMyfetch = async (url: any, options?: any, headers?: any) => { | ||
| 23 | 44 | ||
| 24 | const result = data.value as any | 45 | const result = data.value as any |
| 25 | 46 | ||
| 26 | - if (pending && loadingInstance) { | ||
| 27 | - loadingInstance.close() | ||
| 28 | - } | ||
| 29 | - | ||
| 30 | return result | 47 | return result |
| 31 | } catch (err) { | 48 | } catch (err) { |
| 32 | return Promise.reject(err) | 49 | return Promise.reject(err) |
| 33 | } finally { | 50 | } finally { |
| 34 | - if (loadingInstance) { | ||
| 35 | - loadingInstance.close() | ||
| 36 | - } | 51 | + hideLoading() |
| 37 | } | 52 | } |
| 38 | } | 53 | } |
| 39 | 54 |
-
请 注册 或 登录 后发表评论