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
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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user