feat(frontend): finish backlog UX for audit, dashboard, wizard

Wire sessions audit to paged connection-logs with filters/export,
drop connection-count from log retention, add dashboard quick cards,
and group tunnel wizard protocols by scene.
This commit is contained in:
Codex committed 2026-10-23 16:16:50 +08:00
1 parent f01d3dc5ea
commit 9984e21930
7 files changed
+277 -132

No files matched your search

+160 -88
View File
@@ -2,99 +2,123 @@
<div>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane :label="$t('sessions.title')" name="online">
<el-alert v-if="sessionError" type="error" :closable="false" :title="sessionError" class="session-error"><el-button size="small" @click="loadOnline">{{ $t('common.refresh') }}</el-button></el-alert>
<!-- 在线会话 -->
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header>
<div class="row-between">
<div>
<b>{{ $t('sessions.title') }}</b>
<el-tag size="small" type="success" style="margin-left:8px">{{ $t('sessions.onlineCount', { count: sessions.length }) }}</el-tag>
</div>
<el-button link @click="loadOnline">{{ $t('common.refresh') }}</el-button>
</div>
</template>
<el-table :data="sessions" size="default" v-loading="loading">
<el-table-column :label="$t('common.username')" min-width="120">
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
</el-table-column>
<el-table-column :label="$t('common.protocol')" width="100">
<template #default="{ row }">
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ $t(protocolDisplay(row.protocol)) }}</span>
<el-alert v-if="sessionError" type="error" :closable="false" :title="sessionError" class="session-error"><el-button size="small" @click="loadOnline">{{ $t('common.refresh') }}</el-button></el-alert>
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header>
<div class="row-between">
<div>
<b>{{ $t('sessions.title') }}</b>
<el-tag size="small" type="success" style="margin-left:8px">{{ $t('sessions.onlineCount', { count: sessions.length }) }}</el-tag>
</div>
<el-button link @click="loadOnline">{{ $t('common.refresh') }}</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="real_address" :label="$t('sessions.publicAddress')" min-width="140" />
<el-table-column prop="virtual_address" :label="$t('sessions.vpnIp')" min-width="120" />
<el-table-column :label="$t('sessions.connectedDuration')" width="120">
<template #default="{ row }">{{ fmtDuration(row.connected_at) }}</template>
</el-table-column>
<el-table-column :label="$t('common.traffic')" width="160" align="right" header-align="right">
<template #default="{ row }">
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="100">
<template #default="{ row }">
<el-button type="danger" size="small" link @click="disconnect(row)">{{ $t('sessions.kickOffline') }}</el-button>
</template>
</el-table-column>
<template #empty><el-empty :description="$t('sessions.noOnlineUsers')" /></template>
</el-table>
</el-card>
<!-- 异常流量告警 -->
<el-card class="page-card" shadow="never" style="margin-bottom:16px" v-if="alerts.length">
<template #header><b style="color:var(--color-danger)">{{ $t('sessions.trafficAlert') }}</b></template>
<el-table :data="alerts" size="small">
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="username" :label="$t('sessions.userTunnel')" width="100" />
<el-table-column :label="$t('sessions.alertType')" width="100">
<template #default="{ row }">
<el-tag :type="row.event === 'rate_alert' ? 'warning' : 'danger'" size="small">
{{ { rate_alert: $t('sessions.rateAlert'), quota_exceed: $t('sessions.quotaExceed') }[row.event] || row.event }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
</el-table>
</el-card>
<el-table :data="sessions" size="default" v-loading="loading">
<el-table-column :label="$t('common.username')" min-width="120">
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
</el-table-column>
<el-table-column :label="$t('common.protocol')" width="100">
<template #default="{ row }">
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ $t(protocolDisplay(row.protocol)) }}</span>
</template>
</el-table-column>
<el-table-column prop="real_address" :label="$t('sessions.publicAddress')" min-width="140" />
<el-table-column prop="virtual_address" :label="$t('sessions.vpnIp')" min-width="120" />
<el-table-column :label="$t('sessions.connectedDuration')" width="120">
<template #default="{ row }">{{ fmtDuration(row.connected_at) }}</template>
</el-table-column>
<el-table-column :label="$t('common.traffic')" width="160" align="right" header-align="right">
<template #default="{ row }">
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="100">
<template #default="{ row }">
<el-button type="danger" size="small" link @click="disconnect(row)">{{ $t('sessions.kickOffline') }}</el-button>
</template>
</el-table-column>
<template #empty><el-empty :description="$t('sessions.noOnlineUsers')" /></template>
</el-table>
</el-card>
<el-card class="page-card" shadow="never" style="margin-bottom:16px" v-if="alerts.length">
<template #header><b style="color:var(--color-danger)">{{ $t('sessions.trafficAlert') }}</b></template>
<el-table :data="alerts" size="small">
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="username" :label="$t('sessions.userTunnel')" width="100" />
<el-table-column :label="$t('sessions.alertType')" width="100">
<template #default="{ row }">
<el-tag :type="row.event === 'rate_alert' ? 'warning' : 'danger'" size="small">
{{ { rate_alert: $t('sessions.rateAlert'), quota_exceed: $t('sessions.quotaExceed') }[row.event] || row.event }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane :label="$t('sessions.auditLog')" name="audit">
<el-alert v-if="auditError" type="error" :closable="false" :title="auditError" class="session-error"><el-button size="small" @click="loadAudit">{{ $t('common.refresh') }}</el-button></el-alert>
<!-- 连接审计日志 -->
<el-card class="page-card" shadow="never">
<template #header>
<div class="row-between">
<b>{{ $t('sessions.auditLog') }}</b>
<el-button link @click="loadAudit">{{ $t('common.refresh') }}</el-button>
</div>
</template>
<el-table :data="logs" size="small" max-height="500" v-loading="auditLoading">
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
</el-table-column>
<el-table-column :label="$t('common.username')" min-width="180">
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
</el-table-column>
<el-table-column :label="$t('sessions.event')" width="100">
<template #default="{ row }">
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
<el-alert v-if="auditError" type="error" :closable="false" :title="auditError" class="session-error"><el-button size="small" @click="loadAudit">{{ $t('common.refresh') }}</el-button></el-alert>
<el-card class="page-card" shadow="never">
<template #header>
<div class="row-between">
<div class="filter-bar">
<el-input v-model="auditFilters.username" :placeholder="$t('common.username')" clearable size="small" style="width:120px" @clear="searchAudit" @keyup.enter="searchAudit" />
<el-select v-model="auditFilters.event" :placeholder="$t('logs.eventType')" clearable multiple size="small" style="width:220px" @change="searchAudit">
<el-option :label="$t('sessions.eventConnect')" value="connect" />
<el-option :label="$t('sessions.eventDisconnect')" value="disconnect" />
<el-option :label="$t('sessions.eventAuthFail')" value="auth_fail" />
<el-option :label="$t('sessions.eventQuotaExceed')" value="quota_exceed" />
<el-option :label="$t('sessions.eventRateAlert')" value="rate_alert" />
</el-select>
<el-date-picker v-model="auditDateRange" type="daterange" :start-placeholder="$t('logs.startDate')" :end-placeholder="$t('logs.endDate')" size="small" value-format="YYYY-MM-DD" style="width:260px" @change="searchAudit" clearable />
<el-button type="primary" size="small" @click="searchAudit">{{ $t('common.search') }}</el-button>
</div>
<div style="display:flex;gap:8px">
<el-button size="small" :loading="auditExporting" @click="exportAudit">{{ $t('logs.exportCsv') }}</el-button>
<el-button link @click="loadAudit">{{ $t('common.refresh') }}</el-button>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
</el-table>
</el-card>
<el-table :data="logs" size="small" max-height="500" v-loading="auditLoading">
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="tunnel_id" :label="$t('logs.tunnelId')" width="80" />
<el-table-column :label="$t('common.username')" min-width="160">
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
</el-table-column>
<el-table-column :label="$t('sessions.event')" width="110">
<template #default="{ row }">
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="auditPage"
v-model:page-size="auditPageSize"
:total="auditTotal"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadAudit"
@size-change="searchAudit"
/>
</div>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, reactive, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
@@ -109,7 +133,6 @@ const sessions = ref([])
const logs = ref([])
const alerts = ref([])
const loading = ref(false)
/** 规范名 online / audit;兼容 ?tab=sessions|connection */
function normalizeTab(name) {
if (name === 'sessions' || name === 'online') return 'online'
if (name === 'audit' || name === 'connection') return 'audit'
@@ -119,6 +142,12 @@ const activeTab = ref(normalizeTab(route.query.tab))
const sessionError = ref('')
const auditError = ref('')
const auditLoading = ref(false)
const auditExporting = ref(false)
const auditPage = ref(1)
const auditPageSize = ref(20)
const auditTotal = ref(0)
const auditFilters = reactive({ username: '', event: [] })
const auditDateRange = ref(null)
watch(() => route.query.tab, (name) => {
const next = normalizeTab(name)
@@ -126,7 +155,6 @@ watch(() => route.query.tab, (name) => {
}, { immediate: true })
watch(activeTab, (tab) => {
// 写入规范 query:在线清空;审计固定 tab=audit(别名落地后会改写)
const query = tab === 'online' ? {} : { tab: 'audit' }
const cur = route.query.tab
const needsReplace = normalizeTab(cur) !== tab
@@ -170,17 +198,56 @@ async function loadOnline() {
} finally { loading.value = false }
}
function searchAudit() {
auditPage.value = 1
loadAudit()
}
async function loadAudit() {
auditLoading.value = true
try {
const response = await api.get('/sessions/log')
logs.value = response.data || []
const params = {
page: auditPage.value,
page_size: auditPageSize.value,
username: auditFilters.username,
}
if (auditFilters.event?.length) params.event = auditFilters.event.join(',')
if (auditDateRange.value) {
params.start = auditDateRange.value[0]
params.end = auditDateRange.value[1]
}
const { data } = await api.get('/connection-logs', { params })
logs.value = data.items || []
auditTotal.value = data.total || 0
auditError.value = ''
} catch (e) {
auditError.value = e.response?.data?.error || t('sessions.loadDataFailed')
} finally { auditLoading.value = false }
}
async function exportAudit() {
auditExporting.value = true
const params = { username: auditFilters.username }
if (auditFilters.event?.length) params.event = auditFilters.event.join(',')
if (auditDateRange.value) {
params.start = auditDateRange.value[0]
params.end = auditDateRange.value[1]
}
try {
const { data } = await api.get('/connection-logs/export', { params, responseType: 'blob' })
const url = URL.createObjectURL(data)
try {
const link = document.createElement('a')
link.href = url
link.download = `connection-logs-${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
} finally { setTimeout(() => URL.revokeObjectURL(url), 1000) }
} catch { ElMessage.error(t('logs.exportFailed')) }
finally { auditExporting.value = false }
}
function onTabChange(tab) {
activeTab.value = tab
if (tab === 'audit') loadAudit()
@@ -202,10 +269,15 @@ onMounted(() => {
else loadOnline()
timer = setInterval(() => {
if (activeTab.value === 'online') loadOnline()
else loadAudit()
}, 15000)
})
onUnmounted(() => clearInterval(timer))
</script>
<style scoped>.identity-subject { font-size: 11px; color: var(--el-text-color-secondary); overflow-wrap: anywhere; } .session-error { margin-bottom: var(--space-4); }</style>
<style scoped>
.identity-subject { font-size: 11px; color: var(--el-text-color-secondary); overflow-wrap: anywhere; }
.session-error { margin-bottom: var(--space-4); }
.row-between { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
.filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pagination-bar { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>