8 Commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:48:51 +00:00
8 changed files with 44 additions and 64 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"): for role in ("admin", "senior_editor", "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="handleTabClick(tab)" @click="goSection(tab.key)"
>{{ tab.icon }} {{ tab.label }}</div> >{{ tab.icon }} {{ tab.label }}</div>
</div> </div>
@@ -72,24 +72,29 @@ const route = useRoute()
const showUserMenu = ref(false) const showUserMenu = ref(false)
const navTabsRef = ref(null) const navTabsRef = ref(null)
// Tab 定义,顺序固定 // Tab 定义,顺序固定:配方查询 → 管理配方 → 个人库存 → 精油价目 → 商业核算 → 操作日志 → Bug → 用户管理
// require: 点击时需要的条件,不满足则提示 // require: 'login' = 需要登录, 'business' = 需要商业认证, 'admin' = 需要管理员
// 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: 'login' }, { key: 'projects', icon: '💼', label: '商业核算', require: 'business' },
{ key: 'audit', icon: '📜', label: '操作日志', hide: 'admin' }, { key: 'audit', icon: '📜', label: '操作日志', require: 'admin' },
{ key: 'bugs', icon: '🐛', label: 'Bug', hide: 'admin' }, { key: 'bugs', icon: '🐛', label: 'Bug', require: 'admin' },
{ key: 'users', icon: '👥', label: '用户管理', hide: 'admin' }, { key: 'users', icon: '👥', label: '用户管理', require: 'admin' },
] ]
// 所有人都能看到大部分 tabbug 和用户管理只有 admin 可见 // 根据当前用户角色,过滤出可见的 tab
// 未登录: 配方查询, 精油价目
// 普通登录: 配方查询, 管理配方, 个人库存, 精油价目
// 商业用户: + 商业核算
// 管理员: + 操作日志, Bug, 用户管理
const visibleTabs = computed(() => allTabs.filter(t => { const visibleTabs = computed(() => allTabs.filter(t => {
if (!t.hide) return true if (!t.require) return true
if (t.hide === 'admin') return auth.isAdmin if (t.require === 'login') return auth.isLoggedIn
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)
@@ -119,22 +124,6 @@ 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))
@@ -189,12 +178,12 @@ function onSwipeEnd(e) {
const currentIdx = tabs.indexOf(ui.currentSection) const currentIdx = tabs.indexOf(ui.currentSection)
if (currentIdx < 0) return if (currentIdx < 0) return
let nextIdx = -1 if (dx < 0 && currentIdx < tabs.length - 1) {
if (dx < 0 && currentIdx < tabs.length - 1) nextIdx = currentIdx + 1 // 左滑 → 下一个 tab
else if (dx > 0 && currentIdx > 0) nextIdx = currentIdx - 1 goSection(tabs[currentIdx + 1])
if (nextIdx >= 0) { } else if (dx > 0 && currentIdx > 0) {
const tab = visibleTabs.value[nextIdx] // 右滑 → 上一个 tab
handleTabClick(tab) goSection(tabs[currentIdx - 1])
} }
} }

View File

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

View File

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

View File

@@ -241,7 +241,7 @@
</div> </div>
<!-- Business Verification --> <!-- Business Verification -->
<div v-if="!auth.isBusiness" ref="bizCertRef" class="section-card"> <div v-if="!auth.isBusiness" 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, nextTick, onMounted, watch } from 'vue' import { ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' 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 { useDiaryStore } from '../stores/diary' import { useDiaryStore } from '../stores/diary'
@@ -274,10 +274,8 @@ 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(route.query.tab || 'brand') const activeTab = ref('brand')
const pasteText = ref('') const pasteText = ref('')
const selectedDiaryId = ref(null) const selectedDiaryId = ref(null)
const returnRecipeId = ref(null) const returnRecipeId = ref(null)
@@ -307,11 +305,6 @@ 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.canManage && isIncomplete(name) }" :class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false, 'oil-chip--incomplete': auth.isAdmin && 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 v-if="auth.isBusiness" class="btn-primary" @click="createProject">+ 新建项目</button> <button 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 v-if="auth.isAdmin" class="proj-actions" @click.stop> <div class="proj-actions" @click.stop>
<button class="btn-icon-sm" @click="deleteProject(p)" title="删除">🗑</button> <button class="btn-icon-sm" @click="deleteProject(p)" title="删除">🗑</button>
</div> </div>
</div> </div>
@@ -177,7 +177,6 @@
<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'
@@ -189,14 +188,6 @@ 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)
@@ -246,10 +237,6 @@ 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(false) const showMyRecipes = ref(true)
const showFavorites = ref(false) const showFavorites = ref(true)
const catIdx = ref(0) const catIdx = ref(0)
const sharedCount = ref(0) const sharedCount = ref(0)