mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-22 18:07:55 +00:00
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:
@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<div class="canvas-editor">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar w4f-card">
|
||||
<div class="toolbar-left">
|
||||
<span class="toolbar-title">连接配置</span>
|
||||
<button class="btn local-add" @click="addLocal">+ 本地转发项</button>
|
||||
<button class="btn remote-add" @click="addRemote">+ 远程节点</button>
|
||||
<button class="btn warn" @click="autoLayout">自动排列</button>
|
||||
<button v-if="canWrite" class="btn local-add" @click="addLocal">+ 本地转发项</button>
|
||||
<button v-if="canWrite" class="btn remote-add" @click="addRemote">+ 远程节点</button>
|
||||
<button v-if="canWrite" class="btn warn" @click="autoLayout">自动排列</button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
|
||||
<button class="btn ghost" @click="exportCanvas">导出</button>
|
||||
<button class="btn ghost" :disabled="!canWrite" @click="pickImport" title="导入转发表(覆盖当前)">
|
||||
<button v-if="canWrite" class="btn ghost" @click="pickImport" title="导入转发表(覆盖当前)">
|
||||
导入
|
||||
</button>
|
||||
<input ref="importInput" type="file" accept=".json,application/json" class="hidden-input" @change="onImportFile" />
|
||||
<button class="btn save" :disabled="saving" @click="save">
|
||||
<button v-if="canWrite" class="btn save" :disabled="saving" @click="save">
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-wrap">
|
||||
<div class="flow-wrap w4f-card">
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
@ -30,6 +30,8 @@
|
||||
:max-zoom="2"
|
||||
fit-view-on-init
|
||||
:delete-key-code="null"
|
||||
:nodes-draggable="canWrite"
|
||||
:nodes-connectable="canWrite"
|
||||
:edges-updatable="false"
|
||||
:edges-reconnectable="false"
|
||||
class="flow-canvas"
|
||||
@ -91,6 +93,7 @@
|
||||
max="65535"
|
||||
placeholder="默认 = 本地端口"
|
||||
class="port-input"
|
||||
:disabled="!canWrite"
|
||||
@keyup.enter="confirmPort"
|
||||
/>
|
||||
</div>
|
||||
@ -99,12 +102,13 @@
|
||||
<el-input
|
||||
v-model="groupInput"
|
||||
placeholder="可选,用于转发页一键启停整组"
|
||||
:disabled="!canWrite"
|
||||
@keyup.enter="confirmPort"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn" @click="portDlg.visible = false">取消</button>
|
||||
<button class="btn save" @click="confirmPort">确定</button>
|
||||
<button class="btn save" :disabled="!canWrite" @click="confirmPort">确定</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@ -381,6 +385,7 @@ const edgeId = (source: string, target: string, remotePort: number) =>
|
||||
`${source}-->${target}@${remotePort}`
|
||||
|
||||
const onConnect = (conn: Connection) => {
|
||||
if (!canWrite.value) return
|
||||
const localName = conn.source.startsWith('local::')
|
||||
? conn.source.slice(7)
|
||||
: ''
|
||||
@ -564,6 +569,7 @@ const onRemoteData = (data: CanvasRemote) => {
|
||||
}
|
||||
|
||||
const onEdgeClick = ({ edge }: { edge: Edge }) => {
|
||||
if (!canWrite.value) return
|
||||
editEdge(edge)
|
||||
}
|
||||
|
||||
@ -827,7 +833,7 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $color-bg-secondary;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@ -835,9 +841,9 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: $color-bg-primary;
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@ -935,12 +941,13 @@ onBeforeUnmount(() => {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flow-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--w4f-bg-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
155
web/src/views/LoginView.vue
Normal file
155
web/src/views/LoginView.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<!--
|
||||
LoginView — session-cookie sign-in page. Shown by App.vue when /me is 401
|
||||
(no session cookie). Submitting POST /api/manager/login sets the HttpOnly
|
||||
session cookie; on success auth.login() flips isAuthed and App drops into
|
||||
the shell. There is no browser Basic-Auth prompt anymore (the 401 response
|
||||
omits WWW-Authenticate), so credentials are never cached by the browser and
|
||||
logout works.
|
||||
-->
|
||||
<template>
|
||||
<div class="login-wrap">
|
||||
<form class="login-card" @submit.prevent="submit">
|
||||
<div class="lc-brand">
|
||||
<img class="lc-logo" src="/favicon.svg" alt="webui4frpc" />
|
||||
<b>webui4frpc</b>
|
||||
<small>令牌环 · 转发编排</small>
|
||||
</div>
|
||||
|
||||
<label class="lc-field">
|
||||
<span>用户名</span>
|
||||
<input v-model="username" autocomplete="username" placeholder="用户名" required />
|
||||
</label>
|
||||
<label class="lc-field">
|
||||
<span>密码</span>
|
||||
<input v-model="password" type="password" autocomplete="current-password" placeholder="••••••••" required />
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="lc-error">{{ error }}</p>
|
||||
|
||||
<button class="lc-btn" type="submit" :disabled="busy">
|
||||
{{ busy ? '登录中…' : '登 录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { login } from '../auth'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await login(username.value, password.value)
|
||||
} catch {
|
||||
error.value = '登录失败:用户名或密码错误'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrap {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
font-family: var(--w4f-font);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 32px 28px 28px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--w4f-card);
|
||||
border: 1px solid var(--w4f-line);
|
||||
box-shadow: var(--w4f-sh-lg);
|
||||
backdrop-filter: blur(calc(var(--w4f-glass) * 0.8)) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.8)) saturate(1.3);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-card { backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
}
|
||||
|
||||
.lc-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.lc-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 8px 22px var(--w4f-glow);
|
||||
}
|
||||
.lc-brand b { font-size: 19px; letter-spacing: 0.3px; }
|
||||
.lc-brand small { font-size: 12px; color: var(--w4f-muted); }
|
||||
|
||||
.lc-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--w4f-fg-2);
|
||||
}
|
||||
.lc-field input {
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--w4f-line-strong);
|
||||
background: var(--w4f-card-solid);
|
||||
color: var(--w4f-fg);
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s var(--w4f-ease), box-shadow 0.15s var(--w4f-ease);
|
||||
}
|
||||
.lc-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--w4f-primary);
|
||||
box-shadow: 0 0 0 3px var(--w4f-glow-soft);
|
||||
}
|
||||
|
||||
.lc-error {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--w4f-danger);
|
||||
background: var(--w4f-danger-50);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.lc-btn {
|
||||
margin-top: 4px;
|
||||
padding: 11px 0;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
|
||||
box-shadow: 0 6px 18px var(--w4f-glow);
|
||||
transition: transform 0.15s var(--w4f-ease-spring), box-shadow 0.15s var(--w4f-ease-spring);
|
||||
}
|
||||
.lc-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 24px var(--w4f-glow);
|
||||
}
|
||||
.lc-btn:disabled { opacity: 0.65; cursor: default; }
|
||||
</style>
|
||||
@ -23,10 +23,12 @@
|
||||
clearable
|
||||
size="large"
|
||||
class="binary-input"
|
||||
:disabled="!canWrite"
|
||||
@blur="applyBinaryPath"
|
||||
@keyup.enter="applyBinaryPath"
|
||||
/>
|
||||
<button
|
||||
v-if="canWrite"
|
||||
class="btn install-btn"
|
||||
:disabled="installing"
|
||||
@click="installBinary"
|
||||
@ -57,6 +59,7 @@
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="settings.autoStartProfiles"
|
||||
:disabled="!canWrite"
|
||||
@change="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
@ -65,7 +68,7 @@
|
||||
<span class="setting-label">异常退出自动重启</span>
|
||||
<span class="setting-hint">worker 崩溃后带退避重启</span>
|
||||
</div>
|
||||
<el-switch v-model="settings.restartOnExit" @change="saveSettings" />
|
||||
<el-switch v-model="settings.restartOnExit" :disabled="!canWrite" @change="saveSettings" />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-text">
|
||||
@ -76,6 +79,7 @@
|
||||
v-model="settings.restartIntervalSeconds"
|
||||
:min="1"
|
||||
:max="3600"
|
||||
:disabled="!canWrite"
|
||||
@change="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
@ -89,6 +93,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { api } from '../api'
|
||||
import { canWrite } from '../auth'
|
||||
import type {
|
||||
BinaryStatus,
|
||||
Settings as ManagerSettings,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -14,10 +14,10 @@
|
||||
</header>
|
||||
<el-table :data="users" size="small" stripe empty-text="暂无账号">
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column label="角色" width="110">
|
||||
<el-table-column label="角色" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.role === 'admin' ? 'danger' : 'info'">
|
||||
{{ row.role === 'admin' ? '管理员' : '审计(viewer)' }}
|
||||
<el-tag size="small" :type="row.role === 'superadmin' ? 'danger' : row.role === 'admin' ? 'warning' : 'info'">
|
||||
{{ row.role === 'superadmin' ? '超级管理员' : row.role === 'admin' ? '管理员' : '审计(viewer)' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -97,6 +97,7 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="userForm.role" :disabled="editing && editing?.system">
|
||||
<el-option label="超级管理员 (superadmin)" value="superadmin" />
|
||||
<el-option label="管理员 (admin)" value="admin" />
|
||||
<el-option label="审计 (viewer)" value="viewer" />
|
||||
</el-select>
|
||||
@ -180,7 +181,7 @@ const scopeType = (s: string) => (s === 'admin' ? 'danger' : s === 'write' ? 'wa
|
||||
const userDialog = ref(false)
|
||||
const editing = ref<User | null>(null)
|
||||
const userSaving = ref(false)
|
||||
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer', enabled: true })
|
||||
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer' | 'superadmin', enabled: true })
|
||||
|
||||
const openUserCreate = () => {
|
||||
editing.value = null
|
||||
@ -197,7 +198,7 @@ const submitUser = async () => {
|
||||
userSaving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
const patch: { password?: string; role: 'admin' | 'viewer'; enabled: boolean } = {
|
||||
const patch: { password?: string; role: 'admin' | 'viewer' | 'superadmin'; enabled: boolean } = {
|
||||
role: userForm.value.role,
|
||||
enabled: userForm.value.enabled,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user