From 27c46cb8035f7ea512768e076ee499def3f492c3 Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Thu, 9 Apr 2026 20:13:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=A7=E9=87=8F=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=85=8D=E6=96=B9=E5=92=8C=E6=90=9C=E7=B4=A2=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 存为我的:修复调用错误API,改用 diaryStore.createDiary - 存为我的:同名检测(我的配方 + 公共配方库) - 我的配方:使用 RecipeCard 统一卡片格式 - 管理配方:按钮缩小、编辑时隐藏智能粘贴、精油搜索框支持拼音跳转 - 管理配方:批量操作改为按钮组(打标签/删除/导出卡片/分享到公共库) - 管理配方:我的配方加勾选框、全选按钮、编辑功能 - 搜索:模糊匹配 + 同义词扩展(37组),精确/相似分层显示 - 搜索:无匹配时通知编辑添加,搜索时隐藏无匹配的收藏/我的配方区 - 搜索:配方按首字母排序 - 共享审核:通知高级编辑+管理员,我的配方显示共享状态 - 通知:搜索未收录→已添加按钮,审核类→去审核按钮跳转 - 贡献统计:非管理员显示已贡献公共配方数 - 登录弹窗:加反馈问题按钮(无需登录) - 精油编辑:右上角加保存按钮,支持回车保存 - 后端:新增 /api/me/contribution 接口 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/main.py | 28 +- frontend/src/components/LoginModal.vue | 60 ++++ .../src/components/RecipeDetailOverlay.vue | 32 +- frontend/src/components/UserMenu.vue | 42 ++- frontend/src/views/OilReference.vue | 7 +- frontend/src/views/RecipeManager.vue | 297 ++++++++++++---- frontend/src/views/RecipeSearch.vue | 333 ++++++++++++++---- 7 files changed, 645 insertions(+), 154 deletions(-) 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 @@ -