diff --git a/backend/main.py b/backend/main.py index 9bfa9fe..0c79e1b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1438,23 +1438,38 @@ def get_unmatched_searches(days: int = 7, user=Depends(require_role("admin", "se return [dict(r) for r in rows] +# ── Recipe review history ────────────────────────────── +@app.get("/api/recipe-reviews") +def list_recipe_reviews(user=Depends(require_role("admin"))): + conn = get_db() + rows = conn.execute( + "SELECT a.id, a.action, a.target_name, a.detail, a.created_at, " + "u.display_name, u.username " + "FROM audit_log a LEFT JOIN users u ON a.user_id = u.id " + "WHERE a.action IN ('adopt_recipe', 'reject_recipe') " + "ORDER BY a.id DESC LIMIT 100" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + # ── Contribution stats ───────────────────────────────── @app.get("/api/me/contribution") def my_contribution(user=Depends(get_current_user)): if not user.get("id"): - return {"shared_count": 0} + return {"adopted_count": 0, "shared_count": 0} conn = get_db() - # Count recipes adopted from this user (tracked in audit_log) - count = conn.execute( + # adopted_count: recipes adopted from this user (owner changed to admin) + adopted = conn.execute( "SELECT COUNT(*) FROM audit_log WHERE action = 'adopt_recipe' AND detail LIKE ?", (f'%"from_user": "{user.get("display_name") or user.get("username")}"%',) ).fetchone()[0] - # Also count recipes still owned by user in public library - own_count = conn.execute( + # pending: recipes still owned by user in public library (not yet adopted) + pending = conn.execute( "SELECT COUNT(*) FROM recipes WHERE owner_id = ?", (user["id"],) ).fetchone()[0] conn.close() - return {"shared_count": count + own_count} + return {"adopted_count": adopted, "shared_count": adopted + pending} # ── Notifications ────────────────────────────────────── diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index 09e1720..a959d10 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -400,6 +400,7 @@ const displayRecipe = computed(() => { }) const canEditThisRecipe = computed(() => { + if (props.isDiary) return false if (authStore.canEdit) return true return false }) diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index a20abdd..32e5d81 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -163,11 +163,7 @@ function handleLogout() { auth.logout() ui.showToast('已退出登录') emit('close') - if (router.currentRoute.value.meta.requiresAuth) { - router.push('/') - } else { - window.location.reload() - } + window.location.href = '/' } onMounted(loadNotifications) diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue index 0b6c0af..0d8d43e 100644 --- a/frontend/src/views/RecipeManager.vue +++ b/frontend/src/views/RecipeManager.vue @@ -7,7 +7,7 @@
- {{ r.name }} + {{ r.name }} {{ r._owner_name }} @@ -31,23 +31,25 @@
- -
- -
- {{ tag }} -
-
+ +
+ + +
+ {{ tag }} +
+
+
已选 {{ selectedIds.size + selectedDiaryIds.size }} 项 @@ -60,11 +62,12 @@
-

+

📖 我的配方 ({{ myRecipes.length }}) - 已贡献 {{ sharedCount }} 条 - + 已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} 条 + {{ showMyRecipes ? '▾' : '▸' }}

+
-

🌿 公共配方库 ({{ publicRecipes.length }})

-
+

+ 🌿 公共配方库 ({{ publicRecipes.length }}) + {{ showPublicRecipes ? '▾' : '▸' }} +

+
或手动输入
- -
- - + +
+
+ +
+
+ + +
-
- +
+ 💡 推荐按照单次用量(椰子油10~20滴)添加纯精油,系统会根据容量和稀释比例自动计算。 +
+ + +
@@ -216,42 +232,121 @@ - - - - + + + +
精油滴数单价/滴小计
{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '-' }}{{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '-' }}{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '-' }}{{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '-' }}
- -
总成本: {{ formTotalCost }}
+ + +
+
+ +
+
{{ name }}
+
+
+ + + +
+
-
- - + +
+ +
+ + + + + +
+
+ + +
+
+ 稀释 1: + + +
+
{{ formDilutionHint }}
-
- -
- {{ tag }} + +
+ + +
+ + +
+ +
+ + {{ tag }} + × + +
+
+ + {{ tag }}
-
+ +
+

+ 📋 审核记录 + {{ showReviewHistory ? '▾' : '▸' }} +

