feat(tools): 添加工具箱页面及国际化支持
添加新的工具箱页面,包含三个实用工具卡片。更新国际化文件支持多语言显示。优化页面滚动锁定功能,使其可配置自定义类名。调整导航栏菜单项显示逻辑。 工具箱页面包含入场动画效果,并添加SEO元信息。滚动锁定功能现在支持自定义类名,便于不同场景使用。导航栏现在动态显示中间菜单项,排除最后一项。
This commit is contained in:
@@ -23,6 +23,11 @@
|
||||
height: 0;
|
||||
}
|
||||
|
||||
html.is-scroll-locked,
|
||||
html.is-scroll-locked body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* 首页入场动画期间首帧就隐藏滚动条,避免黑色遮罩边缘露出白色滚动槽。 */
|
||||
html.is-home-intro-active,
|
||||
html.is-home-intro-active body {
|
||||
|
||||
@@ -9,7 +9,7 @@ const loaderBarRef = ref<HTMLElement | null>(null)
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 加载期间锁定页面滚动(注意:Pinia 的 state 是自动解包的,必须用 computed 包装才能保持响应式)
|
||||
usePageScrollLock(computed(() => appStore.isAppLoading))
|
||||
usePageScrollLock(computed(() => appStore.isAppLoading), { className: 'is-scroll-locked' })
|
||||
|
||||
let loaderTween: gsap.core.Tween | null = null
|
||||
let removeLoadListener: (() => void) | null = null
|
||||
@@ -49,7 +49,8 @@ onMounted(() => {
|
||||
gsap.timeline({
|
||||
onComplete: () => {
|
||||
gsap.set(loaderRef.value, { display: 'none' })
|
||||
appStore.setAppLoading(false) // 触发全局状态,通知其他组件入场
|
||||
appStore.setAppLoading(false)
|
||||
window.dispatchEvent(new CustomEvent('app-loading-done'))
|
||||
}
|
||||
})
|
||||
.to(loaderBarRef.value, { scaleX: 1, duration: 0.25, ease: 'power2.out' })
|
||||
|
||||
@@ -19,6 +19,7 @@ const navItems = computed<Array<{ name: string, path: string }>>(() => [
|
||||
{ name: t('nav.studio'), path: '/studio' },
|
||||
{ name: t('nav.work'), path: '/work' },
|
||||
{ name: t('nav.news'), path: '/news' },
|
||||
{ name: t('nav.tools'), path: '/tools' },
|
||||
{ name: t('nav.contact'), path: '/contact' }
|
||||
])
|
||||
|
||||
@@ -250,9 +251,9 @@ onBeforeUnmount(() => {
|
||||
<AppLogo compact click-action="top" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧菜单 (中间两个菜单项,留最后一个 contact 给右上角按钮) -->
|
||||
<!-- 右侧菜单 (去除左侧两个和最后contact菜单后的所有菜单) -->
|
||||
<nav class="flex items-center justify-evenly md:justify-start gap-3 md:gap-4 w-full">
|
||||
<NuxtLink v-for="item in navItems.slice(2, 4)" :key="'bottom-' + item.name" :to="item.path"
|
||||
<NuxtLink v-for="item in navItems.slice(2, navItems.length - 1)" :key="'bottom-' + item.name" :to="item.path"
|
||||
class="group flex items-center font-semibold tracking-[-0.7px] text-black text-sm md:text-base whitespace-nowrap md:overflow-hidden"
|
||||
style="justify-content: flex-start; position: relative;">
|
||||
<div
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { onBeforeUnmount, watch, type Ref, isRef, unref, computed } from 'vue'
|
||||
import { onBeforeUnmount, watch, type Ref, isRef, unref } from 'vue'
|
||||
import { useNuxtApp, useHead } from '#imports'
|
||||
|
||||
// 全局模块级变量,用于记录锁定请求的次数,解决多组件并发锁定的竞态问题
|
||||
let lenisLockCount = 0
|
||||
|
||||
export const usePageScrollLock = (isLocked: Ref<boolean> | boolean) => {
|
||||
export const usePageScrollLock = (
|
||||
isLocked: Ref<boolean> | boolean,
|
||||
options?: { className?: string }
|
||||
) => {
|
||||
const nuxtApp = useNuxtApp()
|
||||
// 由于 $lenis 是从 client 插件中注入的,SSR 期间可能为 undefined
|
||||
// 而且在 setup 期间解构可能拿不到最新的值,我们改为在函数内部动态获取
|
||||
|
||||
// SSR 和 CSR 都支持:自动将 class 注入到 HTML 标签上
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
class: computed(() => unref(isLocked) ? 'is-home-intro-active' : ''),
|
||||
lang: 'zh-CN',
|
||||
},
|
||||
})
|
||||
|
||||
const lockClassName = options?.className ?? 'is-home-intro-active'
|
||||
let currentlyLocked = false
|
||||
|
||||
const updateLock = (locked: boolean) => {
|
||||
@@ -30,7 +32,7 @@ export const usePageScrollLock = (isLocked: Ref<boolean> | boolean) => {
|
||||
lenisLockCount++
|
||||
// 只有第一个锁请求时,才真正停止 Lenis 并滚动到顶部
|
||||
if (lenisLockCount === 1) {
|
||||
document.documentElement.classList.add('is-home-intro-active')
|
||||
document.documentElement.classList.add(lockClassName)
|
||||
// 注意:需要在这里动态获取 $lenis,因为此时 client 插件才可能挂载完成
|
||||
const lenisInstance = nuxtApp.$lenis
|
||||
if (lenisInstance) {
|
||||
@@ -42,7 +44,7 @@ export const usePageScrollLock = (isLocked: Ref<boolean> | boolean) => {
|
||||
lenisLockCount = Math.max(0, lenisLockCount - 1)
|
||||
// 只有当所有锁请求都释放时,才重新启动 Lenis
|
||||
if (lenisLockCount === 0) {
|
||||
document.documentElement.classList.remove('is-home-intro-active')
|
||||
document.documentElement.classList.remove(lockClassName)
|
||||
// 同理,动态获取
|
||||
const lenisInstance = nuxtApp.$lenis
|
||||
if (lenisInstance) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<main class="page-container min-h-screen pt-32 pb-20 px-4 md:px-8 mx-auto max-w-[1200px]">
|
||||
<div
|
||||
ref="headerWrapRef"
|
||||
class="flex flex-col items-center justify-center text-center mb-10 md:mb-16"
|
||||
style="opacity: 0; visibility: hidden;"
|
||||
>
|
||||
<h1 ref="titleRef" class="text-3xl md:text-5xl font-bold mb-4 md:mb-6 tracking-tight text-black">{{ t('tools.title') }}</h1>
|
||||
<p ref="descRef" class="text-base md:text-lg text-gray-600 max-w-2xl">{{ t('tools.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="cardsWrapRef"
|
||||
class="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 lg:gap-8"
|
||||
style="opacity: 0; visibility: hidden;"
|
||||
>
|
||||
<a
|
||||
v-for="tool in toolsData"
|
||||
:key="tool.id"
|
||||
:href="tool.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tool-card group relative flex flex-col p-5 md:p-8 rounded-2xl md:rounded-3xl bg-white border border-gray-100 shadow-sm hover:shadow-xl transition-all duration-500 overflow-hidden cursor-pointer hover:-translate-y-2"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 bg-gradient-to-br"
|
||||
:class="tool.color"
|
||||
></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full">
|
||||
<div class="flex items-center justify-between mb-6 md:mb-8">
|
||||
<div class="w-12 h-12 md:w-14 md:h-14 rounded-2xl flex items-center justify-center bg-gray-50 text-black group-hover:bg-white group-hover:shadow-sm transition-all duration-300">
|
||||
<Icon :name="tool.icon" class="w-6 h-6 md:w-8 md:h-8" />
|
||||
</div>
|
||||
<div class="w-9 h-9 md:w-10 md:h-10 rounded-full flex items-center justify-center bg-gray-50 text-gray-400 group-hover:bg-black group-hover:text-white transition-all duration-300">
|
||||
<Icon name="ph:arrow-up-right" class="w-4 h-4 md:w-5 md:h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg md:text-2xl font-bold mb-2 md:mb-3 text-black">{{ tool.name }}</h3>
|
||||
<p class="text-sm md:text-base text-gray-600 leading-snug md:leading-relaxed flex-grow">{{ t(`tools.items.${tool.id}`) }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSeoMeta } from '#imports'
|
||||
import gsap from 'gsap'
|
||||
import { useAppStore } from '~/stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const headerWrapRef = ref<HTMLElement | null>(null)
|
||||
const titleRef = ref<HTMLElement | null>(null)
|
||||
const descRef = ref<HTMLElement | null>(null)
|
||||
const cardsWrapRef = ref<HTMLElement | null>(null)
|
||||
let cardsCtx: gsap.Context | null = null
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('tools.title'),
|
||||
description: () => t('tools.description'),
|
||||
})
|
||||
|
||||
const playPageIntro = async () => {
|
||||
await nextTick()
|
||||
if (!headerWrapRef.value || !titleRef.value || !descRef.value || !cardsWrapRef.value) return
|
||||
|
||||
cardsCtx?.revert()
|
||||
|
||||
cardsCtx = gsap.context(() => {
|
||||
const cards = gsap.utils.toArray<HTMLElement>('.tool-card', cardsWrapRef.value!)
|
||||
|
||||
gsap.delayedCall(0, () => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
|
||||
|
||||
tl.set(headerWrapRef.value!, { autoAlpha: 1 })
|
||||
tl.fromTo(titleRef.value!, { autoAlpha: 0, y: 20 }, { autoAlpha: 1, y: 0, duration: 0.85, ease: 'power4.out', clearProps: 'transform' })
|
||||
tl.fromTo(descRef.value!, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.7, ease: 'power4.out', clearProps: 'transform' }, '-=0.45')
|
||||
|
||||
tl.set(cardsWrapRef.value!, { autoAlpha: 1 }, '-=0.05')
|
||||
tl.fromTo(
|
||||
cards,
|
||||
{ autoAlpha: 0, y: 34, scale: 0.965 },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 0.95,
|
||||
ease: 'power4.out',
|
||||
stagger: { each: 0.11, from: 'start' },
|
||||
clearProps: 'transform',
|
||||
},
|
||||
'+=0.08'
|
||||
)
|
||||
|
||||
tl.set([headerWrapRef.value!, cardsWrapRef.value!], { clearProps: 'opacity,visibility' })
|
||||
})
|
||||
})
|
||||
})
|
||||
}, headerWrapRef.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const handleLoadingDone = () => {
|
||||
playPageIntro()
|
||||
}
|
||||
|
||||
if (!appStore.isAppLoading) {
|
||||
playPageIntro()
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('app-loading-done', handleLoadingDone, { once: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cardsCtx?.revert()
|
||||
})
|
||||
|
||||
const toolsData = [
|
||||
{
|
||||
id: 'drawio',
|
||||
name: 'Drawio',
|
||||
url: 'https://drawio.virtheart.com',
|
||||
icon: 'ph:flow-arrow-duotone',
|
||||
color: 'from-orange-50/50 to-amber-100/50'
|
||||
},
|
||||
{
|
||||
id: 'excalidraw',
|
||||
name: 'Excalidraw',
|
||||
url: 'https://excalidraw.virtheart.com',
|
||||
icon: 'ph:pencil-circle-duotone',
|
||||
color: 'from-purple-50/50 to-indigo-100/50'
|
||||
},
|
||||
{
|
||||
id: 'itTools',
|
||||
name: 'IT-Tools',
|
||||
url: 'https://ittools.virtheart.com',
|
||||
icon: 'ph:wrench-duotone',
|
||||
color: 'from-blue-50/50 to-cyan-100/50'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
p {
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,19 @@
|
||||
"studio": "Studio",
|
||||
"work": "Work",
|
||||
"news": "News",
|
||||
"tools": "Tools",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"tools": {
|
||||
"title": "Toolbox",
|
||||
"description": "Explore our curated collection of practical online tools to boost your productivity.",
|
||||
"items": {
|
||||
"drawio": "Powerful online diagramming tool for flowcharts, UML, and network topologies.",
|
||||
"excalidraw": "Hand-drawn style whiteboard tool for easy sketching and collaborative thinking.",
|
||||
"itTools": "An all-in-one practical tool collection for developers and IT professionals."
|
||||
},
|
||||
"visit": "Visit Tool"
|
||||
},
|
||||
"home": {
|
||||
"title": "Home",
|
||||
"welcome": "Welcome to Arctic New One"
|
||||
|
||||
@@ -9,8 +9,19 @@
|
||||
"studio": "工作室",
|
||||
"work": "作品",
|
||||
"news": "动态",
|
||||
"tools": "工具",
|
||||
"contact": "联系"
|
||||
},
|
||||
"tools": {
|
||||
"title": "工具箱",
|
||||
"description": "探索我们为您精选的实用在线工具集合,提升您的工作效率。",
|
||||
"items": {
|
||||
"drawio": "强大的在线图表绘制工具,支持流程图、UML、网络拓扑等。",
|
||||
"excalidraw": "手绘风格的白板工具,轻松绘制草图和协作思考。",
|
||||
"itTools": "为开发人员和IT从业者提供的一站式实用工具集合。"
|
||||
},
|
||||
"visit": "访问工具"
|
||||
},
|
||||
"home": {
|
||||
"title": "首页",
|
||||
"welcome": "欢迎来到 Arctic New One"
|
||||
|
||||
Reference in New Issue
Block a user