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

+12 -3
View File
@@ -114,7 +114,13 @@
"fixSuccess": "Fixed: {items}", "fixSuccess": "Fixed: {items}",
"fixFailed": "Fix failed: {items}", "fixFailed": "Fix failed: {items}",
"fixNeedRoot": "Root privileges required to modify system config", "fixNeedRoot": "Root privileges required to modify system config",
"fixNothing": "Nothing to auto-fix" "fixNothing": "Nothing to auto-fix",
"quickErrorTunnels": "Unhealthy tunnels",
"quickErrorHint": "Tunnels in error state",
"quickOnline": "Online sessions",
"quickOnlineHint": "Currently connected users",
"quickAlerts": "Open alerts",
"quickAlertsHint": "Traffic and quota alerts"
}, },
"tunnels": { "tunnels": {
"searchPlaceholder": "Search by name", "searchPlaceholder": "Search by name",
@@ -356,7 +362,10 @@
"installSuccess": "{protocol} {version} installed successfully {method}", "installSuccess": "{protocol} {version} installed successfully {method}",
"methodPackageManager": "(Package Manager)", "methodPackageManager": "(Package Manager)",
"methodDownload": "(One-Click Install)", "methodDownload": "(One-Click Install)",
"installFailed": "Failed to install {protocol}" "installFailed": "Failed to install {protocol}",
"groupEmployee": "Employee access",
"groupSite": "Site interconnect",
"groupPenetrate": "Intranet penetration"
}, },
"users": { "users": {
"title": "Admins", "title": "Admins",
@@ -581,7 +590,7 @@
"cleanupHint": "Delete log data older than specified days", "cleanupHint": "Delete log data older than specified days",
"cleanupPrompt": "Keep logs from the last how many days?", "cleanupPrompt": "Keep logs from the last how many days?",
"cleanupInputError": "Please enter a positive integer", "cleanupInputError": "Please enter a positive integer",
"cleanupDone": "Cleanup done: {audit} audit, {conn} connection, {session} session records deleted", "cleanupDone": "Cleanup done: {audit} audit logs, {session} session records",
"loadFailed": "Failed to load logs", "loadFailed": "Failed to load logs",
"noAlerts": "No traffic alerts" "noAlerts": "No traffic alerts"
}, },
+12 -3
View File
@@ -114,7 +114,13 @@
"fixSuccess": "已修复:{items}", "fixSuccess": "已修复:{items}",
"fixFailed": "修复失败:{items}", "fixFailed": "修复失败:{items}",
"fixNeedRoot": "需要以 root 运行才能修改系统配置", "fixNeedRoot": "需要以 root 运行才能修改系统配置",
"fixNothing": "当前没有可自动修复的项" "fixNothing": "当前没有可自动修复的项",
"quickErrorTunnels": "异常专线",
"quickErrorHint": "状态为错误的专线",
"quickOnline": "在线会话",
"quickOnlineHint": "当前在线拨号用户",
"quickAlerts": "待处理告警",
"quickAlertsHint": "流量异常与配额告警"
}, },
"tunnels": { "tunnels": {
"searchPlaceholder": "搜索名称", "searchPlaceholder": "搜索名称",
@@ -356,7 +362,10 @@
"installSuccess": "{protocol} {version} 安装成功 {method}", "installSuccess": "{protocol} {version} 安装成功 {method}",
"methodPackageManager": "(包管理器安装)", "methodPackageManager": "(包管理器安装)",
"methodDownload": "(一键安装)", "methodDownload": "(一键安装)",
"installFailed": "{protocol} 安装失败" "installFailed": "{protocol} 安装失败",
"groupEmployee": "员工接入",
"groupSite": "站点对接",
"groupPenetrate": "内网穿透"
}, },
"users": { "users": {
"title": "管理员", "title": "管理员",
@@ -581,7 +590,7 @@
"cleanupHint": "删除指定天数之前的日志数据", "cleanupHint": "删除指定天数之前的日志数据",
"cleanupPrompt": "保留最近多少天的日志?", "cleanupPrompt": "保留最近多少天的日志?",
"cleanupInputError": "请输入正整数", "cleanupInputError": "请输入正整数",
"cleanupDone": "清理完成:审计 {audit} 条、连接 {conn} 条、会话 {session} 条", "cleanupDone": "清理完成:操作审计 {audit} 条、会话记录 {session} 条",
"loadFailed": "加载日志失败", "loadFailed": "加载日志失败",
"noAlerts": "暂无流量告警" "noAlerts": "暂无流量告警"
}, },
+42 -1
View File
@@ -31,6 +31,16 @@
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="12" class="quick-actions" v-if="readinessLoaded">
<el-col :xs="24" :sm="8" v-for="item in quickLinks" :key="item.to">
<RouterLink :to="item.to" class="quick-card" :class="item.tone">
<div class="quick-title">{{ item.title }}</div>
<div class="quick-value">{{ item.value }}</div>
<div class="quick-hint">{{ item.hint }}</div>
</RouterLink>
</el-col>
</el-row>
<el-alert v-if="readinessLoaded && !hostReady" class="host-readiness" type="warning" :closable="false" show-icon <el-alert v-if="readinessLoaded && !hostReady" class="host-readiness" type="warning" :closable="false" show-icon
:title="$t('dashboard.hostReadinessWarning')" :description="readinessHint"> :title="$t('dashboard.hostReadinessWarning')" :description="readinessHint">
<template #default> <template #default>
@@ -214,6 +224,13 @@ function copyText(text) { navigator.clipboard.writeText(text).then(() => ElMessa
function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' } function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' }
const alertCount = ref(0)
const errorTunnelCount = ref(0)
const quickLinks = computed(() => [
{ title: t('dashboard.quickErrorTunnels'), value: errorTunnelCount.value, hint: t('dashboard.quickErrorHint'), to: '/tunnels?filter=unhealthy', tone: 'danger' },
{ title: t('dashboard.quickOnline'), value: ov.value.online_sessions ?? '—', hint: t('dashboard.quickOnlineHint'), to: '/sessions?tab=online', tone: 'ok' },
{ title: t('dashboard.quickAlerts'), value: alertCount.value ?? '—', hint: t('dashboard.quickAlertsHint'), to: '/logs?tab=alert', tone: 'warn' },
])
const statCards = computed(() => [ const statCards = computed(() => [
{ label: t('dashboard.totalTunnels'), value: ov.value.total ?? '—', icon: Connection, tone: 'tunnels', to: '/tunnels' }, { label: t('dashboard.totalTunnels'), value: ov.value.total ?? '—', icon: Connection, tone: 'tunnels', to: '/tunnels' },
{ label: t('dashboard.running'), value: ov.value.running ?? '—', icon: Monitor, tone: 'running', to: '/tunnels?state=running' }, { label: t('dashboard.running'), value: ov.value.running ?? '—', icon: Monitor, tone: 'running', to: '/tunnels?state=running' },
@@ -233,11 +250,21 @@ async function load() {
// 轮询可见性守卫:标签页隐藏时跳过(P1 修复) // 轮询可见性守卫:标签页隐藏时跳过(P1 修复)
if (document.visibilityState !== 'visible') return if (document.visibilityState !== 'visible') return
try { try {
const [{ data: o }, { data: p }, { data: r }] = await Promise.all([api.get('/overview'), api.get('/protocols'), api.get('/readiness')]) const [{ data: o }, { data: p }, { data: r }, alertsRes, tunnelsRes] = await Promise.all([
api.get('/overview'), api.get('/protocols'), api.get('/readiness'),
api.get('/traffic/alerts').catch(() => ({ data: [] })),
api.get('/tunnels').catch(() => ({ data: [] })),
])
ov.value = o ov.value = o
protocols.value = p protocols.value = p
readiness.value = r || {} readiness.value = r || {}
readinessLoaded.value = true readinessLoaded.value = true
alertCount.value = Array.isArray(alertsRes.data) ? alertsRes.data.length : 0
const tunnelList = Array.isArray(tunnelsRes.data) ? tunnelsRes.data : (tunnelsRes.data?.items || [])
errorTunnelCount.value = tunnelList.filter(x => {
const st = x.runtime?.state || x.Runtime?.State || x.state || x.State || ''
return String(st).toLowerCase() === 'error'
}).length
loadError.value = false loadError.value = false
await nextTick() await nextTick()
renderPie() renderPie()
@@ -331,4 +358,18 @@ onUnmounted(() => {
.command-block { margin:8px 0; padding:12px; background:var(--bg-inset); border:1px solid var(--border-base); white-space:pre-wrap; overflow:auto; } .command-block { margin:8px 0; padding:12px; background:var(--bg-inset); border:1px solid var(--border-base); white-space:pre-wrap; overflow:auto; }
.other-command { margin-bottom:8px; } .other-command { margin-bottom:8px; }
.other-command code { flex:1; min-width:0; overflow-wrap:anywhere; font-size:12px; } .other-command code { flex:1; min-width:0; overflow-wrap:anywhere; font-size:12px; }
.quick-actions { margin: 0 0 16px; }
.quick-card {
display: block; padding: 14px 16px; border-radius: 12px; text-decoration: none;
background: var(--bg-card); border: 1px solid var(--border-base); color: inherit;
transition: box-shadow .15s, border-color .15s;
}
.quick-card:hover { box-shadow: var(--shadow-sm); border-color: var(--brand-primary); }
.quick-title { font-size: 13px; color: var(--text-secondary); }
.quick-value { font-size: 22px; font-weight: 700; margin: 4px 0; }
.quick-hint { font-size: 12px; color: var(--text-secondary); }
.quick-card.danger .quick-value { color: var(--color-danger); }
.quick-card.warn .quick-value { color: var(--color-warning); }
.quick-card.ok .quick-value { color: var(--color-success, #67c23a); }
</style> </style>
+8 -10
View File
@@ -81,19 +81,13 @@
<!-- 日志统计卡片 --> <!-- 日志统计卡片 -->
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="stats"> <el-card class="page-card" shadow="never" style="margin-top:16px" v-if="stats">
<template #header><b>{{ $t('logs.statsTitle') }}</b></template> <template #header><b>{{ $t('logs.statsTitle') }}</b></template>
<el-descriptions :column="3" size="small" border> <el-descriptions :column="2" size="small" border>
<el-descriptions-item :label="$t('logs.auditCount')"> <el-descriptions-item :label="$t('logs.auditCount')">
{{ stats.audit?.count || 0 }} 条 {{ stats.audit?.count || 0 }} 条
<span v-if="stats.audit?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px"> <span v-if="stats.audit?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px">
{{ $t('logs.from') }} {{ fmtTime(stats.audit.oldest) }} {{ $t('logs.from') }} {{ fmtTime(stats.audit.oldest) }}
</span> </span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item :label="$t('logs.connCount')">
{{ stats.connection?.count || 0 }} 条
<span v-if="stats.connection?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px">
{{ $t('logs.from') }} {{ fmtTime(stats.connection.oldest) }}
</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('logs.sessionCount')"> <el-descriptions-item :label="$t('logs.sessionCount')">
{{ stats.session?.count || 0 }} 条 {{ stats.session?.count || 0 }} 条
</el-descriptions-item> </el-descriptions-item>
@@ -116,8 +110,12 @@ import api, { auth } from '../api'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const TAB_NAMES = ['audit', 'alerts'] function normalizeLogTab(name) {
const activeTab = ref(TAB_NAMES.includes(route.query.tab) ? route.query.tab : 'audit') if (name === 'alert' || name === 'alerts') return 'alerts'
if (name === 'audit') return 'audit'
return 'audit'
}
const activeTab = ref(normalizeLogTab(route.query.tab))
watch(() => route.query.tab, (name) => { watch(() => route.query.tab, (name) => {
// 连接审计已迁到 Sessions;旧链接 ?tab=connection 跳过去 // 连接审计已迁到 Sessions;旧链接 ?tab=connection 跳过去
@@ -228,7 +226,7 @@ async function cleanupLogs() {
}) })
const days = parseInt(value) || 90 const days = parseInt(value) || 90
const { data } = await api.post('/logs/cleanup', { days }) const { data } = await api.post('/logs/cleanup', { days })
ElMessage.success(t('logs.cleanupDone', { audit: data.audit_deleted, conn: data.conn_deleted, session: data.session_deleted })) ElMessage.success(t('logs.cleanupDone', { audit: data.audit_deleted, session: data.session_deleted }))
loadStats() loadStats()
if (activeTab.value === 'audit') loadAudit() if (activeTab.value === 'audit') loadAudit()
if (activeTab.value === 'alerts') loadAlerts() if (activeTab.value === 'alerts') loadAlerts()
+160 -88
View File
@@ -2,99 +2,123 @@
<div> <div>
<el-tabs v-model="activeTab" @tab-change="onTabChange"> <el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane :label="$t('sessions.title')" name="online"> <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-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">
<el-card class="page-card" shadow="never" style="margin-bottom:16px"> <template #header>
<template #header> <div class="row-between">
<div class="row-between"> <div>
<div> <b>{{ $t('sessions.title') }}</b>
<b>{{ $t('sessions.title') }}</b> <el-tag size="small" type="success" style="margin-left:8px">{{ $t('sessions.onlineCount', { count: sessions.length }) }}</el-tag>
<el-tag size="small" type="success" style="margin-left:8px">{{ $t('sessions.onlineCount', { count: sessions.length }) }}</el-tag> </div>
</div> <el-button link @click="loadOnline">{{ $t('common.refresh') }}</el-button>
<el-button link @click="loadOnline">{{ $t('common.refresh') }}</el-button> </div>
</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>
</template> </template>
</el-table-column> <el-table :data="sessions" size="default" v-loading="loading">
<el-table-column prop="real_address" :label="$t('sessions.publicAddress')" min-width="140" /> <el-table-column :label="$t('common.username')" min-width="120">
<el-table-column prop="virtual_address" :label="$t('sessions.vpnIp')" 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 :label="$t('sessions.connectedDuration')" width="120"> </el-table-column>
<template #default="{ row }">{{ fmtDuration(row.connected_at) }}</template> <el-table-column :label="$t('common.protocol')" width="100">
</el-table-column> <template #default="{ row }">
<el-table-column :label="$t('common.traffic')" width="160" align="right" header-align="right"> <span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ $t(protocolDisplay(row.protocol)) }}</span>
<template #default="{ row }"> </template>
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span> </el-table-column>
</template> <el-table-column prop="real_address" :label="$t('sessions.publicAddress')" min-width="140" />
</el-table-column> <el-table-column prop="virtual_address" :label="$t('sessions.vpnIp')" min-width="120" />
<el-table-column :label="$t('common.operation')" width="100"> <el-table-column :label="$t('sessions.connectedDuration')" width="120">
<template #default="{ row }"> <template #default="{ row }">{{ fmtDuration(row.connected_at) }}</template>
<el-button type="danger" size="small" link @click="disconnect(row)">{{ $t('sessions.kickOffline') }}</el-button> </el-table-column>
</template> <el-table-column :label="$t('common.traffic')" width="160" align="right" header-align="right">
</el-table-column> <template #default="{ row }">
<template #empty><el-empty :description="$t('sessions.noOnlineUsers')" /></template> <span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
</el-table> </template>
</el-card> </el-table-column>
<el-table-column :label="$t('common.operation')" width="100">
<!-- 异常流量告警 --> <template #default="{ row }">
<el-card class="page-card" shadow="never" style="margin-bottom:16px" v-if="alerts.length"> <el-button type="danger" size="small" link @click="disconnect(row)">{{ $t('sessions.kickOffline') }}</el-button>
<template #header><b style="color:var(--color-danger)">{{ $t('sessions.trafficAlert') }}</b></template> </template>
<el-table :data="alerts" size="small"> </el-table-column>
<el-table-column prop="created_at" :label="$t('common.time')" width="170"> <template #empty><el-empty :description="$t('sessions.noOnlineUsers')" /></template>
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template> </el-table>
</el-table-column> </el-card>
<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-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>
<el-tab-pane :label="$t('sessions.auditLog')" name="audit"> <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-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">
<el-card class="page-card" shadow="never"> <template #header>
<template #header> <div class="row-between">
<div class="row-between"> <div class="filter-bar">
<b>{{ $t('sessions.auditLog') }}</b> <el-input v-model="auditFilters.username" :placeholder="$t('common.username')" clearable size="small" style="width:120px" @clear="searchAudit" @keyup.enter="searchAudit" />
<el-button link @click="loadAudit">{{ $t('common.refresh') }}</el-button> <el-select v-model="auditFilters.event" :placeholder="$t('logs.eventType')" clearable multiple size="small" style="width:220px" @change="searchAudit">
</div> <el-option :label="$t('sessions.eventConnect')" value="connect" />
</template> <el-option :label="$t('sessions.eventDisconnect')" value="disconnect" />
<el-table :data="logs" size="small" max-height="500" v-loading="auditLoading"> <el-option :label="$t('sessions.eventAuthFail')" value="auth_fail" />
<el-table-column prop="created_at" :label="$t('common.time')" width="170"> <el-option :label="$t('sessions.eventQuotaExceed')" value="quota_exceed" />
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template> <el-option :label="$t('sessions.eventRateAlert')" value="rate_alert" />
</el-table-column> </el-select>
<el-table-column :label="$t('common.username')" min-width="180"> <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 />
<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-button type="primary" size="small" @click="searchAudit">{{ $t('common.search') }}</el-button>
</el-table-column> </div>
<el-table-column :label="$t('sessions.event')" width="100"> <div style="display:flex;gap:8px">
<template #default="{ row }"> <el-button size="small" :loading="auditExporting" @click="exportAudit">{{ $t('logs.exportCsv') }}</el-button>
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag> <el-button link @click="loadAudit">{{ $t('common.refresh') }}</el-button>
</div>
</div>
</template> </template>
</el-table-column> <el-table :data="logs" size="small" max-height="500" v-loading="auditLoading">
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" /> <el-table-column prop="created_at" :label="$t('common.time')" width="170">
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip /> <template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
</el-table> </el-table-column>
</el-card> <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-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue' import { ref, reactive, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -109,7 +133,6 @@ const sessions = ref([])
const logs = ref([]) const logs = ref([])
const alerts = ref([]) const alerts = ref([])
const loading = ref(false) const loading = ref(false)
/** 规范名 online / audit;兼容 ?tab=sessions|connection */
function normalizeTab(name) { function normalizeTab(name) {
if (name === 'sessions' || name === 'online') return 'online' if (name === 'sessions' || name === 'online') return 'online'
if (name === 'audit' || name === 'connection') return 'audit' if (name === 'audit' || name === 'connection') return 'audit'
@@ -119,6 +142,12 @@ const activeTab = ref(normalizeTab(route.query.tab))
const sessionError = ref('') const sessionError = ref('')
const auditError = ref('') const auditError = ref('')
const auditLoading = ref(false) 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) => { watch(() => route.query.tab, (name) => {
const next = normalizeTab(name) const next = normalizeTab(name)
@@ -126,7 +155,6 @@ watch(() => route.query.tab, (name) => {
}, { immediate: true }) }, { immediate: true })
watch(activeTab, (tab) => { watch(activeTab, (tab) => {
// 写入规范 query:在线清空;审计固定 tab=audit(别名落地后会改写)
const query = tab === 'online' ? {} : { tab: 'audit' } const query = tab === 'online' ? {} : { tab: 'audit' }
const cur = route.query.tab const cur = route.query.tab
const needsReplace = normalizeTab(cur) !== tab const needsReplace = normalizeTab(cur) !== tab
@@ -170,17 +198,56 @@ async function loadOnline() {
} finally { loading.value = false } } finally { loading.value = false }
} }
function searchAudit() {
auditPage.value = 1
loadAudit()
}
async function loadAudit() { async function loadAudit() {
auditLoading.value = true auditLoading.value = true
try { try {
const response = await api.get('/sessions/log') const params = {
logs.value = response.data || [] 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 = '' auditError.value = ''
} catch (e) { } catch (e) {
auditError.value = e.response?.data?.error || t('sessions.loadDataFailed') auditError.value = e.response?.data?.error || t('sessions.loadDataFailed')
} finally { auditLoading.value = false } } 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) { function onTabChange(tab) {
activeTab.value = tab activeTab.value = tab
if (tab === 'audit') loadAudit() if (tab === 'audit') loadAudit()
@@ -202,10 +269,15 @@ onMounted(() => {
else loadOnline() else loadOnline()
timer = setInterval(() => { timer = setInterval(() => {
if (activeTab.value === 'online') loadOnline() if (activeTab.value === 'online') loadOnline()
else loadAudit()
}, 15000) }, 15000)
}) })
onUnmounted(() => clearInterval(timer)) onUnmounted(() => clearInterval(timer))
</script> </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>
+40 -25
View File
@@ -8,34 +8,41 @@
<!-- 第一步:协议选择 --> <!-- 第一步:协议选择 -->
<el-alert v-if="step === 0" type="info" :closable="false" :title="$t('tunnelWizard.onDemandHint')" style="margin-bottom:16px" /> <el-alert v-if="step === 0" type="info" :closable="false" :title="$t('tunnelWizard.onDemandHint')" style="margin-bottom:16px" />
<div v-if="step === 0" class="proto-grid"> <div v-if="step === 0" class="proto-groups">
<div v-for="(meta, key) in builtin" :key="key" <div v-for="group in protocolGroups" :key="group.id" class="proto-group">
class="proto-card" <div class="proto-group-title">{{ $t(group.titleKey) }}</div>
:class="{ active: form.protocol === key, disabled: depStatus[key] && !depStatus[key].available }" <div class="proto-grid">
@click="selectProtocol(key)"> <div v-for="key in group.keys.filter(k => builtin[k])" :key="key"
<el-tag v-if="key === 'wireguard'" type="danger" size="small" effect="dark" class="recommend-badge">{{ $t('tunnelWizard.recommend') }}</el-tag> class="proto-card"
<div class="proto-icon"><ProtocolIcon :protocol="key" :size="36" /></div> :class="{ active: form.protocol === key, disabled: depStatus[key] && !depStatus[key].available }"
<div class="proto-name" :style="{ color: meta.color }">{{ $t(meta.name) }}</div> @click="selectProtocol(key)">
<div class="proto-desc">{{ $t(meta.desc) }}</div> <el-tag v-if="key === 'wireguard'" type="danger" size="small" effect="dark" class="recommend-badge">{{ $t('tunnelWizard.recommend') }}</el-tag>
<div class="proto-scene">{{ $t(meta.scenes) }}</div> <div class="proto-icon"><ProtocolIcon :protocol="key" :size="36" /></div>
<div class="dep-badge" v-if="depStatus[key]"> <div class="proto-name" :style="{ color: builtin[key].color }">{{ $t(builtin[key].name) }}</div>
<el-tag :type="depStatus[key].available ? 'success' : 'warning'" size="small"> <div class="proto-desc">{{ $t(builtin[key].desc) }}</div>
{{ depStatus[key].available ? $t('tunnelWizard.depReady') : $t('tunnelWizard.notDeployed') }} <div class="proto-scene">{{ $t(builtin[key].scenes) }}</div>
</el-tag> <div class="dep-badge" v-if="depStatus[key]">
<div v-if="!depStatus[key].available" class="dep-action" @click.stop> <el-tag :type="depStatus[key].available ? 'success' : 'warning'" size="small">
<el-button v-if="canDeploy(key)" size="small" type="primary" :loading="deploying === key" {{ depStatus[key].available ? $t('tunnelWizard.depReady') : $t('tunnelWizard.notDeployed') }}
@click="deployProtocol(key)">{{ $t('tunnelWizard.oneClickInstall') }}</el-button> </el-tag>
<el-button size="small" @click="showInstallCmd(key)">{{ $t('tunnelWizard.installCmd') }}</el-button> <div v-if="!depStatus[key].available" class="dep-action" @click.stop>
<el-button v-if="canDeploy(key)" size="small" type="primary" :loading="deploying === key"
@click="deployProtocol(key)">{{ $t('tunnelWizard.oneClickInstall') }}</el-button>
<el-button size="small" @click="showInstallCmd(key)">{{ $t('tunnelWizard.installCmd') }}</el-button>
</div>
</div>
</div> </div>
<template v-if="group.id === 'penetrate'">
<div v-for="p in customProtocols" :key="p.name" class="proto-card" :class="{ active: form.protocol === p.name }"
@click="selectProtocol(p.name)">
<div class="proto-icon"><ProtocolIcon protocol="custom" :size="36" /></div>
<div class="proto-name" style="color:#7b5ea7">{{ p.display_name }}</div>
<div class="proto-desc">{{ $t('tunnelWizard.customPlugin') }}</div>
<div class="proto-scene">{{ $t('tunnelWizard.customScene') }}</div>
</div>
</template>
</div> </div>
</div> </div>
<div v-for="p in customProtocols" :key="p.name" class="proto-card" :class="{ active: form.protocol === p.name }"
@click="selectProtocol(p.name)">
<div class="proto-icon"><ProtocolIcon protocol="custom" :size="36" /></div>
<div class="proto-name" style="color:#7b5ea7">{{ p.display_name }}</div>
<div class="proto-desc">{{ $t('tunnelWizard.customPlugin') }}</div>
<div class="proto-scene">{{ $t('tunnelWizard.customScene') }}</div>
</div>
</div> </div>
<!-- 第二步:参数表单 --> <!-- 第二步:参数表单 -->
@@ -166,6 +173,12 @@ import RouteListInput from '../components/RouteListInput.vue'
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue' import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols' import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
const protocolGroups = [
{ id: 'employee', titleKey: 'tunnelWizard.groupEmployee', keys: ['wireguard', 'ikev2', 'l2tp', 'openvpn'] },
{ id: 'site', titleKey: 'tunnelWizard.groupSite', keys: ['ipsec', 'gre'] },
{ id: 'penetrate', titleKey: 'tunnelWizard.groupPenetrate', keys: ['frp'] },
]
const { t } = useI18n() const { t } = useI18n()
const step = ref(0) const step = ref(0)
const form = ref({ name: '', protocol: '', config: {}, remark: '', auto_start: true, expire_at: null }) const form = ref({ name: '', protocol: '', config: {}, remark: '', auto_start: true, expire_at: null })
@@ -357,6 +370,8 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.proto-groups { display: flex; flex-direction: column; gap: 22px; }
.proto-group-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 10px; letter-spacing: .4px; }
.proto-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; max-width: 1000px; margin: 0 auto; } .proto-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; max-width: 1000px; margin: 0 auto; }
.proto-card { .proto-card {
border: 2px solid #e8eaee; border-radius: 12px; padding: 22px 16px; text-align: center; border: 2px solid #e8eaee; border-radius: 12px; padding: 22px 16px; text-align: center;
+3 -2
View File
@@ -122,7 +122,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue' import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import api from '../api' import api from '../api'
import { fmtBytes } from '../utils/format' import { fmtBytes } from '../utils/format'
import ProtocolIcon from '../components/ProtocolIcon.vue' import ProtocolIcon from '../components/ProtocolIcon.vue'
@@ -132,6 +132,7 @@ import TunnelLogDialog from './tunnels/components/TunnelLogDialog.vue'
import { protocolDisplay, protocolColor } from '../protocols' import { protocolDisplay, protocolColor } from '../protocols'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute()
const router = useRouter() const router = useRouter()
const list = ref([]) const list = ref([])
const loading = ref(false) const loading = ref(false)
@@ -140,7 +141,7 @@ const changingIds = ref(new Set())
let loadRevision = 0 let loadRevision = 0
const keyword = ref('') const keyword = ref('')
const protocolFilter = ref('') const protocolFilter = ref('')
const stateFilter = ref('') const stateFilter = ref(typeof route.query.state === 'string' ? route.query.state : '')
const protocolOptions = ref([]) const protocolOptions = ref([])
// 批量操作 // 批量操作
const selectedRows = ref([]) const selectedRows = ref([])