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
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:
@@ -781,15 +781,15 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_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()
|
||||
if not row:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
if user["role"] in ("admin", "senior_editor"):
|
||||
return row
|
||||
if row["owner_id"] == user.get("id"):
|
||||
if user["role"] in ("editor",) and row["owner_id"] == user.get("id"):
|
||||
return row
|
||||
raise HTTPException(403, "只能修改自己创建的配方")
|
||||
raise HTTPException(403, "权限不足")
|
||||
|
||||
|
||||
@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"))):
|
||||
conn = get_db()
|
||||
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))
|
||||
if body.display_name is not None:
|
||||
conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (body.display_name, user_id))
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<template v-if="auth.isLoggedIn">
|
||||
<span v-if="auth.isBusiness" class="biz-badge" title="商业认证用户">🏢</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 v-else>
|
||||
<span class="login-btn">登录</span>
|
||||
@@ -24,18 +25,15 @@
|
||||
</div>
|
||||
|
||||
<!-- User Menu Popup -->
|
||||
<UserMenu v-if="showUserMenu" @close="showUserMenu = false" />
|
||||
<UserMenu v-if="showUserMenu" @close="showUserMenu = false; loadUnreadCount()" />
|
||||
|
||||
<!-- Nav tabs -->
|
||||
<div class="nav-tabs" :style="isPreview ? { top: '36px' } : {}">
|
||||
<div class="nav-tab" :class="{ active: ui.currentSection === 'search' }" @click="goSection('search')">🔍 配方查询</div>
|
||||
<div class="nav-tab" :class="{ active: ui.currentSection === 'manage' }" @click="requireLogin('manage')">📋 管理配方</div>
|
||||
<div class="nav-tab" :class="{ active: ui.currentSection === 'inventory' }" @click="requireLogin('inventory')">📦 个人库存</div>
|
||||
<div class="nav-tab" :class="{ active: ui.currentSection === 'oils' }" @click="goSection('oils')">💧 精油价目</div>
|
||||
<div v-if="auth.isBusiness" class="nav-tab" :class="{ active: ui.currentSection === 'projects' }" @click="goSection('projects')">💼 商业核算</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 class="nav-tabs" ref="navTabsRef" :style="isPreview ? { top: '36px' } : {}">
|
||||
<div v-for="tab in visibleTabs" :key="tab.key"
|
||||
class="nav-tab"
|
||||
:class="{ active: ui.currentSection === tab.key }"
|
||||
@click="goSection(tab.key)"
|
||||
>{{ tab.icon }} {{ tab.label }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
@@ -54,7 +52,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useOilsStore } from './stores/oils'
|
||||
@@ -63,6 +61,7 @@ import { useUiStore } from './stores/ui'
|
||||
import LoginModal from './components/LoginModal.vue'
|
||||
import CustomDialog from './components/CustomDialog.vue'
|
||||
import UserMenu from './components/UserMenu.vue'
|
||||
import { api } from './composables/useApi'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const oils = useOilsStore()
|
||||
@@ -71,12 +70,52 @@ const ui = useUiStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
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
|
||||
const routeToSection = { '/': 'search', '/manage': 'manage', '/inventory': 'inventory', '/oils': 'oils', '/projects': 'projects', '/mydiary': 'mydiary', '/audit': 'audit', '/bugs': 'bugs', '/users': 'users' }
|
||||
watch(() => route.path, (path) => {
|
||||
const section = routeToSection[path] || 'search'
|
||||
ui.showSection(section)
|
||||
nextTick(() => scrollActiveTabToCenter())
|
||||
}, { immediate: true })
|
||||
|
||||
// Preview environment detection: pr-{id}.oil.oci.euphon.net
|
||||
@@ -88,6 +127,16 @@ const prId = prMatch ? prMatch[1] : ''
|
||||
function goSection(name) {
|
||||
ui.showSection(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) {
|
||||
@@ -106,44 +155,34 @@ function toggleUserMenu() {
|
||||
showUserMenu.value = !showUserMenu.value
|
||||
}
|
||||
|
||||
// Swipe to switch tabs
|
||||
// ── 左右滑动切换 tab ──
|
||||
// 滑动顺序 = visibleTabs 的顺序(根据用户角色动态决定)
|
||||
// 轮播区域(data-no-tab-swipe)内的滑动不触发 tab 切换
|
||||
const swipeStartX = 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) {
|
||||
const touch = e.touches[0]
|
||||
swipeStartX.value = touch.clientX
|
||||
swipeStartY.value = touch.clientY
|
||||
swipeStartX.value = e.touches[0].clientX
|
||||
swipeStartY.value = e.touches[0].clientY
|
||||
}
|
||||
|
||||
function onSwipeEnd(e) {
|
||||
const touch = e.changedTouches[0]
|
||||
const dx = touch.clientX - swipeStartX.value
|
||||
const dy = touch.clientY - swipeStartY.value
|
||||
// Only trigger if horizontal swipe is dominant and > 50px
|
||||
const dx = e.changedTouches[0].clientX - swipeStartX.value
|
||||
const dy = e.changedTouches[0].clientY - swipeStartY.value
|
||||
// 必须是水平滑动 > 50px,且水平距离大于垂直距离
|
||||
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
|
||||
|
||||
const tabs = tabOrder.value
|
||||
const tabs = visibleTabs.value.map(t => t.key)
|
||||
const currentIdx = tabs.indexOf(ui.currentSection)
|
||||
if (currentIdx < 0) return
|
||||
|
||||
if (dx < -50 && currentIdx < tabs.length - 1) {
|
||||
// Swipe left -> next tab
|
||||
if (dx < 0 && currentIdx < tabs.length - 1) {
|
||||
// 左滑 → 下一个 tab
|
||||
goSection(tabs[currentIdx + 1])
|
||||
} else if (dx > 50 && currentIdx > 0) {
|
||||
// Swipe right -> previous tab
|
||||
} else if (dx > 0 && currentIdx > 0) {
|
||||
// 右滑 → 上一个 tab
|
||||
goSection(tabs[currentIdx - 1])
|
||||
}
|
||||
}
|
||||
@@ -157,6 +196,7 @@ onMounted(async () => {
|
||||
])
|
||||
if (auth.isLoggedIn) {
|
||||
await recipeStore.loadFavorites()
|
||||
await loadUnreadCount()
|
||||
}
|
||||
|
||||
// Periodic refresh
|
||||
@@ -164,6 +204,7 @@ onMounted(async () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
try {
|
||||
await auth.loadMe()
|
||||
await loadUnreadCount()
|
||||
} catch {}
|
||||
}, 15000)
|
||||
})
|
||||
@@ -217,6 +258,19 @@ onMounted(async () => {
|
||||
opacity: 0.95;
|
||||
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 {
|
||||
color: white;
|
||||
background: rgba(255,255,255,0.2);
|
||||
|
||||
@@ -69,6 +69,24 @@ body {
|
||||
.nav-tab:hover { color: var(--sage-dark); }
|
||||
.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 { padding: 24px; max-width: 960px; margin: 0 auto; }
|
||||
|
||||
|
||||
@@ -401,7 +401,6 @@ const displayRecipe = computed(() => {
|
||||
|
||||
const canEditThisRecipe = computed(() => {
|
||||
if (authStore.canEdit) return true
|
||||
if (authStore.isLoggedIn && recipe.value._owner_id === authStore.user.id) return true
|
||||
return false
|
||||
})
|
||||
|
||||
|
||||
@@ -28,16 +28,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Actions
|
||||
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) {
|
||||
await loadMe()
|
||||
}
|
||||
@@ -85,7 +75,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
function canEditRecipe(recipe) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +14,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Actions Bar -->
|
||||
<!-- Search & Actions Bar (editor+) -->
|
||||
<template v-if="auth.canEdit">
|
||||
<div class="manage-toolbar">
|
||||
<div class="search-box">
|
||||
<input
|
||||
@@ -45,6 +46,7 @@
|
||||
>{{ tag }}</span>
|
||||
</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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user