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:
1 parent
1e2f8ef24f
commit
021d012478
25 files changed
+2313
-746
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user