8 Commits

Author SHA1 Message Date
a8e91dc384 feat: 权限修复、搜索改进、滑动切换、通知badge
All checks were successful
Deploy Production / test (push) Successful in 4s
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 4s
PR Preview / deploy-preview (pull_request) Has been skipped
PR Preview / test (pull_request) Has been skipped
PR Preview / teardown-preview (pull_request) Successful in 13s
Deploy Production / deploy (push) Successful in 7s
Test / e2e-test (push) Successful in 52s
权限:
- viewer 不能编辑公共配方(前端+后端双重限制)
- viewer 管理配方页只显示"我的配方"
- 取消 token 链接登录,改为自注册+管理员分配角色
- 用户管理页去掉创建用户和复制链接,禁止设管理员
- 修复改权限 API 路径错误

搜索:
- 模糊匹配+同义词扩展(37组),精确/相似分层
- 精确匹配不搜精油成分(避免"西班牙牛至"污染)
- 所有搜索结果底部加"通知编辑添加"按钮

UI:
- 顶部 tab 栏按用户角色显示,切换时居中滚动
- 左右滑动按 visibleTabs 顺序切换 tab
- 用户名旁红色通知数 badge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:31:17 +00:00
27c46cb803 feat: 大量管理配方和搜索改进
- 存为我的:修复调用错误API,改用 diaryStore.createDiary
- 存为我的:同名检测(我的配方 + 公共配方库)
- 我的配方:使用 RecipeCard 统一卡片格式
- 管理配方:按钮缩小、编辑时隐藏智能粘贴、精油搜索框支持拼音跳转
- 管理配方:批量操作改为按钮组(打标签/删除/导出卡片/分享到公共库)
- 管理配方:我的配方加勾选框、全选按钮、编辑功能
- 搜索:模糊匹配 + 同义词扩展(37组),精确/相似分层显示
- 搜索:无匹配时通知编辑添加,搜索时隐藏无匹配的收藏/我的配方区
- 搜索:配方按首字母排序
- 共享审核:通知高级编辑+管理员,我的配方显示共享状态
- 通知:搜索未收录→已添加按钮,审核类→去审核按钮跳转
- 贡献统计:非管理员显示已贡献公共配方数
- 登录弹窗:加反馈问题按钮(无需登录)
- 精油编辑:右上角加保存按钮,支持回车保存
- 后端:新增 /api/me/contribution 接口

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:31:17 +00:00
80397ec7ca fix: 关闭按钮用 force click 绕过遮挡 + 放宽每滴价格上限到 300
- close button 用 .detail-close-btn + force:true 避免被 login modal 遮挡
- 部分高端精油每滴价格超 100,上限调至 300

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:31:17 +00:00
19eeb7ba9a fix: 测试加 dismissModals 关闭登录弹窗 + 改用 should('exist')
CI 中 login-body 覆盖 detail-overlay 导致 visible 检查失败。
改为 exist 断言 + 自动关闭 login/dialog 弹窗。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:31:17 +00:00
cf5b974ae1 fix: 修复 rebase 后重复 clearBrandImage 声明 + 测试加 dismissDialog
- 删除 MyDiary.vue 重复的 clearBrandImage 函数(rebase 遗留)
- 测试加 dismissDialog() 关闭 CI 中 API 错误弹出的 dialog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:31:17 +00:00
b0d82d4ff7 fix: 修复 recipe-detail 测试选择器和按钮文本
- [class*="detail"] → .detail-overlay 避免匹配多余元素
- 导出图片 → 保存图片(匹配当前 UI)
- admin 编辑测试加入按钮存在性检查,token 失效时不崩溃

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:31:17 +00:00
54003bc466 fix: 搜索过滤收藏、拼音首字母匹配、清除图片、滑动切换、通知已读
1. 搜索时收藏配方也按关键词过滤,不匹配的隐藏
2. 编辑配方添加精油时支持拼音首字母匹配(如xyc→薰衣草)
3. 品牌设置页清除图片立即保存到后端,不需点保存按钮
4. 左右滑动切换tab,轮播区域内滑动切换图片不触发tab切换
5. 通知列表每条未读通知加"已读"按钮,调用POST /api/notifications/{id}/read

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:31:17 +00:00
b764ff7ea3 fix: 退出登录后在受保护页面跳转到配方查询页面
Some checks failed
PR Preview / test (pull_request) Has been skipped
Deploy Production / test (push) Successful in 5s
Test / unit-test (push) Successful in 5s
PR Preview / teardown-preview (pull_request) Successful in 14s
PR Preview / deploy-preview (pull_request) Has been skipped
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 5s
Test / e2e-test (push) Failing after 1m14s
在 router/index.js 中为需要登录才能访问的路由(manage、inventory、
projects、mydiary、audit、bugs、users)添加 meta.requiresAuth 标记。

在 UserMenu.vue 的 handleLogout() 中检查当前路由是否需要登录,
如果是则 router.push('/') 跳回配方查询页,否则原地 reload。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:48:51 +00:00
12 changed files with 168 additions and 933 deletions

View File

