Compare commits
10 Commits
fix/search
...
feat/permi
| Author | SHA1 | Date | |
|---|---|---|---|
| ad95ba7d1f | |||
| c63091b504 | |||
| 6931df4afd | |||
| 6f9c5732eb | |||
| cf07f6b60d | |||
| e26cd700b9 | |||
| 56bc6f2bbb | |||
| 3c3ce30b48 | |||
| 50cf9d3e9b | |||
| 1d424984e0 |
@@ -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]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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">
|
||||
@@ -32,7 +33,7 @@
|
||||
<div v-for="tab in visibleTabs" :key="tab.key"
|
||||
class="nav-tab"
|
||||
:class="{ active: ui.currentSection === tab.key }"
|
||||
@click="goSection(tab.key)"
|
||||
@click="handleTabClick(tab)"
|
||||
>{{ tab.icon }} {{ tab.label }}</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -400,6 +400,7 @@ const displayRecipe = computed(() => {
|
||||
})
|
||||
|
||||
const canEditThisRecipe = computed(() => {
|
||||
if (props.isDiary) return false
|
||||
if (authStore.canEdit) return true
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,26 +241,65 @@
|
||||
</div>
|
||||
|
||||
<!-- Business Verification -->
|
||||
<div v-if="!auth.isBusiness" class="section-card">
|
||||
<div ref="bizCertRef" class="section-card">
|
||||
<h4>💼 商业认证</h4>
|
||||
<p class="hint-text">申请商业认证后可使用商业核算功能。</p>
|
||||
<div class="form-group">
|
||||
<label>申请说明</label>
|
||||
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的申请理由..."></textarea>
|
||||
|
||||
<!-- 已认证 -->
|
||||
<div v-if="auth.isBusiness" class="biz-status biz-approved">
|
||||
<div class="biz-status-icon">✅</div>
|
||||
<div class="biz-status-text">已认证商业用户</div>
|
||||
</div>
|
||||
<button class="btn-primary" @click="applyBusiness" :disabled="!businessReason.trim()">提交申请</button>
|
||||
</div>
|
||||
<div v-else class="section-card">
|
||||
<h4>💼 商业认证</h4>
|
||||
<div class="verified-badge">✅ 已认证商业用户</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, nextTick, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useDiaryStore } from '../stores/diary'
|
||||
@@ -274,8 +313,10 @@ const oils = useOilsStore()
|
||||
const diaryStore = useDiaryStore()
|
||||
const ui = useUiStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const bizCertRef = ref(null)
|
||||
|
||||
const activeTab = ref('brand')
|
||||
const activeTab = ref(route.query.tab || 'brand')
|
||||
const pasteText = ref('')
|
||||
const selectedDiaryId = ref(null)
|
||||
const returnRecipeId = ref(null)
|
||||
@@ -298,13 +339,25 @@ 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() {
|
||||
@@ -616,13 +669,30 @@ async function changePassword() {
|
||||
}
|
||||
|
||||
async function applyBusiness() {
|
||||
if (!businessName.value.trim()) {
|
||||
ui.showToast('请填写商户名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api('/api/business-apply', {
|
||||
const res = await api('/api/business-apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: businessReason.value }),
|
||||
body: JSON.stringify({
|
||||
business_name: businessName.value.trim(),
|
||||
document: businessReason.value.trim(),
|
||||
}),
|
||||
})
|
||||
businessReason.value = ''
|
||||
ui.showToast('申请已提交,请等待审核')
|
||||
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 || '提交失败')
|
||||
}
|
||||
} catch {
|
||||
ui.showToast('提交失败')
|
||||
}
|
||||
@@ -1077,6 +1147,20 @@ 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%);
|
||||
|
||||
@@ -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.isAdmin && isIncomplete(name) }"
|
||||
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false, 'oil-chip--incomplete': auth.canManage && isIncomplete(name) }"
|
||||
:style="chipStyle(name)"
|
||||
@click="openOilDetail(name)"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Project List -->
|
||||
<div class="toolbar">
|
||||
<h3 class="page-title">💼 商业核算</h3>
|
||||
<button class="btn-primary" @click="createProject">+ 新建项目</button>
|
||||
<button v-if="auth.isBusiness" 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 class="proj-actions" @click.stop>
|
||||
<div v-if="auth.isAdmin" class="proj-actions" @click.stop>
|
||||
<button class="btn-icon-sm" @click="deleteProject(p)" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,6 +177,7 @@
|
||||
|
||||
<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'
|
||||
@@ -188,6 +189,14 @@ 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§ion=biz-cert')
|
||||
}
|
||||
}
|
||||
|
||||
const projects = ref([])
|
||||
const selectedProject = ref(null)
|
||||
@@ -237,6 +246,10 @@ async function createProject() {
|
||||
}
|
||||
|
||||
function selectProject(p) {
|
||||
if (!auth.isBusiness) {
|
||||
showCertPrompt()
|
||||
return
|
||||
}
|
||||
selectedProject.value = {
|
||||
...p,
|
||||
ingredients: (p.ingredients || []).map(i => ({ ...i })),
|
||||
|
||||
@@ -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">{{ r.name }}</span>
|
||||
<span class="pending-name clickable" @click="openRecipeDetail(r)">{{ 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,23 +31,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Operations -->
|
||||
<div v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar">
|
||||
<span>已选 {{ selectedIds.size + selectedDiaryIds.size }} 项</span>
|
||||
@@ -60,10 +66,12 @@
|
||||
|
||||
<!-- My Recipes Section (from diary) -->
|
||||
<div class="recipe-section">
|
||||
<h3 class="section-title">
|
||||
<h3 class="section-title clickable" @click="showMyRecipes = !showMyRecipes">
|
||||
<span>📖 我的配方 ({{ myRecipes.length }})</span>
|
||||
<button class="btn-sm btn-outline" @click="toggleSelectAllDiary">全选/取消</button>
|
||||
<span v-if="!auth.isAdmin" class="contrib-tag">已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} 条</span>
|
||||
<span class="toggle-icon">{{ showMyRecipes ? '▾' : '▸' }}</span>
|
||||
</h3>
|
||||
<template v-if="showMyRecipes">
|
||||
<div class="recipe-list">
|
||||
<div
|
||||
v-for="d in myFilteredRecipes"
|
||||
@@ -84,20 +92,27 @@
|
||||
<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">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
|
||||
<div class="recipe-list">
|
||||
<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">
|
||||
<div
|
||||
v-for="r in publicFilteredRecipes"
|
||||
:key="r._id"
|
||||
@@ -141,76 +156,178 @@
|
||||
<textarea
|
||||
v-model="smartPasteText"
|
||||
class="paste-input"
|
||||
placeholder="粘贴配方文本,支持智能识别... 例如: 薰衣草3滴 茶树2滴"
|
||||
placeholder="直接粘贴配方文本,支持多条配方同时识别 例如: 舒缓放松,薰衣草3,茶树2 提神醒脑,柠檬5,椒样薄荷3"
|
||||
rows="4"
|
||||
></textarea>
|
||||
<button class="btn-primary" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">
|
||||
智能识别
|
||||
<button class="btn-primary btn-sm" @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 -->
|
||||
<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>
|
||||
<!-- 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="配方名称" />
|
||||
</div>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay-footer">
|
||||
<button class="btn-outline" @click="closeOverlay">取消</button>
|
||||
<button class="btn-primary" @click="saveCurrentRecipe">保存</button>
|
||||
<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>
|
||||
</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"
|
||||
@@ -232,10 +349,11 @@ import { useDiaryStore } from '../stores/diary'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import { api } from '../composables/useApi'
|
||||
import { showConfirm, showPrompt } from '../composables/useDialog'
|
||||
import { parseSingleBlock } from '../composables/useSmartPaste'
|
||||
import { parseSingleBlock, parseMultiRecipes } 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()
|
||||
@@ -256,10 +374,27 @@ const pendingCount = ref(0)
|
||||
|
||||
// Form state
|
||||
const formName = ref('')
|
||||
const formIngredients = ref([{ oil: '', drops: 1 }])
|
||||
const formIngredients = ref([{ oil: '', drops: 1, _search: '', _open: false }])
|
||||
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)
|
||||
@@ -409,16 +544,34 @@ function resetForm() {
|
||||
formNote.value = ''
|
||||
formTags.value = []
|
||||
smartPasteText.value = ''
|
||||
parsedRecipes.value = []
|
||||
showAddIngRow.value = false
|
||||
newIngOil.value = ''
|
||||
newIngSearch.value = ''
|
||||
newIngDrops.value = 1
|
||||
}
|
||||
|
||||
function handleSmartPaste() {
|
||||
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(', ')}`)
|
||||
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} 条配方`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +597,83 @@ 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)
|
||||
@@ -461,17 +691,18 @@ async function saveCurrentRecipe() {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const cleanIngs = validIngs.map(i => ({ oil: i.oil, drops: i.drops }))
|
||||
const diaryPayload = {
|
||||
name: formName.value.trim(),
|
||||
ingredients: validIngs,
|
||||
ingredients: cleanIngs,
|
||||
note: formNote.value,
|
||||
tags: formTags.value,
|
||||
}
|
||||
|
||||
if (editingRecipe.value && editingRecipe.value._diary_id) {
|
||||
// Editing a diary (personal) recipe
|
||||
// Editing an existing diary recipe
|
||||
try {
|
||||
await diaryStore.updateDiary(editingRecipe.value._diary_id, payload)
|
||||
await diaryStore.updateDiary(editingRecipe.value._diary_id, diaryPayload)
|
||||
ui.showToast('个人配方已更新')
|
||||
closeOverlay()
|
||||
} catch (e) {
|
||||
@@ -480,24 +711,114 @@ async function saveCurrentRecipe() {
|
||||
return
|
||||
}
|
||||
|
||||
if (editingRecipe.value) {
|
||||
payload._id = editingRecipe.value._id
|
||||
payload._version = editingRecipe.value._version
|
||||
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
|
||||
}
|
||||
|
||||
// New recipe: always save to diary (personal)
|
||||
try {
|
||||
await recipeStore.saveRecipe(payload)
|
||||
ui.showToast(editingRecipe.value ? '配方已更新' : '配方已添加')
|
||||
await diaryStore.createDiary(diaryPayload)
|
||||
ui.showToast('已添加到我的配方')
|
||||
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 {}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -510,6 +831,45 @@ 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
|
||||
@@ -534,20 +894,28 @@ async function removeRecipe(recipe) {
|
||||
|
||||
async function approveRecipe(recipe) {
|
||||
try {
|
||||
await api('/api/recipes/' + recipe._id + '/adopt', { method: 'POST' })
|
||||
ui.showToast('已采纳')
|
||||
await recipeStore.loadRecipes()
|
||||
const res = await api('/api/recipes/' + recipe._id + '/adopt', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
ui.showToast('已采纳并通知提交者')
|
||||
await recipeStore.loadRecipes()
|
||||
}
|
||||
} catch {
|
||||
ui.showToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectRecipe(recipe) {
|
||||
const ok = await showConfirm(`确定删除「${recipe.name}」?`)
|
||||
if (!ok) return
|
||||
const reason = await showPrompt(`拒绝「${recipe.name}」的原因(选填):`)
|
||||
if (reason === null) return
|
||||
try {
|
||||
await recipeStore.deleteRecipe(recipe._id)
|
||||
ui.showToast('已删除')
|
||||
const res = await api(`/api/recipes/${recipe._id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: reason || '' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
await recipeStore.loadRecipes()
|
||||
ui.showToast('已拒绝并通知提交者')
|
||||
}
|
||||
} catch {
|
||||
ui.showToast('操作失败')
|
||||
}
|
||||
@@ -622,6 +990,14 @@ 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;
|
||||
@@ -666,6 +1042,10 @@ watch(() => recipeStore.recipes, () => {
|
||||
|
||||
.tag-filter-bar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-toggle-btn {
|
||||
@@ -805,6 +1185,38 @@ 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;
|
||||
@@ -904,6 +1316,75 @@ 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;
|
||||
|
||||
@@ -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 > 0" class="contrib-badge">已贡献 {{ sharedCount }} 条公共配方</span>
|
||||
<span v-if="!auth.isAdmin && sharedCount.total > 0" class="contrib-badge">已贡献 {{ sharedCount.adopted }}/{{ sharedCount.total }} 条</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(true)
|
||||
const showFavorites = ref(true)
|
||||
const showMyRecipes = ref(false)
|
||||
const showFavorites = ref(false)
|
||||
const catIdx = ref(0)
|
||||
const sharedCount = ref(0)
|
||||
const sharedCount = ref({ adopted: 0, total: 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 = data.shared_count || 0
|
||||
sharedCount.value = { adopted: data.adopted_count || 0, total: data.shared_count || 0 }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -688,6 +688,23 @@ 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;
|
||||
|
||||
@@ -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.user_name || app.display_name }}</span>
|
||||
<span class="review-reason">{{ app.reason }}</span>
|
||||
<span class="review-name">{{ app.display_name || app.username }}</span>
|
||||
<span class="review-reason">商户名:{{ app.business_name }}</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 } from '../composables/useDialog'
|
||||
import { showConfirm, showPrompt } from '../composables/useDialog'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
@@ -248,8 +248,13 @@ 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' })
|
||||
const res = await api(`/api/business-applications/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: reason || '' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
businessApps.value = businessApps.value.filter(item => (item._id || item.id) !== id)
|
||||
ui.showToast('已拒绝')
|
||||
|
||||
Reference in New Issue
Block a user