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

+100 -99
View File
@@ -3,168 +3,168 @@
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<b>VPN 拨号用户</b>
<b>{{ $t('vpnUsers.title') }}</b>
<span style="margin-left:8px;color:#909399;font-size:12px">
OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)
{{ $t('vpnUsers.mfaHint') }}
</span>
</div>
<el-button type="primary" @click="openCreate">添加用户</el-button>
<el-button type="primary" @click="openCreate">{{ $t('vpnUsers.addUser') }}</el-button>
</div>
</template>
<el-table :data="users" size="small">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="display_name" label="姓名" />
<el-table-column label="隧道" min-width="160">
<el-table-column prop="username" :label="$t('common.username')" />
<el-table-column prop="display_name" :label="$t('vpnUsers.displayName')" />
<el-table-column :label="$t('vpnUsers.tunnel')" min-width="160">
<template #default="{ row }">
{{ tunnelName(row.tunnel_id) }}
<el-tag size="small" style="margin-left:6px" type="info">{{ tunnelProto(row.tunnel_id) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="90" />
<el-table-column label="OTP / MFA" width="130">
<el-table-column prop="source" :label="$t('common.source')" width="90" />
<el-table-column :label="$t('vpnUsers.otpMfa')" width="130">
<template #default="{ row }">
<template v-if="isOpenVPN(row.tunnel_id)">
<el-tag :type="row.mfa_enabled ? 'success' : 'info'" size="small">
{{ row.mfa_enabled ? '已绑定' : '未绑定' }}
{{ row.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}
</el-tag>
<el-tag v-if="row.mfa_has_pin" size="small" type="warning" style="margin-left:4px">PIN</el-tag>
<el-tag v-if="isLocked(row)" size="small" type="danger" style="margin-left:4px">锁定</el-tag>
<el-tag v-if="isLocked(row)" size="small" type="danger" style="margin-left:4px">{{ $t('vpnUsers.locked') }}</el-tag>
</template>
<span v-else style="color:#c0c4cc">—</span>
</template>
</el-table-column>
<el-table-column label="流量 ↓ / ↑" width="170">
<el-table-column :label="$t('common.traffic')" width="170">
<template #default="{ row }">{{ bytes(row.rx_bytes) }} / {{ bytes(row.tx_bytes) }}</template>
</el-table-column>
<el-table-column label="最近在线" width="180">
<template #default="{ row }">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString('zh-CN') : '-' }}</template>
<el-table-column :label="$t('vpnUsers.lastOnline')" width="180">
<template #default="{ row }">{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<el-table-column :label="$t('common.status')" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? $t('common.enabled') : $t('common.disabled') }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280">
<el-table-column :label="$t('common.operation')" width="280">
<template #default="{ row }">
<el-button link size="small" @click="download(row)">下载配置</el-button>
<el-button v-if="isOpenVPN(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">OTP 管理</el-button>
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button>
<el-button type="danger" link size="small" @click="remove(row)">删除</el-button>
<el-button link size="small" @click="download(row)">{{ $t('vpnUsers.downloadConfig') }}</el-button>
<el-button v-if="isOpenVPN(row.tunnel_id)" link size="small" type="primary" @click="openOTPManager(row)">{{ $t('vpnUsers.otpManagement') }}</el-button>
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-button>
<el-button type="danger" link size="small" @click="remove(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="visible" title="添加 VPN 拨号用户" width="480px">
<el-dialog v-model="visible" :title="$t('vpnUsers.addVpnUserTitle')" width="480px">
<el-form label-width="100px">
<el-form-item label="拨号隧道" required>
<el-form-item :label="$t('vpnUsers.dialTunnel')" required>
<el-select v-model="form.tunnel_id" style="width:100%" filterable>
<el-option v-for="t in tunnels" :key="t.id" :value="t.id"
:label="`${t.name} (${protocolDisplay(t.protocol)})`" />
</el-select>
</el-form-item>
<el-form-item label="用户名" required>
<el-form-item :label="$t('common.username')" required>
<el-input v-model="form.username">
<template #append>
<el-button @click="openPlatformPicker">从已有用户选</el-button>
<el-button @click="openPlatformPicker">{{ $t('vpnUsers.pickFromUsers') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="显示名"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item label="认证来源">
<el-form-item :label="$t('vpnUsers.displayNameLabel')"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item :label="$t('vpnUsers.authSource')">
<el-radio-group v-model="form.source">
<el-radio-button value="local">本地</el-radio-button>
<el-radio-button value="ldap">LDAP</el-radio-button>
<el-radio-button value="local">{{ $t('common.local') }}</el-radio-button>
<el-radio-button value="ldap">{{ $t('common.ldap') }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.source === 'local'" label="密码" required>
<el-input v-model="form.password" type="password" show-password placeholder="至少 8 位" />
<el-form-item v-if="form.source === 'local'" :label="$t('common.password')" required>
<el-input v-model="form.password" type="password" show-password :placeholder="$t('vpnUsers.min8Chars')" />
</el-form-item>
<el-form-item v-else label="LDAP DN" required>
<el-form-item v-else :label="$t('vpnUsers.ldapDn')" required>
<el-input v-model="form.ldap_dn" placeholder="uid=user,ou=people,dc=example,dc=com" />
</el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item :label="$t('common.email')"><el-input v-model="form.email" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="create">创建</el-button>
<el-button @click="visible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="create">{{ $t('vpnUsers.create') }}</el-button>
</template>
</el-dialog>
<!-- 从平台用户选择对话框 -->
<el-dialog v-model="pickerVisible" title="选择已有平台用户" width="560px" align-center>
<el-dialog v-model="pickerVisible" :title="$t('vpnUsers.pickUserTitle')" width="560px" align-center>
<el-table :data="platformUsers" size="small" max-height="360" v-loading="pickerLoading"
@row-click="pickPlatformUser">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="email" label="邮箱" min-width="160" />
<el-table-column label="来源" width="80">
<el-table-column prop="username" :label="$t('common.username')" width="120" />
<el-table-column prop="email" :label="$t('common.email')" min-width="160" />
<el-table-column :label="$t('common.source')" width="80">
<template #default="{ row }">
<el-tag :type="row.source === 'ldap' ? 'primary' : 'info'" size="small">{{ row.source === 'ldap' ? 'LDAP' : '本地' }}</el-tag>
<el-tag :type="row.source === 'ldap' ? 'primary' : 'info'" size="small">{{ row.source === 'ldap' ? $t('common.ldap') : $t('common.local') }}</el-tag>
</template>
</el-table-column>
<template #empty><el-empty description="暂无平台用户" /></template>
<template #empty><el-empty :description="$t('vpnUsers.noPlatformUsers')" /></template>
</el-table>
<p style="text-align:center;color:var(--text-secondary);font-size:12px;margin-top:8px">点击行即选中并自动填充</p>
<p style="text-align:center;color:var(--text-secondary);font-size:12px;margin-top:8px">{{ $t('vpnUsers.clickRowHint') }}</p>
</el-dialog>
<!-- OTP 双因素认证管理 -->
<el-dialog v-model="otpVisible" :title="`双因素认证 — ${otpUser?.username || ''}`" width="520px">
<el-dialog v-model="otpVisible" :title="$t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' })" width="520px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="基于 TOTP 的双因素认证(SHA1 / base32 / 30s / 6 位),兼容主流 Authenticator 应用。支持静态挑战二次输入与 PIN+OTP 组合模式。" />
:title="$t('vpnUsers.otpHint')" />
<el-descriptions :column="1" border size="small" v-if="otpStatus">
<el-descriptions-item label="绑定状态">
<el-descriptions-item :label="$t('vpnUsers.bindStatus')">
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" size="small">
{{ otpStatus.mfa_enabled ? '已绑定 TOTP' : '未绑定' }}
{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.unbound') }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="静态 PIN">{{ otpStatus.mfa_has_pin ? '已设置' : '未设置' }}</el-descriptions-item>
<el-descriptions-item label="失败次数">{{ otpStatus.mfa_fail_count || 0 }}</el-descriptions-item>
<el-descriptions-item label="锁定">
<el-descriptions-item :label="$t('vpnUsers.staticPin')">{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</el-descriptions-item>
<el-descriptions-item :label="$t('vpnUsers.failCount')">{{ otpStatus.mfa_fail_count || 0 }}</el-descriptions-item>
<el-descriptions-item :label="$t('vpnUsers.lockStatus')">
<span v-if="otpStatus.mfa_locked" style="color:#f56c6c">
是(至 {{ otpStatus.mfa_locked_until ? new Date(otpStatus.mfa_locked_until).toLocaleString('zh-CN') : '' }})
{{ $t('vpnUsers.lockedUntil', { time: otpStatus.mfa_locked_until ? new Date(otpStatus.mfa_locked_until).toLocaleString() : '' }) }}
</span>
<span v-else>否</span>
<span v-else>{{ $t('common.no') }}</span>
</el-descriptions-item>
<el-descriptions-item label="最近 OTP 成功">
{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString('zh-CN') : '—' }}
<el-descriptions-item :label="$t('vpnUsers.lastOtpSuccess')">
{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}
</el-descriptions-item>
<el-descriptions-item label="兼容">{{ otpStatus.compatible }}</el-descriptions-item>
<el-descriptions-item :label="$t('vpnUsers.compatible')">{{ otpStatus.compatible }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top:16px;display:flex;flex-wrap:wrap;gap:8px">
<el-button v-if="!otpStatus?.mfa_enabled" type="primary" @click="setupMFA">绑定 Authenticator</el-button>
<el-button v-else type="danger" plain @click="disableMFA">关闭 MFA</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="regenBackup">重新生成备用码</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="showPinDialog">设置/清除 PIN</el-button>
<el-button v-if="otpStatus?.mfa_locked" type="warning" @click="unlockMFA">解锁</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="exportOtpSecrets">导出密钥</el-button>
<el-button v-if="!otpStatus?.mfa_enabled" type="primary" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button>
<el-button v-else type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button>
<el-button v-if="otpStatus?.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button>
<el-button v-if="otpStatus?.mfa_enabled" @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button>
</div>
<div v-if="bindStep" style="margin-top:16px;text-align:center;border-top:1px solid #ebeef5;padding-top:16px">
<p style="font-size:13px;color:#606266">使用 Google Authenticator / Microsoft Authenticator / FreeOTP 扫描</p>
<p style="font-size:13px;color:#606266">{{ $t('vpnUsers.scanHint') }}</p>
<canvas ref="mfaCanvas"></canvas>
<div v-if="mfaSecret" style="margin-top:8px;font-size:12px;color:#909399;word-break:break-all">
base32 密钥:{{ mfaSecret }}
{{ $t('vpnUsers.base32Key', { key: mfaSecret }) }}
</div>
<el-input v-model="mfaCode" placeholder="输入 6 位验证码确认绑定" style="margin-top:12px;max-width:280px" maxlength="8" />
<el-input v-model="mfaCode" :placeholder="$t('vpnUsers.input6DigitCode')" style="margin-top:12px;max-width:280px" maxlength="8" />
<div style="margin-top:10px">
<el-button type="primary" :loading="mfaSaving" @click="enableMFA">验证并启用</el-button>
<el-button @click="bindStep = false">取消</el-button>
<el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button>
<el-button @click="bindStep = false">{{ $t('common.cancel') }}</el-button>
</div>
</div>
<el-alert v-if="otpStatus?.mfa_enabled" type="success" :closable="false" style="margin-top:16px"
title="客户端连接方式:密码框填登录密码,第二因子填 6 位动态验证码(若设置了 PIN,则填 PIN+验证码,如 1234123456)。也支持密码后直接附加 6 位码。" />
:title="$t('vpnUsers.clientConnectHint')" />
</el-dialog>
<el-dialog v-model="pinVisible" title="设置静态 PIN" width="400px">
<el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
title="连接时第二因子需输入 PIN + 6 位动态验证码(无空格拼接)。留空保存可清除已设置的 PIN。" />
<el-input v-model="pinValue" type="password" show-password placeholder="4-32 位 PIN,留空清除" />
:title="$t('vpnUsers.pinHint')" />
<el-input v-model="pinValue" type="password" show-password :placeholder="$t('vpnUsers.pinPlaceholder')" />
<template #footer>
<el-button @click="pinVisible = false">取消</el-button>
<el-button type="primary" @click="savePin">保存</el-button>
<el-button @click="pinVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="savePin">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
</template>
@@ -172,10 +172,12 @@
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api from '../api'
import { protocolDisplay } from '../protocols'
const { t } = useI18n()
const users = ref([])
const tunnels = ref([])
const visible = ref(false)
@@ -206,8 +208,8 @@ const bytes = n => {
}
const tunnelName = id => tunnels.value.find(t => t.id === id)?.name || `#${id}`
const tunnelProto = id => {
const t = tunnels.value.find(x => x.id === id)
return t ? protocolDisplay(t.protocol) : ''
const tun = tunnels.value.find(x => x.id === id)
return tun ? protocolDisplay(tun.protocol) : ''
}
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
@@ -215,7 +217,7 @@ const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) >
async function load() {
const [u, all] = await Promise.all([api.get('/vpn-users'), api.get('/tunnels')])
users.value = u.data || []
tunnels.value = (all.data || []).filter(t => ['openvpn', 'l2tp', 'ikev2'].includes(t.protocol))
tunnels.value = (all.data || []).filter(tun => ['openvpn', 'l2tp', 'ikev2'].includes(tun.protocol))
}
function openCreate() {
@@ -240,20 +242,19 @@ function pickPlatformUser(row) {
form.value.display_name = row.email ? row.email.split('@')[0] : row.username
form.value.email = row.email || ''
form.value.source = row.source === 'ldap' ? 'ldap' : 'local'
// LDAP 用户需填 DN(留空由后端校验提示)
form.value.ldap_dn = row.source === 'ldap' ? `uid=${row.username}` : ''
pickerVisible.value = false
ElMessage.success(`已填充用户「${row.username}」信息`)
ElMessage.success(t('vpnUsers.userFilled', { username: row.username }))
}
async function create() {
try {
await api.post('/vpn-users', form.value)
visible.value = false
ElMessage.success('拨号用户已创建')
ElMessage.success(t('vpnUsers.vpnUserCreated'))
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '创建失败')
ElMessage.error(e.response?.data?.error || t('vpnUsers.createFailed'))
}
}
@@ -263,7 +264,7 @@ async function toggle(row) {
}
async function remove(row) {
await ElMessageBox.confirm(`删除拨号用户 ${row.username}?`, '确认')
await ElMessageBox.confirm(t('vpnUsers.deleteVpnUserConfirm', { username: row.username }), t('common.confirm'))
await api.delete(`/vpn-users/${row.id}`)
load()
}
@@ -277,16 +278,16 @@ async function download(row) {
const data = await res.json()
throw new Error(data.error)
}
const t = tunnels.value.find(x => x.id === row.tunnel_id)
const ext = t?.protocol === 'openvpn' ? 'ovpn' : 'txt'
const tun = tunnels.value.find(x => x.id === row.tunnel_id)
const ext = tun?.protocol === 'openvpn' ? 'ovpn' : 'txt'
const url = URL.createObjectURL(await res.blob())
const a = document.createElement('a')
a.href = url
a.download = `${t?.protocol || 'vpn'}-${row.username}.${ext}`
a.download = `${tun?.protocol || 'vpn'}-${row.username}.${ext}`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
ElMessage.error(e.message || '下载失败')
ElMessage.error(e.message || t('vpnUsers.downloadFailed'))
}
}
@@ -326,49 +327,49 @@ async function setupMFA() {
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 200, margin: 1 })
}
} catch (e) {
ElMessage.error(e.response?.data?.error || '无法开始 MFA 设置')
ElMessage.error(e.response?.data?.error || t('vpnUsers.mfaSetupFailed'))
}
}
async function enableMFA() {
if (!mfaCode.value) return ElMessage.warning('请输入验证码')
if (!mfaCode.value) return ElMessage.warning(t('vpnUsers.inputCode'))
mfaSaving.value = true
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/enable`, { code: mfaCode.value })
bindStep.value = false
const codes = (data.backup_codes || []).join(' ')
ElMessageBox.alert(
`双因素认证已启用。\n\n一次性备用码(请妥善保存,丢失手机时用于登录):\n${codes}\n\n客户端连接方式:密码框填登录密码,第二因子填 6 位动态验证码(若设置了 PIN 则填 PIN+验证码)。`,
'绑定成功',
t('vpnUsers.mfaEnabledMsg', { codes }),
t('vpnUsers.bindSuccess'),
{ type: 'success' }
)
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '验证失败')
ElMessage.error(e.response?.data?.error || t('vpnUsers.verifyFailed'))
} finally {
mfaSaving.value = false
}
}
async function disableMFA() {
await ElMessageBox.confirm(`关闭 ${otpUser.value.username} 的 MFA?`, '确认', { type: 'warning' })
await ElMessageBox.confirm(t('vpnUsers.disableMfaConfirm', { username: otpUser.value.username }), t('common.confirm'), { type: 'warning' })
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/disable`)
ElMessage.success('MFA 已关闭')
ElMessage.success(t('vpnUsers.mfaClosed'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '操作失败')
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
}
}
async function regenBackup() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/backup-codes`)
ElMessageBox.alert(`新备用码(旧码失效):\n\n${(data.backup_codes || []).join(' ')}`, '备用码', { type: 'success' })
ElMessageBox.alert(t('vpnUsers.newBackupCodes', { codes: (data.backup_codes || []).join(' ') }), t('vpnUsers.backupCodesTitle'), { type: 'success' })
} catch (e) {
ElMessage.error(e.response?.data?.error || '生成失败')
ElMessage.error(e.response?.data?.error || t('vpnUsers.generateFailed'))
}
}
@@ -381,22 +382,22 @@ async function savePin() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/pin`, { pin: pinValue.value })
pinVisible.value = false
ElMessage.success(pinValue.value ? 'PIN 已设置' : 'PIN 已清除')
ElMessage.success(pinValue.value ? t('vpnUsers.pinSetSuccess') : t('vpnUsers.pinCleared'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '保存失败')
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
}
}
async function unlockMFA() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/unlock`)
ElMessage.success('已解锁')
ElMessage.success(t('vpnUsers.unlocked'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '解锁失败')
ElMessage.error(e.response?.data?.error || t('vpnUsers.unlockFailed'))
}
}
@@ -404,12 +405,12 @@ async function exportOtpSecrets() {
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa/otp-secrets`)
ElMessageBox.alert(
`TOTP 密钥行(敏感信息,请勿泄露):\n\n${data.line}\n\n格式说明:${data.format}`,
'导出密钥',
t('vpnUsers.otpSecretsMsg', { line: data.line, format: data.format }),
t('vpnUsers.exportKeysTitle'),
{ type: 'warning' }
)
} catch (e) {
ElMessage.error(e.response?.data?.error || '导出失败')
ElMessage.error(e.response?.data?.error || t('vpnUsers.exportFailed'))
}
}