All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 10s
Test / e2e-test (push) Successful in 4m24s
Endpoint fixes: - AuditLog: /api/audit-logs → /api/audit-log - BugTracker: /api/bugs → /api/bug-reports, create → /api/bug-report - BugTracker: fix create body (content+priority, not title/description) - MyDiary: /api/brand-settings → /api/brand - MyDiary: /api/me/display-name → PUT /api/me - RecipeSearch: /api/category-modules → /api/categories Test improvements: - Remove blanket uncaught:exception swallow (only ignore ResizeObserver) - Add endpoint-parity.cy.js: intercept-based test that verifies correct API endpoints are called and wrong ones are NOT called Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
399 lines
9.2 KiB
Vue
399 lines
9.2 KiB
Vue
<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/categories')
|
|
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>
|