Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 9s
Test / e2e-test (push) Has been cancelled
- 预览栏显示部署时间(如 04/11 10:05) - 售价默认299 - vite构建时注入__BUILD_TIME__ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
303 lines
8.7 KiB
Vue
303 lines
8.7 KiB
Vue
<template>
|
||
<div v-if="isPreview" style="background:#e65100;color:white;text-align:center;padding:6px 16px;font-size:13px;font-weight:600;letter-spacing:0.5px;position:sticky;top:0;z-index:100">
|
||
⚠️ 预览环境 · PR #{{ prId }} · 数据为生产副本,修改不影响正式环境 · {{ buildInfo }}
|
||
</div>
|
||
<div class="app-header">
|
||
<div class="header-inner">
|
||
<div class="header-left">
|
||
<div class="header-icon">🌿</div>
|
||
<div class="header-title">
|
||
<h1>doTERRA 配方计算器</h1>
|
||
<p>查询配方 · 计算成本 · 自制配方 · 导出卡片 · 精油知识</p>
|
||
<p v-if="auth.isAdmin" class="version-info">v2.0.0 · 2026-04-10</p>
|
||
</div>
|
||
</div>
|
||
<div class="header-right" @click="toggleUserMenu">
|
||
<template v-if="auth.isLoggedIn">
|
||
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
|
||
<span class="user-name">{{ auth.user.display_name || auth.user.username }} ▾</span>
|
||
<span v-if="unreadNotifCount > 0" class="notif-badge">{{ unreadNotifCount }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<span class="login-btn">登录</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- User Menu Popup -->
|
||
<UserMenu v-if="showUserMenu" @close="showUserMenu = false; loadUnreadCount()" />
|
||
|
||
<!-- Nav tabs -->
|
||
<div class="nav-tabs" ref="navTabsRef" :style="isPreview ? { top: '36px' } : {}">
|
||
<div v-for="tab in visibleTabs" :key="tab.key"
|
||
class="nav-tab"
|
||
:class="{ active: ui.currentSection === tab.key }"
|
||
@click="handleTabClick(tab)"
|
||
>{{ tab.icon }} {{ tab.label }}</div>
|
||
</div>
|
||
|
||
<!-- Main content -->
|
||
<div class="main" @touchstart="onSwipeStart" @touchend="onSwipeEnd">
|
||
<router-view />
|
||
</div>
|
||
|
||
<!-- Login Modal -->
|
||
<LoginModal v-if="ui.showLoginModal" @close="ui.closeLogin()" />
|
||
|
||
<!-- Custom Dialog -->
|
||
<CustomDialog />
|
||
|
||
<!-- Toast messages -->
|
||
<div v-for="toast in ui.toasts" :key="toast.id" class="toast">{{ toast.msg }}</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { useAuthStore } from './stores/auth'
|
||
import { useOilsStore } from './stores/oils'
|
||
import { useRecipesStore } from './stores/recipes'
|
||
import { useUiStore } from './stores/ui'
|
||
import LoginModal from './components/LoginModal.vue'
|
||
import CustomDialog from './components/CustomDialog.vue'
|
||
import UserMenu from './components/UserMenu.vue'
|
||
import { api } from './composables/useApi'
|
||
|
||
const auth = useAuthStore()
|
||
const oils = useOilsStore()
|
||
const recipeStore = useRecipesStore()
|
||
const ui = useUiStore()
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const showUserMenu = ref(false)
|
||
const navTabsRef = ref(null)
|
||
|
||
// Tab 定义,顺序固定
|
||
// require: 点击时需要的条件,不满足则提示
|
||
// hide: 完全隐藏(只有满足条件才显示)
|
||
const allTabs = [
|
||
{ key: 'search', icon: '🔍', label: '配方查询' },
|
||
{ key: 'manage', icon: '📋', label: '管理配方', require: 'login' },
|
||
{ key: 'inventory', icon: '📦', label: '个人库存', require: 'login' },
|
||
{ key: 'oils', icon: '💧', label: '精油价目' },
|
||
{ key: 'projects', icon: '💼', label: '商业核算', require: 'login' },
|
||
]
|
||
|
||
// 所有人都能看到大部分 tab,bug 和用户管理只有 admin 可见
|
||
const visibleTabs = computed(() => allTabs.filter(t => {
|
||
if (!t.hide) return true
|
||
if (t.hide === 'admin') return auth.isAdmin
|
||
return true
|
||
}))
|
||
const unreadNotifCount = ref(0)
|
||
|
||
async function loadUnreadCount() {
|
||
if (!auth.isLoggedIn) return
|
||
try {
|
||
const res = await api('/api/notifications')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
unreadNotifCount.value = data.filter(n => !n.is_read).length
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Sync ui.currentSection from route on load and navigation
|
||
const routeToSection = { '/': 'search', '/manage': 'manage', '/inventory': 'inventory', '/oils': 'oils', '/projects': 'projects', '/mydiary': 'mydiary', '/audit': 'audit', '/bugs': 'bugs', '/users': 'users' }
|
||
watch(() => route.path, (path) => {
|
||
const section = routeToSection[path] || 'search'
|
||
ui.showSection(section)
|
||
nextTick(() => scrollActiveTabToCenter())
|
||
}, { immediate: true })
|
||
|
||
// Preview environment detection: pr-{id}.oil.oci.euphon.net
|
||
const hostname = window.location.hostname
|
||
const prMatch = hostname.match(/^pr-(\d+)\./)
|
||
const isPreview = !!prMatch
|
||
const prId = prMatch ? prMatch[1] : ''
|
||
const buildInfo = __BUILD_TIME__ || ''
|
||
|
||
function handleTabClick(tab) {
|
||
if (tab.require === 'login' && !auth.isLoggedIn) {
|
||
ui.openLogin(() => goSection(tab.key))
|
||
return
|
||
}
|
||
if (tab.require === 'business' && !auth.isBusiness) {
|
||
if (!auth.isLoggedIn) {
|
||
ui.openLogin(() => goSection(tab.key))
|
||
} else {
|
||
ui.showToast('需要商业认证才能使用此功能')
|
||
}
|
||
return
|
||
}
|
||
goSection(tab.key)
|
||
}
|
||
|
||
function goSection(name) {
|
||
ui.showSection(name)
|
||
router.push('/' + (name === 'search' ? '' : name))
|
||
nextTick(() => scrollActiveTabToCenter())
|
||
}
|
||
|
||
function scrollActiveTabToCenter() {
|
||
if (!navTabsRef.value) return
|
||
const active = navTabsRef.value.querySelector('.nav-tab.active')
|
||
if (!active) return
|
||
const container = navTabsRef.value
|
||
const scrollLeft = active.offsetLeft - container.clientWidth / 2 + active.clientWidth / 2
|
||
container.scrollTo({ left: scrollLeft, behavior: 'smooth' })
|
||
}
|
||
|
||
function requireLogin(name) {
|
||
if (!auth.isLoggedIn) {
|
||
ui.openLogin()
|
||
return
|
||
}
|
||
goSection(name)
|
||
}
|
||
|
||
function toggleUserMenu() {
|
||
if (!auth.isLoggedIn) {
|
||
ui.openLogin()
|
||
return
|
||
}
|
||
showUserMenu.value = !showUserMenu.value
|
||
}
|
||
|
||
// ── 左右滑动切换 tab ──
|
||
// 滑动顺序 = visibleTabs 的顺序(根据用户角色动态决定)
|
||
// 轮播区域(data-no-tab-swipe)内的滑动不触发 tab 切换
|
||
const swipeStartX = ref(0)
|
||
const swipeStartY = ref(0)
|
||
|
||
function onSwipeStart(e) {
|
||
swipeStartX.value = e.touches[0].clientX
|
||
swipeStartY.value = e.touches[0].clientY
|
||
}
|
||
|
||
function onSwipeEnd(e) {
|
||
const dx = e.changedTouches[0].clientX - swipeStartX.value
|
||
const dy = e.changedTouches[0].clientY - swipeStartY.value
|
||
// 必须是水平滑动 > 50px,且水平距离大于垂直距离
|
||
if (Math.abs(dx) < 50 || Math.abs(dy) > Math.abs(dx)) return
|
||
// 轮播区域内不触发 tab 切换
|
||
if (e.target.closest && e.target.closest('[data-no-tab-swipe]')) return
|
||
|
||
const tabs = visibleTabs.value.map(t => t.key)
|
||
const currentIdx = tabs.indexOf(ui.currentSection)
|
||
if (currentIdx < 0) return
|
||
|
||
let nextIdx = -1
|
||
if (dx < 0 && currentIdx < tabs.length - 1) nextIdx = currentIdx + 1
|
||
else if (dx > 0 && currentIdx > 0) nextIdx = currentIdx - 1
|
||
if (nextIdx >= 0) {
|
||
const tab = visibleTabs.value[nextIdx]
|
||
handleTabClick(tab)
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await auth.initToken()
|
||
await Promise.all([
|
||
oils.loadOils(),
|
||
recipeStore.loadRecipes(),
|
||
recipeStore.loadTags(),
|
||
])
|
||
if (auth.isLoggedIn) {
|
||
await recipeStore.loadFavorites()
|
||
await loadUnreadCount()
|
||
}
|
||
|
||
// Periodic refresh
|
||
setInterval(async () => {
|
||
if (document.visibilityState !== 'visible') return
|
||
try {
|
||
await auth.loadMe()
|
||
await loadUnreadCount()
|
||
} catch {}
|
||
}, 15000)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.header-inner {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.header-icon { font-size: 36px; flex-shrink: 0; }
|
||
.header-title { color: white; min-width: 0; }
|
||
.header-title h1 {
|
||
font-family: 'Noto Serif SC', serif;
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
letter-spacing: 2px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.header-title p {
|
||
font-size: 12px;
|
||
opacity: 0.8;
|
||
margin-top: 3px;
|
||
letter-spacing: 0.5px;
|
||
white-space: nowrap;
|
||
}
|
||
.version-info {
|
||
font-size: 10px !important;
|
||
opacity: 0.5 !important;
|
||
margin-top: 1px !important;
|
||
}
|
||
.header-right {
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.user-name {
|
||
color: white;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
opacity: 0.95;
|
||
white-space: nowrap;
|
||
}
|
||
.notif-badge {
|
||
background: #e53935;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
min-width: 18px;
|
||
height: 18px;
|
||
line-height: 18px;
|
||
text-align: center;
|
||
border-radius: 9px;
|
||
padding: 0 5px;
|
||
margin-left: 4px;
|
||
}
|
||
.login-btn {
|
||
color: white;
|
||
background: rgba(255,255,255,0.2);
|
||
padding: 5px 14px;
|
||
border-radius: 12px;
|
||
font-size: 13px;
|
||
}
|
||
.biz-badge { font-size: 14px; }
|
||
@media (max-width: 480px) {
|
||
.header-icon { font-size: 28px; }
|
||
.header-title h1 { font-size: 18px; }
|
||
.header-title p { font-size: 10px; }
|
||
}
|
||
</style>
|