feat: session-cookie login/logout + three-tier roles (superadmin/admin/viewer) with read-only UI + remove pink theme

- cookie-based auth (/login /logout) replacing Basic Auth for UI, enabling logout
- roles: superadmin (account management only), admin (full except accounts), viewer/audit (read-only status+cluster, export logs)
- readonly accounts hide edit buttons (added remote node, group ops, canvas layout/save/import, cluster manage, install) instead of greying them
- auditors see status+cluster only; ordinary admins lose the accounts nav; last-admin guard covers superadmin
- remove pink theme entirely (switcher, [data-theme=pink], leftover localStorage), keep white/blue
This commit is contained in:
2026-08-20 09:08:07 +08:00
parent b39bd427fa
commit f29ec81e4a
27 changed files with 1195 additions and 724 deletions

View File

@ -62,13 +62,13 @@
<!-- Action toolbar -->
<div class="toolbar w4f-card">
<template v-if="!isMember">
<button class="w4f-btn" :disabled="busy" @click="onCreate">⊕ 创建集群</button>
<button class="w4f-btn ghost" :disabled="busy" @click="joinDialog.visible = true">↪ 加入集群…</button>
<button v-if="canWrite" class="w4f-btn" :disabled="busy" @click="onCreate">⊕ 创建集群</button>
<button v-if="canWrite" class="w4f-btn ghost" :disabled="busy" @click="joinDialog.visible = true">↪ 加入集群…</button>
<span class="act-hint" v-if="isStandalone && isLeader">本节点为初始 leader,已就绪;可直接创建,或输入对端地址加入既有集群</span>
<span class="act-hint" v-else-if="!selfInRing">本节点已脱离集群,可创建新集群或加入既有集群</span>
</template>
<template v-else>
<button class="w4f-btn danger" :disabled="busy" @click="onLeave">⏏ 退出集群</button>
<button v-if="canWrite" class="w4f-btn danger" :disabled="busy" @click="onLeave">⏏ 退出集群</button>
<span class="act-hint">退出后本节点仅保留 localOnly 转发;可重新创建/加入</span>
</template>
<button class="w4f-btn ghost small" style="margin-left: auto" :disabled="busy" @click="load">⟳ 刷新</button>
@ -92,7 +92,7 @@
<div class="w4f-bar"><i :style="{ width: Math.min(100, n.load.memPct + n.load.netPct) + '%' }" /></div>
</div>
<div class="rn-addr">{{ n.addr }}</div>
<button v-if="n.id !== ring.selfId && n.alive" class="w4f-btn danger small rn-remove" :disabled="busy" @click="onRemoveNode(n.id)">移除节点</button>
<button v-if="canWrite && n.id !== ring.selfId && n.alive" class="w4f-btn danger small rn-remove" :disabled="busy" @click="onRemoveNode(n.id)">移除节点</button>
</div>
<span v-if="i < ring.nodes.length - 1" class="ring-arrow">→</span>
</template>
@ -176,16 +176,16 @@
<el-dialog v-model="joinDialog.visible" title="加入集群" width="420px">
<div class="form-row">
<label>对端地址</label>
<el-input v-model="joinDialog.addr" placeholder="对端 IP:port(如 192.168.1.10:7500)" @keyup.enter="onJoin" />
<el-input v-model="joinDialog.addr" :disabled="!canWrite" placeholder="对端 IP:port(如 192.168.1.10:7500)" @keyup.enter="onJoin" />
</div>
<div class="form-row">
<label>加入密钥</label>
<el-input v-model="joinDialog.key" placeholder="对端节点的加入密钥(nodeKey)" @keyup.enter="onJoin" />
<el-input v-model="joinDialog.key" :disabled="!canWrite" placeholder="对端节点的加入密钥(nodeKey)" @keyup.enter="onJoin" />
</div>
<div class="form-hint">填入对端节点可被本机路由到达的真实 IP:port(或域名:port),本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。需提供对端节点的加入密钥以通过验证。</div>
<template #footer>
<button class="w4f-btn ghost small" @click="joinDialog.visible = false">取消</button>
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim() || !joinDialog.key.trim()" @click="onJoin">加入</button>
<button class="w4f-btn small" :disabled="busy || !canWrite || !joinDialog.addr.trim() || !joinDialog.key.trim()" @click="onJoin">加入</button>
</template>
</el-dialog>
</div>
@ -195,6 +195,7 @@
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { canWrite } from '../auth'
import type { RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
const ring = ref<RingSnapshot | null>(null)
@ -494,7 +495,7 @@ onBeforeUnmount(() => {
.ring-node { position: relative; border: 1px solid var(--w4f-line); border-radius: $radius-md; padding: 12px 14px; min-width: 168px;
background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm);
&.leader { border-color: var(--w4f-secondary); background: var(--w4f-secondary-50); }
&.self { outline: 2px solid rgba(255, 127, 172, 0.45); box-shadow: 0 0 0 4px rgba(255, 127, 172, 0.14); border-color: var(--w4f-primary); }
&.self { outline: 2px solid var(--w4f-primary); box-shadow: 0 0 0 4px var(--w4f-glow-soft); border-color: var(--w4f-primary); }
&.dead { border-color: var(--w4f-danger); background: var(--w4f-danger-50); opacity: 0.72; }
}
.rn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
@ -510,8 +511,8 @@ onBeforeUnmount(() => {
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
.log-list { max-height: 340px; overflow-y: auto; padding-right: 2px; }
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 8px 11px; font-size: 13px; background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm); }
.task-row.pending { background: var(--w4f-primary-50); border-color: rgba(255, 127, 172, 0.25); }
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: rgba(219, 54, 148, 0.25); }
.task-row.pending { background: var(--w4f-primary-50); border-color: var(--w4f-primary-200); }
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: var(--w4f-danger); }
.tk-id, .tp-id { font-weight: 700; color: $color-text-secondary; font-size: 12px; font-family: var(--w4f-mono); }
.tk-name, .tp-name { font-weight: 600; }
.tk-arrow { color: var(--w4f-primary); font-weight: 700; }