feat: 用户管理(xionglin 为管理员)——服务端角色字段与 /api/admin/users 增删改查、客户端管理员专属入口
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { showToast } from '../lib/toast'
|
||||
import { getSyncBase, getToken, setToken } from '../lib/server'
|
||||
import { getCloudCreds } from '../db/auth'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
role: string
|
||||
created_at: string
|
||||
data_bytes: number
|
||||
}
|
||||
|
||||
const users = ref<AdminUser[]>([])
|
||||
const meId = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
|
||||
// ---- 请求辅助:携带令牌;401 自动用云端凭据续期一次 ----
|
||||
async function request(path: string, body?: unknown): Promise<{ ok: boolean; status: number; data: any }> {
|
||||
const base = await getSyncBase()
|
||||
let token = getToken()
|
||||
if (!token) {
|
||||
const creds = getCloudCreds()
|
||||
const lr = await fetch(base + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: creds?.username, password: creds?.password })
|
||||
})
|
||||
if (lr.ok) {
|
||||
const j = await lr.json()
|
||||
token = String(j.token || '')
|
||||
setToken(token)
|
||||
}
|
||||
}
|
||||
const r = await fetch(base + path, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
if (r.status === 401 && (getCloudCreds())) {
|
||||
const creds = getCloudCreds()
|
||||
const lr = await fetch(base + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: creds?.username, password: creds?.password })
|
||||
})
|
||||
if (lr.ok) {
|
||||
const j = await lr.json()
|
||||
token = String(j.token || '')
|
||||
setToken(token)
|
||||
const r2 = await fetch(base + path, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
return { ok: r2.ok, status: r2.status, data: await r2.json().catch(() => ({})) }
|
||||
}
|
||||
}
|
||||
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) }
|
||||
}
|
||||
|
||||
// ---- 列表 ----
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const res = await request('/api/admin/users')
|
||||
if (!res.ok) {
|
||||
loading.value = false
|
||||
error.value = (res.data && res.data.error) || ('加载失败 ' + res.status)
|
||||
return
|
||||
}
|
||||
users.value = res.data.users || []
|
||||
meId.value = res.data.me || ''
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n <= 0) return '--'
|
||||
if (n < 1024) return n + ' B'
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||
return (n / 1024 / 1024).toFixed(2) + ' MB'
|
||||
}
|
||||
function fmtDate(s: string): string {
|
||||
return (s || '').slice(0, 10)
|
||||
}
|
||||
|
||||
// ---- 新增用户 ----
|
||||
const createOpen = ref(false)
|
||||
const newName = ref('')
|
||||
const newPwd = ref('')
|
||||
const newPwd2 = ref('')
|
||||
const createError = ref('')
|
||||
const creating = ref(false)
|
||||
function openCreate() {
|
||||
createOpen.value = true
|
||||
createError.value = ''
|
||||
newName.value = ''
|
||||
newPwd.value = ''
|
||||
newPwd2.value = ''
|
||||
}
|
||||
async function doCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (newPwd.value !== newPwd2.value) { createError.value = '两次输入的密码不一致'; return }
|
||||
if (newPwd.value.length < 6) { createError.value = '密码至少 6 位'; return }
|
||||
creating.value = true
|
||||
const res = await request('/api/admin/users/create', { username: name, password: newPwd.value })
|
||||
creating.value = false
|
||||
if (!res.ok) { createError.value = (res.data && res.data.error) || ('创建失败 ' + res.status); return }
|
||||
showToast('用户「' + name + '」已创建')
|
||||
createOpen.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
// ---- 重置密码 ----
|
||||
const resetTarget = ref<AdminUser | null>(null)
|
||||
const resetPwd = ref('')
|
||||
const resetError = ref('')
|
||||
const resetting = ref(false)
|
||||
function openReset(u: AdminUser) {
|
||||
resetTarget.value = u
|
||||
resetPwd.value = ''
|
||||
resetError.value = ''
|
||||
}
|
||||
async function doReset() {
|
||||
if (!resetTarget.value) return
|
||||
if (resetPwd.value.length < 6) { resetError.value = '密码至少 6 位'; return }
|
||||
resetting.value = true
|
||||
const res = await request('/api/admin/users/password', { userId: resetTarget.value.id, newPassword: resetPwd.value })
|
||||
resetting.value = false
|
||||
if (!res.ok) { resetError.value = (res.data && res.data.error) || ('重置失败 ' + res.status); return }
|
||||
showToast('已重置「' + resetTarget.value.username + '」的登录密码')
|
||||
resetTarget.value = null
|
||||
}
|
||||
|
||||
// ---- 删除用户 ----
|
||||
const deleteTarget = ref<AdminUser | null>(null)
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
const res = await request('/api/admin/users/delete', { userId: deleteTarget.value.id })
|
||||
if (!res.ok) { deleteTarget.value = null; showToast((res.data && res.data.error) || '删除失败') ; return }
|
||||
showToast('已删除账号「' + deleteTarget.value.username + '」及其全部云端数据')
|
||||
deleteTarget.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<div class="card sec">
|
||||
<div class="toolbar">
|
||||
<div class="head-wrap">
|
||||
<div class="sec-title">用户管理</div>
|
||||
<div class="muted head-sub">云端账号统一管理 · 管理员:xionglin</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreate">
|
||||
<Icon name="plus" :size="14" />
|
||||
新增用户
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card table-card admin-table-card">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th><th>角色</th><th>创建时间</th><th>数据量</th><th class="th-ops">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td class="td-name" data-label="用户名">{{ u.username }}</td>
|
||||
<td data-label="角色">
|
||||
<span v-if="u.role === 'admin'" class="role-badge">管理员</span>
|
||||
<span v-else class="muted">普通用户</span>
|
||||
</td>
|
||||
<td class="muted num" data-label="创建时间">{{ fmtDate(u.created_at) }}</td>
|
||||
<td class="muted num" data-label="数据量">{{ fmtBytes(u.data_bytes) }}</td>
|
||||
<td class="td-ops">
|
||||
<div class="ops">
|
||||
<button class="btn btn-xs" @click="openReset(u)">重置密码</button>
|
||||
<button class="btn btn-xs btn-danger" :disabled="u.id === meId" @click="deleteTarget = u">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !users.length">
|
||||
<td colspan="5" class="empty muted">加载结果为空</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loading" class="muted load-line">加载中</div>
|
||||
</div>
|
||||
|
||||
<div class="muted admin-note">重置密码立即生效;删除账号将移除其全部云端数据,不可恢复。自己的账号不能删除。</div>
|
||||
|
||||
<!-- 新增用户弹窗 -->
|
||||
<teleport to="body">
|
||||
<div v-if="createOpen" class="fixed-mask" @click.self="createOpen = false">
|
||||
<div class="dialog create-dialog" role="dialog" aria-label="新增用户">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">新增用户</span>
|
||||
<button class="icon-btn" @click="createOpen = false"><Icon name="close" :size="16" /></button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-row">
|
||||
<label>用户名(2 到 20 个字符)</label>
|
||||
<input class="input" v-model="newName" placeholder="如:xiaowang" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>初始登录密码(至少 6 位)</label>
|
||||
<input class="input" type="password" v-model="newPwd" placeholder="转发给使用者,登录后可自行修改" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>确认密码</label>
|
||||
<input class="input" type="password" v-model="newPwd2" @keydown.enter="doCreate" />
|
||||
</div>
|
||||
<div v-if="createError" class="form-error">{{ createError }}</div>
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="createOpen = false">取消</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="creating || !newName.trim()" @click="doCreate">{{ creating ? '创建中' : '创建' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<teleport to="body">
|
||||
<div v-if="resetTarget" class="fixed-mask" @click.self="resetTarget = null">
|
||||
<div class="dialog create-dialog" role="dialog" aria-label="重置密码">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">重置「{{ resetTarget.username }}」的登录密码</span>
|
||||
<button class="icon-btn" @click="resetTarget = null"><Icon name="close" :size="16" /></button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-row">
|
||||
<label>新登录密码(至少 6 位)</label>
|
||||
<input class="input" type="password" v-model="resetPwd" @keydown.enter="doReset" />
|
||||
</div>
|
||||
<div v-if="resetError" class="form-error">{{ resetError }}</div>
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="resetTarget = null">取消</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="resetting" @click="doReset">{{ resetting ? '提交中' : '重置' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!deleteTarget"
|
||||
title="删除用户确认"
|
||||
:message="deleteTarget ? '将永久删除云端账号「' + deleteTarget.username + '」及其服务器上的全部数据,不可恢复。请确认。' : ''"
|
||||
confirm-text="永久删除" danger
|
||||
@cancel="deleteTarget = null"
|
||||
@confirm="doDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page { display: flex; flex-direction: column; gap: 12px; max-width: 860px; }
|
||||
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; }
|
||||
.head-wrap { flex: 1; min-width: 0; }
|
||||
.sec-title { font-weight: 600; font-size: 13px; }
|
||||
.head-sub { font-size: 12px; margin-top: 2px; }
|
||||
.sec { padding: 14px 16px; }
|
||||
|
||||
.admin-table-card { overflow-x: auto; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.admin-table th { text-align: left; font-weight: 500; color: var(--text-3); font-size: 12px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.admin-table td:last-child { border-bottom: none; }
|
||||
.td-name { font-weight: 500; }
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.load-line { padding: 12px; font-size: 12px; }
|
||||
.admin-note { font-size: 12px; padding: 0 2px; }
|
||||
.ops { display: inline-flex; gap: 6px; }
|
||||
.btn-danger { color: var(--danger); }
|
||||
|
||||
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
|
||||
.form-row label { font-size: 12px; color: var(--text-2); }
|
||||
.fixed-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgba(29, 33, 41, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.dialog { background: var(--bg-page); border-radius: 12px; width: 420px; max-width: 100%; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0; }
|
||||
.dialog-title { font-weight: 600; font-size: 14px; }
|
||||
.dialog-body { padding: 6px 16px 4px; }
|
||||
.dialog-btns { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 16px 16px; }
|
||||
.btn-danger { color: var(--danger); }
|
||||
|
||||
.empty { text-align: center; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.toolbar { flex-wrap: wrap; row-gap: 8px; }
|
||||
.admin-table td, .admin-table th { white-space: normal; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user