feat: 权限细化、商业认证跳转、UI改进

权限:
- viewer 管理配方页只显示我的配方,隐藏公共配方库和工具栏
- 高级编辑者可看到精油价目信息不全的红色提示
- 商业核算删除按钮仅管理员可见
- 搜索未收录通知只发管理员和高级编辑者

Tab 可见性:
- 所有用户可见:配方查询、管理配方、个人库存、精油价目、商业核算
- 需登录的 tab 点击弹登录框,登录后跳转
- 操作日志/Bug/用户管理仅管理员可见

商业核算:
- 未认证用户可看项目列表,点详情提示去认证
- 跳转到我的账户页商业认证区域并自动滚动

其他:
- 我的配方和收藏配方默认折叠

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:49:37 +00:00
parent a8e91dc384
commit 1d424984e0
6 changed files with 63 additions and 32 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 user reports no match, notify editors
if body.get("report_missing"): if body.get("report_missing"):
who = user.get("display_name") or user.get("username") or "用户" 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( conn.execute(
"INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)", "INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)",
(role, "🔍 用户需求:" + query, (role, "🔍 用户需求:" + query,

View File

@@ -32,7 +32,7 @@
<div v-for="tab in visibleTabs" :key="tab.key" <div v-for="tab in visibleTabs" :key="tab.key"
class="nav-tab" class="nav-tab"
:class="{ active: ui.currentSection === tab.key }" :class="{ active: ui.currentSection === tab.key }"
@click="goSection(tab.key)" @click="handleTabClick(tab)"
>{{ tab.icon }} {{ tab.label }}</div> >{{ tab.icon }} {{ tab.label }}</div>
</div> </div>
@@ -72,29 +72,24 @@ const route = useRoute()
const showUserMenu = ref(false) const showUserMenu = ref(false)
const navTabsRef = ref(null) const navTabsRef = ref(null)
// Tab 定义,顺序固定:配方查询 → 管理配方 → 个人库存 → 精油价目 → 商业核算 → 操作日志 → Bug → 用户管理 // Tab 定义,顺序固定
// require: 'login' = 需要登录, 'business' = 需要商业认证, 'admin' = 需要管理员 // require: 点击时需要的条件,不满足则提示
// hide: 完全隐藏(只有满足条件才显示)
const allTabs = [ const allTabs = [
{ key: 'search', icon: '🔍', label: '配方查询' }, { key: 'search', icon: '🔍', label: '配方查询' },
{ key: 'manage', icon: '📋', label: '管理配方', require: 'login' }, { key: 'manage', icon: '📋', label: '管理配方', require: 'login' },
{ key: 'inventory', icon: '📦', label: '个人库存', require: 'login' }, { key: 'inventory', icon: '📦', label: '个人库存', require: 'login' },
{ key: 'oils', icon: '💧', label: '精油价目' }, { key: 'oils', icon: '💧', label: '精油价目' },
{ key: 'projects', icon: '💼', label: '商业核算', require: 'business' }, { key: 'projects', icon: '💼', label: '商业核算', require: 'login' },
{ key: 'audit', icon: '📜', label: '操作日志', require: 'admin' }, { key: 'audit', icon: '📜', label: '操作日志', hide: 'admin' },
{ key: 'bugs', icon: '🐛', label: 'Bug', require: 'admin' }, { key: 'bugs', icon: '🐛', label: 'Bug', hide: 'admin' },
{ key: 'users', icon: '👥', label: '用户管理', require: 'admin' }, { key: 'users', icon: '👥', label: '用户管理', hide: 'admin' },
] ]
// 根据当前用户角色,过滤出可见的 tab // 所有人都能看到大部分 tabbug 和用户管理只有 admin 可见
// 未登录: 配方查询, 精油价目
// 普通登录: 配方查询, 管理配方, 个人库存, 精油价目
// 商业用户: + 商业核算
// 管理员: + 操作日志, Bug, 用户管理
const visibleTabs = computed(() => allTabs.filter(t => { const visibleTabs = computed(() => allTabs.filter(t => {
if (!t.require) return true if (!t.hide) return true
if (t.require === 'login') return auth.isLoggedIn if (t.hide === 'admin') return auth.isAdmin
if (t.require === 'business') return auth.isBusiness
if (t.require === 'admin') return auth.isAdmin
return true return true
})) }))
const unreadNotifCount = ref(0) const unreadNotifCount = ref(0)
@@ -124,6 +119,22 @@ const prMatch = hostname.match(/^pr-(\d+)\./)
const isPreview = !!prMatch const isPreview = !!prMatch
const prId = prMatch ? prMatch[1] : '' 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) { function goSection(name) {
ui.showSection(name) ui.showSection(name)
router.push('/' + (name === 'search' ? '' : name)) router.push('/' + (name === 'search' ? '' : name))
@@ -178,12 +189,12 @@ function onSwipeEnd(e) {
const currentIdx = tabs.indexOf(ui.currentSection) const currentIdx = tabs.indexOf(ui.currentSection)
if (currentIdx < 0) return if (currentIdx < 0) return
if (dx < 0 && currentIdx < tabs.length - 1) { let nextIdx = -1
// 左滑 → 下一个 tab if (dx < 0 && currentIdx < tabs.length - 1) nextIdx = currentIdx + 1
goSection(tabs[currentIdx + 1]) else if (dx > 0 && currentIdx > 0) nextIdx = currentIdx - 1
} else if (dx > 0 && currentIdx > 0) { if (nextIdx >= 0) {
// 右滑 → 上一个 tab const tab = visibleTabs.value[nextIdx]
goSection(tabs[currentIdx - 1]) handleTabClick(tab)
} }
} }

View File

@@ -241,7 +241,7 @@
</div> </div>
<!-- Business Verification --> <!-- Business Verification -->
<div v-if="!auth.isBusiness" class="section-card"> <div v-if="!auth.isBusiness" ref="bizCertRef" class="section-card">
<h4>💼 商业认证</h4> <h4>💼 商业认证</h4>
<p class="hint-text">申请商业认证后可使用商业核算功能</p> <p class="hint-text">申请商业认证后可使用商业核算功能</p>
<div class="form-group"> <div class="form-group">
@@ -259,8 +259,8 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch } from 'vue' import { ref, nextTick, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils' import { useOilsStore } from '../stores/oils'
import { useDiaryStore } from '../stores/diary' import { useDiaryStore } from '../stores/diary'
@@ -274,8 +274,10 @@ const oils = useOilsStore()
const diaryStore = useDiaryStore() const diaryStore = useDiaryStore()
const ui = useUiStore() const ui = useUiStore()
const router = useRouter() 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 pasteText = ref('')
const selectedDiaryId = ref(null) const selectedDiaryId = ref(null)
const returnRecipeId = ref(null) const returnRecipeId = ref(null)
@@ -305,6 +307,11 @@ onMounted(async () => {
displayName.value = auth.user.display_name || '' displayName.value = auth.user.display_name || ''
await loadBrandSettings() await loadBrandSettings()
returnRecipeId.value = localStorage.getItem('oil_return_recipe_id') || null returnRecipeId.value = localStorage.getItem('oil_return_recipe_id') || null
// 从商业核算跳转过来,滚到商业认证区域
if (route.query.section === 'biz-cert') {
await nextTick()
bizCertRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}) })
function goBackToRecipe() { function goBackToRecipe() {

View File

@@ -132,7 +132,7 @@
v-for="name in filteredOilNames" v-for="name in filteredOilNames"
:key="name + '-' + cardVersion" :key="name + '-' + cardVersion"
class="oil-chip" 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)" :style="chipStyle(name)"
@click="openOilDetail(name)" @click="openOilDetail(name)"
> >

View File

@@ -3,7 +3,7 @@
<!-- Project List --> <!-- Project List -->
<div class="toolbar"> <div class="toolbar">
<h3 class="page-title">💼 商业核算</h3> <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>
<div v-if="!selectedProject" class="project-list"> <div v-if="!selectedProject" class="project-list">
@@ -23,7 +23,7 @@
成本 {{ oils.fmtPrice(oils.calcCost(p.ingredients)) }} 成本 {{ oils.fmtPrice(oils.calcCost(p.ingredients)) }}
</span> </span>
</div> </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> <button class="btn-icon-sm" @click="deleteProject(p)" title="删除">🗑</button>
</div> </div>
</div> </div>
@@ -177,6 +177,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
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'
@@ -188,6 +189,14 @@ const auth = useAuthStore()
const oils = useOilsStore() const oils = useOilsStore()
const recipeStore = useRecipesStore() const recipeStore = useRecipesStore()
const ui = useUiStore() 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 projects = ref([])
const selectedProject = ref(null) const selectedProject = ref(null)
@@ -237,6 +246,10 @@ async function createProject() {
} }
function selectProject(p) { function selectProject(p) {
if (!auth.isBusiness) {
showCertPrompt()
return
}
selectedProject.value = { selectedProject.value = {
...p, ...p,
ingredients: (p.ingredients || []).map(i => ({ ...i })), ingredients: (p.ingredients || []).map(i => ({ ...i })),

View File

@@ -189,8 +189,8 @@ const selectedCategory = ref(null)
const categories = ref([]) const categories = ref([])
const selectedRecipeIndex = ref(null) const selectedRecipeIndex = ref(null)
const selectedDiaryRecipe = ref(null) const selectedDiaryRecipe = ref(null)
const showMyRecipes = ref(true) const showMyRecipes = ref(false)
const showFavorites = ref(true) const showFavorites = ref(false)
const catIdx = ref(0) const catIdx = ref(0)
const sharedCount = ref(0) const sharedCount = ref(0)