@@ -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"):
for role in ("admin", "senior_editor", "editor"):
conn.execute(
"INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)",
(role, "🔍 用户需求:" + query,
@@ -863,48 +863,11 @@ 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 id, role, display_name, username FROM users WHERE id = ?", (row["owner_id"],)).fetchone()
old_owner = conn.execute("SELECT 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}
@@ -1438,38 +1401,17 @@ 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 {"adopted_count": 0, "shared_count": 0}
return {"shared_count": 0}
conn = get_db()
# 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(
count = conn.execute(
"SELECT COUNT(*) FROM recipes WHERE owner_id = ?", (user["id"],)
).fetchone()[0]
conn.close()
return {"adopted_count": adopted, "shared_count": adopted + pending}
return {"shared_count": count}
# ── Notifications ──────────────────────────────────────
@@ -1478,15 +1420,11 @@ 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"], created_at)
(user["id"], user["role"])
).fetchall()
conn.close()
return [dict(r) for r in rows]

View File

@@ -9,7 +9,6 @@
<div class="header-title">
<h1>doTERRA 配方计算器</h1>
<p>查询配方 · 计算成本 · 自制配方 · 导出卡片 · 精油知识</p>
<p v-if="auth.isAdmin" class="version-info">v2.0.0 · 2026-04-10</p>
</div>
</div>
<div class="header-right" @click="toggleUserMenu">
@@ -33,7 +32,7 @@
<div v-for="tab in visibleTabs" :key="tab.key"
class="nav-tab"
:class="{ active: ui.currentSection === tab.key }"
@click="handleTabClick(tab)"
@click="goSection(tab.key)"
>{{ tab.icon }} {{ tab.label }}</div>
</div>
@@ -73,24 +72,29 @@ const route = useRoute()
const showUserMenu = ref(false)
const navTabsRef = ref(null)
// Tab 定义,顺序固定
// require: 点击时需要的条件,不满足则提示
// hide: 完全隐藏(只有满足条件才显示)
// Tab 定义,顺序固定:配方查询 → 管理配方 → 个人库存 → 精油价目 → 商业核算 → 操作日志 → Bug → 用户管理
// require: 'login' = 需要登录, 'business' = 需要商业认证, 'admin' = 需要管理员
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: 'login' },
{ key: 'audit', icon: '📜', label: '操作日志', hide: 'admin' },
{ key: 'bugs', icon: '🐛', label: 'Bug', hide: 'admin' },
{ key: 'users', icon: '👥', label: '用户管理', hide: 'admin' },
{ 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' },
]
// 所有人都能看到大部分 tabbug 和用户管理只有 admin 可见
// 根据当前用户角色,过滤出可见的 tab
// 未登录: 配方查询, 精油价目
// 普通登录: 配方查询, 管理配方, 个人库存, 精油价目
// 商业用户: + 商业核算
// 管理员: + 操作日志, Bug, 用户管理
const visibleTabs = computed(() => allTabs.filter(t => {
if (!t.hide) return true
if (t.hide === 'admin') return auth.isAdmin
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
return true
}))
const unreadNotifCount = ref(0)
@@ -120,22 +124,6 @@ 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))
@@ -190,12 +178,12 @@ function onSwipeEnd(e) {
const currentIdx = tabs.indexOf(ui.currentSection)
if (currentIdx < 0) return
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)
if (dx < 0 && currentIdx < tabs.length - 1) {
// 左滑 → 下一个 tab
goSection(tabs[currentIdx + 1])
} else if (dx > 0 && currentIdx > 0) {
// 右滑 → 上一个 tab
goSection(tabs[currentIdx - 1])
}
}
@@ -256,11 +244,6 @@ 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;

View File

@@ -400,7 +400,6 @@ const displayRecipe = computed(() => {
})
const canEditThisRecipe = computed(() => {
if (props.isDiary) return false
if (authStore.canEdit) return true
return false
})

View File

@@ -163,7 +163,11 @@ function handleLogout() {
auth.logout()
ui.showToast('已退出登录')
emit('close')
window.location.href = '/'
if (router.currentRoute.value.meta.requiresAuth) {
router.push('/')
} else {
window.location.reload()
}
}
onMounted(loadNotifications)

View File

@@ -260,99 +260,3 @@ 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,
}
})
}

View File

@@ -10,11 +10,13 @@ const routes = [
path: '/manage',
name: 'RecipeManager',
component: () => import('../views/RecipeManager.vue'),
meta: { requiresAuth: true },
},
{
path: '/inventory',
name: 'Inventory',
component: () => import('../views/Inventory.vue'),
meta: { requiresAuth: true },
},
{
path: '/oils',
@@ -25,26 +27,31 @@ const routes = [
path: '/projects',
name: 'Projects',
component: () => import('../views/Projects.vue'),
meta: { requiresAuth: true },
},
{
path: '/mydiary',
name: 'MyDiary',
component: () => import('../views/MyDiary.vue'),
meta: { requiresAuth: true },
},
{
path: '/audit',
name: 'AuditLog',
component: () => import('../views/AuditLog.vue'),
meta: { requiresAuth: true },
},
{
path: '/bugs',
name: 'BugTracker',
component: () => import('../views/BugTracker.vue'),
meta: { requiresAuth: true },
},
{
path: '/users',
name: 'UserManagement',
component: () => import('../views/UserManagement.vue'),
meta: { requiresAuth: true },
},
]

View File

