diff --git a/backend/main.py b/backend/main.py index d05dbe8..0c79e1b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -324,7 +324,7 @@ def symptom_search(body: dict, user=Depends(get_current_user)): # If user reports no match, notify editors if body.get("report_missing"): who = user.get("display_name") or user.get("username") or "用户" - for role in ("admin", "senior_editor", "editor"): + for role in ("admin", "senior_editor"): conn.execute( "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", (role, "🔍 用户需求:" + query, @@ -863,11 +863,48 @@ def adopt_recipe(recipe_id: int, user=Depends(require_role("admin"))): if row["owner_id"] == user["id"]: conn.close() return {"ok": True, "msg": "already owned"} - old_owner = conn.execute("SELECT display_name, username FROM users WHERE id = ?", (row["owner_id"],)).fetchone() + old_owner = conn.execute("SELECT id, role, display_name, username FROM users WHERE id = ?", (row["owner_id"],)).fetchone() old_name = (old_owner["display_name"] or old_owner["username"]) if old_owner else "unknown" conn.execute("UPDATE recipes SET owner_id = ?, updated_by = ? WHERE id = ?", (user["id"], user["id"], recipe_id)) log_audit(conn, user["id"], "adopt_recipe", "recipe", recipe_id, row["name"], json.dumps({"from_user": old_name})) + # Notify submitter that recipe was approved + if old_owner and old_owner["id"] != user["id"]: + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (old_owner["role"], "🎉 配方已采纳", + f"你共享的配方「{row['name']}」已被采纳到公共配方库!", old_owner["id"]) + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.post("/api/recipes/{recipe_id}/reject") +def reject_recipe(recipe_id: int, body: dict = None, user=Depends(require_role("admin"))): + conn = get_db() + row = conn.execute("SELECT id, name, owner_id FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not row: + conn.close() + raise HTTPException(404, "Recipe not found") + reason = (body or {}).get("reason", "").strip() + # Notify submitter + old_owner = conn.execute("SELECT id, role, display_name, username FROM users WHERE id = ?", (row["owner_id"],)).fetchone() + if old_owner and old_owner["id"] != user["id"]: + msg = f"你共享的配方「{row['name']}」未被采纳。" + if reason: + msg += f"\n原因:{reason}" + msg += "\n你可以修改后重新共享。" + conn.execute( + "INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)", + (old_owner["role"], "配方未被采纳", msg, old_owner["id"]) + ) + # Delete the recipe + conn.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)) + conn.execute("DELETE FROM recipe_tags WHERE recipe_id = ?", (recipe_id,)) + conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,)) + log_audit(conn, user["id"], "reject_recipe", "recipe", recipe_id, row["name"], + json.dumps({"reason": reason})) conn.commit() conn.close() return {"ok": True} @@ -1401,17 +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 = 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] + # 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} + return {"adopted_count": adopted, "shared_count": adopted + pending} # ── Notifications ────────────────────────────────────── @@ -1420,11 +1478,15 @@ def get_notifications(user=Depends(get_current_user)): if not user["id"]: return [] conn = get_db() + # Only show notifications created after user registration + user_created = conn.execute("SELECT created_at FROM users WHERE id = ?", (user["id"],)).fetchone() + created_at = user_created["created_at"] if user_created else "2000-01-01" rows = conn.execute( "SELECT id, title, body, is_read, created_at FROM notifications " "WHERE (target_user_id = ? OR (target_user_id IS NULL AND (target_role = ? OR target_role = 'all'))) " + "AND created_at >= ? " "ORDER BY is_read ASC, id DESC LIMIT 200", - (user["id"], user["role"]) + (user["id"], user["role"], created_at) ).fetchall() conn.close() return [dict(r) for r in rows] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0d23ac5..3de03fe 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,6 +9,7 @@

doTERRA 配方计算器

查询配方 · 计算成本 · 自制配方 · 导出卡片 · 精油知识

+

v2.0.0 · 2026-04-10

