feat: 全协议一键安装 + 国内GitHub镜像加速 + 中英文i18n国际化

后端:
- binmgr.go: 新增 detectChina() 国内/海外自动判断(ipinfo.io)
- binmgr.go: 国内自动走 gh-proxy.com 镜像加速 GitHub 下载
- binmgr.go: 新增 InstallViaPackageManager() 包管理器一键安装
- binmgr.go: 新增 GetDeploySpec()/ProtocolAlias() 导出函数
- api.go: installBinary 支持全部7个协议(FRP/WireGuard/OpenVPN/IPSec/IKEv2/L2TP/StrongSwan)
- api.go: 策略: GitHub下载失败 → 自动降级包管理器安装

前端:
- 安装 vue-i18n@9, 支持中英文切换
- 新增 locales/zh-CN.json + en-US.json (548个翻译key, 17个模块)
- 新增 i18n.js 配置, localStorage 持久化语言偏好
- Layout.vue 右上角语言切换下拉菜单
- 所有17个Vue文件中文文本转为 $t() 调用
- Element Plus 组件语言跟随切换
- TunnelWizard: 所有协议显示「一键安装」按钮(带loading)
- 前端构建零错误
This commit is contained in:
opsx-tools committed 2026-07-23 03:01:46 +08:00
1 parent 1e2f8ef24f
commit 021d012478
25 files changed
+2313 -746

No files matched your search

