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>
This commit was merged in pull request #18.
This commit is contained in:
2026-04-09 21:29:28 +00:00
committed by fam
parent 27c46cb803
commit a8e91dc384
8 changed files with 154 additions and 175 deletions

View File

@@ -1,7 +1,7 @@
<template>
<div class="recipe-manager">
<!-- Review Bar (admin only) -->
<div v-if="auth.isAdmin && pendingCount > 0" class="review-bar" @click="showPending = !showPending">
<div v-if="auth.isAdmin && pendingCount > 0" class="review-bar" @click="showPending = !showPending" >
📝 待审核配方: {{ pendingCount }}
<span class="toggle-icon">{{ showPending ? '▾' : '▸' }}</span>
</div>
@@ -14,37 +14,39 @@
</div>
</div>
<!-- Search & Actions Bar -->
<div class="manage-toolbar">
<div class="search-box">
<input
class="search-input"
v-model="manageSearch"
placeholder="搜索配方..."
/>
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''"></button>
<!-- Search & Actions Bar (editor+) -->
<template v-if="auth.canEdit">
<div class="manage-toolbar">
<div class="search-box">
<input
class="search-input"
v-model="manageSearch"
placeholder="搜索配方..."
/>
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''"></button>
</div>
<div class="toolbar-actions">
<button class="btn-outline btn-sm" @click="showAddOverlay = true">+ 添加配方</button>
<button class="btn-outline btn-sm" @click="exportExcel">📥 导出Excel</button>
</div>
</div>
<div class="toolbar-actions">
<button class="btn-outline btn-sm" @click="showAddOverlay = true">+ 添加配方</button>
<button class="btn-outline btn-sm" @click="exportExcel">📥 导出Excel</button>
</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>
<!-- 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>
</div>
</template>
<!-- Batch Operations -->
<div v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar">
@@ -92,8 +94,8 @@
</div>
</div>
<!-- Public Recipes Section -->
<div class="recipe-section">
<!-- Public Recipes Section (editor+) -->
<div v-if="auth.canEdit" class="recipe-section">
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
<div class="recipe-list">
<div

View File

@@ -128,10 +128,10 @@
<div class="empty-hint">未找到{{ searchQuery }}相关配方</div>
</div>
<!-- Report missing button -->
<div v-if="exactResults.length === 0" class="no-match-box" style="margin-top:12px">
<!-- Report missing button (always shown at bottom) -->
<div class="no-match-box" style="margin-top:12px">
<button v-if="!reportedMissing" class="btn-report-missing" @click="reportMissing">
📢 {{ similarResults.length > 0 ? '以上都不是我想找的,通知编辑添加' : '通知编辑添加此配方' }}
📢 没找到想要的通知编辑添加
</button>
<div v-else class="reported-hint">已通知编辑感谢反馈</div>
</div>

View File

@@ -38,27 +38,7 @@
</div>
</div>
<!-- New User Creation -->
<div class="create-section">
<h4 class="section-title"> 创建新用户</h4>
<div class="create-form">
<input v-model="newUser.username" class="form-input" placeholder="用户名" />
<input v-model="newUser.display_name" class="form-input" placeholder="显示名称" />
<input v-model="newUser.password" class="form-input" type="password" placeholder="密码 (留空自动生成)" />
<select v-model="newUser.role" class="form-select">
<option value="viewer">查看者</option>
<option value="editor">编辑</option>
<option value="senior_editor">高级编辑</option>
<option value="admin">管理员</option>
</select>
<button class="btn-primary" @click="createUser" :disabled="!newUser.username.trim()">创建</button>
</div>
<div v-if="createdLink" class="created-link">
<span>登录链接:</span>
<code>{{ createdLink }}</code>
<button class="btn-sm btn-outline" @click="copyLink(createdLink)">复制</button>
</div>
</div>
<!-- User self-registers, admin assigns roles below -->
<!-- Search & Filter -->
<div class="filter-toolbar">
@@ -100,13 +80,12 @@
:value="u.role"
class="role-select"
@change="changeRole(u, $event.target.value)"
:disabled="u.role === 'admin'"
>
<option value="viewer">查看者</option>
<option value="editor">编辑</option>
<option value="senior_editor">高级编辑</option>
<option value="admin">管理员</option>
</select>
<button class="btn-sm btn-outline" @click="copyUserLink(u)" title="复制登录链接">🔗</button>
<button class="btn-sm btn-delete" @click="removeUser(u)" title="删除用户">🗑</button>
</div>
</div>
@@ -118,7 +97,7 @@
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
@@ -132,15 +111,6 @@ const searchQuery = ref('')
const filterRole = ref('')
const translations = ref([])
const businessApps = ref([])
const createdLink = ref('')
const newUser = reactive({
username: '',
display_name: '',
password: '',
role: 'viewer',
})
const roles = [
{ value: 'admin', label: '管理员' },
{ value: 'senior_editor', label: '高级编辑' },
@@ -206,43 +176,10 @@ async function loadBusinessApps() {
}
}
async function createUser() {
if (!newUser.username.trim()) return
try {
const res = await api('/api/users', {
method: 'POST',
body: JSON.stringify({
username: newUser.username.trim(),
display_name: newUser.display_name.trim() || newUser.username.trim(),
password: newUser.password || undefined,
role: newUser.role,
}),
})
if (res.ok) {
const data = await res.json()
if (data.token) {
const baseUrl = window.location.origin
createdLink.value = `${baseUrl}/?token=${data.token}`
}
newUser.username = ''
newUser.display_name = ''
newUser.password = ''
newUser.role = 'viewer'
await loadUsers()
ui.showToast('用户已创建')
} else {
const err = await res.json().catch(() => ({}))
ui.showToast('创建失败: ' + (err.error || err.message || ''))
}
} catch {
ui.showToast('创建失败')
}
}
async function changeRole(user, newRole) {
const id = user._id || user.id
try {
const res = await api(`/api/users/${id}/role`, {
const res = await api(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify({ role: newRole }),
})
@@ -270,30 +207,6 @@ async function removeUser(user) {
}
}
async function copyUserLink(user) {
try {
const id = user._id || user.id
const res = await api(`/api/users/${id}/token`)
if (res.ok) {
const data = await res.json()
const link = `${window.location.origin}/?token=${data.token}`
await navigator.clipboard.writeText(link)
ui.showToast('链接已复制')
}
} catch {
ui.showToast('获取链接失败')
}
}
async function copyLink(link) {
try {
await navigator.clipboard.writeText(link)
ui.showToast('已复制')
} catch {
ui.showToast('复制失败')
}
}
async function approveTranslation(t) {
const id = t._id || t.id
try {