feat: 后端限速实现 + 前端i18n全面国际化 + 安全与代码质量修复

后端:
- trafficmgr: 实现 tc htb 限速(ApplyRateLimit/RemoveRateLimit),自动推导接口名
- 各协议服务: 代码质量改进与错误处理增强

前端:
- protocols.js: 170个硬编码中文字符串全部转为 i18n key,中英文双语翻译
- 提取 fmtBytes() 到 utils/format.js 公共模块(消除 4 处重复)
- router.js: 添加路由级权限守卫,非管理员无法直接 URL 访问管理页
- Tunnels.vue: SSE 日志流改用 fetch+ReadableStream,token 放 Authorization header
- VPNUsers.vue: 修复 catch 块中文硬编码 fallback
- 新增 TaskProgress 组件、taskmgr 后端任务管理
This commit is contained in:
opsx-tools committed 2026-07-23 12:57:46 +08:00
1 parent 021d012478
commit 91974fc30f
26 files changed
+1977 -377

No files matched your search

+132 -50
View File
@@ -5,7 +5,7 @@
<div>
<el-input v-model="keyword" :placeholder="$t('tunnels.searchPlaceholder')" clearable style="width:200px" @change="load" />
<el-select v-model="protocolFilter" :placeholder="$t('tunnels.allProtocols')" clearable style="width:160px;margin-left:10px" @change="load">
<el-option v-for="p in protocolOptions" :key="p" :label="protocolDisplay(p)" :value="p" />
<el-option v-for="p in protocolOptions" :key="p" :label="$t(protocolDisplay(p))" :value="p" />
</el-select>
<el-select v-model="stateFilter" :placeholder="$t('tunnels.allStates')" clearable style="width:140px;margin-left:10px" @change="load">
<el-option :label="$t('tunnels.stateRunning')" value="running" />
@@ -17,7 +17,16 @@
</div>
</template>
<el-table :data="list" v-loading="loading" size="large">
<!-- 批量操作栏 -->
<div v-if="selectedRows.length > 0" class="batch-bar">
<span class="batch-info">{{ $t('tunnels.selected', { count: selectedRows.length }) }}</span>
<el-button type="success" size="small" @click="batchAction('start')">{{ $t('tunnels.batchStart') }}</el-button>
<el-button type="warning" size="small" @click="batchAction('stop')">{{ $t('tunnels.batchStop') }}</el-button>
<el-button type="danger" size="small" @click="batchAction('delete')">{{ $t('tunnels.batchDelete') }}</el-button>
</div>
<el-table :data="list" v-loading="loading" size="large" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column prop="name" :label="$t('common.name')" min-width="140">
<template #default="{ row }">
<b>{{ row.name }}</b>
@@ -28,7 +37,7 @@
<template #default="{ row }">
<span class="proto-cell">
<ProtocolIcon :protocol="row.protocol" :size="16" :style="{ color: protocolColor(row.protocol) }" />
{{ protocolDisplay(row.protocol) }}
{{ $t(protocolDisplay(row.protocol)) }}
</span>
</template>
</el-table-column>
@@ -86,7 +95,7 @@
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
<template v-if="detail">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('common.protocol')">{{ protocolDisplay(detail.tunnel.protocol) }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
@@ -204,17 +213,22 @@
</div>
<div class="log-view mono" ref="logView">{{ logContent || $t('tunnels.waitingLog') }}</div>
</el-dialog>
<!-- 批量任务进度 -->
<TaskProgress ref="taskProgressRef" />
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Plus, InfoFilled, ArrowDown } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import { useRouter } from 'vue-router'
import api from '../api'
import { fmtBytes } from '../utils/format'
import ProtocolIcon from '../components/ProtocolIcon.vue'
import TaskProgress from '../components/TaskProgress.vue'
import { protocolDisplay, protocolColor } from '../protocols'
const { t } = useI18n()
@@ -240,13 +254,35 @@ const peerDialog = ref(false)
const peerSaving = ref(false)
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
function fmtBytes(n) {
if (!n) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
while (n >= 1024 && i < 4) { n /= 1024; i++ }
return n.toFixed(1) + ' ' + u[i]
// 批量操作
const selectedRows = ref([])
const taskProgressRef = ref(null)
function handleSelectionChange(rows) {
selectedRows.value = rows
}
const selectedIds = computed(() => selectedRows.value.map(r => r.id))
async function batchAction(action) {
const ids = selectedIds.value
if (ids.length === 0) return
const confirmKey = `tunnels.batch${action.charAt(0).toUpperCase() + action.slice(1)}Confirm`
try {
await ElMessageBox.confirm(t(confirmKey, { count: ids.length }), t('common.confirm'), { type: 'warning' })
} catch { return }
try {
const { data } = await api.post(`/tunnels/batch/${action}`, { tunnel_ids: ids })
ElMessage.success(t('tunnels.batchStarted'))
selectedRows.value = []
if (data.task_id && taskProgressRef.value) {
taskProgressRef.value.open(data.task_id)
}
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
}
}
function fmtTime(time) { return time ? new Date(time).toLocaleString() : '-' }
function expiringSoon(row) {
return row.expire_at && new Date(row.expire_at) - Date.now() < 7 * 864e5
@@ -289,13 +325,13 @@ function onMoreCommand(cmd, row) {
else if (cmd === 'logs') openLogStream(row)
}
// ---- 实时日志流(SSE)----
// ---- 实时日志流(SSE via fetch + ReadableStream,避免 token 暴露在 URL)----
const logVisible = ref(false)
const logTunnel = ref(null)
const logContent = ref('')
const logStreaming = ref(false)
const logView = ref(null)
let logEventSource = null
let logAbortController = null
function openLogStream(row) {
logTunnel.value = row
@@ -304,31 +340,49 @@ function openLogStream(row) {
resumeLogStream()
}
function resumeLogStream() {
async function resumeLogStream() {
if (!logTunnel.value) return
stopLogStream()
const token = localStorage.getItem('vpn_token')
logEventSource = new EventSource(`/api/tunnels/${logTunnel.value.id}/logs?token=${token}`)
logAbortController = new AbortController()
logStreaming.value = true
logEventSource.onmessage = (e) => {
logContent.value += e.data + '\n'
nextTick(() => {
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
try {
const resp = await fetch(`/api/tunnels/${logTunnel.value.id}/logs`, {
headers: { 'Authorization': `Bearer ${token}` },
signal: logAbortController.signal
})
if (logContent.value.length > 100000) {
logContent.value = logContent.value.slice(-50000)
if (!resp.ok) { logStreaming.value = false; return }
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data: ')) {
logContent.value += line.slice(6) + '\n'
}
}
nextTick(() => {
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
})
if (logContent.value.length > 100000) {
logContent.value = logContent.value.slice(-50000)
}
}
} catch (e) {
if (e.name !== 'AbortError') console.error('log stream error', e)
}
logEventSource.onerror = () => {
logStreaming.value = false
logEventSource?.close()
}
logStreaming.value = false
}
function stopLogStream() {
logStreaming.value = false
logEventSource?.close()
logEventSource = null
logAbortController?.abort()
logAbortController = null
}
function closeLogStream() {
@@ -378,27 +432,39 @@ async function start(row) {
load()
}
async function stop(row) {
await api.post(`/tunnels/${row.id}/stop`)
ElMessage.success(t('tunnels.stopped'))
try {
await api.post(`/tunnels/${row.id}/stop`)
ElMessage.success(t('tunnels.stopped'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.stopFailed'))
}
load()
}
async function remove(row) {
await ElMessageBox.confirm(t('tunnels.deleteTunnelConfirm', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' })
await api.delete(`/tunnels/${row.id}`)
ElMessage.success(t('common.deleteSuccess'))
load()
try {
await ElMessageBox.confirm(t('tunnels.deleteTunnelConfirm', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' })
await api.delete(`/tunnels/${row.id}`)
ElMessage.success(t('common.deleteSuccess'))
load()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
async function showDetail(row) {
const { data } = await api.get(`/tunnels/${row.id}`)
detail.value = data
detailVisible.value = true
if (row.protocol === 'wireguard') {
await loadPeers(row.id)
} else {
wgPeers.value = []
runtimePeers.value = []
try {
const { data } = await api.get(`/tunnels/${row.id}`)
detail.value = data
detailVisible.value = true
if (row.protocol === 'wireguard') {
await loadPeers(row.id)
} else {
wgPeers.value = []
runtimePeers.value = []
}
loadPolicy(row.id)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
}
loadPolicy(row.id)
}
async function loadPeers(tunnelId) {
@@ -490,7 +556,8 @@ async function qrPeer(row) {
function download(row) {
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
.then(r => r.blob()).then(b => {
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.blob() })
.then(b => {
const a = document.createElement('a')
const url = URL.createObjectURL(b)
a.href = url
@@ -498,21 +565,28 @@ function download(row) {
a.click()
URL.revokeObjectURL(url)
})
.catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function showQr(row) {
const { data } = await api.get(`/tunnels/${row.id}`)
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
try {
const { data } = await api.get(`/tunnels/${row.id}`)
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.qrFailed'))
}
}
let timer
onMounted(async () => {
load()
timer = setInterval(load, 15000)
const { data } = await api.get('/protocols')
protocolOptions.value = data.map(p => p.name)
try {
const { data } = await api.get('/protocols')
protocolOptions.value = data.map(p => p.name)
} catch { /* protocols load optional */ }
})
onUnmounted(() => clearInterval(timer))
</script>
@@ -526,4 +600,12 @@ onUnmounted(() => clearInterval(timer))
background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px;
height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
}
.batch-bar {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; margin-bottom: 12px;
background: var(--el-color-primary-light-9);
border-radius: var(--radius-sm);
border: 1px solid var(--el-color-primary-light-5);
}
.batch-info { font-size: 13px; font-weight: 600; color: var(--el-color-primary); margin-right: auto; }
</style>