@@ -241,65 +241,26 @@
</div>
<!-- Business Verification -->
<div ref="bizCertRef" class="section-card">
<div v-if="!auth.isBusiness" class="section-card">
<h4>💼 商业认证</h4>
<!-- 已认证 -->
<div v-if="auth.isBusiness" class="biz-status biz-approved">
<div class="biz-status-icon"></div>
<div class="biz-status-text">已认证商业用户</div>
<p class="hint-text">申请商业认证后可使用商业核算功能</p>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的申请理由..."></textarea>
</div>
<!-- 审核中 -->
<template v-else-if="bizApp.status === 'pending'">
<div class="biz-status biz-pending">
<div class="biz-status-icon"></div>
<div class="biz-status-text">认证申请审核中</div>
<div class="biz-status-detail">商户名{{ bizApp.business_name }}</div>
<div class="biz-status-detail">提交时间{{ formatDate(bizApp.created_at) }}</div>
</div>
</template>
<!-- 被拒绝可重新申请 -->
<template v-else-if="bizApp.status === 'rejected'">
<div class="biz-status biz-rejected">
<div class="biz-status-icon"></div>
<div class="biz-status-text">认证申请未通过</div>
<div v-if="bizApp.reject_reason" class="biz-reject-reason">原因{{ bizApp.reject_reason }}</div>
</div>
<p class="hint-text">你可以修改信息后重新申请</p>
<div class="form-group">
<label>商户名称</label>
<input v-model="businessName" class="form-input" placeholder="你的商户/品牌名称" />
</div>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的业务情况和申请理由..."></textarea>
</div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessName.trim()">重新申请</button>
</template>
<!-- 首次申请 -->
<template v-else>
<p class="hint-text">申请商业认证后可使用商业核算功能请填写以下信息</p>
<div class="form-group">
<label>商户名称</label>
<input v-model="businessName" class="form-input" placeholder="你的商户/品牌名称" />
</div>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的业务情况和申请理由..."></textarea>
</div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessName.trim()">提交申请</button>
</template>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessReason.trim()">提交申请</button>
</div>
<div v-else class="section-card">
<h4>💼 商业认证</h4>
<div class="verified-badge"> 已认证商业用户</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useDiaryStore } from '../stores/diary'
@@ -313,10 +274,8 @@ const oils = useOilsStore()
const diaryStore = useDiaryStore()
const ui = useUiStore()
const router = useRouter()
const route = useRoute()
const bizCertRef = ref(null)
const activeTab = ref(route.query.tab || 'brand')
const activeTab = ref('brand')
const pasteText = ref('')
const selectedDiaryId = ref(null)
const returnRecipeId = ref(null)
@@ -339,25 +298,13 @@ const displayName = ref('')
const oldPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const businessName = ref('')
const businessReason = ref('')
const bizApp = ref({ status: null })
onMounted(async () => {
await diaryStore.loadDiary()
displayName.value = auth.user.display_name || ''
await loadBrandSettings()
returnRecipeId.value = localStorage.getItem('oil_return_recipe_id') || null
// Load business application status
try {
const bizRes = await api('/api/my-business-application')
if (bizRes.ok) bizApp.value = await bizRes.json()
} catch {}
// 从商业核算跳转过来,滚到商业认证区域
if (route.query.section === 'biz-cert') {
await nextTick()
bizCertRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
function goBackToRecipe() {
@@ -669,30 +616,13 @@ async function changePassword() {
}
async function applyBusiness() {
if (!businessName.value.trim()) {
ui.showToast('请填写商户名称')
return
}
try {
const res = await api('/api/business-apply', {
await api('/api/business-apply', {
method: 'POST',
body: JSON.stringify({
business_name: businessName.value.trim(),
document: businessReason.value.trim(),
}),
body: JSON.stringify({ reason: businessReason.value }),
})
if (res.ok) {
businessName.value = ''
businessReason.value = ''
bizApp.value = { status: 'pending', business_name: businessName.value }
ui.showToast('申请已提交,请等待管理员审核')
// Reload status
const bizRes = await api('/api/my-business-application')
if (bizRes.ok) bizApp.value = await bizRes.json()
} else {
const err = await res.json().catch(() => ({}))
ui.showToast(err.detail || '提交失败')
}
businessReason.value = ''
ui.showToast('申请已提交,请等待审核')
} catch {
ui.showToast('提交失败')
}
@@ -1147,20 +1077,6 @@ async function applyBusiness() {
text-align: center;
}
.biz-status {
padding: 16px;
border-radius: 12px;
text-align: center;
margin-bottom: 12px;
}
.biz-status-icon { font-size: 32px; margin-bottom: 8px; }
.biz-status-text { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.biz-status-detail { font-size: 12px; color: #999; }
.biz-approved { background: #e8f5e9; color: #2e7d5a; }
.biz-pending { background: #fff8e1; color: #e65100; }
.biz-rejected { background: #fce4ec; color: #c62828; }
.biz-reject-reason { font-size: 13px; margin-top: 8px; padding: 8px 12px; background: rgba(0,0,0,0.05); border-radius: 8px; }
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);

View File

@@ -132,7 +132,7 @@
v-for="name in filteredOilNames"
:key="name + '-' + cardVersion"
class="oil-chip"
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false, 'oil-chip--incomplete': auth.canManage && isIncomplete(name) }"
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false, 'oil-chip--incomplete': auth.isAdmin && isIncomplete(name) }"
:style="chipStyle(name)"
@click="openOilDetail(name)"
>

View File

@@ -3,7 +3,7 @@
<!-- Project List -->
<div class="toolbar">
<h3 class="page-title">💼 商业核算</h3>
<button v-if="auth.isBusiness" class="btn-primary" @click="createProject">+ 新建项目</button>
<button class="btn-primary" @click="createProject">+ 新建项目</button>
</div>
<div v-if="!selectedProject" class="project-list">
@@ -23,7 +23,7 @@
成本 {{ oils.fmtPrice(oils.calcCost(p.ingredients)) }}
</span>
</div>
<div v-if="auth.isAdmin" class="proj-actions" @click.stop>
<div class="proj-actions" @click.stop>
<button class="btn-icon-sm" @click="deleteProject(p)" title="删除">🗑</button>
</div>
</div>
@@ -177,7 +177,6 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
@@ -189,14 +188,6 @@ const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const router = useRouter()
async function showCertPrompt() {
const ok = await showConfirm('此功能需要商业认证,是否前往申请认证?', { okText: '去认证', cancelText: '取消' })
if (ok) {
router.push('/mydiary?tab=account&section=biz-cert')
}
}
const projects = ref([])
const selectedProject = ref(null)
@@ -246,10 +237,6 @@ async function createProject() {
}
function selectProject(p) {
if (!auth.isBusiness) {
showCertPrompt()
return
}
selectedProject.value = {
...p,
ingredients: (p.ingredients || []).map(i => ({ ...i })),

View File

@@ -7,7 +7,7 @@
</div>
<div v-if="showPending && pendingRecipes.length" class="pending-list">
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
<span class="pending-name clickable" @click="openRecipeDetail(r)">{{ r.name }}</span>
<span class="pending-name">{{ r.name }}</span>
<span class="pending-owner">{{ r._owner_name }}</span>
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
@@ -31,28 +31,22 @@
</div>
</div>
</template>
<!-- Tag Filter & Select All (visible to all) -->
<div class="tag-filter-bar">
<button
class="btn-sm"
:class="selectedDiaryIds.size > 0 ? 'btn-select-active' : 'btn-outline'"
@click="toggleSelectAllDiary"
>全选</button>
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
🏷 标签筛选 {{ showTagFilter ? '' : '' }}
</button>
<div v-if="showTagFilter" class="tag-list">
<span
v-for="tag in recipeStore.allTags"
:key="tag"
class="tag-chip"
:class="{ active: selectedTags.includes(tag) }"
@click="toggleTag(tag)"
>{{ tag }}</span>
<!-- Tag Filter Bar -->
<div class="tag-filter-bar">
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
🏷 标签筛选 {{ showTagFilter ? '' : '' }}
</button>
<div v-if="showTagFilter" class="tag-list">
<span
v-for="tag in recipeStore.allTags"
:key="tag"
class="tag-chip"
:class="{ active: selectedTags.includes(tag) }"
@click="toggleTag(tag)"
>{{ tag }}</span>
</div>
</div>
</div>
</template>
<!-- Batch Operations -->
<div v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar">
@@ -66,12 +60,10 @@
<!-- My Recipes Section (from diary) -->
<div class="recipe-section">
<h3 class="section-title clickable" @click="showMyRecipes = !showMyRecipes">
<h3 class="section-title">
<span>📖 我的配方 ({{ myRecipes.length }})</span>
<span v-if="!auth.isAdmin" class="contrib-tag">已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} </span>
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
<button class="btn-sm btn-outline" @click="toggleSelectAllDiary">全选/取消</button>
</h3>
<template v-if="showMyRecipes">
<div class="recipe-list">
<div
v-for="d in myFilteredRecipes"
@@ -92,27 +84,20 @@
<span v-for="t in (d.tags || [])" :key="t" class="mini-tag">{{ t }}</span>
</span>
<span class="row-cost">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span>
<span v-if="getDiaryShareStatus(d) === 'shared'" class="share-tag shared">已共享</span>
<span v-else-if="getDiaryShareStatus(d) === 'pending'" class="share-tag pending">等待审核</span>
</div>
<div class="row-actions">
<button v-if="getDiaryShareStatus(d) !== 'shared'" class="btn-icon" @click="shareDiaryToPublic(d)" title="共享到公共配方库">📤</button>
<button class="btn-icon" @click="editDiaryRecipe(d)" title="编辑"></button>
<button class="btn-icon" @click="removeDiaryRecipe(d)" title="删除">🗑</button>
</div>
</div>
<div v-if="myFilteredRecipes.length === 0" class="empty-hint">暂无个人配方</div>
</div>
</template>
</div>
<!-- Public Recipes Section (editor+) -->
<div v-if="auth.canEdit" class="recipe-section">
<h3 class="section-title clickable" @click="showPublicRecipes = !showPublicRecipes">
<span>🌿 公共配方库 ({{ publicRecipes.length }})</span>
<span class="toggle-icon">{{ showPublicRecipes ? '▾' : '▸' }}</span>
</h3>
<div v-if="showPublicRecipes" class="recipe-list">
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
<div class="recipe-list">
<div
v-for="r in publicFilteredRecipes"
:key="r._id"
@@ -156,178 +141,76 @@
<textarea
v-model="smartPasteText"
class="paste-input"
placeholder="直接粘贴配方文本,支持多条配方同时识别&#10;例如: 舒缓放松,薰衣草3茶树2&#10;提神醒脑柠檬5椒样薄荷3"
placeholder="粘贴配方文本,支持智能识别...&#10;例如: 薰衣草3茶树2"
rows="4"
></textarea>
<button class="btn-primary btn-sm" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
🪄 智能识别
<button class="btn-primary" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
智能识别
</button>
</div>
<!-- Parsed results preview -->
<div v-if="parsedRecipes.length > 0" class="parsed-results">
<div v-for="(pr, pi) in parsedRecipes" :key="pi" class="parsed-recipe-card">
<div class="parsed-header">
<input v-model="pr.name" class="form-input parsed-name" placeholder="配方名称" />
<button class="btn-icon-sm" @click="parsedRecipes.splice(pi, 1)" title="放弃"></button>
</div>
<div class="parsed-ings">
<div v-for="(ing, ii) in pr.ingredients" :key="ii" class="parsed-ing">
<span class="parsed-oil">{{ ing.oil }}</span>
<input v-model.number="ing.drops" type="number" min="0" class="form-input-sm" />
<button class="btn-icon-sm" @click="pr.ingredients.splice(ii, 1)"></button>
</div>
</div>
<div v-if="pr.notFound && pr.notFound.length" class="parsed-warn">
未识别: {{ pr.notFound.join('') }}
</div>
<button class="btn-primary btn-sm" @click="saveParsedRecipe(pi)">💾 保存此条</button>
</div>
<div class="parsed-actions">
<button class="btn-primary btn-sm" @click="saveAllParsed" :disabled="parsedRecipes.length === 0">全部保存 ({{ parsedRecipes.length }})</button>
<button class="btn-outline btn-sm" @click="parsedRecipes = []">取消全部</button>
</div>
</div>
<div class="divider-text">或手动输入</div>
</template>
<!-- Manual Form (matches RecipeDetailOverlay editor) -->
<div class="editor-header">
<div style="flex:1;min-width:0">
<input v-model="formName" type="text" class="editor-name-input" placeholder="配方名称" />
<!-- Manual Form -->
<div class="form-group">
<label>配方名称</label>
<input v-model="formName" class="form-input" placeholder="配方名称" />
</div>
<div class="form-group">
<label>成分</label>
<div v-for="(ing, i) in formIngredients" :key="i" class="ing-row">
<div class="oil-search-wrap">
<input
v-model="ing._search"
class="form-select"
placeholder="输入搜索精油..."
@focus="ing._open = true"
@input="ing._open = true"
@blur="onOilBlur(ing)"
/>
<div v-if="ing._open" class="oil-dropdown">
<div
v-for="name in filteredOilNames(ing._search || '')"
:key="name"
class="oil-option"
@mousedown.prevent="selectOil(ing, name)"
>{{ name }}</div>
<div v-if="filteredOilNames(ing._search || '').length === 0" class="oil-option oil-empty">无匹配</div>
</div>
</div>
<input v-model.number="ing.drops" type="number" min="0" class="form-input-sm" placeholder="滴数" />
<button class="btn-icon-sm" @click="formIngredients.splice(i, 1)"></button>
</div>
<div class="editor-header-actions">
<button class="action-btn action-btn-primary action-btn-sm" @click="saveCurrentRecipe">💾 保存</button>
<button class="action-btn action-btn-sm" @click="closeOverlay"> 取消</button>
<button class="btn-outline btn-sm" @click="formIngredients.push({ oil: '', drops: 1, _search: '', _open: false })">+ 添加成分</button>
</div>
<div class="form-group">
<label>备注</label>
<textarea v-model="formNote" class="form-input" rows="2" placeholder="配方备注"></textarea>
</div>
<div class="form-group">
<label>标签</label>
<div class="tag-list">
<span
v-for="tag in recipeStore.allTags"
:key="tag"
class="tag-chip"
:class="{ active: formTags.includes(tag) }"
@click="toggleFormTag(tag)"
>{{ tag }}</span>
</div>
</div>
<div class="editor-tip">
💡 推荐按照单次用量椰子油10~20添加纯精油系统会根据容量和稀释比例自动计算
</div>
<!-- Ingredients table -->
<div class="editor-section">
<table class="editor-table">
<thead>
<tr><th>精油</th><th>滴数</th><th>单价/</th><th>小计</th><th></th></tr>
</thead>
<tbody>
<tr v-for="(ing, i) in formIngredients" :key="i">
<td>
<div class="oil-search-wrap">
<input
v-model="ing._search"
class="form-select"
placeholder="搜索精油..."
@focus="ing._open = true"
@input="ing._open = true"
@blur="onOilBlur(ing)"
/>
<div v-if="ing._open" class="oil-dropdown">
<div
v-for="name in filteredOilNames(ing._search || '')"
:key="name"
class="oil-option"
@mousedown.prevent="selectOil(ing, name)"
>{{ name }}</div>
<div v-if="filteredOilNames(ing._search || '').length === 0" class="oil-option oil-empty">无匹配</div>
</div>
</div>
</td>
<td><input v-model.number="ing.drops" type="number" min="0.5" step="0.5" class="editor-drops" /></td>
<td class="ing-ppd">{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '-' }}</td>
<td class="ing-cost">{{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '-' }}</td>
<td><button class="remove-row-btn" @click="formIngredients.splice(i, 1)"></button></td>
</tr>
</tbody>
</table>
<!-- Add ingredient row -->
<button class="add-row-btn" @click="formIngredients.push({ oil: '', drops: 1, _search: '', _open: false })">+ 添加精油</button>
</div>
<!-- Volume & Dilution -->
<div class="editor-section">
<label class="editor-label">容量与稀释</label>
<div class="volume-controls">
<button class="volume-btn" :class="{ active: formVolume === 'single' }" @click="formVolume = 'single'">单次</button>
<button class="volume-btn" :class="{ active: formVolume === '5' }" @click="formVolume = '5'">5ml</button>
<button class="volume-btn" :class="{ active: formVolume === '10' }" @click="formVolume = '10'">10ml</button>
<button class="volume-btn" :class="{ active: formVolume === '30' }" @click="formVolume = '30'">30ml</button>
<button class="volume-btn" :class="{ active: formVolume === 'custom' }" @click="formVolume = 'custom'">自定义</button>
</div>
<div v-if="formVolume === 'custom'" class="custom-volume-row">
<input v-model.number="formCustomVolume" type="number" min="1" class="drops-sm" placeholder="数量" />
<select v-model="formCustomUnit" class="select-sm">
<option value="drops"></option>
<option value="ml">ml</option>
</select>
</div>
<div class="dilution-row">
<span class="dilution-label">稀释 1:</span>
<select v-model.number="formDilution" class="select-sm">
<option v-for="n in 20" :key="n" :value="n">{{ n }}</option>
</select>
<button class="action-btn action-btn-primary action-btn-sm" @click="applyVolumeDilution">应用到配方</button>
</div>
<div class="hint" style="margin-top:6px;font-size:11px;color:#999">{{ formDilutionHint }}</div>
</div>
<!-- Notes -->
<div class="editor-section">
<label class="editor-label">备注</label>
<textarea v-model="formNote" class="editor-textarea" rows="2" placeholder="配方备注..."></textarea>
</div>
<!-- Tags -->
<div class="editor-section">
<label class="editor-label">标签</label>
<div class="editor-tags">
<span v-for="tag in formTags" :key="tag" class="editor-tag">
{{ tag }}
<span class="tag-remove" @click="toggleFormTag(tag)">×</span>
</span>
</div>
<div class="candidate-tags" v-if="formCandidateTags.length">
<span v-for="tag in formCandidateTags" :key="tag" class="candidate-tag" @click="toggleFormTag(tag)">+ {{ tag }}</span>
</div>
</div>
<!-- Total cost -->
<div class="editor-total">
总计: {{ formTotalCost }}
<div class="overlay-footer">
<button class="btn-outline" @click="closeOverlay">取消</button>
<button class="btn-primary" @click="saveCurrentRecipe">保存</button>
</div>
</div>
</div>
<!-- Review History (admin only) -->
<div v-if="auth.isAdmin" class="recipe-section">
<h3 class="section-title clickable" @click="showReviewHistory = !showReviewHistory">
<span>📋 审核记录</span>
<span class="toggle-icon">{{ showReviewHistory ? '▾' : '▸' }}</span>
</h3>
<div v-if="showReviewHistory" class="review-history">
<div v-for="r in reviewHistory" :key="r.id" class="review-log-item">
<span :class="r.action === 'adopt_recipe' ? 'log-approve' : 'log-reject'">
{{ r.action === 'adopt_recipe' ? '✅ 采纳' : '❌ 拒绝' }}
</span>
<span class="log-recipe">{{ r.target_name }}</span>
<span class="log-from" v-if="r.detail">{{ parseReviewDetail(r.detail) }}</span>
<span class="log-time">{{ formatDate(r.created_at) }}</span>
</div>
<div v-if="reviewHistory.length === 0" class="empty-hint">暂无审核记录</div>
</div>
</div>
<!-- Recipe Detail Overlay -->
<RecipeDetailOverlay
v-if="previewRecipeIndex !== null"
:recipeIndex="previewRecipeIndex"
@close="previewRecipeIndex = null"
/>
<!-- Tag Picker Overlay -->
<TagPicker
v-if="showTagPicker"
@@ -349,11 +232,10 @@ import { useDiaryStore } from '../stores/diary'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showPrompt } from '../composables/useDialog'
import { parseSingleBlock, parseMultiRecipes } from '../composables/useSmartPaste'
import { parseSingleBlock } from '../composables/useSmartPaste'
import { matchesPinyinInitials } from '../composables/usePinyinMatch'
import RecipeCard from '../components/RecipeCard.vue'
import TagPicker from '../components/TagPicker.vue'
import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
const auth = useAuthStore()
const oils = useOilsStore()
@@ -374,27 +256,10 @@ const pendingCount = ref(0)
// Form state
const formName = ref('')
const formIngredients = ref([{ oil: '', drops: 1, _search: '', _open: false }])
const formIngredients = ref([{ oil: '', drops: 1 }])
const formNote = ref('')
const formTags = ref([])
const smartPasteText = ref('')
const parsedRecipes = ref([])
const showAddIngRow = ref(false)
const newIngOil = ref('')
const newIngSearch = ref('')
const newIngDrops = ref(1)
const newIngDropdownOpen = ref(false)
const formVolume = ref('single')
const formCustomVolume = ref(100)
const formCustomUnit = ref('drops')
const formDilution = ref(3)
const formTotalCost = computed(() => {
const cost = formIngredients.value
.filter(i => i.oil && i.drops > 0)
.reduce((sum, i) => sum + oils.pricePerDrop(i.oil) * i.drops, 0)
return oils.fmtPrice(cost)
})
// Tag picker state
const showTagPicker = ref(false)
@@ -544,34 +409,16 @@ function resetForm() {
formNote.value = ''
formTags.value = []
smartPasteText.value = ''
parsedRecipes.value = []
showAddIngRow.value = false
newIngOil.value = ''
newIngSearch.value = ''
newIngDrops.value = 1
}
function handleSmartPaste() {
const results = parseMultiRecipes(smartPasteText.value, oils.oilNames)
if (results.length === 0) {
ui.showToast('未能识别出任何配方')
return
}
if (results.length === 1) {
// Single recipe: populate form directly
const r = results[0]
formName.value = r.name
formIngredients.value = r.ingredients.length > 0
? r.ingredients.map(i => ({ ...i, _search: i.oil, _open: false }))
: [{ oil: '', drops: 1, _search: '', _open: false }]
if (r.notFound.length > 0) {
ui.showToast(`未识别: ${r.notFound.join('、')}`)
}
parsedRecipes.value = []
} else {
// Multiple recipes: show preview cards
parsedRecipes.value = results
ui.showToast(`识别出 ${results.length} 条配方`)
const result = parseSingleBlock(smartPasteText.value, oils.oilNames)
formName.value = result.name
formIngredients.value = result.ingredients.length > 0
? result.ingredients
: [{ oil: '', drops: 1 }]
if (result.notFound.length > 0) {
ui.showToast(`未识别: ${result.notFound.join(', ')}`)
}
}
@@ -597,83 +444,6 @@ 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)
@@ -691,18 +461,17 @@ async function saveCurrentRecipe() {
return
}
const cleanIngs = validIngs.map(i => ({ oil: i.oil, drops: i.drops }))
const diaryPayload = {
const payload = {
name: formName.value.trim(),
ingredients: cleanIngs,
ingredients: validIngs,
note: formNote.value,
tags: formTags.value,
}
if (editingRecipe.value && editingRecipe.value._diary_id) {
// Editing an existing diary recipe
// Editing a diary (personal) recipe
try {
await diaryStore.updateDiary(editingRecipe.value._diary_id, diaryPayload)
await diaryStore.updateDiary(editingRecipe.value._diary_id, payload)
ui.showToast('个人配方已更新')
closeOverlay()
} catch (e) {
@@ -711,114 +480,24 @@ async function saveCurrentRecipe() {
return
}
if (editingRecipe.value && editingRecipe.value._id) {
// Editing an existing public recipe
const payload = {
_id: editingRecipe.value._id,
_version: editingRecipe.value._version,
name: formName.value.trim(),
ingredients: cleanIngs.map(i => ({ oil_name: i.oil, drops: i.drops })),
note: formNote.value,
tags: formTags.value,
}
try {
await recipeStore.saveRecipe(payload)
ui.showToast('配方已更新')
closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
return
if (editingRecipe.value) {
payload._id = editingRecipe.value._id
payload._version = editingRecipe.value._version
}
// New recipe: always save to diary (personal)
try {
await diaryStore.createDiary(diaryPayload)
ui.showToast('已添加到我的配方')
await recipeStore.saveRecipe(payload)
ui.showToast(editingRecipe.value ? '配方已更新' : '配方已添加')
closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
}
async function saveParsedRecipe(index) {
const r = parsedRecipes.value[index]
if (!r.name.trim() || r.ingredients.length === 0) {
ui.showToast('配方名称和成分不能为空')
return
}
try {
await diaryStore.createDiary({
name: r.name.trim(),
ingredients: r.ingredients.map(i => ({ oil: i.oil, drops: i.drops })),
note: '',
tags: [],
})
parsedRecipes.value.splice(index, 1)
ui.showToast(`${r.name}」已保存到我的配方`)
} catch (e) {
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
}
}
async function saveAllParsed() {
let saved = 0
for (let i = parsedRecipes.value.length - 1; i >= 0; i--) {
const r = parsedRecipes.value[i]
if (!r.name.trim() || r.ingredients.length === 0) continue
try {
await diaryStore.createDiary({
name: r.name.trim(),
ingredients: r.ingredients.map(i => ({ oil: i.oil, drops: i.drops })),
note: '',
tags: [],
})
saved++
} catch {}
}
parsedRecipes.value = []
ui.showToast(`已保存 ${saved} 条配方到我的配方`)
closeOverlay()
}
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 () => {
if (auth.isLoggedIn) {
await diaryStore.loadDiary()
try {
const res = await api('/api/me/contribution')
if (res.ok) {
const data = await res.json()
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 {}
}
})
@@ -831,45 +510,6 @@ 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)
if (!pub) return null
if (pub._owner_id === auth.user?.id) return 'pending'
return 'shared'
}
async function shareDiaryToPublic(diary) {
const ok = await showConfirm(`将「${diary.name}」共享到公共配方库?`)
if (!ok) return
try {
const res = await api('/api/recipes', {
method: 'POST',
body: JSON.stringify({
name: diary.name,
note: diary.note || '',
ingredients: (diary.ingredients || []).map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: diary.tags || [],
}),
})
if (res.ok) {
if (auth.isAdmin) {
ui.showToast('已共享到公共配方库')
} else {
ui.showToast('已提交,等待管理员审核')
}
await recipeStore.loadRecipes()
}
} catch {
ui.showToast('共享失败')
}
}
async function removeDiaryRecipe(diary) {
const ok = await showConfirm(`确定删除个人配方 "${diary.name}"`)
if (!ok) return
@@ -894,28 +534,20 @@ async function removeRecipe(recipe) {
async function approveRecipe(recipe) {
try {
const res = await api('/api/recipes/' + recipe._id + '/adopt', { method: 'POST' })
if (res.ok) {
ui.showToast('已采纳并通知提交者')
await recipeStore.loadRecipes()
}
await api('/api/recipes/' + recipe._id + '/adopt', { method: 'POST' })
ui.showToast('已采纳')
await recipeStore.loadRecipes()
} catch {
ui.showToast('操作失败')
}
}
async function rejectRecipe(recipe) {
const reason = await showPrompt(`拒绝${recipe.name}的原因(选填):`)
if (reason === null) return
const ok = await showConfirm(`确定删除${recipe.name}`)
if (!ok) return
try {
const res = await api(`/api/recipes/${recipe._id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason || '' }),
})
if (res.ok) {
await recipeStore.loadRecipes()
ui.showToast('已拒绝并通知提交者')
}
await recipeStore.deleteRecipe(recipe._id)
ui.showToast('已删除')
} catch {
ui.showToast('操作失败')
}
@@ -990,14 +622,6 @@ 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;
@@ -1042,10 +666,6 @@ watch(() => recipeStore.recipes, () => {
.tag-filter-bar {
margin-bottom: 12px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.tag-toggle-btn {
@@ -1185,38 +805,6 @@ watch(() => recipeStore.recipes, () => {
color: #6b6375;
}
.share-tag {
font-size: 11px;
padding: 1px 8px;
border-radius: 8px;
font-weight: 500;
white-space: nowrap;
}
.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;
background: #e8f5e9;
padding: 2px 8px;
border-radius: 8px;
font-weight: 500;
}
.row-cost {
font-size: 13px;
color: #4a9d7e;
@@ -1316,75 +904,6 @@ watch(() => recipeStore.recipes, () => {
border-color: #7ec6a4;
}
.parsed-results { margin: 12px 0; }
.parsed-recipe-card {
background: #f8faf8;
border: 1.5px solid #d4e8d4;
border-radius: 10px;
padding: 12px;
margin-bottom: 10px;
}
.parsed-header { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.parsed-name { flex: 1; font-weight: 600; }
.parsed-ings { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.parsed-ing {
display: flex; align-items: center; gap: 4px;
background: #fff; border: 1px solid #e5e4e7; border-radius: 8px; padding: 4px 8px; font-size: 13px;
}
.parsed-oil { color: #3e3a44; font-weight: 500; }
.parsed-ing .form-input-sm { width: 50px; padding: 4px 6px; font-size: 12px; }
.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; color: #3e3a44; }
.editor-name-input::placeholder { color: #ccc; font-weight: 400; }
.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; }
.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; }
.btn-select-active { background: #e8f5e9; color: #2e7d5a; border: 1.5px solid #7ec6a4; border-radius: 10px; padding: 7px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 600; }
.divider-text {
text-align: center;
color: #b0aab5;

View File

@@ -52,7 +52,7 @@
<template v-if="!searchQuery || myDiaryRecipes.length > 0">
<div class="section-header" @click="showMyRecipes = !showMyRecipes">
<span>📖 我的配方 ({{ myDiaryRecipes.length }})</span>
<span v-if="!auth.isAdmin && sharedCount.total > 0" class="contrib-badge">已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} </span>
<span v-if="!auth.isAdmin && sharedCount > 0" class="contrib-badge">已贡献 {{ sharedCount }} 公共配方</span>
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
</div>
<div v-if="showMyRecipes" class="recipe-grid">
@@ -189,10 +189,10 @@ const selectedCategory = ref(null)
const categories = ref([])
const selectedRecipeIndex = ref(null)
const selectedDiaryRecipe = ref(null)
const showMyRecipes = ref(false)
const showFavorites = ref(false)
const showMyRecipes = ref(true)
const showFavorites = ref(true)
const catIdx = ref(0)
const sharedCount = ref({ adopted: 0, total: 0 })
const sharedCount = ref(0)
onMounted(async () => {
try {
@@ -209,7 +209,7 @@ onMounted(async () => {
const cRes = await api('/api/me/contribution')
if (cRes.ok) {
const data = await cRes.json()
sharedCount.value = { adopted: data.adopted_count || 0, total: data.shared_count || 0 }
sharedCount.value = data.shared_count || 0
}
} catch {}
}
@@ -688,23 +688,6 @@ function onCarouselTouchEnd(e) {
color: #e65100;
}
.share-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.9);
border: 1px solid #d4cfc7;
border-radius: 8px;
padding: 2px 8px;
font-size: 14px;
cursor: pointer;
}
.share-btn:hover {
background: #e8f5e9;
border-color: #7ec6a4;
}
.contrib-badge {
font-size: 11px;
color: #4a9d7e;

View File

@@ -27,8 +27,8 @@
<div class="review-list">
<div v-for="app in businessApps" :key="app._id || app.id" class="review-item">
<div class="review-info">
<span class="review-name">{{ app.display_name || app.username }}</span>
<span class="review-reason">商户名{{ app.business_name }}</span>
<span class="review-name">{{ app.user_name || app.display_name }}</span>
<span class="review-reason">{{ app.reason }}</span>
</div>
<div class="review-actions">
<button class="btn-sm btn-approve" @click="approveBusiness(app)">通过</button>
@@ -101,7 +101,7 @@ import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm, showPrompt } from '../composables/useDialog'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const ui = useUiStore()
@@ -248,13 +248,8 @@ async function approveBusiness(app) {
async function rejectBusiness(app) {
const id = app._id || app.id
const reason = await showPrompt('请输入拒绝原因(选填):')
if (reason === null) return
try {
const res = await api(`/api/business-applications/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason || '' }),
})
const res = await api(`/api/business-applications/${id}/reject`, { method: 'POST' })
if (res.ok) {
businessApps.value = businessApps.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已拒绝')