diff --git a/backend/main.py b/backend/main.py index 746bcc9..09e827b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -766,14 +766,15 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)): c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,)) c.execute("INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", (rid, tag)) log_audit(conn, user["id"], "create_recipe", "recipe", rid, recipe.name) - # Notify admin when non-admin creates a recipe - if user["role"] != "admin": + # Notify admin and senior editors when non-admin creates a recipe + if user["role"] not in ("admin", "senior_editor"): who = user.get("display_name") or user["username"] - conn.execute( - "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", - ("admin", "📝 新配方待审核", - f"{who} 新增了配方「{recipe.name}」,请到管理配方查看并采纳。") - ) + for role in ("admin", "senior_editor"): + conn.execute( + "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", + (role, "📝 新配方待审核", + f"{who} 共享了配方「{recipe.name}」,请到管理配方查看。\n[recipe_id:{rid}]") + ) conn.commit() conn.close() return {"id": rid} @@ -1397,6 +1398,19 @@ def get_unmatched_searches(days: int = 7, user=Depends(require_role("admin", "se 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} + conn = get_db() + count = conn.execute( + "SELECT COUNT(*) FROM recipes WHERE owner_id = ?", (user["id"],) + ).fetchone()[0] + conn.close() + return {"shared_count": count} + + # ── Notifications ────────────────────────────────────── @app.get("/api/notifications") def get_notifications(user=Depends(get_current_user)): diff --git a/frontend/src/components/LoginModal.vue b/frontend/src/components/LoginModal.vue index 9481c4d..17e6878 100644 --- a/frontend/src/components/LoginModal.vue +++ b/frontend/src/components/LoginModal.vue @@ -51,6 +51,15 @@ + +
+ +
+ + +
@@ -60,6 +69,7 @@ import { ref } from 'vue' import { useAuthStore } from '../stores/auth' import { useUiStore } from '../stores/ui' +import { api } from '../composables/useApi' const emit = defineEmits(['close']) @@ -73,6 +83,9 @@ const confirmPassword = ref('') const displayName = ref('') const errorMsg = ref('') const loading = ref(false) +const showFeedback = ref(false) +const feedbackText = ref('') +const feedbackLoading = ref(false) async function submit() { errorMsg.value = '' @@ -115,6 +128,26 @@ async function submit() { loading.value = false } } + +async function submitFeedback() { + if (!feedbackText.value.trim()) return + feedbackLoading.value = true + try { + const res = await api('/api/bug-report', { + method: 'POST', + body: JSON.stringify({ content: feedbackText.value.trim(), priority: 0 }), + }) + if (res.ok) { + feedbackText.value = '' + showFeedback.value = false + ui.showToast('反馈已提交,感谢!') + } + } catch { + ui.showToast('提交失败') + } finally { + feedbackLoading.value = false + } +} diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index 68ecc11..9bad572 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -9,7 +9,7 @@ - @@ -359,7 +359,9 @@ import { matchesPinyinInitials } from '../composables/usePinyinMatch' // TagPicker replaced with inline tag editing const props = defineProps({ - recipeIndex: { type: Number, required: true }, + recipeIndex: { type: Number, default: null }, + recipeData: { type: Object, default: null }, + isDiary: { type: Boolean, default: false }, }) const emit = defineEmits(['close']) @@ -386,9 +388,10 @@ const generatingImage = ref(false) const previewOverride = ref(null) // ---- Source recipe ---- -const recipe = computed(() => - recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' } -) +const recipe = computed(() => { + if (props.recipeData) return props.recipeData + return recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' } +}) // ---- Display recipe: previewOverride when in preview mode, otherwise saved recipe ---- const displayRecipe = computed(() => { @@ -710,22 +713,31 @@ async function saveToDiary() { return } const name = await showPrompt('保存为我的配方,名称:', recipe.value.name) - // null = user cancelled (clicked 取消) if (name === null) return - // empty string = user cleared the name field if (!name.trim()) { ui.showToast('请输入配方名称') return } + const trimmed = name.trim() + const dupDiary = diaryStore.userDiary.some(d => d.name === trimmed) + const dupPublic = recipesStore.recipes.some(r => r.name === trimmed) + if (dupDiary) { + ui.showToast('我的配方中已有同名配方「' + trimmed + '」') + return + } + if (dupPublic) { + ui.showToast('公共配方库中已有同名配方「' + trimmed + '」') + return + } try { const payload = { name: name.trim(), note: recipe.value.note || '', - ingredients: recipe.value.ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })), + ingredients: recipe.value.ingredients.map(i => ({ oil: i.oil, drops: i.drops })), tags: recipe.value.tags || [], + source_recipe_id: recipe.value._id || null, } - console.log('[saveToDiary] saving recipe:', payload) - await recipesStore.saveRecipe(payload) + await diaryStore.createDiary(payload) ui.showToast('已保存!可在「配方查询 → 我的配方」查看') } catch (e) { console.error('[saveToDiary] failed:', e) diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index b44b453..a20abdd 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -30,7 +30,14 @@ class="notif-item" :class="{ unread: !n.is_read }">
{{ n.title }}
- +
+ + + + + + +
{{ n.body }}
{{ formatTime(n.created_at) }}
@@ -108,6 +115,29 @@ async function submitBug() { } } +function isSearchMissing(n) { + return n.title && n.title.includes('用户需求') +} + +function isReviewable(n) { + if (!n.title) return false + return n.title.includes('待审核') || n.title.includes('商业认证') || n.title.includes('申请') +} + +async function markAdded(n) { + await markOneRead(n) +} + +function goReview(n) { + markOneRead(n) + emit('close') + if (n.title.includes('配方')) { + router.push('/manage') + } else if (n.title.includes('商业认证') || n.title.includes('申请')) { + router.push('/users') + } +} + async function markOneRead(n) { try { await api(`/api/notifications/${n.id}/read`, { method: 'POST', body: '{}' }) @@ -209,6 +239,16 @@ onMounted(loadNotifications) font-family: inherit; white-space: nowrap; flex-shrink: 0; } .notif-mark-one:hover { background: #f0faf5; border-color: #7a9e7e; } +.notif-actions { display: flex; gap: 4px; flex-shrink: 0; } +.notif-action-btn { + background: none; border: 1px solid #ccc; border-radius: 6px; + font-size: 11px; cursor: pointer; padding: 2px 8px; + font-family: inherit; white-space: nowrap; +} +.notif-btn-added { color: #4a9d7e; border-color: #7ec6a4; } +.notif-btn-added:hover { background: #e8f5e9; } +.notif-btn-review { color: #e65100; border-color: #ffb74d; } +.notif-btn-review:hover { background: #fff3e0; } .notif-body { color: #888; font-size: 12px; margin-top: 2px; white-space: pre-line; } .notif-time { color: #bbb; font-size: 11px; margin-top: 2px; } .notif-empty { text-align: center; color: #ccc; padding: 16px; font-size: 13px; } diff --git a/frontend/src/views/OilReference.vue b/frontend/src/views/OilReference.vue index e29a476..bd6a961 100644 --- a/frontend/src/views/OilReference.vue +++ b/frontend/src/views/OilReference.vue @@ -260,11 +260,14 @@ -