feat: header重排、商业认证标识、共享配方、待审核、权限优化
Header: - 登录按钮固定右侧,适配各屏幕 - 登录后不显示版本号,用户名在原位 - 商业认证用户名前显示🏢 emoji - 响应式布局(手机端字号缩小) 配方共享: - 个人配方卡片加📤共享按钮 - 共享到公共配方库,非管理员需管理员审核 管理配方: - 待审核栏显示配方名、来源、精油列表 - 采纳调用 /api/recipes/{id}/adopt - 拒绝=删除(带确认) - 从recipes列表动态计算pending(不依赖不存在的API) 权限: - senior_editor可编辑精油价目和公共配方(auth.canEdit已包含) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,38 +2,23 @@
|
||||
<div v-if="isPreview" style="background:#e65100;color:white;text-align:center;padding:6px 16px;font-size:13px;font-weight:600;letter-spacing:0.5px;position:sticky;top:0;z-index:100">
|
||||
⚠️ 预览环境 · PR #{{ prId }} · 数据为生产副本,修改不影响正式环境
|
||||
</div>
|
||||
<div class="app-header" style="position:relative">
|
||||
<div class="header-inner" style="padding-right:80px">
|
||||
<div class="header-icon">🌿</div>
|
||||
<div class="header-title" style="text-align:left;flex:1">
|
||||
<h1 style="display:flex;justify-content:space-between;align-items:center;gap:8px;white-space:nowrap">
|
||||
<span style="flex-shrink:0">doTERRA 配方计算器
|
||||
<span v-if="auth.isAdmin" style="font-size:10px;font-weight:400;opacity:0.5;vertical-align:top">v2.2.0</span>
|
||||
</span>
|
||||
<span
|
||||
style="cursor:pointer;color:white;font-size:13px;font-weight:500;flex-shrink:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB',sans-serif;letter-spacing:0.3px;opacity:0.95"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
<template v-if="auth.isLoggedIn">
|
||||
👤 {{ auth.user.display_name || auth.user.username }}
|
||||
▾
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="background:rgba(255,255,255,0.2);padding:4px 12px;border-radius:12px">登录</span>
|
||||
</template>
|
||||
</span>
|
||||
</h1>
|
||||
<p style="display:flex;flex-wrap:wrap;gap:4px 8px;margin:0">
|
||||
<span style="white-space:nowrap">查询配方</span>
|
||||
<span style="opacity:0.5">·</span>
|
||||
<span style="white-space:nowrap">计算成本</span>
|
||||
<span style="opacity:0.5">·</span>
|
||||
<span style="white-space:nowrap">自制配方</span>
|
||||
<span style="opacity:0.5">·</span>
|
||||
<span style="white-space:nowrap">导出卡片</span>
|
||||
<span style="opacity:0.5">·</span>
|
||||
<span style="white-space:nowrap">精油知识</span>
|
||||
</p>
|
||||
<div class="app-header">
|
||||
<div class="header-inner">
|
||||
<div class="header-left">
|
||||
<div class="header-icon">🌿</div>
|
||||
<div class="header-title">
|
||||
<h1>doTERRA 配方计算器</h1>
|
||||
<p>查询配方 · 计算成本 · 自制配方 · 导出卡片 · 精油知识</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right" @click="toggleUserMenu">
|
||||
<template v-if="auth.isLoggedIn">
|
||||
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
|
||||
<span class="user-name">{{ auth.user.display_name || auth.user.username }} ▾</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="login-btn">登录</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,3 +126,73 @@ onMounted(async () => {
|
||||
}, 15000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.header-icon { font-size: 36px; flex-shrink: 0; }
|
||||
.header-title { color: white; min-width: 0; }
|
||||
.header-title h1 {
|
||||
font-family: 'Noto Serif SC', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.header-title p {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-top: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.header-right {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.user-name {
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
opacity: 0.95;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.login-btn {
|
||||
color: white;
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 5px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.biz-badge {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-icon { font-size: 28px; }
|
||||
.header-title h1 { font-size: 18px; }
|
||||
.header-title p { font-size: 10px; }
|
||||
.user-name { font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,10 +7,15 @@
|
||||
</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-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>
|
||||
<div class="pending-info">
|
||||
<span class="pending-name">{{ r.name }}</span>
|
||||
<span class="pending-owner">来自 {{ r._owner_name }}</span>
|
||||
<span class="pending-oils">{{ r.ingredients.map(i => i.oil).join('、') }}</span>
|
||||
</div>
|
||||
<div class="pending-actions">
|
||||
<button class="btn-sm btn-approve" @click="approveRecipe(r)">✅ 采纳</button>
|
||||
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">🗑 删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +201,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
@@ -431,10 +436,8 @@ async function removeRecipe(recipe) {
|
||||
|
||||
async function approveRecipe(recipe) {
|
||||
try {
|
||||
await api('/api/recipes/' + recipe._id + '/approve', { method: 'POST' })
|
||||
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
|
||||
pendingCount.value--
|
||||
ui.showToast('已通过')
|
||||
await api('/api/recipes/' + recipe._id + '/adopt', { method: 'POST' })
|
||||
ui.showToast('已采纳')
|
||||
await recipeStore.loadRecipes()
|
||||
} catch {
|
||||
ui.showToast('操作失败')
|
||||
@@ -442,11 +445,12 @@ async function approveRecipe(recipe) {
|
||||
}
|
||||
|
||||
async function rejectRecipe(recipe) {
|
||||
const { showConfirm } = await import('../composables/useDialog')
|
||||
const ok = await showConfirm(`确定删除「${recipe.name}」?`)
|
||||
if (!ok) return
|
||||
try {
|
||||
await api('/api/recipes/' + recipe._id + '/reject', { method: 'POST' })
|
||||
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
|
||||
pendingCount.value--
|
||||
ui.showToast('已拒绝')
|
||||
await recipeStore.deleteRecipe(recipe._id)
|
||||
ui.showToast('已删除')
|
||||
} catch {
|
||||
ui.showToast('操作失败')
|
||||
}
|
||||
@@ -474,16 +478,14 @@ function onTagPickerSave(tags) {
|
||||
showTagPicker.value = false
|
||||
}
|
||||
|
||||
// Load pending if admin
|
||||
if (auth.isAdmin) {
|
||||
api('/api/recipes/pending').then(async res => {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
pendingRecipes.value = data
|
||||
pendingCount.value = data.length
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
// Compute pending: recipes created by non-admin users (need admin review)
|
||||
watch(() => recipeStore.recipes, () => {
|
||||
if (auth.isAdmin) {
|
||||
const pending = recipeStore.recipes.filter(r => r._owner_id && r._owner_id !== auth.user.id)
|
||||
pendingRecipes.value = pending
|
||||
pendingCount.value = pending.length
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<div class="card-oils">{{ (d.ingredients || []).map(i => i.oil).join('、') }}</div>
|
||||
<div class="card-bottom">
|
||||
<span class="card-price">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span>
|
||||
<button class="share-btn" @click.stop="shareDiaryToPublic(d)" title="共享到公共配方库">📤</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="myDiaryRecipes.length === 0" class="empty-hint">暂无个人配方</div>
|
||||
@@ -282,6 +283,31 @@ async function handleToggleFav(recipe) {
|
||||
await recipeStore.toggleFavorite(recipe._id)
|
||||
}
|
||||
|
||||
async function shareDiaryToPublic(diary) {
|
||||
const { showConfirm } = await import('../composables/useDialog')
|
||||
const ok = await showConfirm(`将「${diary.name}」共享到公共配方库?\n共享后所有用户都能看到。`)
|
||||
if (!ok) return
|
||||
try {
|
||||
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 (auth.isAdmin) {
|
||||
ui.showToast('已共享到公共配方库')
|
||||
} else {
|
||||
ui.showToast('已提交,等待管理员审核')
|
||||
}
|
||||
await recipeStore.loadRecipes()
|
||||
} catch {
|
||||
ui.showToast('共享失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
// fuzzyResults computed handles the filtering reactively
|
||||
}
|
||||
@@ -552,6 +578,17 @@ function clearSearch() {
|
||||
font-weight: 600;
|
||||
color: var(--sage-dark, #5a7d5e);
|
||||
}
|
||||
.share-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.share-btn:hover { opacity: 1; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.recipe-grid {
|
||||
|
||||
Reference in New Issue
Block a user