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

@ -8,7 +8,7 @@
</div>
<div class="tb-right">
<button class="w4f-btn ghost small" @click="loadStatus"> 刷新</button>
<button class="w4f-btn small" @click="openAdd"> 添加远程节点</button>
<button v-if="canWrite" class="w4f-btn small" @click="openAdd"> 添加远程节点</button>
</div>
</div>
@ -43,9 +43,9 @@
<span class="k-ic"></span>{{ g.label }}
<span class="grp-count">{{ g.items.length }} </span>
<span class="grp-actions">
<button class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
<button class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
<button v-if="canWrite" class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
<button v-if="canWrite" class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
<button v-if="canWrite && g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
</span>
</h3>
<div class="card-grid">
@ -70,13 +70,14 @@
<span v-if="f.localProto" class="w4f-tag w4f-tag-primary">{{ f.localProto }}</span>
<span
class="w4f-tag w4f-tag-info group-chip"
@click="editGroup(f)"
:title="'点击修改分组(当前:' + (f.group || '未分组') + ''"
:class="{ readonly: !canWrite }"
@click="canWrite && editGroup(f)"
:title="canWrite ? '点击修改分组(当前:' + (f.group || '未分组') + '' : '当前分组:' + (f.group || '未分组')"
>{{ f.group || '未分组' }}</span>
<span v-if="f.kind === 'remote'" class="fwd-owner" :title="f.ownerId || ''">{{ ownerLabel(f) }}</span>
<span class="fwd-actions">
<button v-if="!f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
<button v-else class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
<button v-if="canWrite && !f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
<button v-else-if="canWrite" class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
</span>
</div>
</div>
@ -101,8 +102,8 @@
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
</span>
<span class="nc-actions">
<button class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
<button class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
<button v-if="canWrite" class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
<button v-if="canWrite" class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
</span>
</div>
<div class="nc-meta">
@ -172,31 +173,31 @@
<el-dialog v-model="dialog.visible" :title="dialog.isNew ? '添加远程节点' : '编辑远程节点'" width="440px">
<div class="form-row">
<label>名称</label>
<el-input v-model="dialog.remote.name" :disabled="!dialog.isNew" placeholder="如 aliyun-hz" />
<el-input v-model="dialog.remote.name" :disabled="!canWrite || !dialog.isNew" placeholder="如 aliyun-hz" />
</div>
<div class="form-row">
<label>服务器 IP</label>
<el-input v-model="dialog.remote.ip" placeholder="frps 地址" />
<el-input v-model="dialog.remote.ip" :disabled="!canWrite" placeholder="frps 地址" />
</div>
<div class="form-row">
<label>端口</label>
<el-input-number v-model="dialog.remote.port" :min="1" :max="65535" />
<el-input-number v-model="dialog.remote.port" :disabled="!canWrite" :min="1" :max="65535" />
</div>
<div class="form-row">
<label>Token</label>
<el-input v-model="dialog.remote.token" placeholder="可选" />
<el-input v-model="dialog.remote.token" :disabled="!canWrite" placeholder="可选" />
</div>
<div class="form-row">
<label>URL</label>
<el-input v-model="dialog.remote.url" placeholder="可选" />
<el-input v-model="dialog.remote.url" :disabled="!canWrite" placeholder="可选" />
</div>
<div class="form-row">
<label>启用</label>
<el-switch v-model="dialog.remote.enabled" />
<el-switch v-model="dialog.remote.enabled" :disabled="!canWrite" />
</div>
<template #footer>
<button class="w4f-btn ghost small" @click="dialog.visible = false">取消</button>
<button class="w4f-btn small" @click="saveRemote">保存</button>
<button class="w4f-btn small" :disabled="!canWrite" @click="saveRemote">保存</button>
</template>
</el-dialog>
</div>
@ -206,6 +207,7 @@
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import { canWrite } from '../auth'
import type { ForwardStatus, Remote, StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
@ -616,7 +618,7 @@ onBeforeUnmount(() => {
.fwd-actions { display: flex; gap: 6px; }
.grp-count { font-size: 12px; color: $color-text-muted; font-weight: 600; }
.grp-actions { margin-left: auto; display: flex; gap: 6px; }
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } }
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } &.readonly { cursor: default; &:hover { background: none; } } }
/* local service card */
.local-card { padding: 14px 16px; }