Files
H3ConuMS-v2/frontend/src/views/UserManage.vue
T
v6ole f1f8518985 ```
feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息

- 在JWT令牌中添加用户角色信息
- 新增get_my_permissions接口用于获取当前用户权限码列表
- 重构认证回调逻辑,增加错误日志记录
- 更新用户信息获取接口使用Authorization头验证
```
2026-04-06 00:40:08 +08:00

455 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-wrap">
<div class="page-header">
<h2 class="page-title">用户管理</h2>
<div class="header-filters">
<input
v-model="keyword"
class="filter-input"
placeholder="搜索用户名/邮箱"
@input="onSearch"
/>
<select v-model="filterRole" class="filter-select" @change="onSearch">
<option value="">全部角色</option>
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>角色</th>
<th>区域/学校</th>
<th>状态</th>
<th>最后登录</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="td-center">加载中...</td>
</tr>
<tr v-else-if="!users.length">
<td colspan="7" class="td-center td-empty">暂无用户</td>
</tr>
<tr v-for="u in users" :key="u.id">
<td class="td-username">{{ u.username }}</td>
<td class="td-muted">{{ u.email || '—' }}</td>
<td>
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
</td>
<td class="td-muted">
<span v-if="u.assigned_area">{{ u.assigned_area }}</span>
<span v-if="u.assigned_school"> / {{ u.assigned_school }}</span>
<span v-if="!u.assigned_area && !u.assigned_school"></span>
</td>
<td>
<span class="status-badge" :class="u.is_active ? 'status-active' : 'status-disabled'">
{{ u.is_active ? '启用' : '禁用' }}
</span>
</td>
<td class="td-muted">{{ formatTime(u.last_login) }}</td>
<td>
<div class="action-btns">
<button class="btn-sm btn-primary" @click="openEdit(u)">编辑</button>
<button
class="btn-sm"
:class="u.is_active ? 'btn-danger' : 'btn-success'"
@click="handleToggle(u)"
>
{{ u.is_active ? '禁用' : '启用' }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination" v-if="total > pageSize">
<button class="page-btn" :disabled="page === 0" @click="page--; loadUsers()">上一页</button>
<span class="page-info">{{ page + 1 }} / {{ Math.ceil(total / pageSize) }}</span>
<button class="page-btn" :disabled="(page + 1) * pageSize >= total" @click="page++; loadUsers()">下一页</button>
</div>
<!-- 编辑弹窗 -->
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
<div class="modal">
<div class="modal-header">
<span class="modal-title">编辑用户{{ editUser.username }}</span>
<button class="modal-close" @click="editUser = null"></button>
</div>
<div class="modal-body">
<div class="form-row">
<label class="form-label">角色</label>
<select v-model="editForm.role" class="form-select" @change="onRoleChange">
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<!-- 区域管理员多选区域 -->
<div class="form-row" v-if="editForm.role === 'area_admin'">
<label class="form-label">分配区域可多选</label>
<div class="multi-toolbar">
<input v-model="areaSearch" class="multi-search" placeholder="搜索区域…" />
<button class="multi-btn" @click="selectAllAreas">全选</button>
<button class="multi-btn" @click="invertAreas">反选</button>
</div>
<div class="multi-select-wrap">
<div
v-for="r in filteredRegions"
:key="r"
class="multi-option"
:class="{ selected: editForm.selectedAreas.includes(r) }"
@click="toggleArea(r)"
>{{ r }}</div>
<div v-if="!filteredRegions.length" class="multi-empty">暂无匹配区域</div>
</div>
<div class="selected-hint" v-if="editForm.selectedAreas.length">
已选{{ editForm.selectedAreas.join('、') }}
</div>
</div>
<!-- 学校管理员先选区域筛选再多选学校 -->
<div class="form-row" v-if="editForm.role === 'school_admin'">
<label class="form-label">按区域筛选学校</label>
<select v-model="schoolFilterRegion" class="form-select" @change="loadSchools">
<option value="">全部区域</option>
<option v-for="r in allRegions" :key="r" :value="r">{{ r }}</option>
</select>
</div>
<div class="form-row" v-if="editForm.role === 'school_admin'">
<label class="form-label">分配学校可多选</label>
<div class="multi-toolbar">
<input v-model="schoolSearch" class="multi-search" placeholder="搜索学校…" />
<button class="multi-btn" @click="selectAllSchools">全选</button>
<button class="multi-btn" @click="invertSchools">反选</button>
</div>
<div class="multi-select-wrap">
<div
v-for="s in filteredSchoolList"
:key="s"
class="multi-option"
:class="{ selected: editForm.selectedSchools.includes(s) }"
@click="toggleSchool(s)"
>{{ s }}</div>
<div v-if="!filteredSchoolList.length" class="multi-empty">暂无匹配学校</div>
</div>
<div class="selected-hint" v-if="editForm.selectedSchools.length">
已选 {{ editForm.selectedSchools.length }} {{ editForm.selectedSchools.join('、') }}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" @click="editUser = null">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getUsers, updateUserRole, toggleUser } from '../api/user'
import request from '../utils/request'
const users = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(0)
const pageSize = 20
const keyword = ref('')
const filterRole = ref('')
let searchTimer = null
const editUser = ref(null)
const editForm = ref({ role: 'user', selectedAreas: [], selectedSchools: [] })
const saving = ref(false)
const allRegions = ref([])
const allSchools = ref([])
const filteredSchools = ref([])
const schoolFilterRegion = ref('')
// 搜索词
const areaSearch = ref('')
const schoolSearch = ref('')
// 过滤后的区域/学校列表
const filteredRegions = computed(() =>
areaSearch.value
? allRegions.value.filter(r => r.includes(areaSearch.value))
: allRegions.value
)
const filteredSchoolList = computed(() =>
schoolSearch.value
? filteredSchools.value.filter(s => s.includes(schoolSearch.value))
: filteredSchools.value
)
// 全选/反选 - 区域
const selectAllAreas = () => {
editForm.value.selectedAreas = [...filteredRegions.value]
}
const invertAreas = () => {
editForm.value.selectedAreas = filteredRegions.value.filter(
r => !editForm.value.selectedAreas.includes(r)
)
}
// 全选/反选 - 学校
const selectAllSchools = () => {
const toAdd = filteredSchoolList.value.filter(s => !editForm.value.selectedSchools.includes(s))
editForm.value.selectedSchools = [...editForm.value.selectedSchools, ...toAdd]
}
const invertSchools = () => {
editForm.value.selectedSchools = filteredSchoolList.value.filter(
s => !editForm.value.selectedSchools.includes(s)
)
}
const roleOptions = [
{ value: 'admin', label: '超级管理员' },
{ value: 'area_admin', label: '区域管理员' },
{ value: 'school_admin', label: '学校管理员' },
{ value: 'user', label: '普通用户' },
]
const roleLabel = (role) => roleOptions.find(r => r.value === role)?.label || role
const formatTime = (t) => {
if (!t) return '—'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const loadRegions = async () => {
try {
const { data } = await request.get('/devices/regions')
allRegions.value = data
} catch {}
}
const loadSchools = async () => {
try {
const { data } = await request.get('/devices/schools', {
params: schoolFilterRegion.value ? { region: schoolFilterRegion.value } : {}
})
allSchools.value = data
filteredSchools.value = data
} catch {}
}
const loadUsers = async () => {
loading.value = true
try {
const res = await getUsers({
skip: page.value * pageSize,
limit: pageSize,
keyword: keyword.value || undefined,
role: filterRole.value || undefined,
})
users.value = res.data.items
total.value = res.data.total
} finally {
loading.value = false
}
}
const onSearch = () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => { page.value = 0; loadUsers() }, 300)
}
const openEdit = (u) => {
editUser.value = u
schoolFilterRegion.value = ''
areaSearch.value = ''
schoolSearch.value = ''
const areas = u.assigned_area ? u.assigned_area.split(',').map(s => s.trim()).filter(Boolean) : []
const schools = u.assigned_school ? u.assigned_school.split(',').map(s => s.trim()).filter(Boolean) : []
editForm.value = { role: u.role, selectedAreas: areas, selectedSchools: schools }
if (u.role === 'school_admin') loadSchools()
}
const onRoleChange = () => {
editForm.value.selectedAreas = []
editForm.value.selectedSchools = []
schoolFilterRegion.value = ''
areaSearch.value = ''
schoolSearch.value = ''
if (editForm.value.role === 'school_admin') loadSchools()
}
const toggleArea = (r) => {
const idx = editForm.value.selectedAreas.indexOf(r)
if (idx >= 0) editForm.value.selectedAreas.splice(idx, 1)
else editForm.value.selectedAreas.push(r)
}
const toggleSchool = (s) => {
const idx = editForm.value.selectedSchools.indexOf(s)
if (idx >= 0) editForm.value.selectedSchools.splice(idx, 1)
else editForm.value.selectedSchools.push(s)
}
const handleSave = async () => {
saving.value = true
try {
const payload = { role: editForm.value.role, assigned_area: null, assigned_school: null }
if (editForm.value.role === 'area_admin' && editForm.value.selectedAreas.length) {
payload.assigned_area = editForm.value.selectedAreas.join(',')
}
if (editForm.value.role === 'school_admin' && editForm.value.selectedSchools.length) {
payload.assigned_school = editForm.value.selectedSchools.join(',')
}
await updateUserRole(editUser.value.id, payload)
editUser.value = null
await loadUsers()
} finally {
saving.value = false
}
}
const handleToggle = async (u) => {
await toggleUser(u.id)
await loadUsers()
}
onMounted(() => {
loadUsers()
loadRegions()
loadSchools()
})
</script>
<style scoped>
.page-wrap { padding: 24px; max-width: 1200px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
.header-filters { display: flex; gap: 10px; }
.filter-input, .filter-select {
padding: 7px 12px; border: 1px solid var(--border-default); border-radius: var(--radius-md);
background: var(--bg-elevated); color: var(--text-primary); font-size: 13px; outline: none;
}
.filter-input:focus, .filter-select:focus { border-color: var(--accent); }
.table-wrap { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th {
padding: 10px 14px; text-align: left; font-weight: 500; color: var(--text-muted);
background: var(--bg-surface); border-bottom: 1px solid var(--border-subtle); white-space: nowrap;
}
.data-table td { padding: 11px 14px; border-bottom: 1px solid var(--border-subtle); color: var(--text-secondary); }
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--bg-hover); }
.td-center { text-align: center; }
.td-empty { color: var(--text-muted); }
.td-username { font-weight: 500; color: var(--text-primary); }
.td-muted { color: var(--text-muted); font-size: 12px; }
.role-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500;
}
.role-admin { background: rgba(139,92,246,0.15); color: #a78bfa; }
.role-area_admin { background: rgba(59,130,246,0.15); color: #60a5fa; }
.role-school_admin { background: rgba(16,185,129,0.15); color: #34d399; }
.role-user { background: var(--bg-elevated); color: var(--text-muted); }
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.status-active { background: rgba(16,185,129,0.15); color: var(--success); }
.status-disabled { background: rgba(239,68,68,0.1); color: var(--danger); }
.action-btns { display: flex; gap: 6px; }
.btn-sm { padding: 4px 10px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; border: 1px solid transparent; font-family: var(--font-sans); }
.btn-primary { background: var(--accent-dim); border-color: var(--border-accent); color: var(--accent); }
.btn-primary:hover { background: var(--accent); color: #fff; }
.btn-danger { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.3); color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-success { background: rgba(16,185,129,0.1); border-color: rgba(16,185,129,0.3); color: var(--success); }
.btn-success:hover { background: var(--success); color: #fff; }
.pagination { display: flex; align-items: center; gap: 12px; margin-top: 16px; justify-content: flex-end; }
.page-btn { padding: 6px 14px; border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-elevated); color: var(--text-secondary); font-size: 13px; cursor: pointer; }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-info { font-size: 13px; color: var(--text-muted); }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 500; display: flex; align-items: center; justify-content: center; }
.modal { background: var(--bg-surface); border: 1px solid var(--border-default); border-radius: var(--radius-lg); width: 480px; max-width: 92vw; max-height: 85vh; display: flex; flex-direction: column; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 14px; border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; }
.modal-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.modal-close { background: none; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 4px; }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
.form-row { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.form-select {
padding: 8px 12px; border: 1px solid var(--border-default); border-radius: var(--radius-md);
background: var(--bg-elevated); color: var(--text-primary); font-size: 13px; outline: none;
}
.form-select:focus { border-color: var(--accent); }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border-subtle); flex-shrink: 0; }
.btn { padding: 7px 18px; border-radius: var(--radius-md); font-size: 13px; cursor: pointer; border: 1px solid transparent; font-family: var(--font-sans); }
.btn-ghost { background: var(--bg-elevated); border-color: var(--border-default); color: var(--text-secondary); }
.btn-ghost:hover { border-color: var(--border-strong); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* 多选标签区 */
.multi-toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.multi-search {
flex: 1;
padding: 5px 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-elevated);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.multi-search:focus { border-color: var(--accent); }
.multi-btn {
padding: 4px 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-secondary);
font-size: 11px;
cursor: pointer;
white-space: nowrap;
font-family: var(--font-sans);
}
.multi-btn:hover { border-color: var(--accent); color: var(--accent); }
.multi-select-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-elevated);
max-height: 180px;
overflow-y: auto;
}
.multi-option {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
cursor: pointer;
border: 1px solid var(--border-default);
color: var(--text-secondary);
background: var(--bg-surface);
transition: all 0.15s;
user-select: none;
}
.multi-option:hover { border-color: var(--accent); color: var(--accent); }
.multi-option.selected { background: var(--accent-dim); border-color: var(--border-accent); color: var(--accent); font-weight: 500; }
.multi-empty { font-size: 12px; color: var(--text-muted); padding: 4px; }
.selected-hint { font-size: 11px; color: var(--text-muted); line-height: 1.5; }
</style>