Files
ONE/frontend/src/admin/AccountView.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

409 lines
13 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 { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { toastOk, toastErr } from './toast'
import { isSupported as waSupported, register as waRegister } from '../webauthn'
// 账户页:我是谁(头像 / 昵称 / 简介)+ 我能用什么方式登录
// (密码走环境变量、第三方身份绑定、passkey)。
//
// 和「设置」的分工是刻意的:站点的事在设置,账号的事在这里。
// 作者昵称/简介以前在设置里,已移过来 —— 同一字段两处编辑迟早漂移。
const acct = ref(null)
const loading = ref(true)
const saving = ref(false)
const uploading = ref(false)
const regBusy = ref(false)
const fileInput = ref(null)
// 修改登录密码(owner / admin 都在这里改;密码存在自己的账号行上)
const pw = ref({ old: '', next: '', confirm: '' })
const pwBusy = ref(false)
async function changePassword() {
if (pw.value.next !== pw.value.confirm) {
toastErr('两次输入的新密码不一致')
return
}
if (pw.value.next.length < 6) {
toastErr('新密码至少 6 位')
return
}
pwBusy.value = true
try {
await adminApi.changePassword({ old_password: pw.value.old, new_password: pw.value.next })
toastOk('密码已更新,下次登录用新密码')
pw.value = { old: '', next: '', confirm: '' }
} catch (e) {
toastErr(e.message || '修改失败')
} finally {
pwBusy.value = false
}
}
const route = useRoute()
const router = useRouter()
async function load() {
console.warn('ACCT: load() start')
loading.value = true
try {
acct.value = await adminApi.account()
console.warn('ACCT: loaded', !!acct.value)
} catch (e) {
toastErr(e.message || '读取账户信息失败')
} finally {
loading.value = false
}
}
// 绑定完成是从 provider 整页跳回来的,用 query 带平台名:
// 提示一次就把参数抹掉,免得刷新页面重复弹。
async function reportBindResult() {
const p = route.query.bound
if (typeof p !== 'string' || !p) return
toastOk(p === 'github' ? '已绑定 GitHub' : '已绑定 ' + p)
router.replace({ path: route.path })
}
async function save(patch) {
saving.value = true
try {
acct.value = await adminApi.saveAccount(patch)
toastOk('已保存')
} catch (e) {
toastErr(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function onPickAvatar(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许连续选同一个文件
if (!file) return
uploading.value = true
try {
const data = await adminApi.uploadFile(file)
const f = Array.isArray(data) ? data[0] : null
if (!f || !f.key) throw new Error('上传失败')
await save({ avatar_key: f.key })
} catch (e) {
toastErr(e.message || '头像上传失败')
} finally {
uploading.value = false
}
}
async function removeAvatar() {
await save({ avatar_key: '' })
}
function startBind(provider) {
// 整页跳转:OAuth 回来后由后端重定向回本页并带 ?bound=
window.location.assign('/api/auth/' + provider + '/bind')
}
async function unbind(provider) {
const label = provider === 'github' ? 'GitHub' : provider
if (!window.confirm('解绑 ' + label + '?之后就不能再用它登录后台了。')) return
try {
await adminApi.unbindIdentity(provider)
toastOk('已解绑')
await load()
} catch (e) {
toastErr(e.message || '解绑失败')
}
}
async function addPasskey() {
if (!waSupported()) {
toastErr('这个浏览器不支持 passkey')
return
}
const name = window.prompt('给这把凭据起个名字(如「MacBook 指纹」)', '')
if (name === null) return
regBusy.value = true
try {
const { options, token } = await adminApi.passkeyBegin()
const cred = await waRegister(options)
await adminApi.passkeyFinish({ token, name: name.trim(), credential: cred })
toastOk('passkey 已添加')
await load()
} catch (e) {
toastErr(e.message || 'passkey 注册失败')
} finally {
regBusy.value = false
}
}
async function removePasskey(pk) {
if (!window.confirm('删除 passkey「' + (pk.name || '未命名') + '」?')) return
try {
await adminApi.deletePasskey(pk.id)
toastOk('已删除')
await load()
} catch (e) {
toastErr(e.message || '删除失败')
}
}
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleDateString('zh-CN')
}
const providerLabel = { github: 'GitHub', google: 'Google', telegram: 'Telegram' }
onMounted(async () => {
await load()
reportBindResult()
})
</script>
<template>
<section v-if="loading" class="loading">载入中…</section>
<section v-else-if="acct">
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 4px;">账户</h1>
<p class="field-hint" style="margin-bottom: 20px;">
头像、昵称与登录方式。站点标题、皮肤这些站点级配置在
<router-link to="/admin/settings">设置</router-link>。
</p>
<!-- ---------- 资料 ---------- -->
<div class="panel">
<div class="panel-title"><h2>公开资料</h2></div>
<div class="field">
<label>头像</label>
<div class="avatar-row">
<span class="avatar-box">
<img v-if="acct.avatar_url" :src="acct.avatar_url" alt="" />
<span v-else class="avatar-ph">无</span>
</span>
<div class="avatar-acts">
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif" hidden @change="onPickAvatar" />
<button class="btn" :disabled="uploading" @click="fileInput.click()">
{{ uploading ? '上传中…' : (acct.avatar_key ? '换一个' : '上传头像') }}
</button>
<button v-if="acct.avatar_key" class="btn" :disabled="saving" @click="removeAvatar">移除</button>
</div>
</div>
<p class="field-hint">
头像会出现在时间线、文章页与关于页。没设置时那些位置显示站标。
</p>
</div>
<div class="field">
<label>昵称</label>
<input v-model="acct.name" class="input" maxlength="40" spellcheck="false" />
<p class="field-hint">评论区与文章署名用的名字。</p>
</div>
<div class="field">
<label>简介</label>
<textarea v-model="acct.bio" class="textarea" rows="2" maxlength="200" />
</div>
<div class="field">
<label>用户名(handle)</label>
<input :value="acct.handle" class="input" disabled />
<p class="field-hint">
后台账号的用户名即登录名:站主的在「设置 → 登录与存储」里改,
管理员账号由站主在「用户」页创建。
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save({ name: acct.name, bio: acct.bio })">
{{ saving ? '保存中…' : '保存资料' }}
</button>
</div>
<!-- ---------- 登录密码 ---------- -->
<div class="panel" style="margin-top: 18px;">
<div class="panel-title"><h2>登录密码</h2></div>
<div class="field">
<label>旧密码</label>
<input v-model="pw.old" class="input" type="password" autocomplete="current-password" placeholder="从未设过密码可不填" />
</div>
<div class="field">
<label>新密码(6-72 位)</label>
<input v-model="pw.next" class="input" type="password" autocomplete="new-password" />
</div>
<div class="field">
<label>确认新密码</label>
<input v-model="pw.confirm" class="input" type="password" autocomplete="new-password" />
</div>
<button class="btn btn-primary" :disabled="pwBusy" @click="changePassword">
{{ pwBusy ? '提交中…' : '更新密码' }}
</button>
</div>
<!-- ---------- 登录方式 ---------- -->
<div class="panel" style="margin-top: 18px;">
<div class="panel-title"><h2>登录方式</h2></div>
<div class="method">
<div class="method-head">
<b>密码</b>
<span class="tag on">始终可用</span>
</div>
<p class="field-hint">
站主密码由环境变量 <code>ONE_ADMIN_PASSWORD</code> 管理,不存在数据库里,
因此也就无法在这里被改掉或解绑。这条退路保证:哪怕解绑了所有第三方、
删光所有 passkey,你仍然能登录进来。
</p>
</div>
<div class="method">
<div class="method-head">
<b>第三方账号</b>
<span v-if="acct.identities.length" class="tag on">已绑定 {{ acct.identities.length }}</span>
<span v-else class="tag">未绑定</span>
</div>
<ul v-if="acct.identities.length" class="list">
<li v-for="it in acct.identities" :key="it.id">
<span>{{ providerLabel[it.provider] || it.provider }}</span>
<code>{{ it.display }}</code>
<span class="when">绑于 {{ fmtDate(it.created_at) }}</span>
<button class="btn btn-sm" @click="unbind(it.provider)">解绑</button>
</li>
</ul>
<p v-else class="field-hint">还没有绑定任何第三方账号。</p>
<div v-if="acct.providers.length" class="bind-acts">
<button
v-for="p in acct.providers"
:key="p"
class="btn btn-sm"
:disabled="acct.identities.some((i) => i.provider === p)"
@click="startBind(p)"
>
绑定 {{ providerLabel[p] || p }}
</button>
</div>
<p class="field-hint">
绑定后,用该账号 OAuth 登录会<b>直接进入后台</b>(等于站主身份)。
所以绑定动作必须在已登录后台时发起,且一个外部账号只能属于一个用户。
</p>
</div>
<div class="method">
<div class="method-head">
<b>Passkey</b>
<span v-if="acct.passkeys.length" class="tag on">{{ acct.passkeys.length }} 把</span>
<span v-else class="tag">未添加</span>
</div>
<ul v-if="acct.passkeys.length" class="list">
<li v-for="pk in acct.passkeys" :key="pk.id">
<span>{{ pk.name || '未命名设备' }}</span>
<span class="when">
加于 {{ fmtDate(pk.created_at) }}<template v-if="pk.last_used_at"> · 上次用 {{ fmtDate(pk.last_used_at) }}</template>
</span>
<button class="btn btn-sm" @click="removePasskey(pk)">删除</button>
</li>
</ul>
<button class="btn btn-sm" :disabled="regBusy" @click="addPasskey">
{{ regBusy ? '等待浏览器…' : '添加 passkey' }}
</button>
<p class="field-hint">
用设备指纹 / 面容 / 安全密钥登录,无需密码,也无需先输用户名。
<template v-if="!acct.passkeys.length && !acct.providers.length">
服务端需配置 <code>ONE_WEBAUTHN_ORIGINS</code> 才会开放此入口。
</template>
</p>
</div>
</div>
</section>
</template>
<style scoped>
.avatar-row {
display: flex;
align-items: center;
gap: 14px;
}
.avatar-box {
width: 64px;
height: 64px;
border-radius: 50%;
overflow: hidden;
background: var(--admin-sunken, #f3efe6);
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.avatar-box img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-ph {
font-size: 12px;
color: var(--admin-muted);
}
.avatar-acts {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.method {
padding: 14px 0;
border-top: 1px solid var(--admin-line, #e6e0d4);
}
.method:first-of-type {
border-top: 0;
padding-top: 0;
}
.method-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.tag {
font-size: 11px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--admin-line, #e6e0d4);
color: var(--admin-muted);
}
.tag.on {
border-color: var(--accent-line, rgba(61, 127, 156, 0.35));
color: var(--admin-accent, #3d7f9c);
}
.list {
list-style: none;
margin: 0 0 10px;
padding: 0;
}
.list li {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
font-size: 13.5px;
}
.list code {
font-size: 12px;
color: var(--admin-muted);
}
.when {
margin-left: auto;
font-size: 12px;
color: var(--admin-muted);
}
.bind-acts {
display: flex;
gap: 8px;
margin: 8px 0;
}
.btn-sm {
padding: 4px 12px;
font-size: 12.5px;
}
</style>