- Replace single-file 8441-line HTML with Vue 3 SPA - Pinia stores: auth, oils, recipes, diary, ui - Composables: useApi, useDialog, useSmartPaste, useOilTranslation - 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc. - 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc. - 14 Cypress E2E test specs (113 tests), all passing - Multi-stage Dockerfile (Node build + Python runtime) - Demo video generation scripts (TTS + subtitles + screen recording) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
126 lines
4.6 KiB
Vue
126 lines
4.6 KiB
Vue
<template>
|
|
<div class="app-header" style="position:relative">
|
|
<div class="header-inner" style="padding-right:80px">
|
|
<div class="header-icon">🌿</div>
|
|
<div class="header-title" style="text-align:left;flex:1">
|
|
<h1 style="display:flex;justify-content:space-between;align-items:center;gap:8px;white-space:nowrap">
|
|
<span style="flex-shrink:0">doTERRA 配方计算器
|
|
<span v-if="auth.isAdmin" style="font-size:10px;font-weight:400;opacity:0.5;vertical-align:top">v2.2.0</span>
|
|
</span>
|
|
<span
|
|
style="cursor:pointer;color:white;font-size:13px;font-weight:500;flex-shrink:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif;letter-spacing:0.3px;opacity:0.95"
|
|
@click="toggleUserMenu"
|
|
>
|
|
<template v-if="auth.isLoggedIn">
|
|
👤 {{ auth.user.display_name || auth.user.username }} ▾
|
|
</template>
|
|
<template v-else>
|
|
<span style="background:rgba(255,255,255,0.2);padding:4px 12px;border-radius:12px">登录</span>
|
|
</template>
|
|
</span>
|
|
</h1>
|
|
<p style="display:flex;flex-wrap:wrap;gap:4px 8px;margin:0">
|
|
<span style="white-space:nowrap">查询配方</span>
|
|
<span style="opacity:0.5">·</span>
|
|
<span style="white-space:nowrap">计算成本</span>
|
|
<span style="opacity:0.5">·</span>
|
|
<span style="white-space:nowrap">自制配方</span>
|
|
<span style="opacity:0.5">·</span>
|
|
<span style="white-space:nowrap">导出卡片</span>
|
|
<span style="opacity:0.5">·</span>
|
|
<span style="white-space:nowrap">精油知识</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Menu Popup -->
|
|
<UserMenu v-if="showUserMenu" @close="showUserMenu = false" />
|
|
|
|
<!-- Nav tabs -->
|
|
<div class="nav-tabs">
|
|
<div class="nav-tab" :class="{ active: ui.currentSection === 'search' }" @click="goSection('search')">🔍 配方查询</div>
|
|
<div class="nav-tab" :class="{ active: ui.currentSection === 'manage' }" @click="requireLogin('manage')">📋 管理配方</div>
|
|
<div class="nav-tab" :class="{ active: ui.currentSection === 'inventory' }" @click="requireLogin('inventory')">📦 个人库存</div>
|
|
<div class="nav-tab" :class="{ active: ui.currentSection === 'oils' }" @click="goSection('oils')">💧 精油价目</div>
|
|
<div v-if="auth.isBusiness" class="nav-tab" :class="{ active: ui.currentSection === 'projects' }" @click="goSection('projects')">💼 商业核算</div>
|
|
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'audit' }" @click="goSection('audit')">📜 操作日志</div>
|
|
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'bugs' }" @click="goSection('bugs')">🐛 Bug</div>
|
|
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'users' }" @click="goSection('users')">👥 用户管理</div>
|
|
</div>
|
|
|
|
<!-- Main content -->
|
|
<div class="main">
|
|
<router-view />
|
|
</div>
|
|
|
|
<!-- Login Modal -->
|
|
<LoginModal v-if="ui.showLoginModal" @close="ui.closeLogin()" />
|
|
|
|
<!-- Custom Dialog -->
|
|
<CustomDialog />
|
|
|
|
<!-- Toast messages -->
|
|
<div v-for="(toast, i) in ui.toasts" :key="i" class="toast">{{ toast }}</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } 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'
|
|
|
|
const auth = useAuthStore()
|
|
const oils = useOilsStore()
|
|
const recipeStore = useRecipesStore()
|
|
const ui = useUiStore()
|
|
const router = useRouter()
|
|
const showUserMenu = ref(false)
|
|
|
|
function goSection(name) {
|
|
ui.showSection(name)
|
|
router.push('/' + (name === 'search' ? '' : name))
|
|
}
|
|
|
|
function requireLogin(name) {
|
|
if (!auth.isLoggedIn) {
|
|
ui.openLogin()
|
|
return
|
|
}
|
|
goSection(name)
|
|
}
|
|
|
|
function toggleUserMenu() {
|
|
if (!auth.isLoggedIn) {
|
|
ui.openLogin()
|
|
return
|
|
}
|
|
showUserMenu.value = !showUserMenu.value
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await auth.initToken()
|
|
await Promise.all([
|
|
oils.loadOils(),
|
|
recipeStore.loadRecipes(),
|
|
recipeStore.loadTags(),
|
|
])
|
|
if (auth.isLoggedIn) {
|
|
await recipeStore.loadFavorites()
|
|
}
|
|
|
|
// Periodic refresh
|
|
setInterval(async () => {
|
|
if (document.visibilityState !== 'visible') return
|
|
try {
|
|
await auth.loadMe()
|
|
} catch {}
|
|
}, 15000)
|
|
})
|
|
</script>
|