Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E
- 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>
This commit is contained in:
398
frontend/src/views/RecipeSearch.vue
Normal file
398
frontend/src/views/RecipeSearch.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="recipe-search">
|
||||
<!-- Category Carousel -->
|
||||
<div class="cat-wrap" v-if="categories.length">
|
||||
<button class="cat-arrow cat-arrow-left" @click="scrollCat(-1)" :disabled="catScrollPos <= 0">‹</button>
|
||||
<div class="cat-track" ref="catTrack">
|
||||
<div
|
||||
v-for="cat in categories"
|
||||
:key="cat.name"
|
||||
class="cat-card"
|
||||
:class="{ active: selectedCategory === cat.name }"
|
||||
@click="toggleCategory(cat.name)"
|
||||
>
|
||||
<span class="cat-icon">{{ cat.icon || '📁' }}</span>
|
||||
<span class="cat-label">{{ cat.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="cat-arrow cat-arrow-right" @click="scrollCat(1)">›</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索配方名、精油、标签..."
|
||||
@input="onSearch"
|
||||
/>
|
||||
<button v-if="searchQuery" class="search-clear-btn" @click="clearSearch">✕</button>
|
||||
<button class="search-btn" @click="onSearch">🔍</button>
|
||||
</div>
|
||||
|
||||
<!-- Personal Section (logged in) -->
|
||||
<div v-if="auth.isLoggedIn" class="personal-section">
|
||||
<div class="section-header" @click="showMyRecipes = !showMyRecipes">
|
||||
<span>📖 我的配方</span>
|
||||
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div v-if="showMyRecipes" class="recipe-grid">
|
||||
<RecipeCard
|
||||
v-for="(r, i) in myRecipesPreview"
|
||||
:key="r._id"
|
||||
:recipe="r"
|
||||
:index="findGlobalIndex(r)"
|
||||
@click="openDetail(findGlobalIndex(r))"
|
||||
@toggle-fav="handleToggleFav(r)"
|
||||
/>
|
||||
<div v-if="myRecipesPreview.length === 0" class="empty-hint">暂无个人配方</div>
|
||||
</div>
|
||||
|
||||
<div class="section-header" @click="showFavorites = !showFavorites">
|
||||
<span>⭐ 收藏配方</span>
|
||||
<span class="toggle-icon">{{ showFavorites ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<div v-if="showFavorites" class="recipe-grid">
|
||||
<RecipeCard
|
||||
v-for="(r, i) in favoritesPreview"
|
||||
:key="r._id"
|
||||
:recipe="r"
|
||||
:index="findGlobalIndex(r)"
|
||||
@click="openDetail(findGlobalIndex(r))"
|
||||
@toggle-fav="handleToggleFav(r)"
|
||||
/>
|
||||
<div v-if="favoritesPreview.length === 0" class="empty-hint">暂无收藏配方</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fuzzy Search Results -->
|
||||
<div v-if="searchQuery && fuzzyResults.length" class="search-results-section">
|
||||
<div class="section-label">🔍 搜索结果 ({{ fuzzyResults.length }})</div>
|
||||
<div class="recipe-grid">
|
||||
<RecipeCard
|
||||
v-for="(r, i) in fuzzyResults"
|
||||
:key="r._id"
|
||||
:recipe="r"
|
||||
:index="findGlobalIndex(r)"
|
||||
@click="openDetail(findGlobalIndex(r))"
|
||||
@toggle-fav="handleToggleFav(r)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Recipe Grid -->
|
||||
<div v-if="!searchQuery || fuzzyResults.length === 0">
|
||||
<div class="section-label">🌿 公共配方库 ({{ filteredRecipes.length }})</div>
|
||||
<div class="recipe-grid">
|
||||
<RecipeCard
|
||||
v-for="(r, i) in filteredRecipes"
|
||||
:key="r._id"
|
||||
:recipe="r"
|
||||
:index="findGlobalIndex(r)"
|
||||
@click="openDetail(findGlobalIndex(r))"
|
||||
@toggle-fav="handleToggleFav(r)"
|
||||
/>
|
||||
<div v-if="filteredRecipes.length === 0" class="empty-hint">暂无配方</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Detail Overlay -->
|
||||
<RecipeDetailOverlay
|
||||
v-if="selectedRecipeIndex !== null"
|
||||
:recipeIndex="selectedRecipeIndex"
|
||||
@close="selectedRecipeIndex = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import { api } from '../composables/useApi'
|
||||
import RecipeCard from '../components/RecipeCard.vue'
|
||||
import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const oils = useOilsStore()
|
||||
const recipeStore = useRecipesStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref(null)
|
||||
const categories = ref([])
|
||||
const selectedRecipeIndex = ref(null)
|
||||
const showMyRecipes = ref(true)
|
||||
const showFavorites = ref(true)
|
||||
const catScrollPos = ref(0)
|
||||
const catTrack = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api('/api/category-modules')
|
||||
if (res.ok) {
|
||||
categories.value = await res.json()
|
||||
}
|
||||
} catch {
|
||||
// category modules are optional
|
||||
}
|
||||
})
|
||||
|
||||
function toggleCategory(name) {
|
||||
selectedCategory.value = selectedCategory.value === name ? null : name
|
||||
}
|
||||
|
||||
function scrollCat(dir) {
|
||||
if (!catTrack.value) return
|
||||
const scrollAmount = 200
|
||||
catTrack.value.scrollLeft += dir * scrollAmount
|
||||
catScrollPos.value = catTrack.value.scrollLeft + dir * scrollAmount
|
||||
}
|
||||
|
||||
const filteredRecipes = computed(() => {
|
||||
let list = recipeStore.recipes
|
||||
if (selectedCategory.value) {
|
||||
list = list.filter(r => r.tags && r.tags.includes(selectedCategory.value))
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const fuzzyResults = computed(() => {
|
||||
if (!searchQuery.value.trim()) return []
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
return recipeStore.recipes.filter(r => {
|
||||
const nameMatch = r.name.toLowerCase().includes(q)
|
||||
const oilMatch = r.ingredients.some(ing => ing.oil.toLowerCase().includes(q))
|
||||
const tagMatch = r.tags && r.tags.some(t => t.toLowerCase().includes(q))
|
||||
return nameMatch || oilMatch || tagMatch
|
||||
})
|
||||
})
|
||||
|
||||
const myRecipesPreview = computed(() => {
|
||||
if (!auth.isLoggedIn) return []
|
||||
return recipeStore.recipes
|
||||
.filter(r => r._owner_id === auth.user.id)
|
||||
.slice(0, 6)
|
||||
})
|
||||
|
||||
const favoritesPreview = computed(() => {
|
||||
if (!auth.isLoggedIn) return []
|
||||
return recipeStore.recipes
|
||||
.filter(r => recipeStore.isFavorite(r))
|
||||
.slice(0, 6)
|
||||
})
|
||||
|
||||
function findGlobalIndex(recipe) {
|
||||
return recipeStore.recipes.findIndex(r => r._id === recipe._id)
|
||||
}
|
||||
|
||||
function openDetail(index) {
|
||||
if (index >= 0) {
|
||||
selectedRecipeIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFav(recipe) {
|
||||
if (!auth.isLoggedIn) {
|
||||
ui.openLogin()
|
||||
return
|
||||
}
|
||||
await recipeStore.toggleFavorite(recipe._id)
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
// fuzzyResults computed handles the filtering reactively
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
selectedCategory.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recipe-search {
|
||||
padding: 0 12px 24px;
|
||||
}
|
||||
|
||||
.cat-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cat-track {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.cat-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
background: #f8f7f5;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
min-width: 64px;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.cat-card:hover {
|
||||
background: #f0eeeb;
|
||||
}
|
||||
|
||||
.cat-card.active {
|
||||
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
||||
border-color: #7ec6a4;
|
||||
color: #2e7d5a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cat-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.cat-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cat-arrow {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #6b6375;
|
||||
}
|
||||
|
||||
.cat-arrow:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: #f8f7f5;
|
||||
border-radius: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1.5px solid #e5e4e7;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
color: #3e3a44;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #b0aab5;
|
||||
}
|
||||
|
||||
.search-btn,
|
||||
.search-clear-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: #6b6375;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover,
|
||||
.search-btn:hover {
|
||||
background: #eae8e5;
|
||||
}
|
||||
|
||||
.personal-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: #f8f7f5;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #3e3a44;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background: #f0eeeb;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #3e3a44;
|
||||
padding: 8px 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.search-results-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.recipe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: #b0aab5;
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.recipe-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user