Files
Scoreboard/web/src/admin/components/system/UsersPanel.vue
T

450 lines
14 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 lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import MockBadge from './MockBadge.vue'
import { describeError, USER_ROLES, usersApi, type AdminUser, type AdminUserInput } from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
/**
* 用户管理(devdocs/08 第 2 节)。
*
* 用户接口尚未进共享 ApiClient,走后端就绪时走 /api/users,未就绪时降级示例数据。
* 角色只提供文档里定义的三档,避免现场出现「看着是管理员、其实没权限」的自造角色。
*/
const auth = useAuthStore()
const toast = useToastStore()
const rows = ref<AdminUser[]>([])
const loading = ref(false)
const loadError = ref<string | null>(null)
const usingMock = ref(false)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const form = ref<AdminUserInput>({ username: '', displayName: '', role: 'recorder', active: true, password: '' })
const saving = ref(false)
const formError = ref<string | null>(null)
const confirmDisable = ref(false)
const resetTarget = ref<AdminUser | null>(null)
const resetting = ref(false)
const revealed = ref<{ username: string; password: string } | null>(null)
const isAdmin = computed(() => auth.user?.role === 'admin')
const editorTitle = computed(() => (editingId.value === null ? '新建用户' : `编辑 ${form.value.username}`))
const roleLabel = (role: string): string => USER_ROLES.find((item) => item.value === role)?.label ?? role
async function load(): Promise<void> {
loading.value = true
loadError.value = null
try {
const result = await usersApi.list()
rows.value = result.data
usingMock.value = result.mock
} catch (caught) {
loadError.value = describeError(caught)
} finally {
loading.value = false
}
}
function openCreate(): void {
editingId.value = null
form.value = { username: '', displayName: '', role: 'recorder', active: true, password: '' }
formError.value = null
editorOpen.value = true
}
function openEdit(row: AdminUser): void {
editingId.value = row.id
form.value = {
username: row.username,
displayName: row.displayName,
role: row.role,
active: row.active,
password: '',
}
formError.value = null
editorOpen.value = true
}
function validate(): string | null {
if (form.value.username.trim() === '') return '账号不能为空'
if (form.value.displayName.trim() === '') return '显示名不能为空'
if (editingId.value === null && form.value.password.length < 8) return '新建用户必须设置至少 8 位的初始密码'
return null
}
async function save(): Promise<void> {
const message = validate()
if (message !== null) {
formError.value = message
return
}
// 停用是危险操作:现有会话可能还在现场跑,必须二次确认。
const current = rows.value.find((row) => row.id === editingId.value)
if (current !== undefined && current.active && !form.value.active) {
confirmDisable.value = true
return
}
await commitSave()
}
async function commitSave(): Promise<void> {
confirmDisable.value = false
saving.value = true
formError.value = null
try {
if (editingId.value === null) {
const created = await usersApi.create({ ...form.value, username: form.value.username.trim() })
usingMock.value = usingMock.value || created.mock
toast.success(`已新建用户 ${created.data.username}`, usingMock.value ? '示例数据' : null)
} else {
const updated = await usersApi.update(editingId.value, {
displayName: form.value.displayName.trim(),
role: form.value.role,
active: form.value.active,
})
usingMock.value = usingMock.value || updated.mock
toast.success(`已更新 ${updated.data.username}`, usingMock.value ? '示例数据' : null)
}
editorOpen.value = false
await load()
} catch (caught) {
formError.value = describeError(caught)
} finally {
saving.value = false
}
}
async function resetPassword(): Promise<void> {
const target = resetTarget.value
if (target === null) return
resetting.value = true
try {
const result = await usersApi.resetPassword(target.id)
usingMock.value = usingMock.value || result.mock
resetTarget.value = null
// 明文只在这一次响应里存在,关闭弹窗即丢弃,服务端也不会再返回。
revealed.value = { username: target.username, password: result.data.password }
await load()
} catch (caught) {
toast.error('重置密码失败', describeError(caught))
} finally {
resetting.value = false
}
}
async function copyPassword(): Promise<void> {
const value = revealed.value?.password
if (value === undefined) return
try {
await navigator.clipboard.writeText(value)
toast.success('已复制到剪贴板')
} catch {
toast.warning('浏览器拒绝访问剪贴板', '请手动选中复制')
}
}
onMounted(() => {
void load()
})
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">用户管理</span>
<div class="head-actions">
<MockBadge v-if="usingMock" />
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="loading" @click="load">
<MxIcon name="refresh" :size="14" /> 刷新
</button>
<button class="mx-btn mx-btn-primary mx-btn-sm" type="button" :disabled="!isAdmin" @click="openCreate">
<MxIcon name="user" :size="14" /> 新建用户
</button>
</div>
</div>
<div class="mx-card-body">
<p v-if="!isAdmin" class="notice">只有管理员能管理用户,当前账号为只读。</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<div v-if="loading" class="state-block">
<span class="mx-activity-dot"></span> 正在加载用户列表…
</div>
<div v-else-if="rows.length === 0" class="mx-empty">
<div class="mx-empty-title">还没有用户</div>
<div class="mx-empty-desc">首次启动会自动生成管理员账号,也可以在这里新建录入员</div>
</div>
<div v-else class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>账号</th>
<th>显示名</th>
<th>角色</th>
<th>状态</th>
<th>最近登录</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td class="mono">
{{ row.username }}
<span v-if="row.mustChangePassword" class="mx-tag mx-tag-warning mx-tag-sm">待改密</span>
</td>
<td>{{ row.displayName }}</td>
<td>{{ roleLabel(row.role) }}</td>
<td>
<span class="mx-badge" :class="row.active ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ row.active ? '启用' : '停用' }}
</span>
</td>
<td class="muted mono">{{ row.lastLoginAt === null ? '从未登录' : formatDateTime(row.lastLoginAt) }}</td>
<td class="right actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!isAdmin" @click="openEdit(row)">
编辑
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!isAdmin"
@click="resetTarget = row"
>
重置密码
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="editorOpen" class="editor">
<div class="editor-head">
<span class="mx-card-title">{{ editorTitle }}</span>
<button class="mx-modal-close" type="button" @click="editorOpen = false">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="form-grid">
<div class="field">
<label class="mx-label" for="user-username">账号</label>
<input
id="user-username"
v-model="form.username"
class="mx-input"
:disabled="editingId !== null"
placeholder="登录名,创建后不可改"
/>
</div>
<div class="field">
<label class="mx-label" for="user-display">显示名</label>
<input id="user-display" v-model="form.displayName" class="mx-input" placeholder="界面与审计日志里显示的名字" />
</div>
<div class="field">
<label class="mx-label" for="user-role">角色</label>
<div class="mx-input-wrap">
<select id="user-role" v-model="form.role" class="mx-input">
<option v-for="role in USER_ROLES" :key="role.value" :value="role.value">{{ role.label }}</option>
</select>
</div>
</div>
<div v-if="editingId === null" class="field">
<label class="mx-label" for="user-password">初始密码</label>
<input
id="user-password"
v-model="form.password"
class="mx-input"
type="text"
autocomplete="new-password"
placeholder="至少 8 位,首次登录强制修改"
/>
</div>
<div v-if="editingId !== null" class="field switch-field">
<span class="mx-label">启用</span>
<span class="switch-row">
<label class="mx-switch">
<input v-model="form.active" type="checkbox" />
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
</label>
<span class="switch-text">{{ form.active ? '可登录' : '停用后立即失效' }}</span>
</span>
</div>
</div>
<p class="notice">
角色只有「管理员 / 录入员」两种;未登录访客的只读身份由系统默认提供,不需要建账号。
</p>
<p v-if="formError !== null" class="notice error">{{ formError }}</p>
<div class="editor-foot">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="saving" @click="editorOpen = false">
取消
</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="saving" @click="save">
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
</div>
<ConfirmDialog
:open="resetTarget !== null"
tone="danger"
title="重置该用户密码?"
:message="`将为 ${resetTarget?.username ?? ''} 生成一个新的随机密码,旧密码立即失效,该用户的所有登录会话同时作废。`"
confirm-text="重置密码"
:pending="resetting"
@confirm="resetPassword"
@cancel="resetTarget = null"
/>
<ConfirmDialog
:open="confirmDisable"
tone="danger"
title="停用该用户?"
:message="`停用后 ${form.username} 将无法登录,正在使用的会话也会失效。`"
confirm-text="停用"
:pending="saving"
@confirm="commitSave"
@cancel="confirmDisable = false"
/>
<div class="mx-modal-overlay" :class="{ open: revealed !== null }" @click.self="revealed = null">
<div class="mx-modal mx-modal-sm" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">新密码(只显示一次)</span>
<button class="mx-modal-close" type="button" @click="revealed = null">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<p class="reveal-hint">请立即抄送给 {{ revealed?.username }},关闭后无法再次查看。</p>
<div class="reveal-value mono">{{ revealed?.password }}</div>
<button class="mx-btn mx-btn-secondary full" type="button" @click="copyPassword">复制密码</button>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-primary" type="button" @click="revealed = null">我已记下</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mx-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.right {
text-align: right;
}
.actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.muted {
color: var(--mx-t3);
font-size: 12px;
}
.notice {
font-size: 12px;
color: var(--mx-t3);
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
.state-block {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t3);
padding: 18px 0;
}
.editor {
border: 1px solid var(--mx-border);
border-radius: var(--mx-r-sm);
padding: 14px;
background: var(--mx-s2);
}
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.switch-field {
justify-content: flex-end;
}
.switch-row {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
}
.switch-text {
font-size: 12px;
color: var(--mx-t3);
}
.editor-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.reveal-hint {
font-size: 12px;
color: var(--mx-t3);
margin-bottom: 10px;
}
.reveal-value {
padding: 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
font-size: 16px;
letter-spacing: 1px;
text-align: center;
margin-bottom: 12px;
word-break: break-all;
}
.full {
width: 100%;
}
@media (max-width: 1023px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>