Files
ONE/frontend/src/admin/UsersView.vue
T
Sakurasan 4bb2ff4145 多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内,
  首次登录把 env / settings 引导凭据自迁移成行哈希
- 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库,
  改角色 / 停用账号即时生效(存量会话立即 401)
- 登录:先查 users 表,再走 settings 哈希 / env 引导链;
  admin/admin 开发模式在任何账号设过密码后失效
- 权限:系统设置、用户管理仅 owner;内容管理 admin+owner;
  admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除),
  设置页「登录与存储」tab 对管理员隐藏
- 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码);
  评论区管理员身份跟随各自账号,不再统一挂站主名下
- 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
2026-10-01 22:35:37 +08:00

147 lines
5.4 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.
<script setup>
import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi, session } from '../api'
import { formatDateShort } from '../utils'
// 用户管理(站主专属):创建内容管理员(admin 角色)、重置密码、停用 / 删除。
// owner 行(站主本人)只读展示——它是「谁登录算站主」的锚点,密码走账户页改。
const users = ref([])
const loading = ref(true)
const error = ref('')
const creating = ref(false)
const form = ref({ username: '', password: '' })
async function load() {
loading.value = true
error.value = ''
try {
const data = await adminApi.users()
users.value = data.users || []
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
async function create() {
if (creating.value) return
creating.value = true
try {
await adminApi.createUser({ username: form.value.username.trim(), password: form.value.password })
toastOk(`已创建 ${form.value.username.trim()}`)
form.value = { username: '', password: '' }
await load()
} catch (e) {
toastErr(e.message || '创建失败')
} finally {
creating.value = false
}
}
async function resetPassword(u) {
const pw = window.prompt(`给「${u.handle}」设置新密码(6-72 位):`)
if (!pw) return
try {
await adminApi.patchUser(u.id, { password: pw })
toastOk('密码已重置')
} catch (e) {
toastErr(e.message || '重置失败')
}
}
async function toggleBan(u) {
if (u.banned) {
try {
await adminApi.patchUser(u.id, { banned: false })
toastOk(`已恢复「${u.handle}」`)
await load()
} catch (e) {
toastErr(e.message || '操作失败')
}
return
}
if (!window.confirm(`停用「${u.handle}」?停用后其立即无法登录,存量会话同步失效。`)) return
try {
await adminApi.patchUser(u.id, { banned: true })
toastOk('已停用')
await load()
} catch (e) {
toastErr(e.message || '操作失败')
}
}
async function remove(u) {
if (!window.confirm(`删除「${u.handle}」?其发布的评论保留,但无法再登录。`)) return
try {
await adminApi.deleteUser(u.id)
toastOk('已删除')
await load()
} catch (e) {
toastErr(e.message || '删除失败')
}
}
const roleLabel = (r) => (r === 'owner' ? '站主' : '管理员')
</script>
<template>
<section v-if="loading" class="loading">载入中…</section>
<section v-else-if="error" class="empty">{{ error }}</section>
<section v-else>
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 14px;">用户</h1>
<div class="panel">
<div class="panel-title"><h2>添加内容管理员</h2></div>
<form style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;" @submit.prevent="create">
<div class="field" style="margin: 0; flex: 1; min-width: 160px;">
<label>用户名(2-32 位,字母 / 数字 / _ - . @)</label>
<input v-model="form.username" class="input" spellcheck="false" autocomplete="off" placeholder="editor" />
</div>
<div class="field" style="margin: 0; flex: 1; min-width: 160px;">
<label>初始密码(6-72 位)</label>
<input v-model="form.password" class="input" type="password" autocomplete="new-password" placeholder="至少 6 位" />
</div>
<button class="btn btn-primary" type="submit" :disabled="creating">添加</button>
</form>
<p class="field-hint" style="margin-top: 10px;">
管理员可以写文章、管理评论与文件;系统设置、用户管理与第三方凭据只有你能动。
他们自己的密码在「账户」页修改。
</p>
</div>
<div class="panel" style="margin-top: 18px;">
<div class="panel-title"><h2>后台账号</h2></div>
<div class="recent-list">
<div v-for="u in users" :key="u.id" class="recent-item" style="gap: 14px;">
<span class="title" style="font-weight: 700;">{{ u.handle }}</span>
<span class="meta" style="font-size: 12px;">{{ roleLabel(u.role) }}</span>
<span v-if="u.banned" class="meta" style="color: #b4553f;">已停用</span>
<span class="meta" style="margin-left: auto; white-space: normal; text-align: right;">
{{ formatDateShort(u.created_at) }}
</span>
<span v-if="u.role !== 'owner'" style="display: flex; gap: 4px; flex: none;">
<button class="btn" style="font-size: 12px; padding: 4px 10px;" type="button" @click="resetPassword(u)">重置密码</button>
<button class="btn" style="font-size: 12px; padding: 4px 10px;" type="button" @click="toggleBan(u)">
{{ u.banned ? '恢复' : '停用' }}
</button>
<button v-if="u.id !== 0" class="btn danger" style="font-size: 12px; padding: 4px 10px;" type="button" @click="remove(u)">删除</button>
</span>
<span v-else class="meta" style="flex: none;">站主本人 · 密码在账户页修改</span>
</div>
</div>
<p class="field-hint" style="margin-top: 12px;">
当前登录:{{ session.user }}({{ roleLabel(session.role) }})。
停用或删除后,对方的存量登录会话立即失效。
</p>
</div>
</section>
</template>
<style scoped>
.btn.danger {color: #b4553f;}
</style>