Files
oil-formula-calculator/frontend/src/views/RecipeSearch.vue
Hera Zhao ce5d31ee84
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 4s
PR Preview / deploy-preview (pull_request) Successful in 13s
Test / e2e-test (push) Failing after 52s
fix: 对齐+左对齐+所有用户可新增
- 配方查询页section-label与section-header padding对齐
- 管理配方页标题左对齐,toggle图标靠右
- 新增按钮对所有用户可见(新增到个人配方)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:46:18 +00:00

834 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="recipe-search">
<!-- Category Carousel (full-width image slides) -->
<div class="cat-wrap" v-if="categories.length && !selectedCategory" data-no-tab-swipe @touchstart="onCarouselTouchStart" @touchend="onCarouselTouchEnd">
<div class="cat-track" :style="{ transform: `translateX(-${catIdx * 100}%)` }">
<div
v-for="cat in categories"
:key="cat.name"
class="cat-card"
:style="{ backgroundImage: cat.bg_image ? `url(${cat.bg_image})` : `linear-gradient(135deg, ${cat.color_from || '#7a9e7e'}, ${cat.color_to || '#5a7d5e'})` }"
@click="selectCategory(cat)"
>
<div class="cat-inner">
<div class="cat-icon">{{ cat.icon || '🌿' }}</div>
<div class="cat-name">{{ cat.name }}</div>
<div v-if="cat.subtitle" class="cat-sub">{{ cat.subtitle }}</div>
</div>
</div>
</div>
<button class="cat-arrow left" @click="slideCat(-1)"></button>
<button class="cat-arrow right" @click="slideCat(1)"></button>
</div>
<div class="cat-dots" v-if="categories.length > 1 && !selectedCategory">
<span
v-for="(cat, i) in categories"
:key="i"
class="cat-dot"
:class="{ active: catIdx === i }"
@click="catIdx = i"
></span>
</div>
<!-- Category filter active banner -->
<div v-if="selectedCategory" class="cat-filter-bar">
<span>📂 {{ selectedCategory }}</span>
<button @click="selectedCategory = null; catIdx = 0" class="btn-sm btn-outline"> 返回全部</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">
<template v-if="!searchQuery || myDiaryRecipes.length > 0">
<div class="section-header" @click="showMyRecipes = !showMyRecipes">
<span>📖 我的配方 ({{ myDiaryRecipes.length }})</span>
<span v-if="!auth.isAdmin && sharedCount.total > 0" class="contrib-badge">已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} </span>
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
</div>
<div v-if="showMyRecipes" class="recipe-grid">
<div v-for="d in myDiaryRecipes" :key="'diary-' + d.id" class="diary-card-wrap">
<RecipeCard
:recipe="diaryAsRecipe(d)"
:index="-1"
@click="openDiaryDetail(d)"
/>
<span v-if="getDiaryShareStatus(d) === 'shared'" class="share-status shared">已共享</span>
<span v-else-if="getDiaryShareStatus(d) === 'pending'" class="share-status pending">审核中</span>
</div>
<div v-if="myDiaryRecipes.length === 0" class="empty-hint">暂无个人配方</div>
</div>
</template>
<template v-if="!searchQuery || favoritesPreview.length > 0">
<div class="section-header" @click="showFavorites = !showFavorites">
<span> 收藏配方 ({{ favoritesPreview.length }})</span>
<span class="toggle-icon">{{ showFavorites ? '▾' : '▸' }}</span>
</div>
<div v-if="showFavorites" class="recipe-grid">
<RecipeCard
v-for="r 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>
</template>
</div>
<!-- Search Results (public recipes) -->
<div v-if="searchQuery" class="search-results-section">
<!-- Exact matches -->
<template v-if="exactResults.length > 0">
<div class="section-label">🔍 搜索结果 ({{ exactResults.length }})</div>
<div class="recipe-grid">
<RecipeCard
v-for="r in exactResults"
:key="r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
</div>
</template>
<!-- Similar/related matches -->
<template v-if="similarResults.length > 0">
<div class="section-label similar-label">
{{ exactResults.length > 0 ? '💡 相关配方' : '💡 没有完全匹配,以下是相关配方' }}
({{ similarResults.length }})
</div>
<div class="recipe-grid">
<RecipeCard
v-for="r in similarResults"
:key="'sim-' + r._id"
:recipe="r"
:index="findGlobalIndex(r)"
@click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)"
/>
</div>
</template>
<!-- No results at all -->
<div v-if="exactResults.length === 0 && similarResults.length === 0" class="no-match-box">
<div class="empty-hint">未找到{{ searchQuery }}相关配方</div>
</div>
<!-- Report missing button (always shown at bottom) -->
<div class="no-match-box" style="margin-top:12px">
<button v-if="!reportedMissing" class="btn-report-missing" @click="reportMissing">
📢 没找到想要的通知编辑添加
</button>
<div v-else class="reported-hint">已通知编辑感谢反馈</div>
</div>
</div>
<!-- Public Recipe Grid -->
<div v-if="!searchQuery">
<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 || selectedDiaryRecipe !== null"
:recipeIndex="selectedRecipeIndex"
:recipeData="selectedDiaryRecipe"
:isDiary="selectedDiaryRecipe !== null"
@close="selectedRecipeIndex = null; selectedDiaryRecipe = null"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useDiaryStore } from '../stores/diary'
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 diaryStore = useDiaryStore()
const ui = useUiStore()
const route = useRoute()
const router = useRouter()
const searchQuery = ref('')
const selectedCategory = ref(null)
const categories = ref([])
const selectedRecipeIndex = ref(null)
const selectedDiaryRecipe = ref(null)
const showMyRecipes = ref(false)
const showFavorites = ref(false)
const catIdx = ref(0)
const sharedCount = ref({ adopted: 0, total: 0 })
onMounted(async () => {
try {
const res = await api('/api/categories')
if (res.ok) {
categories.value = await res.json()
}
} catch {}
// Load personal diary recipes & contribution stats
if (auth.isLoggedIn) {
await diaryStore.loadDiary()
try {
const cRes = await api('/api/me/contribution')
if (cRes.ok) {
const data = await cRes.json()
sharedCount.value = { adopted: data.adopted_count || 0, total: data.shared_count || 0 }
}
} catch {}
}
// Return to a recipe card after QR upload redirect
const openRecipeId = route.query.openRecipe
if (openRecipeId) {
router.replace({ path: '/', query: {} })
const tryOpen = () => {
const idx = recipeStore.recipes.findIndex(r => String(r._id) === String(openRecipeId))
if (idx >= 0) {
openDetail(idx)
return true
}
return false
}
if (!tryOpen()) {
// Recipes might not be loaded yet, watch until available
const stop = watch(
() => recipeStore.recipes.length,
() => { if (tryOpen()) stop() },
)
}
}
})
function selectCategory(cat) {
selectedCategory.value = cat.tag_name || cat.name
}
function slideCat(dir) {
const len = categories.value.length
catIdx.value = (catIdx.value + dir + len) % len
}
// Public recipes (all recipes in the public library)
const filteredRecipes = computed(() => {
let list = recipeStore.recipes
if (selectedCategory.value) {
list = list.filter(r => r.tags && r.tags.includes(selectedCategory.value))
}
return list.slice().sort((a, b) => a.name.localeCompare(b.name, 'zh'))
})
// Synonym groups for broader fuzzy matching
const synonymGroups = [
['胸', '乳腺', '乳房', '丰胸', '胸部'],
['瘦', '减肥', '减脂', '消脂', '纤体', '塑形', '体重'],
['痘', '痤疮', '粉刺', '暗疮', '长痘', '祛痘'],
['斑', '色斑', '淡斑', '雀斑', '黑色素', '美白', '亮肤'],
['皱', '抗皱', '皱纹', '紧致', '抗衰', '抗老'],
['睡', '眠', '失眠', '助眠', '安眠', '好眠', '入睡'],
['焦虑', '紧张', '压力', '情绪', '放松', '舒缓', '安神', '宁神'],
['头', '头痛', '头疼', '偏头痛', '头晕'],
['咳', '咳嗽', '止咳', '清咽'],
['鼻', '鼻炎', '鼻塞', '过敏性鼻炎', '打喷嚏'],
['感冒', '发烧', '发热', '流感', '风寒', '风热'],
['胃', '消化', '肠胃', '胃痛', '胃胀', '积食', '便秘'],
['肝', '护肝', '养肝', '肝脏', '排毒'],
['肾', '补肾', '养肾', '肾虚'],
['腰', '腰痛', '腰酸', '腰椎'],
['肩', '肩颈', '颈椎', '肩周'],
['关节', '骨骼', '骨质', '风湿', '类风湿'],
['肌肉', '酸痛', '疼痛', '拉伤'],
['月经', '痛经', '经期', '姨妈', '生理期', '调经'],
['子宫', '卵巢', '生殖', '备孕', '怀孕', '孕'],
['前列腺', '男性', '阳'],
['湿', '祛湿', '排湿', '湿气', '化湿'],
['免疫', '免疫力', '抵抗力'],
['脱发', '掉发', '生发', '头发', '发际线', '秃'],
['过敏', '敏感', '荨麻疹', '湿疹', '皮炎'],
['血压', '高血压', '低血压', '血管', '循环'],
['血糖', '糖尿病', '降糖'],
['淋巴', '排毒', '水肿', '浮肿'],
['呼吸', '肺', '支气管', '哮喘', '气管'],
['眼', '眼睛', '视力', '近视', '干眼'],
['耳', '耳鸣', '中耳炎', '耳朵'],
['口', '口腔', '口臭', '牙', '牙龈', '牙疼'],
['皮肤', '护肤', '保湿', '修复', '焕肤'],
['疤', '疤痕', '伤疤', '妊娠纹'],
['心', '心脏', '心悸', '养心'],
['甲状腺', '甲亢', '甲减'],
['高', '长高', '增高', '个子'],
['静脉', '静脉曲张'],
['痔', '痔疮'],
]
function expandQuery(q) {
const terms = [q]
for (const group of synonymGroups) {
if (group.some(t => q.includes(t) || t.includes(q))) {
for (const t of group) {
if (!terms.includes(t)) terms.push(t)
}
}
}
return terms
}
// Search results: exact matches (query in recipe name or tags, NOT oil names to avoid noise like 西班牙牛至)
const exactResults = 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 tagMatch = r.tags && r.tags.some(t => t.toLowerCase().includes(q))
return nameMatch || tagMatch
}).sort((a, b) => a.name.localeCompare(b.name, 'zh'))
})
// Similar results: synonym expansion, only match against recipe NAME (not ingredients/tags)
// Filter out single-char expanded terms to avoid overly broad matches
const similarResults = computed(() => {
if (!searchQuery.value.trim()) return []
const q = searchQuery.value.trim()
const exactIds = new Set(exactResults.value.map(r => r._id))
const terms = expandQuery(q).filter(t => t.length >= 2 || t === q)
return recipeStore.recipes.filter(r => {
if (exactIds.has(r._id)) return false
const name = r.name
// Match by expanded synonyms (name only, not ingredients)
if (terms.some(t => name.includes(t))) return true
return false
}).sort((a, b) => a.name.localeCompare(b.name, 'zh')).slice(0, 30)
})
const reportedMissing = ref(false)
async function reportMissing() {
try {
await api('/api/symptom-search', {
method: 'POST',
body: JSON.stringify({ query: searchQuery.value.trim(), report_missing: true }),
})
reportedMissing.value = true
ui.showToast('已通知编辑,感谢反馈!')
} catch {
ui.showToast('通知失败')
}
}
// Personal recipes from diary (separate from public recipes)
const myDiaryRecipes = computed(() => {
if (!auth.isLoggedIn) return []
let list = diaryStore.userDiary
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim().toLowerCase()
list = list.filter(d => {
return d.name.toLowerCase().includes(q) ||
(d.ingredients || []).some(ing => ing.oil?.toLowerCase().includes(q))
})
}
return list
})
const favoritesPreview = computed(() => {
if (!auth.isLoggedIn) return []
let list = recipeStore.recipes.filter(r => recipeStore.isFavorite(r))
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim().toLowerCase()
list = list.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
})
}
return list.slice(0, 6)
})
function findGlobalIndex(recipe) {
return recipeStore.recipes.findIndex(r => r._id === recipe._id)
}
function openDetail(index) {
if (index >= 0) {
selectedRecipeIndex.value = index
}
}
function getDiaryShareStatus(d) {
const pub = recipeStore.recipes.find(r => r.name === d.name && r._owner_id === auth.user?.id)
if (pub) return 'shared'
return null
}
function diaryAsRecipe(d) {
return {
_id: 'diary-' + d.id,
name: d.name,
note: d.note || '',
tags: d.tags || [],
ingredients: d.ingredients || [],
}
}
function openDiaryDetail(diary) {
selectedDiaryRecipe.value = diaryAsRecipe(diary)
}
async function handleToggleFav(recipe) {
if (!auth.isLoggedIn) {
ui.openLogin()
return
}
await recipeStore.toggleFavorite(recipe._id)
}
async function shareDiaryToPublic(diary) {
const { showConfirm } = await import('../composables/useDialog')
const ok = await showConfirm(`将「${diary.name}」共享到公共配方库?\n共享后所有用户都能看到。`)
if (!ok) return
try {
await api('/api/recipes', {
method: 'POST',
body: JSON.stringify({
name: diary.name,
note: diary.note || '',
ingredients: (diary.ingredients || []).map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: diary.tags || [],
}),
})
if (auth.isAdmin) {
ui.showToast('已共享到公共配方库')
} else {
ui.showToast('已提交,等待管理员审核')
}
await recipeStore.loadRecipes()
} catch {
ui.showToast('共享失败')
}
}
function onSearch() {
reportedMissing.value = false
}
function clearSearch() {
searchQuery.value = ''
selectedCategory.value = null
reportedMissing.value = false
}
// Carousel swipe
const carouselTouchStartX = ref(0)
function onCarouselTouchStart(e) {
carouselTouchStartX.value = e.touches[0].clientX
}
function onCarouselTouchEnd(e) {
const dx = e.changedTouches[0].clientX - carouselTouchStartX.value
if (Math.abs(dx) > 50) {
slideCat(dx < 0 ? 1 : -1)
}
}
</script>
<style scoped>
.recipe-search {
padding: 0 12px 24px;
}
.cat-wrap {
position: relative;
margin: 0 -12px 20px;
overflow: hidden;
}
.cat-track {
display: flex;
transition: transform 0.4s ease;
will-change: transform;
}
.cat-card {
flex: 0 0 100%;
min-height: 200px;
position: relative;
overflow: hidden;
cursor: pointer;
background-size: cover;
background-position: center;
}
.cat-card::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0,0,0,0.45), rgba(0,0,0,0.25));
}
.cat-inner {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 36px 24px;
color: white;
text-align: center;
}
.cat-icon {
font-size: 48px;
margin-bottom: 10px;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3));
}
.cat-name {
font-family: 'Noto Serif SC', serif;
font-size: 24px;
font-weight: 700;
letter-spacing: 3px;
text-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
.cat-sub {
font-size: 13px;
margin-top: 6px;
opacity: 0.9;
letter-spacing: 1px;
}
.cat-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255,255,255,0.25);
border: none;
color: white;
font-size: 18px;
cursor: pointer;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.cat-arrow:hover { background: rgba(255,255,255,0.45); }
.cat-arrow.left { left: 12px; }
.cat-arrow.right { right: 12px; }
.cat-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 14px;
}
.cat-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border, #e0d4c0);
cursor: pointer;
transition: all 0.25s;
}
.cat-dot.active {
background: var(--sage, #7a9e7e);
width: 22px;
border-radius: 4px;
}
.cat-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--sage-mist, #eef4ee);
border-radius: 10px;
padding: 10px 16px;
margin-bottom: 16px;
font-size: 14px;
font-weight: 600;
color: var(--sage-dark, #5a7d5e);
}
.cat-label {
font-size: 12px;
}
.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;
}
.diary-card-wrap {
position: relative;
}
.share-status {
position: absolute;
top: 8px;
right: 8px;
font-size: 10px;
padding: 2px 8px;
border-radius: 8px;
font-weight: 600;
}
.share-status.shared {
background: #e8f5e9;
color: #2e7d32;
}
.share-status.pending {
background: #fff3e0;
color: #e65100;
}
.share-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.9);
border: 1px solid #d4cfc7;
border-radius: 8px;
padding: 2px 8px;
font-size: 14px;
cursor: pointer;
}
.share-btn:hover {
background: #e8f5e9;
border-color: #7ec6a4;
}
.contrib-badge {
font-size: 11px;
color: #4a9d7e;
background: #e8f5e9;
padding: 2px 8px;
border-radius: 8px;
font-weight: 500;
margin-left: auto;
}
.section-label {
font-size: 14px;
font-weight: 600;
color: #3e3a44;
padding: 10px 12px;
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;
}
.similar-label {
color: #e65100;
background: #fff8e1;
padding: 8px 14px;
border-radius: 10px;
}
.no-match-box {
text-align: center;
padding: 12px 0;
}
.btn-report-missing {
background: linear-gradient(135deg, #ffb74d, #e65100);
color: #fff;
border: none;
border-radius: 10px;
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
font-family: inherit;
margin-top: 8px;
}
.btn-report-missing:hover {
opacity: 0.9;
}
.reported-hint {
color: #4a9d7e;
font-size: 13px;
font-weight: 500;
}
.diary-card {
background: white;
border-radius: 14px;
padding: 16px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
border: 2px solid transparent;
border-left: 3px solid var(--sage, #7a9e7e);
transition: all 0.2s;
}
.diary-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
.diary-card .card-name {
font-family: 'Noto Serif SC', serif;
font-size: 15px;
font-weight: 600;
color: #2c2416;
margin-bottom: 6px;
}
.diary-card .card-oils {
font-size: 12px;
color: #9a8570;
line-height: 1.6;
}
.diary-card .card-bottom {
display: flex;
justify-content: space-between;
margin-top: 8px;
}
.diary-card .card-price {
font-size: 13px;
font-weight: 600;
color: var(--sage-dark, #5a7d5e);
}
.share-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
border-radius: 6px;
opacity: 0.5;
transition: opacity 0.2s;
}
.share-btn:hover { opacity: 1; }
@media (max-width: 600px) {
.recipe-grid {
grid-template-columns: 1fr;
}
}
</style>