feat(perm): admin permission editor in user management

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-05-30 14:03:54 +08:00
1 parent 5177b41951
commit d70664067c
1 file changed
+166 -1
+166 -1
View File
@@ -492,7 +492,8 @@
<section v-if="session.isAdmin.value" 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-row-wrap">
<div class="user-item">
<div class="user-info"> <div class="user-info">
<div class="user-name"> <div class="user-name">
{{ u.username }} {{ u.username }}
@@ -504,6 +505,15 @@
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div> <div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
</div> </div>
<div class="user-actions"> <div class="user-actions">
<span v-if="u.role === 'admin'" class="perm-admin-label">全部权限(管理员)</span>
<button
v-else
class="btn-sm"
:class="{ 'btn-primary': permEditingId === u.id }"
@click="onTogglePermEditor(u)"
>
<Icon icon="mdi:shield-key" /> 权限
</button>
<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>
@@ -526,6 +536,54 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Inline permission editor (members only) -->
<div v-if="permEditingId === u.id" class="perm-editor">
<div v-if="permLoading" class="user-empty">加载权限中…</div>
<template v-else>
<div class="perm-group">
<div class="perm-group-title">能力</div>
<div class="perm-checks">
<label v-for="cap in CAPABILITIES" :key="cap.token" class="perm-check">
<input
type="checkbox"
:checked="permDraft.capabilities.includes(cap.token)"
@change="toggleCapability(cap.token, ($event.target as HTMLInputElement).checked)"
/>
{{ cap.label }}
</label>
</div>
</div>
<div class="perm-group">
<div class="perm-group-title">机器人</div>
<label class="perm-check">
<input type="checkbox" v-model="permDraft.botsAll" />
全部机器人
</label>
<div v-if="!permDraft.botsAll" class="perm-checks perm-bots">
<label v-for="bot in store.bots" :key="bot.id" class="perm-check">
<input
type="checkbox"
:checked="permDraft.selectedBotIds.includes(bot.id)"
@change="toggleBotSelection(bot.id, ($event.target as HTMLInputElement).checked)"
/>
{{ bot.name }}
</label>
<span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
</div>
</div>
<p v-if="permError" class="user-error">{{ permError }}</p>
<div class="form-actions">
<button class="btn-sm" @click="permEditingId = null">取消</button>
<button class="btn-sm btn-primary" :disabled="permSaving" @click="onSavePermissions(u)">
{{ permSaving ? '保存中…' : '保存' }}
</button>
</div>
</template>
</div>
</div>
<div v-if="userList.length === 0 && !userLoadError" class="user-empty">加载中…</div> <div v-if="userList.length === 0 && !userLoadError" class="user-empty">加载中…</div>
<div v-if="userLoadError" class="user-error">{{ userLoadError }}</div> <div v-if="userLoadError" class="user-error">{{ userLoadError }}</div>
</div> </div>
@@ -1133,6 +1191,91 @@ async function onConfirmReset() {
} }
} }
// --- Per-user permission editor (members only) ---
const CAPABILITIES: { token: string; label: string }[] = [
{ token: 'player.control', label: '播放控制' },
{ token: 'player.queue', label: '队列管理' },
{ token: 'bot.manage', label: '机器人管理' },
{ token: 'platform.auth', label: '平台登录凭据' },
{ token: 'quality', label: '音质设置' },
];
const permEditingId = ref<string | null>(null);
const permLoading = ref(false);
const permSaving = ref(false);
const permError = ref('');
const permDraft = reactive<{ capabilities: string[]; botsAll: boolean; selectedBotIds: string[] }>({
capabilities: [],
botsAll: true,
selectedBotIds: [],
});
async function onTogglePermEditor(u: UserListEntry) {
if (permEditingId.value === u.id) {
permEditingId.value = null;
return;
}
permEditingId.value = u.id;
permError.value = '';
permLoading.value = true;
permDraft.capabilities = [];
permDraft.botsAll = true;
permDraft.selectedBotIds = [];
try {
const res = await fetch(`/api/users/${u.id}/permissions`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
permDraft.capabilities = Array.isArray(body.capabilities) ? [...body.capabilities] : [];
if (body.bots === 'all') {
permDraft.botsAll = true;
permDraft.selectedBotIds = [];
} else {
permDraft.botsAll = false;
permDraft.selectedBotIds = Array.isArray(body.bots) ? [...body.bots] : [];
}
} catch (e) {
permError.value = (e as Error).message;
} finally {
permLoading.value = false;
}
}
function toggleCapability(token: string, checked: boolean) {
const has = permDraft.capabilities.includes(token);
if (checked && !has) permDraft.capabilities.push(token);
else if (!checked && has) permDraft.capabilities = permDraft.capabilities.filter((t) => t !== token);
}
function toggleBotSelection(id: string, checked: boolean) {
const has = permDraft.selectedBotIds.includes(id);
if (checked && !has) permDraft.selectedBotIds.push(id);
else if (!checked && has) permDraft.selectedBotIds = permDraft.selectedBotIds.filter((b) => b !== id);
}
async function onSavePermissions(u: UserListEntry) {
permSaving.value = true;
permError.value = '';
try {
const res = await fetch(`/api/users/${u.id}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
capabilities: [...permDraft.capabilities],
bots: permDraft.botsAll ? 'all' : [...permDraft.selectedBotIds],
}),
});
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
permEditingId.value = null;
} catch (e) {
permError.value = (e as Error).message;
} finally {
permSaving.value = false;
}
}
function formatDate(ms: number): string { function formatDate(ms: number): string {
const d = new Date(ms); const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -1905,6 +2048,28 @@ onUnmounted(() => {
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); } .role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
.user-role-select { flex: 0 0 110px; } .user-role-select { flex: 0 0 110px; }
.user-row-wrap { display: flex; flex-direction: column; gap: 0; }
.perm-admin-label { font-size: 12px; color: var(--text-secondary); align-self: center; }
.perm-editor {
margin-top: -2px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 12px;
}
.perm-group { display: flex; flex-direction: column; gap: 8px; }
.perm-group-title { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.perm-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.perm-bots { padding-left: 16px; }
.perm-check {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary); cursor: pointer;
}
.perm-check input { cursor: pointer; }
// --- Account section (own password change) --- // --- Account section (own password change) ---
.account-info-card { .account-info-card {
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;