+
+
+ + {{ r.action === 'adopt_recipe' ? '✅ 采纳' : '❌ 拒绝' }} + + {{ r.target_name }} + {{ parseReviewDetail(r.detail) }} + {{ formatDate(r.created_at) }} +
+
暂无审核记录
+
+
+ + + + { const cost = formIngredients.value @@ -458,6 +563,11 @@ function resetForm() { formNote.value = '' formTags.value = [] smartPasteText.value = '' + parsedRecipes.value = [] + showAddIngRow.value = false + newIngOil.value = '' + newIngSearch.value = '' + newIngDrops.value = 1 } function handleSmartPaste() { @@ -506,6 +616,83 @@ function onOilBlur(ing) { }, 150) } +const formCandidateTags = computed(() => + recipeStore.allTags.filter(t => !formTags.value.includes(t)) +) + +const DROPS_PER_ML = 18.6 + +const formDilutionHint = computed(() => { + const eoIngs = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0) + const eoDrops = eoIngs.reduce((s, i) => s + i.drops, 0) + if (formVolume.value === 'single') { + const cocoDrops = Math.round(eoDrops * formDilution.value) + const totalDrops = eoDrops + cocoDrops + return `单次用量:纯精油约 ${eoDrops} 滴 + 椰子油约 ${cocoDrops} 滴,共 ${totalDrops} 滴 (${(totalDrops / DROPS_PER_ML).toFixed(1)}ml),稀释 1:${formDilution.value}` + } + let totalDrops + if (formVolume.value === 'custom') { + totalDrops = formCustomUnit.value === 'ml' ? Math.round(formCustomVolume.value * DROPS_PER_ML) : formCustomVolume.value + } else { + totalDrops = Math.round(Number(formVolume.value) * DROPS_PER_ML) + } + const targetEo = Math.round(totalDrops / (1 + formDilution.value)) + const cocoDrops = totalDrops - targetEo + return `总容量 ${totalDrops} 滴 (${(totalDrops / DROPS_PER_ML).toFixed(1)}ml),纯精油约 ${targetEo} 滴 + 椰子油约 ${cocoDrops} 滴,稀释 1:${formDilution.value}` +}) + +function applyVolumeDilution() { + const eoIngs = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油') + if (eoIngs.length === 0) { ui.showToast('请先添加精油'); return } + + let targetTotalDrops + if (formVolume.value === 'single') { + const targetEoDrops = 10 + const currentEoTotal = eoIngs.reduce((s, i) => s + (i.drops || 0), 0) + if (currentEoTotal <= 0) return + const scale = targetEoDrops / currentEoTotal + eoIngs.forEach(i => { i.drops = Math.max(0.5, Math.round(i.drops * scale * 2) / 2) }) + const actualEo = eoIngs.reduce((s, i) => s + i.drops, 0) + setFormCoconut(actualEo * formDilution.value) + ui.showToast('已应用单次用量') + return + } + + if (formVolume.value === 'custom') { + targetTotalDrops = formCustomUnit.value === 'ml' ? Math.round(formCustomVolume.value * DROPS_PER_ML) : formCustomVolume.value + } else { + targetTotalDrops = Math.round(Number(formVolume.value) * DROPS_PER_ML) + } + + const targetEoDrops = Math.round(targetTotalDrops / (1 + formDilution.value)) + const currentEoTotal = eoIngs.reduce((s, i) => s + (i.drops || 0), 0) + if (currentEoTotal <= 0) return + const scale = targetEoDrops / currentEoTotal + eoIngs.forEach(i => { i.drops = Math.max(0.5, Math.round(i.drops * scale * 2) / 2) }) + const actualEo = eoIngs.reduce((s, i) => s + i.drops, 0) + setFormCoconut(targetTotalDrops - actualEo) + ui.showToast('已应用容量设置') +} + +function setFormCoconut(drops) { + drops = Math.max(0, Math.round(drops)) + const idx = formIngredients.value.findIndex(i => i.oil === '椰子油') + if (idx >= 0) { + formIngredients.value[idx].drops = drops + } else if (drops > 0) { + formIngredients.value.push({ oil: '椰子油', drops, _search: '椰子油', _open: false }) + } +} + +function confirmAddIng() { + if (!newIngOil.value || !newIngDrops.value) return + formIngredients.value.push({ oil: newIngOil.value, drops: newIngDrops.value, _search: newIngOil.value, _open: false }) + newIngOil.value = '' + newIngSearch.value = '' + newIngDrops.value = 1 + showAddIngRow.value = false +} + function toggleFormTag(tag) { const idx = formTags.value.indexOf(tag) if (idx >= 0) formTags.value.splice(idx, 1) @@ -613,7 +800,26 @@ async function saveAllParsed() { closeOverlay() } -const sharedCount = ref(0) +const sharedCount = ref({ adopted: 0, total: 0 }) +const previewRecipeIndex = ref(null) +const showMyRecipes = ref(true) +const showPublicRecipes = ref(false) +const showReviewHistory = ref(false) +const reviewHistory = ref([]) + +function parseReviewDetail(detail) { + try { + const d = JSON.parse(detail) + if (d.from_user) return `来自: ${d.from_user}` + if (d.reason) return `原因: ${d.reason}` + } catch {} + return '' +} + +function formatDate(d) { + if (!d) return '' + return new Date(d + 'Z').toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) +} // Load diary on mount onMounted(async () => { @@ -623,10 +829,16 @@ onMounted(async () => { const res = await api('/api/me/contribution') if (res.ok) { const data = await res.json() - sharedCount.value = data.shared_count || 0 + sharedCount.value = { adopted: data.adopted_count || 0, total: data.shared_count || 0 } } } catch {} } + if (auth.isAdmin) { + try { + const res = await api('/api/recipe-reviews') + if (res.ok) reviewHistory.value = await res.json() + } catch {} + } }) function editDiaryRecipe(diary) { @@ -638,6 +850,11 @@ function editDiaryRecipe(diary) { showAddOverlay.value = true } +function openRecipeDetail(recipe) { + const idx = recipeStore.recipes.findIndex(r => r._id === recipe._id) + if (idx >= 0) previewRecipeIndex.value = idx +} + function getDiaryShareStatus(d) { // Check if a public recipe with same name exists, owned by current user or adopted by admin const pub = recipeStore.recipes.find(r => r.name === d.name) @@ -792,6 +1009,14 @@ watch(() => recipeStore.recipes, () => { font-weight: 600; flex: 1; } +.pending-name.clickable { + cursor: pointer; + color: #4a9d7e; + text-decoration: underline; +} +.pending-name.clickable:hover { + color: #2e7d5a; +} .pending-owner { color: #999; @@ -836,6 +1061,10 @@ watch(() => recipeStore.recipes, () => { .tag-filter-bar { margin-bottom: 12px; + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; } .tag-toggle-btn { @@ -985,6 +1214,19 @@ watch(() => recipeStore.recipes, () => { .share-tag.shared { background: #e8f5e9; color: #2e7d32; } .share-tag.pending { background: #fff3e0; color: #e65100; } +.review-history { max-height: 300px; overflow-y: auto; } +.review-log-item { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; + border-bottom: 1px solid #f5f5f5; font-size: 13px; flex-wrap: wrap; +} +.log-approve { color: #2e7d32; font-weight: 600; white-space: nowrap; } +.log-reject { color: #c62828; font-weight: 600; white-space: nowrap; } +.log-recipe { font-weight: 500; color: #3e3a44; } +.log-from { color: #999; font-size: 12px; } +.log-time { color: #bbb; font-size: 11px; margin-left: auto; white-space: nowrap; } +.section-title.clickable { cursor: pointer; display: flex; justify-content: space-between; align-items: center; } +.toggle-icon { font-size: 12px; color: #999; } + .contrib-tag { font-size: 11px; color: #4a9d7e; @@ -1113,12 +1355,52 @@ watch(() => recipeStore.recipes, () => { .parsed-warn { color: #e65100; font-size: 12px; margin-bottom: 6px; } .parsed-actions { display: flex; gap: 8px; justify-content: center; margin-top: 8px; } +.editor-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.editor-name-input { width: 100%; font-size: 17px; font-weight: 600; border: none; border-bottom: 2px solid #e5e4e7; padding: 6px 0; outline: none; font-family: inherit; background: transparent; } +.editor-name-input:focus { border-bottom-color: #7ec6a4; } +.editor-header-actions { display: flex; gap: 6px; flex-shrink: 0; } +.editor-tip { font-size: 12px; color: #999; background: #f8f7f5; padding: 8px 12px; border-radius: 8px; margin-bottom: 12px; } +.editor-section { margin-bottom: 16px; } +.editor-label { font-size: 13px; font-weight: 600; color: #3e3a44; margin-bottom: 6px; display: block; } .editor-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 8px; } .editor-table th { text-align: left; padding: 6px 4px; color: #999; font-weight: 500; font-size: 12px; border-bottom: 1px solid #eee; } .editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; } -.cell-muted { color: #b0aab5; font-size: 12px; } -.cell-cost { color: #4a9d7e; font-weight: 500; font-size: 13px; } -.form-total { text-align: right; font-size: 14px; font-weight: 600; color: #4a9d7e; margin-top: 8px; } +.editor-drops { width: 65px; padding: 6px 8px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; } +.editor-drops:focus { border-color: #7ec6a4; } +.editor-input { padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; outline: none; font-family: inherit; width: 100%; box-sizing: border-box; } +.editor-input:focus { border-color: #7ec6a4; } +.editor-textarea { width: 100%; padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; font-family: inherit; outline: none; resize: vertical; box-sizing: border-box; } +.editor-textarea:focus { border-color: #7ec6a4; } +.ing-ppd { color: #b0aab5; font-size: 12px; } +.ing-cost { color: #4a9d7e; font-weight: 500; font-size: 13px; } +.remove-row-btn { border: none; background: none; color: #ccc; cursor: pointer; font-size: 16px; padding: 2px 4px; } +.remove-row-btn:hover { color: #c0392b; } +.add-row-btn { border: 1.5px dashed #d4cfc7; background: none; color: #999; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; width: 100%; } +.add-row-btn:hover { border-color: #7ec6a4; color: #4a9d7e; } +.add-ingredient-row { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; } +.editor-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.editor-tag { background: #e8f5e9; color: #2e7d5a; padding: 4px 10px; border-radius: 12px; font-size: 12px; display: flex; align-items: center; gap: 4px; } +.tag-remove { cursor: pointer; font-size: 14px; color: #999; } +.tag-remove:hover { color: #c0392b; } +.candidate-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.candidate-tag { background: #f0eeeb; color: #6b6375; padding: 4px 10px; border-radius: 12px; font-size: 12px; cursor: pointer; } +.candidate-tag:hover { background: #e8f5e9; color: #2e7d5a; } +.editor-total { text-align: right; font-size: 15px; font-weight: 600; color: #4a9d7e; padding: 10px 0; border-top: 1px solid #eee; } +.action-btn { border: 1.5px solid #d4cfc7; background: #fff; color: #6b6375; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; font-family: inherit; white-space: nowrap; } +.action-btn:hover { background: #f8f7f5; } +.action-btn-primary { background: linear-gradient(135deg, #7ec6a4, #4a9d7e); color: #fff; border-color: transparent; } +.action-btn-primary:hover { opacity: 0.9; } +.action-btn-sm { padding: 5px 12px; font-size: 12px; } +.volume-controls { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } +.volume-btn { padding: 6px 14px; border: 1.5px solid #d4cfc7; border-radius: 8px; background: #fff; font-size: 13px; cursor: pointer; font-family: inherit; color: #6b6375; } +.volume-btn.active { background: #e8f5e9; border-color: #7ec6a4; color: #2e7d5a; font-weight: 600; } +.volume-btn:hover { border-color: #7ec6a4; } +.custom-volume-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; } +.dilution-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; } +.dilution-label { font-size: 12px; color: #3e3a44; white-space: nowrap; } +.drops-sm { width: 50px; padding: 4px 6px; border: 1.5px solid #d4cfc7; border-radius: 6px; font-size: 12px; text-align: center; outline: none; font-family: inherit; } +.drops-sm:focus { border-color: #7ec6a4; } +.select-sm { padding: 4px 6px; border: 1.5px solid #d4cfc7; border-radius: 6px; font-size: 12px; font-family: inherit; background: #fff; width: auto; } .divider-text { text-align: center; diff --git a/frontend/src/views/RecipeSearch.vue b/frontend/src/views/RecipeSearch.vue index eaf465d..ba5b0ce 100644 --- a/frontend/src/views/RecipeSearch.vue +++ b/frontend/src/views/RecipeSearch.vue @@ -52,7 +52,7 @@