@@ -32,7 +33,7 @@
@@ -72,29 +73,24 @@ const route = useRoute() const showUserMenu = ref(false) const navTabsRef = ref(null) -// Tab 定义,顺序固定:配方查询 → 管理配方 → 个人库存 → 精油价目 → 商业核算 → 操作日志 → Bug → 用户管理 -// require: 'login' = 需要登录, 'business' = 需要商业认证, 'admin' = 需要管理员 +// Tab 定义,顺序固定 +// require: 点击时需要的条件,不满足则提示 +// hide: 完全隐藏(只有满足条件才显示) const allTabs = [ { key: 'search', icon: '🔍', label: '配方查询' }, { key: 'manage', icon: '📋', label: '管理配方', require: 'login' }, { key: 'inventory', icon: '📦', label: '个人库存', require: 'login' }, { key: 'oils', icon: '💧', label: '精油价目' }, - { key: 'projects', icon: '💼', label: '商业核算', require: 'business' }, - { key: 'audit', icon: '📜', label: '操作日志', require: 'admin' }, - { key: 'bugs', icon: '🐛', label: 'Bug', require: 'admin' }, - { key: 'users', icon: '👥', label: '用户管理', require: 'admin' }, + { key: 'projects', icon: '💼', label: '商业核算', require: 'login' }, + { key: 'audit', icon: '📜', label: '操作日志', hide: 'admin' }, + { key: 'bugs', icon: '🐛', label: 'Bug', hide: 'admin' }, + { key: 'users', icon: '👥', label: '用户管理', hide: 'admin' }, ] -// 根据当前用户角色,过滤出可见的 tab -// 未登录: 配方查询, 精油价目 -// 普通登录: 配方查询, 管理配方, 个人库存, 精油价目 -// 商业用户: + 商业核算 -// 管理员: + 操作日志, Bug, 用户管理 +// 所有人都能看到大部分 tab,bug 和用户管理只有 admin 可见 const visibleTabs = computed(() => allTabs.filter(t => { - if (!t.require) return true - if (t.require === 'login') return auth.isLoggedIn - if (t.require === 'business') return auth.isBusiness - if (t.require === 'admin') return auth.isAdmin + if (!t.hide) return true + if (t.hide === 'admin') return auth.isAdmin return true })) const unreadNotifCount = ref(0) @@ -124,6 +120,22 @@ const prMatch = hostname.match(/^pr-(\d+)\./) const isPreview = !!prMatch const prId = prMatch ? prMatch[1] : '' +function handleTabClick(tab) { + if (tab.require === 'login' && !auth.isLoggedIn) { + ui.openLogin(() => goSection(tab.key)) + return + } + if (tab.require === 'business' && !auth.isBusiness) { + if (!auth.isLoggedIn) { + ui.openLogin(() => goSection(tab.key)) + } else { + ui.showToast('需要商业认证才能使用此功能') + } + return + } + goSection(tab.key) +} + function goSection(name) { ui.showSection(name) router.push('/' + (name === 'search' ? '' : name)) @@ -178,12 +190,12 @@ function onSwipeEnd(e) { const currentIdx = tabs.indexOf(ui.currentSection) if (currentIdx < 0) return - if (dx < 0 && currentIdx < tabs.length - 1) { - // 左滑 → 下一个 tab - goSection(tabs[currentIdx + 1]) - } else if (dx > 0 && currentIdx > 0) { - // 右滑 → 上一个 tab - goSection(tabs[currentIdx - 1]) + let nextIdx = -1 + if (dx < 0 && currentIdx < tabs.length - 1) nextIdx = currentIdx + 1 + else if (dx > 0 && currentIdx > 0) nextIdx = currentIdx - 1 + if (nextIdx >= 0) { + const tab = visibleTabs.value[nextIdx] + handleTabClick(tab) } } @@ -244,6 +256,11 @@ onMounted(async () => { letter-spacing: 0.5px; white-space: nowrap; } +.version-info { + font-size: 10px !important; + opacity: 0.5 !important; + margin-top: 1px !important; +} .header-right { flex-shrink: 0; cursor: pointer; 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/composables/useSmartPaste.js b/frontend/src/composables/useSmartPaste.js index a0b9a56..a609767 100644 --- a/frontend/src/composables/useSmartPaste.js +++ b/frontend/src/composables/useSmartPaste.js @@ -260,3 +260,99 @@ export function parseSingleBlock(raw, oilNames) { notFound } } + +/** + * Parse multi-recipe text. Each time an unrecognized non-number token + * appears after some oils have been found, it starts a new recipe. + */ +export function parseMultiRecipes(raw, oilNames) { + // First split by lines/commas, then within each part also try space splitting + const roughParts = raw.split(/[,,、\n\r]+/).map(s => s.trim()).filter(s => s) + const parts = [] + for (const rp of roughParts) { + // If the part has spaces and contains mixed name+oil, split by spaces too + // But only if spaces actually separate meaningful chunks + const spaceParts = rp.split(/\s+/).filter(s => s) + if (spaceParts.length > 1) { + parts.push(...spaceParts) + } else { + // No spaces or single chunk — try to separate name prefix from oil+number + // e.g. "长高芳香调理8" → check if any oil is inside + const hasOilInside = oilNames.some(oil => rp.includes(oil)) + if (hasOilInside && rp.length > 2) { + // Find the earliest oil match position + let earliest = rp.length + let earliestOil = '' + for (const oil of oilNames) { + const pos = rp.indexOf(oil) + if (pos >= 0 && pos < earliest) { + earliest = pos + earliestOil = oil + } + } + if (earliest > 0) { + parts.push(rp.substring(0, earliest)) + parts.push(rp.substring(earliest)) + } else { + parts.push(rp) + } + } else { + parts.push(rp) + } + } + } + + const recipes = [] + let current = { nameParts: [], ingredientParts: [], foundOil: false } + + for (const part of parts) { + const hasNumber = /\d/.test(part) + const hasOil = oilNames.some(oil => part.includes(oil)) || + Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias)) + // Also check fuzzy: 3+ char parts + const fuzzyOil = !hasOil && part.replace(/\d+\.?\d*/g, '').length >= 2 && + findOil(part.replace(/\d+\.?\d*/g, '').trim(), oilNames) + + if (current.foundOil && !hasOil && !fuzzyOil && !hasNumber && part.length >= 2) { + // New recipe starts + recipes.push(current) + current = { nameParts: [], ingredientParts: [], foundOil: false } + current.nameParts.push(part) + } else if (!current.foundOil && !hasOil && !fuzzyOil && !hasNumber) { + current.nameParts.push(part) + } else { + current.foundOil = true + current.ingredientParts.push(part) + } + } + recipes.push(current) + + // Convert each block to parsed recipe + return recipes.filter(r => r.ingredientParts.length > 0 || r.nameParts.length > 0).map(r => { + const allIngs = [] + const notFound = [] + for (const p of r.ingredientParts) { + const parsed = parseOilChunk(p, oilNames) + for (const item of parsed) { + if (item.notFound) notFound.push(item.oil) + else allIngs.push(item) + } + } + // Deduplicate + const deduped = [] + const seen = {} + for (const item of allIngs) { + if (seen[item.oil] !== undefined) { + deduped[seen[item.oil]].drops += item.drops + } else { + seen[item.oil] = deduped.length + deduped.push({ ...item }) + } + } + return { + name: r.nameParts.join(' ') || '未命名配方', + ingredients: deduped, + notFound, + } + }) +} diff --git a/frontend/src/views/MyDiary.vue b/frontend/src/views/MyDiary.vue index fda3760..a9632eb 100644 --- a/frontend/src/views/MyDiary.vue +++ b/frontend/src/views/MyDiary.vue @@ -241,26 +241,65 @@ -
+

💼 商业认证

-

申请商业认证后可使用商业核算功能。

-
- - + + +
+
+
已认证商业用户
- -
-
-

💼 商业认证

-
✅ 已认证商业用户
+ + + + + + + + +