| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522 |
- <template>
- <div id="app" class="bg-white text-slate-800 font-sans antialiased">
- <!-- 背景装饰 -->
- <div class="fixed top-0 left-0 w-full h-full overflow-hidden -z-10 pointer-events-none">
- <div class="absolute top-[-20%] left-[-20%] w-[60%] h-[60%] bg-blue-100/30 rounded-full filter blur-3xl animate-pulse"></div>
- <div class="absolute bottom-[-20%] right-[-20%] w-[60%] h-[60%] bg-pink-100/30 rounded-full filter blur-3xl animate-pulse animation-delay-4000"></div>
- </div>
- <ElConfigProvider :locale="langStore.elLocale">
- <div class="min-h-screen flex flex-col">
- <!-- Header -->
- <header
- ref="navRef"
- class="sticky top-0 z-50 w-full transition-all duration-300"
- :class="scrolled
- ? 'bg-white/95 backdrop-blur-xl shadow-lg shadow-slate-200/50 border-b border-slate-200/40'
- : 'bg-white/80 backdrop-blur-lg border-b border-slate-200/60'"
- >
- <!-- 顶部阅读进度条 -->
- <div class="relative h-0.5 w-full bg-slate-100/60 overflow-hidden">
- <div
- class="absolute top-0 left-0 h-full bg-gradient-to-r from-blue-500 via-purple-500 via-pink-500 to-orange-400 transition-[width] duration-100 ease-out"
- :style="{ width: readProgress + '%' }"
- ></div>
- </div>
- <div class="max-w-screen-2xl mx-auto px-4 lg:px-8 w-full">
- <div class="flex items-center h-16 gap-4">
- <!-- Logo(左侧,flex-1让它占据左边空间)-->
- <div class="flex-1 min-w-0">
- <a href="/" @click.prevent="navigate('/')" class="flex items-center gap-2 group">
- <!-- Logo 图标 -->
- <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-md group-hover:shadow-blue-300/50 transition-shadow duration-300">
- <svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
- <path d="M13 10V3L4 14h7v7l9-11h-7z"/>
- </svg>
- </div>
- <span class="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-pink-500 group-hover:from-blue-600 group-hover:to-purple-600 transition-all duration-300">
- {{ $t('common.title') }}
- </span>
- </a>
- </div>
- <!-- Navigation(中间,flex-none不压缩)-->
- <nav class="hidden md:flex items-center gap-0.5 whitespace-nowrap flex-none">
- <template v-for="item in navigation" :key="item.name">
- <!-- 有子菜单的导航项 -->
- <div v-if="item.children" class="relative group">
- <button
- class="flex items-center gap-1 px-2.5 py-2 rounded-lg text-sm font-medium transition-all duration-200 whitespace-nowrap"
- :class="isActive(item.path)
- ? 'text-blue-600 bg-blue-50'
- : 'text-slate-600 hover:text-blue-500 hover:bg-slate-50'"
- @click="navigate(item.path)"
- >
- {{ $t(item.name) }}
- <svg class="w-3.5 h-3.5 transition-transform duration-200 group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
- </svg>
- <!-- 活跃下划线 -->
- <span v-if="isActive(item.path)" class="absolute bottom-0 left-3 right-3 h-0.5 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></span>
- </button>
- <!-- 下拉子菜单 -->
- <div class="absolute top-full left-1/2 -translate-x-1/2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 translate-y-1 group-hover:translate-y-0">
- <div class="bg-white rounded-2xl shadow-xl shadow-slate-200/60 border border-slate-100 p-2 min-w-[200px]">
- <!-- 小三角 -->
- <div class="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-white border-l border-t border-slate-100 rotate-45"></div>
- <a
- v-for="child in item.children"
- :key="child.name"
- href="#"
- @click.prevent="navigate(child.path)"
- class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-slate-600 hover:text-blue-600 hover:bg-blue-50 transition-all duration-150 group/item"
- >
- <span class="text-lg">{{ child.icon }}</span>
- <div>
- <div class="font-medium">{{ child.label }}</div>
- <div class="text-xs text-slate-400">{{ child.desc }}</div>
- </div>
- </a>
- </div>
- </div>
- </div>
- <!-- 普通导航项 -->
- <a
- v-else
- :href="item.href"
- class="relative px-2.5 py-2 rounded-lg text-sm font-medium transition-all duration-200 whitespace-nowrap"
- :class="isActive(item.path)
- ? 'text-blue-600 bg-blue-50'
- : 'text-slate-600 hover:text-blue-500 hover:bg-slate-50'"
- @click.prevent="navigate(item.path)"
- >
- {{ $t(item.name) }}
- <!-- 活跃下划线动画 -->
- <span
- class="absolute bottom-0 left-3 right-3 h-0.5 rounded-full transition-all duration-300"
- :class="isActive(item.path) ? 'bg-gradient-to-r from-blue-500 to-purple-500 opacity-100' : 'opacity-0'"
- ></span>
- </a>
- </template>
- </nav>
- <!-- Mobile Menu Button -->
- <button class="md:hidden flex items-center justify-center w-10 h-10 rounded-lg hover:bg-slate-100 transition-colors ml-auto"
- aria-label="菜单"
- @click="mobileMenuOpen = !mobileMenuOpen">
- <svg class="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path v-if="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
- <path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
- </svg>
- </button>
- <!-- User Menu(右侧,flex-1 justify-end)-->
- <div class="hidden md:flex items-center gap-2 flex-1 justify-end">
- <!-- 搜索图标按钮 -->
- <button
- @click="searchOpen = !searchOpen"
- class="w-9 h-9 flex items-center justify-center rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 transition-all duration-200"
- :aria-label="$t('common.search') || '搜索'"
- >
- <svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
- <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
- </svg>
- </button>
- <!-- 通知图标 -->
- <button
- class="relative w-9 h-9 flex items-center justify-center rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 transition-all duration-200"
- aria-label="通知"
- >
- <svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
- <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
- </svg>
- <!-- 红点 -->
- <span class="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full ring-2 ring-white"></span>
- </button>
- <!-- 分割线 -->
- <div class="w-px h-5 bg-slate-200 mx-1"></div>
- <template v-if="appStore.token">
- <el-dropdown>
- <span class="flex items-center gap-2 cursor-pointer px-2 py-1.5 rounded-xl hover:bg-slate-50 transition-colors">
- <el-avatar :size="28" :src="appStore.userInfo?.userAvatar || appStore.avatarDefault" />
- <span class="text-sm font-medium text-slate-700">{{ appStore.userInfo?.nickName || '用户' }}</span>
- <el-icon class="el-icon--right text-slate-400"><arrow-down /></el-icon>
- </span>
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item @click="toPersonal">{{ $t('personalCenter.personalCenter') }}</el-dropdown-item>
- <el-dropdown-item @click="handleLogout">{{ $t('common.logout') }}</el-dropdown-item>
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- </template>
- <template v-else>
- <button
- @click="openLoginDialog"
- class="px-3 py-1.5 text-sm font-medium text-slate-600 hover:text-blue-500 hover:bg-slate-50 rounded-lg transition-all duration-200"
- >
- {{ $t('common.login') }}
- </button>
- <button
- @click="openLoginDialog"
- class="relative px-4 py-1.5 text-sm font-semibold text-white rounded-full overflow-hidden group transition-all duration-300 hover:shadow-lg hover:shadow-blue-300/40 hover:-translate-y-0.5"
- >
- <!-- 渐变背景 -->
- <span class="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-600 transition-all duration-300 group-hover:from-blue-600 group-hover:to-purple-700"></span>
- <!-- 光晕动效 -->
- <span class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-r from-transparent via-white/20 to-transparent -skew-x-12 translate-x-[-100%] group-hover:translate-x-[200%] transition-transform duration-700"></span>
- <span class="relative">注册</span>
- </button>
- </template>
- <LangSwitch />
- </div>
- </div>
- </div>
- <!-- 搜索框展开区域 -->
- <Transition name="search-bar">
- <div v-if="searchOpen" class="border-t border-slate-100 bg-white/98 backdrop-blur-xl">
- <div class="max-w-screen-2xl mx-auto px-4 lg:px-8 py-3">
- <div class="relative max-w-2xl mx-auto">
- <svg class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
- <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
- </svg>
- <input
- ref="searchInputRef"
- v-model="searchQuery"
- type="text"
- :placeholder="$t('common.search_placeholder')"
- class="w-full pl-11 pr-12 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-400 transition-all"
- @keyup.enter="doSearch"
- @keyup.escape="searchOpen = false"
- />
- <button
- @click="searchOpen = false"
- class="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-md text-slate-400 hover:text-slate-600 hover:bg-slate-200 transition-colors"
- >
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
- <path d="M18 6 6 18M6 6l12 12"/>
- </svg>
- </button>
- </div>
- </div>
- </div>
- </Transition>
- <!-- Mobile Menu -->
- <Transition name="mobile-menu">
- <div v-if="mobileMenuOpen" class="md:hidden border-t border-slate-200/60 bg-white/95 backdrop-blur-lg">
- <div class="max-w-screen-2xl mx-auto px-4 lg:px-8 py-4 space-y-2">
- <a v-for="item in navigation" :key="item.name" :href="item.href"
- class="block px-4 py-2.5 rounded-xl font-medium text-slate-600 hover:text-blue-500 hover:bg-blue-50 transition-all duration-200"
- :class="{ 'text-blue-600 bg-blue-50 font-semibold': isActive(item.path) }"
- @click.prevent="navigate(item.path); mobileMenuOpen = false">
- {{ $t(item.name) }}
- </a>
- <div class="pt-2 border-t border-slate-100 flex items-center gap-3">
- <template v-if="appStore.token">
- <button @click="toPersonal" class="flex-1 px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-xl hover:bg-slate-200 transition-colors">
- {{ $t('personalCenter.personalCenter') }}
- </button>
- <button @click="handleLogout" class="flex-1 px-4 py-2 text-sm font-medium text-red-600 bg-red-50 rounded-xl hover:bg-red-100 transition-colors">
- {{ $t('common.logout') }}
- </button>
- </template>
- <template v-else>
- <button @click="openLoginDialog; mobileMenuOpen = false" class="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-xl hover:bg-slate-200 transition-colors">
- {{ $t('common.login') }}
- </button>
- <button @click="openLoginDialog; mobileMenuOpen = false" class="flex-1 px-4 py-2 text-sm font-semibold text-white bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl hover:from-blue-600 hover:to-purple-700 transition-all duration-200">
- 注册
- </button>
- </template>
- <LangSwitch />
- </div>
- </div>
- </div>
- </Transition>
- </header>
- <!-- Main Content -->
- <main class="flex-grow">
- <router-view />
- </main>
- <!-- Footer -->
- <footer class="bg-white border-t border-slate-100">
- <div class="max-w-screen-2xl mx-auto px-6 py-8">
- <div class="text-center text-sm text-slate-500">
- <div class="flex justify-center items-center space-x-4 mb-4">
- <a href="#" @click.prevent="router.push({name:'Agreement',query:{type:'service_agreement'}})" class="hover:text-blue-500 transition-colors">{{ $t('agreement.service_agreement') }}</a>
- <span class="text-slate-300">|</span>
- <a href="#" @click.prevent="router.push({name:'Agreement',query:{type:'privacy_policy'}})" class="hover:text-blue-500 transition-colors">{{ $t('agreement.privacy_policy') }}</a>
- </div>
- <div class="space-x-4">
- <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="hover:text-blue-500 transition-colors">粤ICP备2025364959号-1</a>
- <span class="text-slate-300">|</span>
- <span>广州暴米智能科技有限公司</span>
- </div>
- </div>
- </div>
- </footer>
- </div>
- <LoginDialog ref="loginDialogRef" @login-success="handleLoginSuccess" />
- </ElConfigProvider>
- </div>
- </template>
- <script setup>
- import { logout } from '@/api/auth.js'
- import LoginDialog from './components/LoginDialog.vue'
- import { computed, ref, onMounted, onUnmounted, provide, watch, nextTick } from 'vue'
- import LangSwitch from './components/LangSwitch.vue'
- import { ElConfigProvider, ElMessage } from 'element-plus'
- import { useRoute, useRouter } from 'vue-router'
- import { isLogin } from '@/utils/util.js'
- import { useLangStore } from '@/pinia/langStore'
- import { useAppStore } from '@/pinia/appStore'
- import { useI18n } from 'vue-i18n'
- const langStore = useLangStore()
- const appStore = useAppStore()
- const route = useRoute()
- const router = useRouter()
- const { t } = useI18n()
- // 移动端菜单状态
- const mobileMenuOpen = ref(false)
- // 滚动状态
- const scrolled = ref(false)
- // 阅读进度条
- const readProgress = ref(0)
- // 导航栏 ref,用于动态设置 --nav-height CSS 变量
- const navRef = ref(null)
- let resizeObserver = null
- let navResizeObserver = null
- const updateNavHeight = () => {
- if (navRef.value) {
- const h = navRef.value.offsetHeight
- document.documentElement.style.setProperty('--nav-height', h + 'px')
- }
- }
- const calcProgress = () => {
- const scrollTop = window.scrollY || document.documentElement.scrollTop
- // 可滚动总高度 = 文档总高度 - 视口高度
- const docHeight = Math.max(
- document.body.scrollHeight,
- document.documentElement.scrollHeight,
- document.body.offsetHeight,
- document.documentElement.offsetHeight
- )
- const winHeight = window.innerHeight
- const scrollable = docHeight - winHeight
- if (scrollable <= 0) {
- readProgress.value = 100
- return
- }
- readProgress.value = Math.min(100, Math.round((scrollTop / scrollable) * 1000) / 10)
- }
- const handleScroll = () => {
- scrolled.value = window.scrollY > 20
- calcProgress()
- }
- onMounted(() => {
- window.addEventListener('scroll', handleScroll, { passive: true })
- // 监听页面高度变化(懒加载、动态内容等)
- resizeObserver = new ResizeObserver(() => {
- calcProgress()
- })
- resizeObserver.observe(document.body)
- calcProgress()
- // 动态设置导航栏高度 CSS 变量
- updateNavHeight()
- navResizeObserver = new ResizeObserver(updateNavHeight)
- if (navRef.value) navResizeObserver.observe(navRef.value)
- })
- onUnmounted(() => {
- window.removeEventListener('scroll', handleScroll)
- resizeObserver?.disconnect()
- navResizeObserver?.disconnect()
- })
- // 路由切换时重置进度条
- watch(() => route.path, () => {
- readProgress.value = 0
- // 等待新页面渲染完成后重新计算
- nextTick(() => calcProgress())
- })
- // 搜索状态
- const searchOpen = ref(false)
- const searchQuery = ref('')
- const searchInputRef = ref(null)
- watch(searchOpen, async (val) => {
- if (val) {
- await nextTick()
- searchInputRef.value?.focus()
- }
- })
- const doSearch = () => {
- if (searchQuery.value.trim()) {
- router.push({ path: '/', query: { q: searchQuery.value.trim() } })
- searchOpen.value = false
- searchQuery.value = ''
- }
- }
- // Update i18n locale and dynamic title
- watch(() => langStore.currentLang, () => {
- langStore.updateDynamicTitle()
- })
- const navigation = ref([
- {
- name: 'common.gongzuoliu',
- href: '#',
- path: '/',
- children: [
- { path: '/', icon: '🔍', label: t('common.gongzuoliu_search'), desc: t('common.gongzuoliu_search_desc') },
- { path: '/workflow-trade', icon: '💼', label: t('common.gongzuoliu_trade'), desc: t('common.gongzuoliu_trade_desc') },
- ]
- },
- { name: 'common.gongzuoliu_trade', href: '#', path: '/workflow-trade' },
- {
- name: 'route.learning_system',
- href: '#',
- path: '/learning-system',
- children: [
- { path: '/learning-system', icon: '🎓', label: t('common.learning_course'), desc: t('common.learning_course_desc') },
- { path: '/learn-note', icon: '📝', label: t('common.learning_note'), desc: t('common.learning_note_desc') },
- ]
- },
- { name: 'common.xuxibiji', href: '#', path: '/learn-note' },
- { name: 'route.mibiShop', href: '#', path: '/mibi-shop' },
- ])
- // navigation 配置与参考图一致:工作流(下拉) > 工作流交易 > 学习教程(下拉) > 学习笔记 > 米币商城
- const isActive = (path) => {
- if (path === '/') return route.path === '/' || route.path === '/index'
- return route.path.startsWith(path)
- }
- const navigate = (path) => {
- if (['/learn-note'].includes(path) && !isLogin({ callback: openLoginDialog, t })) {
- return
- }
- router.push(path)
- mobileMenuOpen.value = false
- }
- const loginDialogRef = ref(null)
- watch(() => appStore.showLoginDialog, (newVal) => {
- if (newVal) openLoginDialog()
- })
- onMounted(() => {
- appStore.USERINFO()
- })
- const openLoginDialog = () => {
- loginDialogRef.value?.open()
- }
- const handleLoginSuccess = () => {
- }
- const toPersonal = () => {
- router.push('/personal-center/wallet')
- }
- const handleLogout = () => {
- logout().then(() => {
- appStore.LOGOUT()
- ElMessage.success(t('login.logoutSuccess'))
- router.push('/')
- })
- }
- provide('openLoginDialog', openLoginDialog)
- const routerChildRef = ref(null)
- </script>
- <style>
- /* 页面切换过渡动画 */
- .page-enter-active,
- .page-leave-active {
- transition: opacity 0.2s ease, transform 0.2s ease;
- }
- .page-enter-from {
- opacity: 0;
- transform: translateY(8px);
- }
- .page-leave-to {
- opacity: 0;
- transform: translateY(-8px);
- }
- /* 移动端菜单过渡动画 */
- .mobile-menu-enter-active,
- .mobile-menu-leave-active {
- transition: opacity 0.2s ease, max-height 0.3s ease;
- max-height: 400px;
- overflow: hidden;
- }
- .mobile-menu-enter-from,
- .mobile-menu-leave-to {
- opacity: 0;
- max-height: 0;
- }
- /* 搜索栏过渡动画 */
- .search-bar-enter-active,
- .search-bar-leave-active {
- transition: opacity 0.2s ease, max-height 0.25s ease;
- max-height: 80px;
- overflow: hidden;
- }
- .search-bar-enter-from,
- .search-bar-leave-to {
- opacity: 0;
- max-height: 0;
- }
- /* 背景动画延迟 */
- .animation-delay-4000 {
- animation-delay: 4s;
- }
- /* 注册按钮光晕动效 */
- .register-btn-shine {
- animation: shine 3s infinite;
- }
- @keyframes shine {
- 0% { transform: translateX(-100%) skewX(-12deg); }
- 20% { transform: translateX(200%) skewX(-12deg); }
- 100% { transform: translateX(200%) skewX(-12deg); }
- }
- </style>
|