Align management UI with web design guide

This commit is contained in:
opsx committed 2026-10-22 21:46:28 +08:00
1 parent 373a0904be
commit 347799cb1e
18 files changed
+367 -165

No files matched your search

+34 -11
View File
@@ -9,7 +9,7 @@
</span>
</div>
<div class="vpn-header-actions">
<el-button :icon="Refresh" circle :loading="loading" @click="load" />
<el-button :icon="Refresh" circle :loading="loading" :aria-label="$t('common.refresh')" @click="load" />
<el-button @click="openBulkImport">{{ $t('vpnUsers.bulkImport') }}</el-button>
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button>
</div>
@@ -25,7 +25,8 @@
<span>{{ $t('vpnUsers.selectedUsers', { count: selected.length }) }}</span>
<el-button type="danger" size="small" @click="batchRemove">{{ $t('vpnUsers.batchDelete') }}</el-button>
</div>
<el-table :data="filteredUsers" size="small" v-loading="loading" @selection-change="rows => selected = rows">
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="vpn-load-error"><el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button></el-alert>
<el-table :data="filteredUsers" size="default" v-loading="loading" @selection-change="rows => selected = rows">
<el-table-column type="selection" width="44" />
<el-table-column prop="username" :label="$t('common.username')" />
<el-table-column prop="display_name" :label="$t('vpnUsers.displayName')" />
@@ -48,7 +49,7 @@
<span v-else style="color:#c0c4cc">—</span>
</template>
</el-table-column>
<el-table-column :label="$t('common.traffic')" width="170">
<el-table-column :label="$t('common.traffic')" width="170" align="right" header-align="right">
<template #default="{ row }">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</template>
</el-table-column>
<el-table-column :label="$t('vpnUsers.lastOnline')" width="180">
@@ -59,14 +60,20 @@
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? $t('common.enabled') : $t('common.disabled') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="280">
<el-table-column :label="$t('common.operation')" width="175" fixed="right">
<template #default="{ row }">
<el-button v-if="tunnels.find(x => x.id === row.tunnel_id)?.protocol !== 'wireguard'" link size="small" @click="download(row)">{{ $t('vpnUsers.downloadConfig') }}</el-button>
<el-button v-if="supportsMFA(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">{{ $t('vpnUsers.otpManagement') }}</el-button>
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-button>
<el-button type="danger" link size="small" @click="remove(row)">{{ $t('common.delete') }}</el-button>
<el-dropdown trigger="click" @command="command => userCommand(command, row)">
<el-button link size="small">{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="supportsMFA(row.tunnel_id)" command="otp">{{ $t('vpnUsers.otpManagement') }}</el-dropdown-item>
<el-dropdown-item command="toggle">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-dropdown-item>
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</template>
</el-table-column>
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
</el-card>
@@ -89,7 +96,7 @@
</el-form>
<template #footer>
<el-button @click="visible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="create">{{ $t('vpnUsers.create') }}</el-button>
<el-button type="primary" :loading="creating" @click="create">{{ $t('vpnUsers.create') }}</el-button>
</template>
</el-dialog>
@@ -159,7 +166,7 @@
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckFilled, Key, Refresh } from '@element-plus/icons-vue'
import { CircleCheckFilled, Key, Refresh, ArrowDown } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api from '../api'
@@ -170,6 +177,8 @@ const { t } = useI18n()
const users = ref([])
const tunnels = ref([])
const loading = ref(false)
const loadError = ref('')
const creating = ref(false)
const keyword = ref('')
const tunnelFilter = ref(null)
const selected = ref([])
@@ -214,9 +223,18 @@ async function load() {
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
users.value = u.data || []
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2', 'wireguard'].includes(tun.protocol))
loadError.value = ''
} catch (e) {
loadError.value = e.response?.data?.error || t('common.loadFailed')
} finally { loading.value = false }
}
function userCommand(command, row) {
if (command === 'otp') openOTPManager(row)
else if (command === 'toggle') toggle(row)
else if (command === 'delete') remove(row)
}
function openCreate() {
form.value = {
tunnel_id: tunnels.value[0]?.id || null,
@@ -248,6 +266,8 @@ async function importPlatformUsers() {
}
async function create() {
if (creating.value) return
creating.value = true
try {
await api.post('/vpn-users', form.value)
visible.value = false
@@ -255,7 +275,7 @@ async function create() {
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.createFailed'))
}
} finally { creating.value = false }
}
async function toggle(row) {
@@ -447,8 +467,11 @@ onMounted(load)
<style scoped>
.mfa-dialog-title { display: flex; align-items: center; gap: 12px; }
.vpn-header, .vpn-header-actions, .vpn-toolbar, .vpn-batch-bar { display:flex; align-items:center; gap:8px; }
.vpn-header { justify-content:space-between; }
.vpn-header { justify-content:space-between; flex-wrap:wrap; }
.vpn-header-actions { flex-wrap:wrap; }
.vpn-toolbar { padding: 0 0 12px; }
.vpn-load-error { margin-bottom: var(--space-4); }
@media (max-width: 640px) { .vpn-toolbar { flex-wrap: wrap; } .vpn-toolbar > * { flex: 1 1 180px; max-width: 100%; } }
.vpn-batch-bar { justify-content:space-between; margin: 0 0 10px; padding: 8px 10px; background:#fef3c7; color:#92400e; border:1px solid #fde68a; font-size:13px; }
.mfa-title-icon, .mfa-empty-icon { display: grid; place-items: center; color: #2563eb; background: #eff6ff; }
.mfa-title-icon { width: 36px; height: 36px; border-radius: 6px; font-size: 19px; }