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 @@
@@ -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 @@
-
+
💼 商业认证
-
申请商业认证后可使用商业核算功能。
-
-
-
💼 商业认证
-
✅ 已认证商业用户
+
+
+
+
+
⏳
+
认证申请审核中
+
商户名:{{ bizApp.business_name }}
+
提交时间:{{ formatDate(bizApp.created_at) }}
+
+
+
+
+
+
+
❌
+
认证申请未通过
+
原因:{{ bizApp.reject_reason }}
+
+ 你可以修改信息后重新申请。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 申请商业认证后可使用商业核算功能,请填写以下信息。
+
+
+
+
+
+
+
+
+
+