feat(web): role-aware UI (badge, selector, toggle button, hide admin-only sections for members)

This commit is contained in:
saopig1 committed 2026-05-27 15:38:42 +08:00
1 parent a73f797bcb
commit 780726a4e3
3 files changed
+76 -7

No files matched your search

+10
View File
@@ -91,6 +91,9 @@
<div v-if="session.currentUser.value" class="nav-user"> <div v-if="session.currentUser.value" class="nav-user">
<span class="nav-user-name">{{ session.currentUser.value.username }}</span> <span class="nav-user-name">{{ session.currentUser.value.username }}</span>
<span class="nav-user-role" :class="`role-${session.currentUser.value.role}`">
{{ session.currentUser.value.role === 'admin' ? '管理员' : '成员' }}
</span>
<button class="nav-user-logout" @click="onLogout" title="退出"> <button class="nav-user-logout" @click="onLogout" title="退出">
<Icon icon="mdi:logout" /> <Icon icon="mdi:logout" />
</button> </button>
@@ -670,4 +673,11 @@ onUnmounted(() => {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
&:hover { background: var(--bg-secondary); color: var(--text-primary); } &:hover { background: var(--bg-secondary); color: var(--text-primary); }
} }
.nav-user-role {
font-size: 11px; padding: 2px 6px; border-radius: 4px;
font-weight: 500;
}
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
</style> </style>
+2
View File
@@ -3,6 +3,7 @@ import { ref, computed, readonly } from "vue";
interface User { interface User {
id: string; id: string;
username: string; username: string;
role: 'admin' | 'member';
} }
const currentUser = ref<User | null>(null); const currentUser = ref<User | null>(null);
@@ -75,6 +76,7 @@ export function useSession() {
currentUser: readonly(currentUser), currentUser: readonly(currentUser),
needsSetup: readonly(needsSetup), needsSetup: readonly(needsSetup),
isAuthenticated: computed(() => currentUser.value !== null), isAuthenticated: computed(() => currentUser.value !== null),
isAdmin: computed(() => currentUser.value?.role === 'admin'),
ready: readonly(ready), ready: readonly(ready),
refresh, refresh,
login, login,
+64 -7
View File
@@ -460,13 +460,16 @@
</section> </section>
<!-- User Management --> <!-- User Management -->
<section class="settings-section"> <section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">用户管理</h2> <h2 class="section-title">用户管理</h2>
<div class="user-list"> <div class="user-list">
<div v-for="u in userList" :key="u.id" class="user-item"> <div v-for="u in userList" :key="u.id" class="user-item">
<div class="user-info"> <div class="user-info">
<div class="user-name"> <div class="user-name">
{{ u.username }} {{ u.username }}
<span class="user-role-badge" :class="`role-${u.role}`">
{{ u.role === 'admin' ? '管理员' : '成员' }}
</span>
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span> <span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span>
</div> </div>
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div> <div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
@@ -475,10 +478,19 @@
<button class="btn-sm" @click="openResetPassword(u)"> <button class="btn-sm" @click="openResetPassword(u)">
<Icon icon="mdi:lock-reset" /> 重置密码 <Icon icon="mdi:lock-reset" /> 重置密码
</button> </button>
<button
class="btn-sm"
:disabled="changingRoleId === u.id || isLastAdmin(u)"
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')"
@click="onToggleRole(u)"
>
<Icon icon="mdi:account-cog" />
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }}
</button>
<button <button
class="btn-sm btn-delete" class="btn-sm btn-delete"
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id)" :disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)"
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : ''" :title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')"
@click="onDeleteUser(u)" @click="onDeleteUser(u)"
> >
<Icon icon="mdi:delete" /> <Icon icon="mdi:delete" />
@@ -492,6 +504,10 @@
<form class="user-add-form" @submit.prevent="onCreateUser"> <form class="user-add-form" @submit.prevent="onCreateUser">
<input v-model="newUser.username" class="input" placeholder="新用户名 (3-32 字符)" required /> <input v-model="newUser.username" class="input" placeholder="新用户名 (3-32 字符)" required />
<input v-model="newUser.password" type="password" class="input" placeholder="密码 (≥8 位)" minlength="8" required /> <input v-model="newUser.password" type="password" class="input" placeholder="密码 (≥8 位)" minlength="8" required />
<select v-model="newUser.role" class="input user-role-select">
<option value="member">成员</option>
<option value="admin">管理员</option>
</select>
<button class="btn-sm btn-primary" type="submit" :disabled="creatingUser"> <button class="btn-sm btn-primary" type="submit" :disabled="creatingUser">
{{ creatingUser ? '创建中…' : '添加用户' }} {{ creatingUser ? '创建中…' : '添加用户' }}
</button> </button>
@@ -519,7 +535,7 @@
</section> </section>
<!-- Audit Log --> <!-- Audit Log -->
<section class="settings-section"> <section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title"> <h2 class="section-title">
操作审计 操作审计
<button class="audit-refresh-btn" @click="loadAudit" :disabled="auditLoading" title="刷新"> <button class="audit-refresh-btn" @click="loadAudit" :disabled="auditLoading" title="刷新">
@@ -923,16 +939,46 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo
// --- User Management --- // --- User Management ---
const session = useSession(); const session = useSession();
interface UserListEntry { id: string; username: string; createdAt: number } interface UserListEntry { id: string; username: string; createdAt: number; role: 'admin' | 'member' }
const userList = ref<UserListEntry[]>([]); const userList = ref<UserListEntry[]>([]);
const userLoadError = ref(''); const userLoadError = ref('');
const userMutationError = ref(''); const userMutationError = ref('');
const newUser = reactive({ username: '', password: '' }); const newUser = reactive({ username: '', password: '', role: 'member' as 'admin' | 'member' });
const creatingUser = ref(false); const creatingUser = ref(false);
const resetTarget = ref<UserListEntry | null>(null); const resetTarget = ref<UserListEntry | null>(null);
const resetPassword = ref(''); const resetPassword = ref('');
const resetError = ref(''); const resetError = ref('');
const resettingPw = ref(false); const resettingPw = ref(false);
const changingRoleId = ref<string | null>(null);
function isLastAdmin(u: UserListEntry): boolean {
if (u.role !== 'admin') return false;
const adminCount = userList.value.filter((x) => x.role === 'admin').length;
return adminCount <= 1;
}
async function onToggleRole(u: UserListEntry) {
const newRole = u.role === 'admin' ? 'member' : 'admin';
if (!confirm(`确认将 ${u.username} 切换为${newRole === 'admin' ? '管理员' : '成员'}?`)) return;
userMutationError.value = '';
changingRoleId.value = u.id;
try {
const res = await fetch(`/api/users/${u.id}/role`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
});
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
await loadUsers();
} catch (e) {
userMutationError.value = (e as Error).message;
} finally {
changingRoleId.value = null;
}
}
async function loadUsers() { async function loadUsers() {
userLoadError.value = ''; userLoadError.value = '';
@@ -953,7 +999,7 @@ async function onCreateUser() {
const res = await fetch('/api/users', { const res = await fetch('/api/users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: newUser.username, password: newUser.password }), body: JSON.stringify({ username: newUser.username, password: newUser.password, role: newUser.role }),
}); });
if (!res.ok) { if (!res.ok) {
const b = await res.json().catch(() => ({})); const b = await res.json().catch(() => ({}));
@@ -961,6 +1007,7 @@ async function onCreateUser() {
} }
newUser.username = ''; newUser.username = '';
newUser.password = ''; newUser.password = '';
newUser.role = 'member';
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
userMutationError.value = (e as Error).message; userMutationError.value = (e as Error).message;
@@ -1080,8 +1127,10 @@ onMounted(() => {
checkAuthStatus(); checkAuthStatus();
loadQuality(); loadQuality();
loadIdleTimeout(); loadIdleTimeout();
if (session.isAdmin.value) {
loadUsers(); loadUsers();
loadAudit(); loadAudit();
}
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -1776,4 +1825,12 @@ onUnmounted(() => {
gap: 4px; gap: 4px;
} }
} }
.user-role-badge {
font-size: 11px; padding: 2px 6px; border-radius: 4px; margin-left: 6px;
font-weight: 500;
}
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
.user-role-select { flex: 0 0 110px; }
</style> </style>