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
+100
-99
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user