feat: header重排、共享配方、待审核、权限优化
Some checks failed
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 54s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 15s
Some checks failed
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 54s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 15s
Header: - 登录按钮固定右侧,flex布局自适应所有屏幕 - 登录后不显示版本号,用户名在右侧 - 商业认证用户显示🏢标识 - 手机端响应式适配 配方共享: - 个人配方卡片加📤共享按钮 - 提交到公共库,非管理员需审核 管理配方: - 待审核栏从recipes动态计算(不依赖不存在的API) - 采纳用/adopt端点,拒绝=确认删除 - senior_editor可编辑精油和公共配方 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ backups/
|
|||||||
# Frontend
|
# Frontend
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|||||||
@@ -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">
|
<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 }} · 数据为生产副本,修改不影响正式环境
|
⚠️ 预览环境 · PR #{{ prId }} · 数据为生产副本,修改不影响正式环境
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header" style="position:relative">
|
<div class="app-header">
|
||||||
<div class="header-inner" style="padding-right:80px">
|
<div class="header-inner">
|
||||||
<div class="header-icon">🌿</div>
|
<div class="header-left">
|
||||||
<div class="header-title" style="text-align:left;flex:1">
|
<div class="header-icon">🌿</div>
|
||||||
<h1 style="display:flex;justify-content:space-between;align-items:center;gap:8px;white-space:nowrap">
|
<div class="header-title">
|
||||||
<span style="flex-shrink:0">doTERRA 配方计算器
|
<h1>doTERRA 配方计算器</h1>
|
||||||
<span v-if="auth.isAdmin" style="font-size:10px;font-weight:400;opacity:0.5;vertical-align:top">v2.2.0</span>
|
<p>查询配方 · 计算成本 · 自制配方 · 导出卡片 · 精油知识</p>
|
||||||
</span>
|
</div>
|
||||||
<span
|
</div>
|
||||||
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"
|
<div class="header-right" @click="toggleUserMenu">
|
||||||
@click="toggleUserMenu"
|
<template v-if="auth.isLoggedIn">
|
||||||
>
|
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
|
||||||
<template v-if="auth.isLoggedIn">
|
<span class="user-name">{{ auth.user.display_name || auth.user.username }} ▾</span>
|
||||||
👤 {{ auth.user.display_name || auth.user.username }}
|
</template>
|
||||||
▾
|
<template v-else>
|
||||||
</template>
|
<span class="login-btn">登录</span>
|
||||||
<template v-else>
|
</template>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,3 +126,66 @@ onMounted(async () => {
|
|||||||
}, 15000)
|
}, 15000)
|
||||||
})
|
})
|
||||||
</script>
|
</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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, reactive, onMounted } from 'vue'
|
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useOilsStore } from '../stores/oils'
|
import { useOilsStore } from '../stores/oils'
|
||||||
import { useRecipesStore } from '../stores/recipes'
|
import { useRecipesStore } from '../stores/recipes'
|
||||||
@@ -431,10 +431,8 @@ async function removeRecipe(recipe) {
|
|||||||
|
|
||||||
async function approveRecipe(recipe) {
|
async function approveRecipe(recipe) {
|
||||||
try {
|
try {
|
||||||
await api('/api/recipes/' + recipe._id + '/approve', { method: 'POST' })
|
await api('/api/recipes/' + recipe._id + '/adopt', { method: 'POST' })
|
||||||
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
|
ui.showToast('已采纳')
|
||||||
pendingCount.value--
|
|
||||||
ui.showToast('已通过')
|
|
||||||
await recipeStore.loadRecipes()
|
await recipeStore.loadRecipes()
|
||||||
} catch {
|
} catch {
|
||||||
ui.showToast('操作失败')
|
ui.showToast('操作失败')
|
||||||
@@ -442,11 +440,11 @@ async function approveRecipe(recipe) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function rejectRecipe(recipe) {
|
async function rejectRecipe(recipe) {
|
||||||
|
const ok = await showConfirm(`确定删除「${recipe.name}」?`)
|
||||||
|
if (!ok) return
|
||||||
try {
|
try {
|
||||||
await api('/api/recipes/' + recipe._id + '/reject', { method: 'POST' })
|
await recipeStore.deleteRecipe(recipe._id)
|
||||||
pendingRecipes.value = pendingRecipes.value.filter(r => r._id !== recipe._id)
|
ui.showToast('已删除')
|
||||||
pendingCount.value--
|
|
||||||
ui.showToast('已拒绝')
|
|
||||||
} catch {
|
} catch {
|
||||||
ui.showToast('操作失败')
|
ui.showToast('操作失败')
|
||||||
}
|
}
|
||||||
@@ -474,16 +472,13 @@ function onTagPickerSave(tags) {
|
|||||||
showTagPicker.value = false
|
showTagPicker.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load pending if admin
|
watch(() => recipeStore.recipes, () => {
|
||||||
if (auth.isAdmin) {
|
if (auth.isAdmin) {
|
||||||
api('/api/recipes/pending').then(async res => {
|
const pending = recipeStore.recipes.filter(r => r._owner_id && r._owner_id !== auth.user.id)
|
||||||
if (res.ok) {
|
pendingRecipes.value = pending
|
||||||
const data = await res.json()
|
pendingCount.value = pending.length
|
||||||
pendingRecipes.value = data
|
}
|
||||||
pendingCount.value = data.length
|
}, { immediate: true })
|
||||||
}
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
<div class="card-oils">{{ (d.ingredients || []).map(i => i.oil).join('、') }}</div>
|
<div class="card-oils">{{ (d.ingredients || []).map(i => i.oil).join('、') }}</div>
|
||||||
<div class="card-bottom">
|
<div class="card-bottom">
|
||||||
<span class="card-price">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span>
|
<span class="card-price">{{ oils.fmtPrice(oils.calcCost(d.ingredients || [])) }}</span>
|
||||||
|
<button class="share-btn" @click.stop="shareDiaryToPublic(d)" title="共享到公共配方库">📤</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="myDiaryRecipes.length === 0" class="empty-hint">暂无个人配方</div>
|
<div v-if="myDiaryRecipes.length === 0" class="empty-hint">暂无个人配方</div>
|
||||||
@@ -282,6 +283,31 @@ async function handleToggleFav(recipe) {
|
|||||||
await recipeStore.toggleFavorite(recipe._id)
|
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() {
|
function onSearch() {
|
||||||
// fuzzyResults computed handles the filtering reactively
|
// fuzzyResults computed handles the filtering reactively
|
||||||
}
|
}
|
||||||
@@ -553,6 +579,18 @@ function clearSearch() {
|
|||||||
color: var(--sage-dark, #5a7d5e);
|
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) {
|
@media (max-width: 600px) {
|
||||||
.recipe-grid {
|
.recipe-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user