+43 -42
View File
@@ -2,72 +2,72 @@
<el-row :gutter="16">
<el-col :xs="24" :lg="10">
<el-alert v-if="auth.mustChange" type="warning" :closable="false" show-icon
title="首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能"
:title="$t('settings.mustChangePassword')"
style="margin-bottom:16px" />
<el-card class="page-card" shadow="never">
<template #header><b>修改密码</b></template>
<template #header><b>{{ $t('settings.changePassword') }}</b></template>
<el-form label-width="90px" @submit.prevent="changePwd">
<el-form-item label="原密码">
<el-form-item :label="$t('settings.oldPassword')">
<el-input v-model="pwd.old_password" type="password" show-password autocomplete="current-password" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwd.new_password" type="password" show-password autocomplete="new-password" placeholder="至少 8 位" />
<el-form-item :label="$t('settings.newPassword')">
<el-input v-model="pwd.new_password" type="password" show-password autocomplete="new-password" :placeholder="$t('settings.min8Chars')" />
</el-form-item>
<el-form-item label="确认新密码">
<el-form-item :label="$t('settings.confirmNewPassword')">
<el-input v-model="pwd.confirm" type="password" show-password autocomplete="new-password" @keyup.enter="changePwd" />
</el-form-item>
<el-button type="primary" style="width:100%" :loading="pwdLoading" @click="changePwd">修改</el-button>
<el-button type="primary" style="width:100%" :loading="pwdLoading" @click="changePwd">{{ $t('settings.change') }}</el-button>
</el-form>
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>双因素认证(MFA)</b></template>
<el-alert type="info" :closable="false" title="使用 Microsoft Authenticator、Google Authenticator 等应用扫描二维码。备用码可在丢失手机时登录。" style="margin-bottom:10px" />
<el-button v-if="!mfa.enabled" type="primary" @click="setupMFA">设置 MFA</el-button>
<el-button v-else type="danger" @click="disableMFA">关闭 MFA</el-button>
<template #header><b>{{ $t('settings.mfaTitle') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('settings.mfaHint')" style="margin-bottom:10px" />
<el-button v-if="!mfa.enabled" type="primary" @click="setupMFA">{{ $t('settings.setupMfa') }}</el-button>
<el-button v-else type="danger" @click="disableMFA">{{ $t('settings.disableMfa') }}</el-button>
<div v-if="mfa.uri" style="margin-top:12px">
<canvas ref="mfaCanvas" style="display:block;margin:0 auto"></canvas>
<el-input v-model="mfa.code" placeholder="输入 App 显示的 6 位验证码" style="margin-top:8px" maxlength="6" />
<el-button type="success" style="width:100%;margin-top:8px" :loading="mfaLoading" @click="enableMFA">验证并启用</el-button>
<el-input v-model="mfa.code" :placeholder="$t('settings.input6DigitCode')" style="margin-top:8px" maxlength="6" />
<el-button type="success" style="width:100%;margin-top:8px" :loading="mfaLoading" @click="enableMFA">{{ $t('settings.verifyAndEnable') }}</el-button>
</div>
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>部署说明</b></template>
<template #header><b>{{ $t('settings.deployTitle') }}</b></template>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="交付形态">单二进制(前端已内嵌),无需 Docker</el-descriptions-item>
<el-descriptions-item label="数据目录">./data(可用 VPN_DATA_DIR 覆盖)</el-descriptions-item>
<el-descriptions-item label="监听地址">:8080(VPN_LISTEN_ADDR 覆盖)</el-descriptions-item>
<el-descriptions-item label="密钥存储">PSK / 私钥 AES-GCM 加密落库,配置文件 0600 权限</el-descriptions-item>
<el-descriptions-item label="系统依赖">wireguard-tools / strongswan / xl2tpd / openvpn / frp</el-descriptions-item>
<el-descriptions-item label="生产建议">经 Nginx/Caddy 反向代理终止 TLS 后暴露</el-descriptions-item>
<el-descriptions-item :label="$t('settings.deliveryForm')">{{ $t('settings.deliveryDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.dataDir')">{{ $t('settings.dataDirDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.listenAddr')">{{ $t('settings.listenAddrDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.keyStorage')">{{ $t('settings.keyStorageDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.systemDeps')">{{ $t('settings.systemDepsDesc') }}</el-descriptions-item>
<el-descriptions-item :label="$t('settings.prodSuggestion')">{{ $t('settings.prodSuggestionDesc') }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
<el-card class="page-card" shadow="never">
<template #header><b>操作审计(最近 100 条)</b></template>
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
<el-table :data="logs" size="small" max-height="560" v-loading="auditLoading">
<el-table-column prop="created_at" label="时间" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString('zh-CN') }}</template>
<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="用户" width="90" />
<el-table-column prop="action" label="动作" width="140" />
<el-table-column prop="target" label="对象" show-overflow-tooltip />
<template #empty><el-empty description="暂无审计日志" /></template>
<el-table-column prop="username" :label="$t('common.username')" width="90" />
<el-table-column prop="action" :label="$t('common.action')" width="140" />
<el-table-column prop="target" :label="$t('common.target')" show-overflow-tooltip />
<template #empty><el-empty :description="$t('settings.noAuditLogs')" /></template>
</el-table>
</el-card>
</el-col>
<!-- MFA 备用码持久化弹窗(P0 修复:不再用会消失的 Toast) -->
<el-dialog v-model="backupVisible" title="MFA 已启用 — 请保存备用码" width="480px" align-center
<!-- MFA 备用码持久化弹窗 -->
<el-dialog v-model="backupVisible" :title="$t('settings.mfaEnabledTitle')" width="480px" align-center
:close-on-click-modal="false" :close-on-press-escape="false">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px"
title="这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!" />
:title="$t('settings.backupCodesHint')" />
<div class="backup-codes">
<code v-for="(c, i) in backupCodes" :key="i">{{ c }}</code>
</div>
<template #footer>
<el-button @click="copyBackup">复制全部</el-button>
<el-button type="primary" @click="backupVisible = false">我已保存</el-button>
<el-button @click="copyBackup">{{ $t('settings.copyAll') }}</el-button>
<el-button type="primary" @click="backupVisible = false">{{ $t('settings.savedConfirm') }}</el-button>
</template>
</el-dialog>
</el-row>
@@ -76,9 +76,11 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api, { auth } from '../api'
const { t } = useI18n()
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
const pwdLoading = ref(false)
const logs = ref([])
@@ -90,16 +92,16 @@ const backupVisible = ref(false)
const backupCodes = ref([])
async function changePwd() {
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning('两次新密码不一致')
if (pwd.value.new_password.length < 8) return ElMessage.warning('新密码至少 8 位')
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning(t('settings.passwordMismatch'))
if (pwd.value.new_password.length < 8) return ElMessage.warning(t('settings.passwordMin8'))
pwdLoading.value = true
try {
await api.post('/password', { old_password: pwd.value.old_password, new_password: pwd.value.new_password })
auth.clearMustChange()
ElMessage.success('密码已修改')
ElMessage.success(t('settings.passwordChanged'))
pwd.value = { old_password: '', new_password: '', confirm: '' }
} catch (e) {
ElMessage.error(e.response?.data?.error || '修改失败')
ElMessage.error(e.response?.data?.error || t('settings.changeFailed'))
} finally { pwdLoading.value = false }
}
@@ -123,7 +125,7 @@ async function setupMFA() {
mfa.value.uri = data.otpauth_url
await nextTick()
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 180 })
} catch (e) { ElMessage.error(e.response?.data?.error || '设置失败') }
} catch (e) { ElMessage.error(e.response?.data?.error || t('settings.setupFailed')) }
}
async function enableMFA() {
@@ -131,11 +133,10 @@ async function enableMFA() {
try {
const { data } = await api.post('/mfa/enable', { code: mfa.value.code })
mfa.value = { enabled: true, uri: '', code: '' }
// P0 修复:备用码用持久弹窗展示,不再用会消失的 Toast
backupCodes.value = data.backup_codes || []
backupVisible.value = true
} catch (e) {
ElMessage.error(e.response?.data?.error || '验证失败')
ElMessage.error(e.response?.data?.error || t('settings.verifyFailed'))
} finally { mfaLoading.value = false }
}
@@ -143,12 +144,12 @@ async function disableMFA() {
try {
await api.post('/mfa/disable')
mfa.value = { enabled: false, uri: '', code: '' }
ElMessage.success('MFA 已关闭')
} catch (e) { ElMessage.error(e.response?.data?.error || '关闭失败') }
ElMessage.success(t('settings.mfaClosed'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('settings.closeFailed')) }
}
function copyBackup() {
navigator.clipboard.writeText(backupCodes.value.join('\n')).then(() => ElMessage.success('已复制到剪贴板'))
navigator.clipboard.writeText(backupCodes.value.join('\n')).then(() => ElMessage.success(t('common.copiedToClipboard')))
}
</script>