feat: 区分我的配方(diary)和公共配方库(recipes)
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 5s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Failing after 1m19s

配方查询页:
- 我的配方 → /api/diary (user_diary表),左绿色边框区分
- 收藏配方 → 收藏的公共配方
- 公共配方库 → /api/recipes (recipes表),所有公共配方
- 搜索同时过滤个人和公共配方

管理配方页:
- 我的配方 → diary store,支持搜索/标签过滤
- 公共配方库 → 所有公共配方,所有用户可见
- 管理员创建的公共配方不再误归为"我的配方"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:44:57 +00:00
parent 0c19153156
commit 2983036388
2 changed files with 145 additions and 49 deletions

View File

@@ -58,40 +58,33 @@
<button class="btn-sm btn-outline" @click="clearSelection">取消选择</button> <button class="btn-sm btn-outline" @click="clearSelection">取消选择</button>
</div> </div>
<!-- My Recipes Section --> <!-- My Recipes Section (from diary) -->
<div class="recipe-section"> <div class="recipe-section">
<h3 class="section-title">📖 我的配方 ({{ myRecipes.length }})</h3> <h3 class="section-title">📖 我的配方 ({{ myRecipes.length }})</h3>
<div class="recipe-list"> <div class="recipe-list">
<div <div
v-for="r in myFilteredRecipes" v-for="d in myFilteredRecipes"
:key="r._id" :key="'diary-' + d.id"
class="recipe-row" class="recipe-row diary-row"
:class="{ selected: selectedIds.has(r._id) }"
> >
<input <div class="row-info" @click="editDiaryRecipe(d)">
type="checkbox" <span class="row-name">{{ d.name }}</span>
:checked="selectedIds.has(r._id)"
@change="toggleSelect(r._id)"
class="row-check"
/>
<div class="row-info" @click="editRecipe(r)">
<span class="row-name">{{ r.name }}</span>
<span class="row-tags"> <span class="row-tags">
<span v-for="t in r.tags" :key="t" class="mini-tag">{{ t }}</span> <span v-for="t in (d.tags || [])" :key="t" class="mini-tag">{{ t }}</span>
</span> </span>
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span> <span class="row-cost">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span>
</div> </div>
<div class="row-actions"> <div class="row-actions">
<button class="btn-icon" @click="editRecipe(r)" title="编辑"></button> <button class="btn-icon" @click="editDiaryRecipe(d)" title="编辑"></button>
<button class="btn-icon" @click="removeRecipe(r)" title="删除">🗑</button> <button class="btn-icon" @click="removeDiaryRecipe(d)" title="删除">🗑</button>
</div> </div>
</div> </div>
<div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div> <div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div>
</div> </div>
</div> </div>
<!-- Public Recipes Section (admin/senior_editor only) --> <!-- Public Recipes Section -->
<div v-if="auth.canManage" class="recipe-section"> <div class="recipe-section">
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3> <h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
<div class="recipe-list"> <div class="recipe-list">
<div <div
@@ -203,10 +196,11 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, reactive } from 'vue' import { ref, computed, reactive, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils' import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
import { useDiaryStore } from '../stores/diary'
import { useUiStore } from '../stores/ui' import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi' import { api } from '../composables/useApi'
import { showConfirm, showPrompt } from '../composables/useDialog' import { showConfirm, showPrompt } from '../composables/useDialog'
@@ -217,6 +211,7 @@ import TagPicker from '../components/TagPicker.vue'
const auth = useAuthStore() const auth = useAuthStore()
const oils = useOilsStore() const oils = useOilsStore()
const recipeStore = useRecipesStore() const recipeStore = useRecipesStore()
const diaryStore = useDiaryStore()
const ui = useUiStore() const ui = useUiStore()
const manageSearch = ref('') const manageSearch = ref('')
@@ -243,13 +238,11 @@ const tagPickerName = ref('')
const tagPickerTags = ref([]) const tagPickerTags = ref([])
// Computed lists // Computed lists
const myRecipes = computed(() => // "我的配方" = diary (user_diary table), personal recipes
recipeStore.recipes.filter(r => r._owner_id === auth.user.id) const myRecipes = computed(() => diaryStore.userDiary)
)
const publicRecipes = computed(() => // "公共配方库" = all recipes in public library (recipes table)
recipeStore.recipes.filter(r => r._owner_id !== auth.user.id) const publicRecipes = computed(() => recipeStore.recipes)
)
function filterBySearchAndTags(list) { function filterBySearchAndTags(list) {
let result = list let result = list
@@ -257,7 +250,7 @@ function filterBySearchAndTags(list) {
if (q) { if (q) {
result = result.filter(r => result = result.filter(r =>
r.name.toLowerCase().includes(q) || r.name.toLowerCase().includes(q) ||
r.ingredients.some(ing => ing.oil.toLowerCase().includes(q)) || (r.ingredients || []).some(ing => (ing.oil || '').toLowerCase().includes(q)) ||
(r.tags && r.tags.some(t => t.toLowerCase().includes(q))) (r.tags && r.tags.some(t => t.toLowerCase().includes(q)))
) )
} }
@@ -401,6 +394,30 @@ async function saveCurrentRecipe() {
} }
} }
// Load diary on mount
onMounted(async () => {
if (auth.isLoggedIn) {
await diaryStore.loadDiary()
}
})
function editDiaryRecipe(diary) {
// For now, navigate to MyDiary page to edit
// TODO: inline editing
ui.showToast('请到「我的」页面编辑个人配方')
}
async function removeDiaryRecipe(diary) {
const ok = await showConfirm(`确定删除个人配方 "${diary.name}"`)
if (!ok) return
try {
await diaryStore.deleteDiary(diary.id)
ui.showToast('已删除')
} catch {
ui.showToast('删除失败')
}
}
async function removeRecipe(recipe) { async function removeRecipe(recipe) {
const ok = await showConfirm(`确定删除配方 "${recipe.name}"`) const ok = await showConfirm(`确定删除配方 "${recipe.name}"`)
if (!ok) return if (!ok) return

View File

@@ -50,28 +50,32 @@
<!-- Personal Section (logged in) --> <!-- Personal Section (logged in) -->
<div v-if="auth.isLoggedIn" class="personal-section"> <div v-if="auth.isLoggedIn" class="personal-section">
<div class="section-header" @click="showMyRecipes = !showMyRecipes"> <div class="section-header" @click="showMyRecipes = !showMyRecipes">
<span>📖 我的配方</span> <span>📖 我的配方 ({{ myDiaryRecipes.length }})</span>
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span> <span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
</div> </div>
<div v-if="showMyRecipes" class="recipe-grid"> <div v-if="showMyRecipes" class="recipe-grid">
<RecipeCard <div
v-for="(r, i) in myRecipesPreview" v-for="d in myDiaryRecipes"
:key="r._id" :key="'diary-' + d.id"
:recipe="r" class="recipe-card diary-card"
:index="findGlobalIndex(r)" @click="openDiaryDetail(d)"
@click="openDetail(findGlobalIndex(r))" >
@toggle-fav="handleToggleFav(r)" <div class="card-name">{{ d.name }}</div>
/> <div class="card-oils">{{ (d.ingredients || []).map(i => i.oil).join('、') }}</div>
<div v-if="myRecipesPreview.length === 0" class="empty-hint">暂无个人配方</div> <div class="card-bottom">
<span class="card-price">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span>
</div>
</div>
<div v-if="myDiaryRecipes.length === 0" class="empty-hint">暂无个人配方</div>
</div> </div>
<div class="section-header" @click="showFavorites = !showFavorites"> <div class="section-header" @click="showFavorites = !showFavorites">
<span> 收藏配方</span> <span> 收藏配方 ({{ favoritesPreview.length }})</span>
<span class="toggle-icon">{{ showFavorites ? '▾' : '▸' }}</span> <span class="toggle-icon">{{ showFavorites ? '▾' : '▸' }}</span>
</div> </div>
<div v-if="showFavorites" class="recipe-grid"> <div v-if="showFavorites" class="recipe-grid">
<RecipeCard <RecipeCard
v-for="(r, i) in favoritesPreview" v-for="r in favoritesPreview"
:key="r._id" :key="r._id"
:recipe="r" :recipe="r"
:index="findGlobalIndex(r)" :index="findGlobalIndex(r)"
@@ -82,9 +86,9 @@
</div> </div>
</div> </div>
<!-- Fuzzy Search Results --> <!-- Search Results (public recipes) -->
<div v-if="searchQuery && fuzzyResults.length" class="search-results-section"> <div v-if="searchQuery" class="search-results-section">
<div class="section-label">🔍 搜索结果 ({{ fuzzyResults.length }})</div> <div class="section-label">🔍 公共配方搜索结果 ({{ fuzzyResults.length }})</div>
<div class="recipe-grid"> <div class="recipe-grid">
<RecipeCard <RecipeCard
v-for="(r, i) in fuzzyResults" v-for="(r, i) in fuzzyResults"
@@ -94,11 +98,12 @@
@click="openDetail(findGlobalIndex(r))" @click="openDetail(findGlobalIndex(r))"
@toggle-fav="handleToggleFav(r)" @toggle-fav="handleToggleFav(r)"
/> />
<div v-if="fuzzyResults.length === 0" class="empty-hint">未找到匹配的公共配方</div>
</div> </div>
</div> </div>
<!-- Public Recipe Grid --> <!-- Public Recipe Grid -->
<div v-if="!searchQuery || fuzzyResults.length === 0"> <div v-if="!searchQuery">
<div class="section-label">🌿 公共配方库 ({{ filteredRecipes.length }})</div> <div class="section-label">🌿 公共配方库 ({{ filteredRecipes.length }})</div>
<div class="recipe-grid"> <div class="recipe-grid">
<RecipeCard <RecipeCard
@@ -128,6 +133,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils' import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
import { useDiaryStore } from '../stores/diary'
import { useUiStore } from '../stores/ui' import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi' import { api } from '../composables/useApi'
import RecipeCard from '../components/RecipeCard.vue' import RecipeCard from '../components/RecipeCard.vue'
@@ -136,6 +142,7 @@ import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
const auth = useAuthStore() const auth = useAuthStore()
const oils = useOilsStore() const oils = useOilsStore()
const recipeStore = useRecipesStore() const recipeStore = useRecipesStore()
const diaryStore = useDiaryStore()
const ui = useUiStore() const ui = useUiStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -154,8 +161,11 @@ onMounted(async () => {
if (res.ok) { if (res.ok) {
categories.value = await res.json() categories.value = await res.json()
} }
} catch { } catch {}
// category modules are optional
// Load personal diary recipes
if (auth.isLoggedIn) {
await diaryStore.loadDiary()
} }
// Return to a recipe card after QR upload redirect // Return to a recipe card after QR upload redirect
@@ -189,6 +199,7 @@ function slideCat(dir) {
catIdx.value = (catIdx.value + dir + len) % len catIdx.value = (catIdx.value + dir + len) % len
} }
// Public recipes (all recipes in the public library)
const filteredRecipes = computed(() => { const filteredRecipes = computed(() => {
let list = recipeStore.recipes let list = recipeStore.recipes
if (selectedCategory.value) { if (selectedCategory.value) {
@@ -197,6 +208,7 @@ const filteredRecipes = computed(() => {
return list return list
}) })
// Search results from public recipes
const fuzzyResults = computed(() => { const fuzzyResults = computed(() => {
if (!searchQuery.value.trim()) return [] if (!searchQuery.value.trim()) return []
const q = searchQuery.value.trim().toLowerCase() const q = searchQuery.value.trim().toLowerCase()
@@ -208,11 +220,18 @@ const fuzzyResults = computed(() => {
}) })
}) })
const myRecipesPreview = computed(() => { // Personal recipes from diary (separate from public recipes)
const myDiaryRecipes = computed(() => {
if (!auth.isLoggedIn) return [] if (!auth.isLoggedIn) return []
return recipeStore.recipes let list = diaryStore.userDiary
.filter(r => r._owner_id === auth.user.id) if (searchQuery.value.trim()) {
.slice(0, 6) 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(() => { const favoritesPreview = computed(() => {
@@ -232,6 +251,29 @@ function openDetail(index) {
} }
} }
function openDiaryDetail(diary) {
// Create a temporary recipe-like object from diary and open it
const tmpRecipe = {
_id: null,
_diary_id: diary.id,
name: diary.name,
note: diary.note || '',
tags: diary.tags || [],
ingredients: diary.ingredients || [],
_owner_id: auth.user.id,
}
recipeStore.recipes.push(tmpRecipe)
const tmpIdx = recipeStore.recipes.length - 1
selectedRecipeIndex.value = tmpIdx
// Clean up temp recipe when detail closes
const unwatch = watch(selectedRecipeIndex, (val) => {
if (val === null) {
recipeStore.recipes.splice(tmpIdx, 1)
unwatch()
}
})
}
async function handleToggleFav(recipe) { async function handleToggleFav(recipe) {
if (!auth.isLoggedIn) { if (!auth.isLoggedIn) {
ui.openLogin() ui.openLogin()
@@ -474,6 +516,43 @@ function clearSearch() {
padding: 24px 0; padding: 24px 0;
} }
.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);
}
@media (max-width: 600px) { @media (max-width: 600px) {
.recipe-grid { .recipe-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;