feat: 权限修复、搜索改进、滑动切换、通知badge
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Successful in 1m5s
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Successful in 1m5s
权限: - viewer 不能编辑公共配方(前端+后端双重限制) - viewer 管理配方页只显示"我的配方" - 取消 token 链接登录,改为自注册+管理员分配角色 - 用户管理页去掉创建用户和复制链接,禁止设管理员 - 修复改权限 API 路径错误 搜索: - 模糊匹配+同义词扩展(37组),精确/相似分层 - 精确匹配不搜精油成分(避免"西班牙牛至"污染) - 所有搜索结果底部加"通知编辑添加"按钮 UI: - 顶部 tab 栏按用户角色显示,切换时居中滚动 - 左右滑动按 visibleTabs 顺序切换 tab - 用户名旁红色通知数 badge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -781,15 +781,15 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)):
|
|||||||
|
|
||||||
|
|
||||||
def _check_recipe_permission(conn, recipe_id, user):
|
def _check_recipe_permission(conn, recipe_id, user):
|
||||||
"""Check if user can modify this recipe."""
|
"""Check if user can modify this recipe. Requires editor+ role."""
|
||||||
row = conn.execute("SELECT owner_id, name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
row = conn.execute("SELECT owner_id, name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Recipe not found")
|
raise HTTPException(404, "Recipe not found")
|
||||||
if user["role"] in ("admin", "senior_editor"):
|
if user["role"] in ("admin", "senior_editor"):
|
||||||
return row
|
return row
|
||||||
if row["owner_id"] == user.get("id"):
|
if user["role"] in ("editor",) and row["owner_id"] == user.get("id"):
|
||||||
return row
|
return row
|
||||||
raise HTTPException(403, "只能修改自己创建的配方")
|
raise HTTPException(403, "权限不足")
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/recipes/{recipe_id}")
|
@app.put("/api/recipes/{recipe_id}")
|
||||||
@@ -974,6 +974,9 @@ def delete_user(user_id: int, user=Depends(require_role("admin"))):
|
|||||||
def update_user(user_id: int, body: UserUpdate, user=Depends(require_role("admin"))):
|
def update_user(user_id: int, body: UserUpdate, user=Depends(require_role("admin"))):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
if body.role is not None:
|
if body.role is not None:
|
||||||
|
if body.role == "admin":
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(403, "不能将用户设为管理员")
|
||||||
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id))
|
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id))
|
||||||
if body.display_name is not None:
|
if body.display_name is not None:
|
||||||
conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (body.display_name, user_id))
|
conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (body.display_name, user_id))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<template v-if="auth.isLoggedIn">
|
<template v-if="auth.isLoggedIn">
|
||||||
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
|
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</span>
|
||||||
<span class="user-name">{{ auth.user.display_name || auth.user.username }} ▾</span>
|
<span class="user-name">{{ auth.user.display_name || auth.user.username }} ▾</span>
|
||||||
|
<span v-if="unreadNotifCount > 0" class="notif-badge">{{ unreadNotifCount }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="login-btn">登录</span>
|
<span class="login-btn">登录</span>
|
||||||
@@ -24,18 +25,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Menu Popup -->
|
<!-- User Menu Popup -->
|
||||||
<UserMenu v-if="showUserMenu" @close="showUserMenu = false" />
|
<UserMenu v-if="showUserMenu" @close="showUserMenu = false; loadUnreadCount()" />
|
||||||
|
|
||||||
<!-- Nav tabs -->
|
<!-- Nav tabs -->
|
||||||
<div class="nav-tabs" :style="isPreview ? { top: '36px' } : {}">
|
<div class="nav-tabs" ref="navTabsRef" :style="isPreview ? { top: '36px' } : {}">
|
||||||
<div class="nav-tab" :class="{ active: ui.currentSection === 'search' }" @click="goSection('search')">🔍 配方查询</div>
|
<div v-for="tab in visibleTabs" :key="tab.key"
|
||||||
<div class="nav-tab" :class="{ active: ui.currentSection === 'manage' }" @click="requireLogin('manage')">📋 管理配方</div>
|
class="nav-tab"
|
||||||
<div class="nav-tab" :class="{ active: ui.currentSection === 'inventory' }" @click="requireLogin('inventory')">📦 个人库存</div>
|
:class="{ active: ui.currentSection === tab.key }"
|
||||||
<div class="nav-tab" :class="{ active: ui.currentSection === 'oils' }" @click="goSection('oils')">💧 精油价目</div>
|
@click="goSection(tab.key)"
|
||||||
<div v-if="auth.isBusiness" class="nav-tab" :class="{ active: ui.currentSection === 'projects' }" @click="goSection('projects')">💼 商业核算</div>
|
>{{ tab.icon }} {{ tab.label }}</div>
|
||||||
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'audit' }" @click="goSection('audit')">📜 操作日志</div>
|
|
||||||
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'bugs' }" @click="goSection('bugs')">🐛 Bug</div>
|
|
||||||
<div v-if="auth.isAdmin" class="nav-tab" :class="{ active: ui.currentSection === 'users' }" @click="goSection('users')">👥 用户管理</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
@@ -54,7 +52,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } 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'
|
||||||
@@ -63,6 +61,7 @@ import { useUiStore } from './stores/ui'
|
|||||||
import LoginModal from './components/LoginModal.vue'
|
import LoginModal from './components/LoginModal.vue'
|
||||||
import CustomDialog from './components/CustomDialog.vue'
|
import CustomDialog from './components/CustomDialog.vue'
|
||||||
import UserMenu from './components/UserMenu.vue'
|
import UserMenu from './components/UserMenu.vue'
|
||||||
|
import { api } from './composables/useApi'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const oils = useOilsStore()
|
const oils = useOilsStore()
|
||||||
@@ -71,12 +70,52 @@ const ui = useUiStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const showUserMenu = ref(false)
|
const showUserMenu = ref(false)
|
||||||
|
const navTabsRef = ref(null)
|
||||||
|
|
||||||
|
// Tab 定义,顺序固定:配方查询 → 管理配方 → 个人库存 → 精油价目 → 商业核算 → 操作日志 → Bug → 用户管理
|
||||||
|
// require: 'login' = 需要登录, 'business' = 需要商业认证, 'admin' = 需要管理员
|
||||||
|
const allTabs = [
|
||||||
|
{ key: 'search', icon: '🔍', label: '配方查询' },
|
||||||
|
{ key: 'manage', icon: '📋', label: '管理配方', require: 'login' },
|
||||||
|
{ key: 'inventory', icon: '📦', label: '个人库存', require: 'login' },
|
||||||
|
{ key: 'oils', icon: '💧', label: '精油价目' },
|
||||||
|
{ key: 'projects', icon: '💼', label: '商业核算', require: 'business' },
|
||||||
|
{ key: 'audit', icon: '📜', label: '操作日志', require: 'admin' },
|
||||||
|
{ key: 'bugs', icon: '🐛', label: 'Bug', require: 'admin' },
|
||||||
|
{ key: 'users', icon: '👥', label: '用户管理', require: 'admin' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 根据当前用户角色,过滤出可见的 tab
|
||||||
|
// 未登录: 配方查询, 精油价目
|
||||||
|
// 普通登录: 配方查询, 管理配方, 个人库存, 精油价目
|
||||||
|
// 商业用户: + 商业核算
|
||||||
|
// 管理员: + 操作日志, Bug, 用户管理
|
||||||
|
const visibleTabs = computed(() => allTabs.filter(t => {
|
||||||
|
if (!t.require) return true
|
||||||
|
if (t.require === 'login') return auth.isLoggedIn
|
||||||
|
if (t.require === 'business') return auth.isBusiness
|
||||||
|
if (t.require === 'admin') return auth.isAdmin
|
||||||
|
return true
|
||||||
|
}))
|
||||||
|
const unreadNotifCount = ref(0)
|
||||||
|
|
||||||
|
async function loadUnreadCount() {
|
||||||
|
if (!auth.isLoggedIn) return
|
||||||
|
try {
|
||||||
|
const res = await api('/api/notifications')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
unreadNotifCount.value = data.filter(n => !n.is_read).length
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Sync ui.currentSection from route on load and navigation
|
// Sync ui.currentSection from route on load and navigation
|
||||||
const routeToSection = { '/': 'search', '/manage': 'manage', '/inventory': 'inventory', '/oils': 'oils', '/projects': 'projects', '/mydiary': 'mydiary', '/audit': 'audit', '/bugs': 'bugs', '/users': 'users' }
|
const routeToSection = { '/': 'search', '/manage': 'manage', '/inventory': 'inventory', '/oils': 'oils', '/projects': 'projects', '/mydiary': 'mydiary', '/audit': 'audit', '/bugs': 'bugs', '/users': 'users' }
|
||||||
watch(() => route.path, (path) => {
|
watch(() => route.path, (path) => {
|
||||||
const section = routeToSection[path] || 'search'
|
const section = routeToSection[path] || 'search'
|
||||||
ui.showSection(section)
|
ui.showSection(section)
|
||||||
|
nextTick(() => scrollActiveTabToCenter())
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Preview environment detection: pr-{id}.oil.oci.euphon.net
|
// Preview environment detection: pr-{id}.oil.oci.euphon.net
|
||||||
@@ -88,6 +127,16 @@ const prId = prMatch ? prMatch[1] : ''
|
|||||||
function goSection(name) {
|
function goSection(name) {
|
||||||
ui.showSection(name)
|
ui.showSection(name)
|
||||||
router.push('/' + (name === 'search' ? '' : name))
|
router.push('/' + (name === 'search' ? '' : name))
|
||||||
|
nextTick(() => scrollActiveTabToCenter())
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollActiveTabToCenter() {
|
||||||
|
if (!navTabsRef.value) return
|
||||||
|
const active = navTabsRef.value.querySelector('.nav-tab.active')
|
||||||
|
if (!active) return
|
||||||
|
const container = navTabsRef.value
|
||||||
|
const scrollLeft = active.offsetLeft - container.clientWidth / 2 + active.clientWidth / 2
|
||||||
|
container.scrollTo({ left: scrollLeft, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireLogin(name) {
|
function requireLogin(name) {
|
||||||
@@ -106,44 +155,34 @@ function toggleUserMenu() {
|
|||||||
showUserMenu.value = !showUserMenu.value
|
showUserMenu.value = !showUserMenu.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swipe to switch tabs
|
// ── 左右滑动切换 tab ──
|
||||||
|
// 滑动顺序 = visibleTabs 的顺序(根据用户角色动态决定)
|
||||||
|
// 轮播区域(data-no-tab-swipe)内的滑动不触发 tab 切换
|
||||||
const swipeStartX = ref(0)
|
const swipeStartX = ref(0)
|
||||||
const swipeStartY = ref(0)
|
const swipeStartY = ref(0)
|
||||||
|
|
||||||
// Tab order for swipe navigation (only user-accessible tabs)
|
|
||||||
const tabOrder = computed(() => {
|
|
||||||
const tabs = ['search', 'oils']
|
|
||||||
if (auth.isLoggedIn) {
|
|
||||||
tabs.splice(1, 0, 'manage', 'inventory')
|
|
||||||
}
|
|
||||||
if (auth.isBusiness) tabs.push('projects')
|
|
||||||
return tabs
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSwipeStart(e) {
|
function onSwipeStart(e) {
|
||||||
const touch = e.touches[0]
|
swipeStartX.value = e.touches[0].clientX
|
||||||
swipeStartX.value = touch.clientX
|
swipeStartY.value = e.touches[0].clientY
|
||||||
swipeStartY.value = touch.clientY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSwipeEnd(e) {
|
function onSwipeEnd(e) {
|
||||||
const touch = e.changedTouches[0]
|
const dx = e.changedTouches[0].clientX - swipeStartX.value
|
||||||
const dx = touch.clientX - swipeStartX.value
|
const dy = e.changedTouches[0].clientY - swipeStartY.value
|
||||||
const dy = touch.clientY - swipeStartY.value
|
// 必须是水平滑动 > 50px,且水平距离大于垂直距离
|
||||||
// Only trigger if horizontal swipe is dominant and > 50px
|
|
||||||
if (Math.abs(dx) < 50 || Math.abs(dy) > Math.abs(dx)) return
|
if (Math.abs(dx) < 50 || Math.abs(dy) > Math.abs(dx)) return
|
||||||
// Check if the swipe originated inside a carousel (data-no-tab-swipe)
|
// 轮播区域内不触发 tab 切换
|
||||||
if (e.target.closest && e.target.closest('[data-no-tab-swipe]')) return
|
if (e.target.closest && e.target.closest('[data-no-tab-swipe]')) return
|
||||||
|
|
||||||
const tabs = tabOrder.value
|
const tabs = visibleTabs.value.map(t => t.key)
|
||||||
const currentIdx = tabs.indexOf(ui.currentSection)
|
const currentIdx = tabs.indexOf(ui.currentSection)
|
||||||
if (currentIdx < 0) return
|
if (currentIdx < 0) return
|
||||||
|
|
||||||
if (dx < -50 && currentIdx < tabs.length - 1) {
|
if (dx < 0 && currentIdx < tabs.length - 1) {
|
||||||
// Swipe left -> next tab
|
// 左滑 → 下一个 tab
|
||||||
goSection(tabs[currentIdx + 1])
|
goSection(tabs[currentIdx + 1])
|
||||||
} else if (dx > 50 && currentIdx > 0) {
|
} else if (dx > 0 && currentIdx > 0) {
|
||||||
// Swipe right -> previous tab
|
// 右滑 → 上一个 tab
|
||||||
goSection(tabs[currentIdx - 1])
|
goSection(tabs[currentIdx - 1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +196,7 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
if (auth.isLoggedIn) {
|
if (auth.isLoggedIn) {
|
||||||
await recipeStore.loadFavorites()
|
await recipeStore.loadFavorites()
|
||||||
|
await loadUnreadCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic refresh
|
// Periodic refresh
|
||||||
@@ -164,6 +204,7 @@ onMounted(async () => {
|
|||||||
if (document.visibilityState !== 'visible') return
|
if (document.visibilityState !== 'visible') return
|
||||||
try {
|
try {
|
||||||
await auth.loadMe()
|
await auth.loadMe()
|
||||||
|
await loadUnreadCount()
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 15000)
|
}, 15000)
|
||||||
})
|
})
|
||||||
@@ -217,6 +258,19 @@ onMounted(async () => {
|
|||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.notif-badge {
|
||||||
|
background: #e53935;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
.login-btn {
|
.login-btn {
|
||||||
color: white;
|
color: white;
|
||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255,255,255,0.2);
|
||||||
|
|||||||
@@ -69,6 +69,24 @@ body {
|
|||||||
.nav-tab:hover { color: var(--sage-dark); }
|
.nav-tab:hover { color: var(--sage-dark); }
|
||||||
.nav-tab.active { color: var(--sage-dark); border-bottom-color: var(--sage); }
|
.nav-tab.active { color: var(--sage-dark); border-bottom-color: var(--sage); }
|
||||||
|
|
||||||
|
.section-title-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
padding: 12px 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.section-title-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sage-dark);
|
||||||
|
border-bottom: 2px solid var(--sage);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Main content */
|
/* Main content */
|
||||||
.main { padding: 24px; max-width: 960px; margin: 0 auto; }
|
.main { padding: 24px; max-width: 960px; margin: 0 auto; }
|
||||||
|
|
||||||
|
|||||||
@@ -401,7 +401,6 @@ const displayRecipe = computed(() => {
|
|||||||
|
|
||||||
const canEditThisRecipe = computed(() => {
|
const canEditThisRecipe = computed(() => {
|
||||||
if (authStore.canEdit) return true
|
if (authStore.canEdit) return true
|
||||||
if (authStore.isLoggedIn && recipe.value._owner_id === authStore.user.id) return true
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,16 +28,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
async function initToken() {
|
async function initToken() {
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const urlToken = params.get('token')
|
|
||||||
if (urlToken) {
|
|
||||||
token.value = urlToken
|
|
||||||
localStorage.setItem('oil_auth_token', urlToken)
|
|
||||||
// Clean URL
|
|
||||||
const url = new URL(window.location)
|
|
||||||
url.searchParams.delete('token')
|
|
||||||
window.history.replaceState({}, '', url)
|
|
||||||
}
|
|
||||||
if (token.value) {
|
if (token.value) {
|
||||||
await loadMe()
|
await loadMe()
|
||||||
}
|
}
|
||||||
@@ -85,7 +75,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
function canEditRecipe(recipe) {
|
function canEditRecipe(recipe) {
|
||||||
if (isAdmin.value || user.value.role === 'senior_editor') return true
|
if (isAdmin.value || user.value.role === 'senior_editor') return true
|
||||||
if (recipe._owner_id === user.value.id) return true
|
if (canEdit.value && recipe._owner_id === user.value.id) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search & Actions Bar -->
|
<!-- Search & Actions Bar (editor+) -->
|
||||||
|
<template v-if="auth.canEdit">
|
||||||
<div class="manage-toolbar">
|
<div class="manage-toolbar">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input
|
<input
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
>{{ tag }}</span>
|
>{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Batch Operations -->
|
<!-- Batch Operations -->
|
||||||
<div v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar">
|
<div v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar">
|
||||||
@@ -92,8 +94,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Public Recipes Section -->
|
<!-- Public Recipes Section (editor+) -->
|
||||||
<div class="recipe-section">
|
<div v-if="auth.canEdit" class="recipe-section">
|
||||||
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
|
<h3 class="section-title">🌿 公共配方库 ({{ publicRecipes.length }})</h3>
|
||||||
<div class="recipe-list">
|
<div class="recipe-list">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -128,10 +128,10 @@
|
|||||||
<div class="empty-hint">未找到「{{ searchQuery }}」相关配方</div>
|
<div class="empty-hint">未找到「{{ searchQuery }}」相关配方</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Report missing button -->
|
<!-- Report missing button (always shown at bottom) -->
|
||||||
<div v-if="exactResults.length === 0" class="no-match-box" style="margin-top:12px">
|
<div class="no-match-box" style="margin-top:12px">
|
||||||
<button v-if="!reportedMissing" class="btn-report-missing" @click="reportMissing">
|
<button v-if="!reportedMissing" class="btn-report-missing" @click="reportMissing">
|
||||||
📢 {{ similarResults.length > 0 ? '以上都不是我想找的,通知编辑添加' : '通知编辑添加此配方' }}
|
📢 没找到想要的?通知编辑添加
|
||||||
</button>
|
</button>
|
||||||
<div v-else class="reported-hint">已通知编辑,感谢反馈!</div>
|
<div v-else class="reported-hint">已通知编辑,感谢反馈!</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,27 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New User Creation -->
|
<!-- User self-registers, admin assigns roles below -->
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Search & Filter -->
|
<!-- Search & Filter -->
|
||||||
<div class="filter-toolbar">
|
<div class="filter-toolbar">
|
||||||
@@ -100,13 +80,12 @@
|
|||||||
:value="u.role"
|
:value="u.role"
|
||||||
class="role-select"
|
class="role-select"
|
||||||
@change="changeRole(u, $event.target.value)"
|
@change="changeRole(u, $event.target.value)"
|
||||||
|
:disabled="u.role === 'admin'"
|
||||||
>
|
>
|
||||||
<option value="viewer">查看者</option>
|
<option value="viewer">查看者</option>
|
||||||
<option value="editor">编辑</option>
|
<option value="editor">编辑</option>
|
||||||
<option value="senior_editor">高级编辑</option>
|
<option value="senior_editor">高级编辑</option>
|
||||||
<option value="admin">管理员</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button class="btn-sm btn-outline" @click="copyUserLink(u)" title="复制登录链接">🔗</button>
|
|
||||||
<button class="btn-sm btn-delete" @click="removeUser(u)" title="删除用户">🗑️</button>
|
<button class="btn-sm btn-delete" @click="removeUser(u)" title="删除用户">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +97,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, reactive, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useUiStore } from '../stores/ui'
|
import { useUiStore } from '../stores/ui'
|
||||||
import { api } from '../composables/useApi'
|
import { api } from '../composables/useApi'
|
||||||
@@ -132,15 +111,6 @@ const searchQuery = ref('')
|
|||||||
const filterRole = ref('')
|
const filterRole = ref('')
|
||||||
const translations = ref([])
|
const translations = ref([])
|
||||||
const businessApps = ref([])
|
const businessApps = ref([])
|
||||||
const createdLink = ref('')
|
|
||||||
|
|
||||||
const newUser = reactive({
|
|
||||||
username: '',
|
|
||||||
display_name: '',
|
|
||||||
password: '',
|
|
||||||
role: 'viewer',
|
|
||||||
})
|
|
||||||
|
|
||||||
const roles = [
|
const roles = [
|
||||||
{ value: 'admin', label: '管理员' },
|
{ value: 'admin', label: '管理员' },
|
||||||
{ value: 'senior_editor', 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) {
|
async function changeRole(user, newRole) {
|
||||||
const id = user._id || user.id
|
const id = user._id || user.id
|
||||||
try {
|
try {
|
||||||
const res = await api(`/api/users/${id}/role`, {
|
const res = await api(`/api/users/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ role: newRole }),
|
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) {
|
async function approveTranslation(t) {
|
||||||
const id = t._id || t.id
|
const id = t._id || t.id
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user