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

+8 -5
View File
@@ -18,6 +18,9 @@
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
protocol: { type: String, required: true },
@@ -25,19 +28,19 @@ const props = defineProps({
})
// 品牌名称映射
const NAMES = {
const NAMES = computed(() => ({
wireguard: 'WireGuard',
ipsec: 'IPSec',
ikev2: 'IKEv2',
l2tp: 'L2TP',
frp: 'FRP',
openvpn: 'OpenVPN',
custom: '自定义协议',
}
custom: t('protocolIcon.custom'),
}))
const label = computed(() => {
const key = props.protocol.startsWith('custom:') ? 'custom' : props.protocol
return NAMES[key] || props.protocol
return NAMES.value[key] || props.protocol
})
// SVG path 数据
@@ -45,7 +48,7 @@ const label = computed(() => {
// 其余: 原创矢量图标
const ICON_PATHS = {
// WireGuard 官方 Logo (Simple Icons, CC0)
wireguard: 'M23.98 11.645S24.533 0 11.735 0C.418 0 .064 11.17.064 11.17S-1.6 24 11.997 24C25.04 24 23.98 11.645 23.98 11.645zM8.155 7.576c2.4-1.47 5.469-.571 6.618 1.638.218.419.246 1.063.108 1.503-.477 1.516-1.601 2.366-3.145 2.728.455-.39.817-.832.933-1.442a2.112 2.112 0 0 0-.364-1.677 2.14 2.14 0 0 0-2.465-.75c-.95.36-1.47 1.228-1.377 2.294.087.99.839 1.632 2.245 1.876-.21.111-.372.193-.53.281a5.113 5.113 0 0 0-1.644 1.43c-.143.192-.24.208-.458.075-2.827-1.729-3.009-6.067.078-7.956zM6.04 18.258c-.455.116-.895.286-1.359.438.227-1.532 2.021-2.943 3.539-2.782a3.91 3.91 0 0 0-.74 2.072c-.504.093-.98.155-1.44.272zM15.703 3.3c.448.017.898.01 1.347.02a2.324 2.324 0 0 1 .334.047 3.249 3.249 0 0 1-.34.434c-.16.15-.341.296-.573.069-.055-.055-.187-.042-.283-.044-.447-.005-.894-.02-1.34-.003a8.323 8.323 0 0 0-1.154.118c-.072.013-.178.25-.146.338.078.207.191.435.359.567.619.49 1.277.928 1.9 1.413.604.472 1.167.99 1.51 1.7.446.928.46 1.9.267 2.877-.322 1.63-1.147 2.98-2.483 3.962-.538.395-1.205.62-1.821.903-.543.25-1.1.465-1.644.712-.98.446-1.53 1.51-1.369 2.615.149 1.015 1.04 1.862 2.059 2.037 1.223.21 2.486-.586 2.785-1.83.336-1.397-.423-2.646-1.845-3.024l-.256-.066c.38-.17.708-.291 1.012-.458q.793-.437 1.558-.925c.15-.096.231-.096.36.014.977.846 1.56 1.898 1.724 3.187.27 2.135-.74 4.096-2.646 5.101-2.948 1.555-6.557-.215-7.208-3.484-.558-2.8 1.418-5.34 3.797-5.83 1.023-.211 1.958-.637 2.685-1.425.47-.508.697-.944.775-1.141a3.165 3.165 0 0 0 .217-1.158 2.71 2.71 0 0 0-.237-.992c-.248-.566-1.2-1.466-1.435-1.656l-2.24-1.754c-.079-.065-.168-.06-.36-.047-.23.016-.815.048-1.067-.018.204-.155.76-.38 1-.56-.726-.49-1.554-.314-2.315-.46.176-.328 1.046-.831 1.541-.888a7.323 7.323 0 0 0-.135-.822c-.03-.111-.154-.22-.263-.283-.262-.154-.541-.281-.843-.434a1.755 1.755 0 0 1 .906-.28 3.385 3.385 0 0 1 .908.088c.54.123.97.042 1.399-.324-.338-.136-.676-.26-1.003-.407a9.843 9.843 0 0 1-.942-.493c.85.118 1.671.437 2.54.32l.022-.118-2.018-.47c1.203-.11 2.323-.128 3.384.388.299.146.61.266.897.432.14.08.233.24.348.365.09.098.164.23.276.29.424.225.89.234 1.366.223l.01-.16c.479.15 1.017.702 1.017 1.105-.776 0-1.55-.003-2.325.004-.083 0-.165.061-.247.094.078.046.155.128.235.131z',
wireguard: 'M23.98 11.645S24.533 0 11.735 0C.418 0 .064 11.17.064 11.17S-1.6 24 11.997 24C25.04 24 23.98 11.645 23.98 11.645zM8.155 7.576c2.4-1.47 5.469-.571 6.618 1.638.218.419.246 1.063.108 1.503-.477 1.516-1.601 2.366-3.145 2.728.455-.39.817-.832.933-1.442a2.112 2.112 0 0 0-.364-1.677 2.14 2.14 0 0 0-2.465-.75c-.95.36-1.47 1.228-1.377 2.294.087.99.839 1.632 2.245 1.876-.21.111-.372.193-.53.281a5.113 5.113 0 0 0-1.644 1.43c-.143.192-.24.208-.458.075-2.827-1.729-3.009-6.067.078-7.956zM6.04 18.258c-.455.116-.895.286-1.359.438.227-1.532 2.021-2.943 3.539-2.782a3.91 3.91 0 0 0-.74 2.072c-.504.093-.98.155-1.44.272zM15.703 3.3c.448.017.898.01 1.347.02a2.324 2.324 0 0 1 .334.047 3.249 3.249 0 0 1-.34.434c-.16.15-.341.296-.573.069-.055-.055-.187-.042-.283-.044-.447-.005-.894-.02-1.34-.003a8.323 8.323 0 0 0-1.154.118c-.072.013-.178.25-.146.338.078.207.191.435.359.567.619.49 1.277.928 1.9 1.413.604.472 1.167.99 1.51 1.7.446.928.46 1.9.267 2.877-.322 1.63-1.147 2.98-2.483 3.962-.538.395-1.205.62-1.821.903-.543.25-1.1.465-1.644.712-.98.446-1.53 1.51-1.369 2.615.149 1.015 1.04 1.862 2.059 2.037 1.223.21 2.486-.586 2.785-1.83.336-1.397-.423-2.646-1.845-3.024l-.256-.066c.38-.17.708-.291 1.012-.458q.793-.437 1.558-.925c.15-.096.231-.096.36.014.977.846 1.56 1.898 1.724 3.187.27 2.135-.74 4.096-2.646 5.101-2.948 1.555-6.557-.215-7.208-3.484-.558-2.8 1.418-5.34 3.797-5.83 1.023-.211 1.958-.637 2.685-1.425.47-.508.697-.944.775-1.141a3.165 3.165 0 0 0 .217-1.158 2.71 2.71 0 0 0-.237-.992c-.248-.566-1.2-1.466-1.435-1.656l-2.24-1.754c-.079-.065-.168-.06-.36-.047-.23.016-.815.048-1.067-.018.204-.155.76-.38 1-.56-.726-.49-1.554-.314-2.315-.46.176-.328 1.046-.831 1.541-.888a7.323 7.323 0 0 0-.135-.822c-.03-.111-.154-.22-.263-.283-.262-.154-.541-.281-.843-.434a1.755 1.755 0 0 1 .906-.28 3.385 3.385 0 0 1 .908.088c.54.123.97.042 1.399-.324-.338-.136-.676-.26-1.003-.407a9.843 9.843 0 0 1-.942-.493c.85.118 1.671.437 2.54.32l.022-.118-2.018-.47c1.203-.11 2.323-.128 3.384.388.299.146.61.266.89...',
// OpenVPN 官方 Logo (Simple Icons, CC0)
openvpn: 'M12 .357C5.385.357 0 5.69 0 12.254c0 4.36 2.358 8.153 5.896 10.204l.77-5.076a7.046 7.046 0 01-1.846-4.719c0-3.897 3.18-7.076 7.13-7.076 3.948 0 7.126 3.18 7.126 7.076 0 1.847-.717 3.488-1.846 4.77L18 22.51c3.59-2.05 6-5.899 6-10.258C24 5.69 18.615.357 12 .357zm-.05 8.157a3.786 3.786 0 00-3.796 3.795 3.738 3.738 0 002.461 3.54L9.13 23.643h5.64l-1.435-7.795c1.385-.564 2.41-1.898 2.41-3.54a3.786 3.786 0 00-3.795-3.794z',
+17
View File
@@ -0,0 +1,17 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
const savedLocale = localStorage.getItem('vpn_locale') || 'zh-CN'
const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'en-US': enUS,
},
})
export default i18n
+584
View File
@@ -0,0 +1,584 @@
{
"common": {
"appName": "VPN Management Platform",
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"create": "Create",
"close": "Close",
"search": "Search",
"refresh": "Refresh",
"back": "Back",
"submit": "Submit",
"yes": "Yes",
"no": "No",
"enabled": "Enabled",
"disabled": "Disabled",
"loading": "Loading",
"operation": "Actions",
"status": "Status",
"name": "Name",
"description": "Description",
"username": "Username",
"password": "Password",
"email": "Email",
"source": "Source",
"local": "Local",
"ldap": "LDAP",
"remark": "Remark",
"protocol": "Protocol",
"node": "Node",
"time": "Time",
"target": "Target",
"action": "Action",
"traffic": "Traffic ↓ / ↑",
"ipAddress": "IP Address",
"detail": "Detail",
"copy": "Copy",
"copied": "Copied",
"download": "Download",
"permanent": "Permanent",
"saveSuccess": "Saved",
"saveFailed": "Save failed",
"deleteSuccess": "Deleted",
"deleteFailed": "Delete failed",
"loadFailed": "Failed to load",
"operationFailed": "Operation failed",
"deleteConfirm": "Confirm Delete",
"noData": "No data",
"never": "Never",
"copiedToClipboard": "Copied to clipboard",
"logout": "Logout"
},
"login": {
"title": "VPN Management Platform",
"subtitle": "Unified multi-protocol VPN management — simple, secure, and controllable",
"protocols": "WireGuard · IPSec · L2TP · FRP · OpenVPN · Custom Protocol Plugins",
"feature1Title": "Multi-Protocol Unified Management",
"feature1Desc": "WireGuard / IPSec / L2TP / FRP / OpenVPN",
"feature2Title": "Enterprise-Grade Security",
"feature2Desc": "AES-GCM encrypted keys + Two-Factor Authentication",
"feature3Title": "One-Click Configuration",
"feature3Desc": "Wizard-driven generation, instant client config download",
"feature4Title": "Lightweight & Zero Dependencies",
"feature4Desc": "Single binary delivery, no Docker required",
"welcome": "Welcome Back",
"welcomeHint": "Please enter your credentials to sign in",
"usernameLabel": "Username",
"usernamePlaceholder": "Enter username",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"loginButton": "Sign In",
"defaultAccount": "Default account: <strong>admin / admin123</strong> — please change password after first login",
"errorEmpty": "Please enter username and password",
"loginSuccess": "Login successful",
"changePasswordFirst": "Please change your password first",
"loginFailed": "Login failed. Please check your network or contact the administrator."
},
"dashboard": {
"welcomeTitle": "Welcome to VPN Management Platform",
"welcomeDesc": "No tunnels yet — create your first one in 3 steps: Choose protocol → Fill parameters → Auto-generate config",
"createNow": "Create Tunnel Now",
"totalTunnels": "Total Tunnels",
"running": "Running",
"onlineUsers": "Online Users",
"totalTraffic": "Total Traffic ↓↑",
"protocolDistribution": "Protocol Distribution",
"protocolEngineStatus": "Protocol Engine Status",
"depCheckResult": "System dependency check results",
"depReady": "Ready",
"demoMode": "Demo Mode",
"deploy": "Deploy",
"installCmd": "Install Command",
"expiringTunnels": "Tunnels Expiring Soon (within 7 days)",
"expiryTime": "Expiry Time",
"noTunnels": "No tunnels yet",
"deploySuccess": "{name} {version} deployed successfully",
"deployFailed": "Failed to deploy {name}",
"contactAdmin": "Please contact the system administrator to install",
"recommendedCmd": "Recommended Command",
"otherDistros": "Other Distros",
"copyRecommended": "Copy Recommended",
"getInstallCmdFailed": "Failed to get install command"
},
"tunnels": {
"searchPlaceholder": "Search by name",
"allProtocols": "All Protocols",
"allStates": "All States",
"stateRunning": "Running",
"stateStopped": "Stopped",
"stateError": "Error",
"stateUndeployed": "Undeployed",
"createTunnel": "Create Tunnel",
"createFirst": "Create First Tunnel",
"noTunnels": "No tunnels yet",
"expireTime": "Expiry Time",
"localNode": "Local",
"start": "Start",
"stop": "Stop",
"started": "Started",
"startFailed": "Start failed",
"stopped": "Stopped",
"more": "More",
"viewLogs": "View Logs",
"downloadConfig": "Download Config",
"showQrCode": "Show QR Code",
"ipsecTest": "IPSec Test",
"delete": "Delete",
"detail": "Detail",
"protocol": "Protocol",
"createTime": "Created",
"autoStart": "Auto Start",
"runningParams": "Runtime Parameters",
"clientConfig": "Client Configuration",
"noClientConfig": "No client config available for this protocol",
"downloadClientConfig": "Download Client Config",
"ipsecParamCheck": "IPSec Parameter Check & Connectivity Test",
"trafficPolicy": "Traffic Policy",
"quotaGB": "Quota (GB)",
"quotaUnlimited": "0 = Unlimited. Used: {used}",
"rateLimitMbps": "Rate Limit (Mbps)",
"rateUnlimited": "0 = Unlimited",
"exceedAction": "Exceed Action",
"actionWarn": "Warn Only",
"actionBlock": "Auto Stop",
"actionThrottle": "Throttle to 1Mbps",
"resetCycle": "Reset Cycle",
"resetNone": "Never",
"resetDaily": "Daily",
"resetMonthly": "Monthly",
"savePolicy": "Save Policy",
"policySaved": "Traffic policy saved",
"peerList": "Peer List",
"addPeer": "Add Peer",
"peerId": "Peer ID",
"address": "Address",
"peerOnline": "Online",
"peerOffline": "Offline",
"config": "Config",
"qrCode": "QR Code",
"scanQrTitle": "Scan to Import WireGuard Client",
"scanQrHint": "Use WireGuard App's \"Add from QR code\"",
"addPeerTitle": "Add WireGuard Peer",
"autoGenId": "Leave empty for auto-generated client-N",
"peerNamePlaceholder": "Phone / Laptop",
"clientAddress": "Client Address",
"defaultAllowedIPs": "Leave empty for tunnel default",
"externalPublicKey": "External Public Key",
"externalKeyHint": "Leave empty for platform-managed key; fill in for external device",
"logTitle": "Instance Log — {name}",
"liveOutput": "● Live output",
"logStopped": "Stopped",
"pause": "Pause",
"resume": "Resume",
"clearScreen": "Clear",
"waitingLog": "Waiting for log output...",
"fillClientAddress": "Please enter client address",
"peerAdded": "Peer added",
"addFailed": "Add failed",
"deletePeerConfirm": "Delete peer \"{id}\"?",
"downloadFailed": "Download failed",
"qrFailed": "Failed to generate QR code",
"noClientConfigWarn": "No client config available",
"deleteTunnelConfirm": "Delete tunnel \"{name}\"? Configuration and keys will be permanently removed.",
"peerAlreadyAdded": "Peer already added"
},
"tunnelEditor": {
"title": "Edit Tunnel",
"backToList": "Back to List",
"sensitiveFieldHint": "Sensitive fields like passwords, PSK, and tokens are not displayed. Leave empty to keep the current value.",
"tunnelName": "Tunnel Name",
"protocolType": "Protocol Type",
"keepOriginal": "Leave empty to keep original",
"expireTime": "Expiry Time",
"expirePlaceholder": "Leave empty for permanent",
"autoStart": "Auto Start",
"saveConfig": "Save Configuration",
"loadFailed": "Failed to load tunnel",
"fillName": "Please enter tunnel name",
"configSaved": "Configuration saved"
},
"tunnelWizard": {
"step1": "Select Protocol",
"step2": "Fill Parameters",
"step3": "Generate Config",
"recommend": "Recommended",
"depReady": "✓ Dependencies Ready",
"notDeployed": "Not Deployed",
"oneClickInstall": "One-Click Install",
"installCmd": "Install Command",
"customPlugin": "Custom Protocol Plugin",
"customScene": "Custom Integration",
"tunnelName": "Tunnel Name",
"namePlaceholder": "e.g., HQ-Branch Interconnect",
"protocolType": "Protocol Type",
"reselect": "Reselect",
"advancedOptions": "Advanced Options",
"expireTime": "Expiry Time",
"expirePlaceholder": "Leave empty for permanent",
"autoStartAfterCreate": "Auto-start after creation",
"previousStep": "Previous",
"generateConfig": "Generate Config",
"createSuccess": "Tunnel Created Successfully",
"configGenerated": "\"{name}\" configuration has been generated",
"clientConfig": "Client Configuration",
"downloadConfigFile": "Download Config File",
"showQrCode": "Show QR Code",
"goIpsecPeer": "Go to IPSec Peer Page",
"backToList": "Back to List",
"installCmdTitle": "{name} Install Command",
"pkgManagerDetected": "System package manager detected: {pm}. Recommended command below.",
"noPkgManager": "No system package manager detected. Install commands for various platforms below.",
"aptDefault": "apt (default)",
"recommended": "Recommended",
"copyThisCmd": "Copy This Command",
"otherDistros": "Other Distros",
"fillName": "Please enter tunnel name",
"fillRequired": "Please fill in required field: {label}",
"createFailed": "Creation failed",
"installSuccess": "{protocol} {version} installed successfully {method}",
"methodPackageManager": "(Package Manager)",
"methodDownload": "(One-Click Install)",
"installFailed": "Failed to install {protocol}"
},
"users": {
"title": "User Management",
"batchImport": "Batch Import",
"ldapImport": "LDAP Import",
"addUser": "Add User",
"role": "Role",
"roleAdmin": "Admin",
"roleOperator": "Operator",
"roleViewer": "Viewer",
"noUsers": "No users yet",
"ldapTitle": "LDAP Identity Source",
"ldapHint": "LDAP users can be auto-synced on first login, or pre-imported using the \"LDAP Import\" on the left.",
"ldapUrl": "LDAP URL",
"bindDn": "Bind DN",
"bindPassword": "Bind Password",
"keepOriginal": "Leave empty to keep original",
"baseDn": "Base DN",
"userFilter": "User Filter",
"usernameAttr": "Username Attribute",
"testConnection": "Test Connection",
"editUser": "Edit User",
"loginUsername": "Login username",
"keepUnchanged": "Leave empty to keep unchanged",
"min8Chars": "At least 8 characters",
"batchImportTitle": "Batch Import Users",
"batchHint": "One user per line: username,password or just username (auto-generates password). Password must be at least 8 characters.",
"batchPlaceholder": "One per line: username,password (e.g., user1,Pass1234); or username only for auto-generated password",
"defaultRole": "Default Role",
"startImport": "Start Import",
"ldapImportTitle": "Import Users from LDAP",
"ldapNotEnabled": "LDAP is not enabled. Please configure and test the connection on the right.",
"refreshUserList": "Refresh User List",
"userCount": "{count} users total, {selected} selected",
"fullName": "Name",
"imported": "Imported",
"pendingImport": "Pending",
"clickRefreshHint": "Click refresh above to load LDAP users",
"ldapDisabled": "LDAP is not enabled",
"importSelected": "Import Selected ({count})",
"updated": "Updated",
"fillUsername": "Please enter username",
"passwordMin8": "Password must be at least 8 characters",
"userCreated": "User created",
"deleteUserConfirm": "Delete user \"{username}\"?",
"enterAtLeastOne": "Please enter at least one line of user data",
"importResult": "Success: {success}, Skipped: {skipped}, Failed: {failed}",
"importResultTitle": "Import Result",
"importFailed": "Import failed",
"ldapLoadFailed": "LDAP load failed",
"ldapConfigSaved": "LDAP configuration saved",
"ldapConnected": "LDAP connection successful",
"ldapConnectFailed": "Connection failed"
},
"vpnUsers": {
"title": "VPN Dial-in Users",
"mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)",
"addUser": "Add User",
"displayName": "Name",
"tunnel": "Tunnel",
"otpMfa": "OTP / MFA",
"bound": "Bound",
"unbound": "Unbound",
"locked": "Locked",
"lastOnline": "Last Online",
"downloadConfig": "Download Config",
"otpManagement": "OTP Management",
"addVpnUserTitle": "Add VPN Dial-in User",
"dialTunnel": "Dial-in Tunnel",
"pickFromUsers": "Pick from Users",
"displayNameLabel": "Display Name",
"authSource": "Auth Source",
"min8Chars": "At least 8 characters",
"ldapDn": "LDAP DN",
"create": "Create",
"pickUserTitle": "Select Platform User",
"noPlatformUsers": "No platform users",
"clickRowHint": "Click a row to select and auto-fill",
"otpDialogTitle": "Two-Factor Auth — {username}",
"otpHint": "TOTP-based two-factor authentication (SHA1 / base32 / 30s / 6 digits), compatible with major Authenticator apps. Supports static challenge and PIN+OTP combo mode.",
"bindStatus": "Bind Status",
"boundTotp": "TOTP Bound",
"staticPin": "Static PIN",
"pinSet": "Set",
"pinNotSet": "Not Set",
"failCount": "Fail Count",
"lockStatus": "Lock",
"lockedUntil": "Yes (until {time})",
"lastOtpSuccess": "Last OTP Success",
"compatible": "Compatible",
"bindAuthenticator": "Bind Authenticator",
"disableMfa": "Disable MFA",
"regenBackupCodes": "Regenerate Backup Codes",
"setClearPin": "Set / Clear PIN",
"unlock": "Unlock",
"exportKeys": "Export Keys",
"scanHint": "Scan with Google Authenticator / Microsoft Authenticator / FreeOTP",
"base32Key": "Base32 key: {key}",
"input6DigitCode": "Enter 6-digit code to confirm binding",
"verifyAndEnable": "Verify & Enable",
"clientConnectHint": "Client connection: enter login password in the password field, enter 6-digit TOTP code as the second factor (if PIN is set, enter PIN+code, e.g., 1234123456). You can also append the 6-digit code directly to the password.",
"setPinTitle": "Set Static PIN",
"pinHint": "Second factor requires PIN + 6-digit TOTP code (concatenated without spaces). Leave empty to clear the PIN.",
"pinPlaceholder": "4-32 digit PIN, leave empty to clear",
"userFilled": "Filled user \"{username}\" info",
"vpnUserCreated": "VPN dial-in user created",
"createFailed": "Creation failed",
"downloadFailed": "Download failed",
"mfaSetupFailed": "Unable to start MFA setup",
"inputCode": "Please enter verification code",
"mfaEnabledMsg": "Two-factor authentication enabled.\n\nOne-time backup codes (save these — used when you lose your phone):\n{codes}\n\nClient connection: enter login password in the password field, enter 6-digit TOTP code as the second factor (if PIN is set, enter PIN+code).",
"bindSuccess": "Binding Successful",
"verifyFailed": "Verification failed",
"disableMfaConfirm": "Disable MFA for {username}?",
"mfaClosed": "MFA disabled",
"backupCodesTitle": "Backup Codes",
"newBackupCodes": "New backup codes (old codes invalidated):\n\n{codes}",
"generateFailed": "Generation failed",
"pinSetSuccess": "PIN set",
"pinCleared": "PIN cleared",
"unlocked": "Unlocked",
"unlockFailed": "Unlock failed",
"exportKeysTitle": "Export Keys",
"otpSecretsMsg": "TOTP key line (sensitive — do not share):\n\n{line}\n\nFormat: {format}",
"exportFailed": "Export failed",
"deleteVpnUserConfirm": "Delete VPN dial-in user {username}?"
},
"sessions": {
"title": "Online Sessions",
"onlineCount": "{count} online",
"publicAddress": "Public Address",
"vpnIp": "VPN IP",
"connectedDuration": "Duration",
"kickOffline": "Kick",
"noOnlineUsers": "No online users",
"trafficAlert": "⚠ Abnormal Traffic Alert",
"userTunnel": "User/Tunnel",
"alertType": "Type",
"rateAlert": "Traffic Burst",
"quotaExceed": "Quota Exceeded",
"auditLog": "Connection Audit Log",
"event": "Event",
"eventConnect": "Connect",
"eventDisconnect": "Disconnect",
"eventAuthFail": "Auth Failed",
"eventQuotaExceed": "Quota Exceeded",
"eventRateAlert": "Traffic Burst",
"seconds": "{n}s",
"minutes": "{n}m",
"hoursMinutes": "{h}h {m}m",
"loadDataFailed": "Failed to load data",
"kickConfirm": "Kick user \"{username}\" offline?",
"kicked": "User kicked offline"
},
"settings": {
"mustChangePassword": "First login or password has been reset by admin. You must change your password before using other features.",
"changePassword": "Change Password",
"oldPassword": "Current Password",
"newPassword": "New Password",
"min8Chars": "At least 8 characters",
"confirmNewPassword": "Confirm New Password",
"change": "Change",
"mfaTitle": "Two-Factor Authentication (MFA)",
"mfaHint": "Scan the QR code with Microsoft Authenticator, Google Authenticator, etc. Backup codes can be used to log in when you lose your phone.",
"setupMfa": "Set Up MFA",
"disableMfa": "Disable MFA",
"input6DigitCode": "Enter the 6-digit code from your app",
"verifyAndEnable": "Verify & Enable",
"deployTitle": "Deployment Info",
"deliveryForm": "Delivery",
"deliveryDesc": "Single binary (frontend embedded), no Docker required",
"dataDir": "Data Directory",
"dataDirDesc": "./data (override with VPN_DATA_DIR)",
"listenAddr": "Listen Address",
"listenAddrDesc": ":8080 (override with VPN_LISTEN_ADDR)",
"keyStorage": "Key Storage",
"keyStorageDesc": "PSK / Private keys encrypted with AES-GCM, config files with 0600 permissions",
"systemDeps": "System Dependencies",
"systemDepsDesc": "wireguard-tools / strongswan / xl2tpd / openvpn / frp",
"prodSuggestion": "Production Tip",
"prodSuggestionDesc": "Expose via Nginx/Caddy reverse proxy with TLS termination",
"auditTitle": "Audit Log (Latest 100)",
"noAuditLogs": "No audit logs yet",
"mfaEnabledTitle": "MFA Enabled — Please Save Backup Codes",
"backupCodesHint": "These backup codes are shown only once. Use them to log in if you lose your phone. Copy and save them now!",
"copyAll": "Copy All",
"savedConfirm": "I've Saved Them",
"passwordMismatch": "New passwords do not match",
"passwordMin8": "New password must be at least 8 characters",
"passwordChanged": "Password changed",
"changeFailed": "Change failed",
"setupFailed": "Setup failed",
"verifyFailed": "Verification failed",
"mfaClosed": "MFA disabled",
"closeFailed": "Disable failed",
"mustChangeHint": "First login or password has been reset. Please change your password first.",
"confirmChange": "Confirm Change",
"strength": "Strength: {level}",
"strengthVeryWeak": "Very Weak",
"strengthWeak": "Weak",
"strengthFair": "Fair",
"strengthStrong": "Strong",
"strengthVeryStrong": "Very Strong"
},
"plugins": {
"title": "Custom Protocol Plugins",
"registerPlugin": "Register Plugin",
"pluginHint": "Plugins implement the unified protocol contract via <b>command hooks</b>: the platform pipes tunnel config JSON to hook commands via stdin.",
"pluginContract": "Contract: <code>Create / Start / Stop / Status / Config</code>; Status hook outputs <code>running</code> or <code>stopped</code> on stdout; Config hook outputs client config text.",
"protocolId": "Protocol ID",
"enabled": "Enabled",
"disabled": "Disabled",
"noPlugins": "No custom protocol plugins yet",
"editPlugin": "Edit Plugin",
"registerPluginTitle": "Register Protocol Plugin",
"protocolIdPlaceholder": "e.g., myproto (alphanumeric)",
"displayName": "Display Name",
"optional": "Optional",
"protocolIdRequired": "Protocol ID and Start hook are required"
},
"nodes": {
"title": "Node Management",
"addNode": "Add Node",
"nodeHint": "Nodes are used to deploy tunnels to relay servers. The local node is the built-in default; remote nodes support SSH credential verification (password/private key). Verified nodes show \"Verified\" status.",
"type": "Type",
"typeLocal": "Local",
"typeRemote": "Remote",
"sshUser": "SSH User",
"authMethod": "Auth Method",
"authPassword": "Password",
"authKey": "Private Key",
"statusReady": "Ready",
"statusVerified": "Verified",
"statusFailed": "Failed",
"statusUnverified": "Unverified",
"verify": "Verify",
"noNodes": "No remote nodes yet. Click the button above to add one.",
"editNode": "Edit Node",
"namePlaceholder": "e.g., Beijing Relay Node",
"addressPlaceholder": "IP or domain, e.g., 192.168.1.100",
"sshPort": "SSH Port",
"remoteNode": "Remote Node",
"localNode": "Local Node",
"defaultRoot": "Default: root",
"keepUnchanged": "Leave empty to keep unchanged",
"sshPasswordPlaceholder": "SSH login password",
"privateKeyPlaceholder": "Paste OpenSSH/PEM format private key",
"testConnection": "Test Connection",
"determine": "OK",
"fillAddressFirst": "Please enter the address first",
"requestFailed": "Request failed",
"fillNameAndAddress": "Please enter name and address",
"added": "Added",
"updated": "Updated",
"verifySuccess": "Verification successful: {msg}",
"verifyFailed": "Verification failed: {msg}",
"deleteNodeConfirm": "Delete node \"{name}\"? This action cannot be undone."
},
"notFound": {
"title": "404",
"subtitle": "The page you visited does not exist or has been removed",
"backHome": "Back to Home"
},
"mfaVerify": {
"title": "Two-Factor Authentication",
"hint": "Enter the 6-digit code from your Authenticator app",
"verifyAndLogin": "Verify & Login",
"backToLogin": "Back to Login",
"inputFullCode": "Please enter the full 6-digit code",
"sessionExpired": "Login session expired. Please log in again.",
"verifySuccess": "Verification successful",
"codeError": "Incorrect code. Please try again."
},
"ipsecPeer": {
"localParams": "Local IPSec Parameters",
"loadTemplate": "Load Device Template",
"localPublicIp": "Local Public IP",
"remoteGateway": "Remote Gateway",
"localSubnets": "Local Subnets",
"remoteSubnets": "Remote Subnets",
"psk": "Pre-Shared Key",
"phase1": "Phase 1 (IKE SA)",
"ikeVersion": "IKE Version",
"proposal": "Proposal",
"localId": "Local ID",
"remoteId": "Remote ID",
"phase2": "Phase 2 (IPSec SA)",
"espProposal": "ESP Proposal",
"pfsGroup": "PFS Group",
"dpdPolicy": "DPD Policy",
"dpdRestart": "restart (reconnect on disconnect)",
"dpdClear": "clear (remove SA)",
"dpdTrap": "trap (trigger on demand)",
"createIpsecTunnel": "Create IPSec Tunnel",
"paramConsistency": "Parameter Consistency Check",
"peerIkeVersion": "Peer IKE Version",
"peerPhase1": "Peer Phase 1",
"peerPhase2": "Peer Phase 2",
"peerPfsGroup": "Peer PFS Group",
"peerLocalSubnets": "Peer Local Subnets",
"peerIkePlaceholder": "Peer's IKE proposal",
"peerEspPlaceholder": "Peer's ESP proposal",
"peerSubnetsPlaceholder": "Peer's local (= your remote)",
"startCompare": "Start Comparison",
"paramsConsistent": "Parameters are consistent. Tunnel can be established.",
"paramsInconsistent": "Parameter inconsistencies found:",
"connectivityTest": "One-Click Connectivity Test",
"selectIpsecTunnel": "Select an existing IPSec tunnel",
"testConnectivity": "Test connectivity to remote gateway",
"reachable": "Reachable",
"unreachable": "Unreachable",
"latency": "Latency",
"templateLoaded": "Loaded \"{name}\" template: {note}",
"fillRequiredFields": "Please fill in all required fields (gateway/subnets/PSK)",
"ipsecCreated": "IPSec tunnel created and started",
"createFailed": "Creation failed",
"testFailed": "Test failed"
},
"router": {
"dashboard": "Dashboard",
"tunnels": "Tunnels",
"sessions": "Sessions",
"createTunnel": "Create Tunnel",
"editTunnel": "Edit Tunnel",
"ipsecPeer": "IPSec Peer Test",
"nodes": "Nodes",
"users": "Users",
"vpnUsers": "VPN Dial-in Users",
"plugins": "Protocol Plugins",
"settings": "Settings",
"notFound": "Page Not Found",
"sidebarOps": "Tunnel Ops",
"sidebarResource": "Resource Mgmt",
"sidebarSystem": "System"
},
"protocolIcon": {
"custom": "Custom Protocol"
}
}
+584
View File
@@ -0,0 +1,584 @@
{
"common": {
"appName": "VPN 管理平台",
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"add": "添加",
"create": "创建",
"close": "关闭",
"search": "搜索",
"refresh": "刷新",
"back": "返回",
"submit": "提交",
"yes": "是",
"no": "否",
"enabled": "启用",
"disabled": "禁用",
"loading": "加载中",
"operation": "操作",
"status": "状态",
"name": "名称",
"description": "说明",
"username": "用户名",
"password": "密码",
"email": "邮箱",
"source": "来源",
"local": "本地",
"ldap": "LDAP",
"remark": "备注",
"protocol": "协议",
"node": "节点",
"time": "时间",
"target": "对象",
"action": "动作",
"traffic": "流量 ↓ / ↑",
"ipAddress": "IP 地址",
"detail": "详情",
"copy": "复制",
"copied": "已复制",
"download": "下载",
"permanent": "永久",
"saveSuccess": "已保存",
"saveFailed": "保存失败",
"deleteSuccess": "已删除",
"deleteFailed": "删除失败",
"loadFailed": "加载失败",
"operationFailed": "操作失败",
"deleteConfirm": "删除确认",
"noData": "暂无数据",
"never": "不",
"copiedToClipboard": "已复制到剪贴板",
"logout": "退出登录"
},
"login": {
"title": "VPN 专线管理平台",
"subtitle": "统一管理多协议 VPN,配置简单、安全可控",
"protocols": "WireGuard · IPSec · L2TP · FRP · OpenVPN · 自定义协议插件",
"feature1Title": "多协议统一管理",
"feature1Desc": "WireGuard / IPSec / L2TP / FRP / OpenVPN",
"feature2Title": "企业级安全",
"feature2Desc": "密钥 AES-GCM 加密 + 双因素认证",
"feature3Title": "一键创建配置",
"feature3Desc": "向导式生成,客户端配置秒级下载",
"feature4Title": "轻量零依赖",
"feature4Desc": "单二进制交付,无需 Docker",
"welcome": "欢迎登录",
"welcomeHint": "请输入账号密码登录管理平台",
"usernameLabel": "用户名",
"usernamePlaceholder": "请输入用户名",
"passwordLabel": "密码",
"passwordPlaceholder": "请输入密码",
"loginButton": "登 录",
"defaultAccount": "默认账号 <strong>admin / admin123</strong>,登录后请立即修改",
"errorEmpty": "请输入用户名和密码",
"loginSuccess": "登录成功",
"changePasswordFirst": "首次登录请先修改密码",
"loginFailed": "登录失败,请检查网络或联系管理员"
},
"dashboard": {
"welcomeTitle": "欢迎使用 VPN 管理平台",
"welcomeDesc": "还没有专线,3 步即可创建第一条:选择协议 → 填写参数 → 自动生成配置",
"createNow": "立即创建专线",
"totalTunnels": "专线总数",
"running": "运行中",
"onlineUsers": "在线用户",
"totalTraffic": "累计流量 ↓↑",
"protocolDistribution": "协议分布",
"protocolEngineStatus": "协议引擎状态",
"depCheckResult": "系统依赖检测结果",
"depReady": "已就绪",
"demoMode": "演示模式",
"deploy": "下载部署",
"installCmd": "安装命令",
"expiringTunnels": "即将到期的专线(7 天内)",
"expiryTime": "到期时间",
"noTunnels": "暂无专线",
"deploySuccess": "{name} {version} 部署成功",
"deployFailed": "{name} 部署失败",
"contactAdmin": "请联系系统管理员安装",
"recommendedCmd": "推荐命令",
"otherDistros": "其他发行版",
"copyRecommended": "复制推荐命令",
"getInstallCmdFailed": "获取安装命令失败"
},
"tunnels": {
"searchPlaceholder": "搜索名称",
"allProtocols": "全部协议",
"allStates": "全部状态",
"stateRunning": "运行中",
"stateStopped": "已停止",
"stateError": "异常",
"stateUndeployed": "未部署",
"createTunnel": "创建专线",
"createFirst": "创建第一条专线",
"noTunnels": "还没有专线",
"expireTime": "到期时间",
"localNode": "本机节点",
"start": "启动",
"stop": "停止",
"started": "已启动",
"startFailed": "启动失败",
"stopped": "已停止",
"more": "更多",
"viewLogs": "查看日志",
"downloadConfig": "下载配置",
"showQrCode": "显示二维码",
"ipsecTest": "IPSec 对接测试",
"delete": "删除",
"detail": "详情",
"protocol": "协议",
"createTime": "创建时间",
"autoStart": "自动启动",
"runningParams": "运行参数",
"clientConfig": "客户端配置",
"noClientConfig": "该协议暂无客户端配置",
"downloadClientConfig": "下载客户端配置",
"ipsecParamCheck": "IPSec 参数检查与连通性测试",
"trafficPolicy": "流量策略",
"quotaGB": "配额(GB)",
"quotaUnlimited": "0 = 不限。已用:{used}",
"rateLimitMbps": "限速(Mbps)",
"rateUnlimited": "0 = 不限",
"exceedAction": "超额动作",
"actionWarn": "仅告警",
"actionBlock": "自动停止",
"actionThrottle": "限速至1Mbps",
"resetCycle": "重置周期",
"resetNone": "不重置",
"resetDaily": "每天",
"resetMonthly": "每月",
"savePolicy": "保存策略",
"policySaved": "流量策略已保存",
"peerList": "Peer 列表",
"addPeer": "添加 Peer",
"peerId": "Peer ID",
"address": "地址",
"peerOnline": "在线",
"peerOffline": "离线",
"config": "配置",
"qrCode": "二维码",
"scanQrTitle": "扫码导入 WireGuard 客户端",
"scanQrHint": "使用 WireGuard App「从二维码添加」",
"addPeerTitle": "添加 WireGuard Peer",
"autoGenId": "留空自动生成 client-N",
"peerNamePlaceholder": "手机 / 笔记本",
"clientAddress": "客户端地址",
"defaultAllowedIPs": "留空使用隧道默认",
"externalPublicKey": "外部公钥",
"externalKeyHint": "留空则平台托管生成密钥;填写则为外部设备",
"logTitle": "实例日志 — {name}",
"liveOutput": "● 实时输出中",
"logStopped": "已停止",
"pause": "暂停",
"resume": "继续",
"clearScreen": "清屏",
"waitingLog": "等待日志输出...",
"fillClientAddress": "请填写客户端地址",
"peerAdded": "Peer 已添加",
"addFailed": "添加失败",
"deletePeerConfirm": "删除 Peer「{id}」?",
"downloadFailed": "下载失败",
"qrFailed": "生成二维码失败",
"noClientConfigWarn": "无客户端配置",
"deleteTunnelConfirm": "确定删除专线「{name}」?配置与密钥将一并清除。",
"peerAlreadyAdded": "Peer 已添加"
},
"tunnelEditor": {
"title": "编辑专线",
"backToList": "返回列表",
"sensitiveFieldHint": "密码、PSK、Token 等敏感字段不会回显;留空表示继续使用原值。",
"tunnelName": "专线名称",
"protocolType": "协议类型",
"keepOriginal": "留空保持原值",
"expireTime": "到期时间",
"expirePlaceholder": "留空为永久",
"autoStart": "自动启动",
"saveConfig": "保存配置",
"loadFailed": "加载专线失败",
"fillName": "请填写专线名称",
"configSaved": "配置已保存"
},
"tunnelWizard": {
"step1": "选择协议",
"step2": "填写参数",
"step3": "生成配置",
"recommend": "推荐",
"depReady": "✓ 依赖就绪",
"notDeployed": "未部署",
"oneClickInstall": "一键安装",
"installCmd": "安装命令",
"customPlugin": "自定义协议插件",
"customScene": "自定义对接",
"tunnelName": "专线名称",
"namePlaceholder": "例如:总部-分支互联",
"protocolType": "协议类型",
"reselect": "重新选择",
"advancedOptions": "高级选项",
"expireTime": "到期时间",
"expirePlaceholder": "留空为永久",
"autoStartAfterCreate": "创建后自动启动",
"previousStep": "上一步",
"generateConfig": "生成配置",
"createSuccess": "专线创建成功",
"configGenerated": "「{name}」配置已生成",
"clientConfig": "客户端配置",
"downloadConfigFile": "下载配置文件",
"showQrCode": "显示二维码",
"goIpsecPeer": "前往 IPSec 对接页",
"backToList": "返回列表",
"installCmdTitle": "{name} 安装命令",
"pkgManagerDetected": "检测到当前系统包管理器:{pm},推荐使用以下命令",
"noPkgManager": "未检测到系统包管理器,以下是各平台安装命令供参考",
"aptDefault": "apt (默认)",
"recommended": "推荐",
"copyThisCmd": "复制此命令",
"otherDistros": "其他发行版",
"fillName": "请填写专线名称",
"fillRequired": "请填写必填项:{label}",
"createFailed": "创建失败",
"installSuccess": "{protocol} {version} 安装成功 {method}",
"methodPackageManager": "(包管理器安装)",
"methodDownload": "(一键安装)",
"installFailed": "{protocol} 安装失败"
},
"users": {
"title": "管理用户",
"batchImport": "批量导入",
"ldapImport": "LDAP 导入",
"addUser": "添加用户",
"role": "角色",
"roleAdmin": "管理员",
"roleOperator": "运维",
"roleViewer": "只读",
"noUsers": "还没有用户",
"ldapTitle": "LDAP 身份源",
"ldapHint": "LDAP 用户可首次登录后自动同步,也可使用左侧「LDAP 导入」预先添加。",
"ldapUrl": "LDAP URL",
"bindDn": "Bind DN",
"bindPassword": "Bind 密码",
"keepOriginal": "留空保持原值",
"baseDn": "Base DN",
"userFilter": "用户过滤",
"usernameAttr": "用户名属性",
"testConnection": "测试连接",
"editUser": "编辑用户",
"loginUsername": "登录用户名",
"keepUnchanged": "留空不修改",
"min8Chars": "至少 8 位",
"batchImportTitle": "批量导入用户",
"batchHint": "每行一个用户,格式:用户名,密码 或仅用户名(自动生成密码)。密码至少 8 位。",
"batchPlaceholder": "每行一个:用户名,密码(如 user1,Pass1234);或仅用户名自动生成密码",
"defaultRole": "默认角色",
"startImport": "开始导入",
"ldapImportTitle": "从 LDAP 导入用户",
"ldapNotEnabled": "LDAP 未启用,请先在右侧配置并测试连接",
"refreshUserList": "刷新用户列表",
"userCount": "共 {count} 个用户,已选 {selected} 个",
"fullName": "姓名",
"imported": "已导入",
"pendingImport": "待导入",
"clickRefreshHint": "点击上方刷新加载 LDAP 用户",
"ldapDisabled": "LDAP 未启用",
"importSelected": "导入入选中 ({count})",
"updated": "已更新",
"fillUsername": "请填写用户名",
"passwordMin8": "密码至少 8 位",
"userCreated": "用户已创建",
"deleteUserConfirm": "确定删除用户「{username}」?",
"enterAtLeastOne": "请输入至少一行用户数据",
"importResult": "成功 {success},跳过 {skipped},失败 {failed}",
"importResultTitle": "导入结果",
"importFailed": "导入失败",
"ldapLoadFailed": "LDAP 加载失败",
"ldapConfigSaved": "LDAP 配置已保存",
"ldapConnected": "LDAP 连接成功",
"ldapConnectFailed": "连接失败"
},
"vpnUsers": {
"title": "VPN 拨号用户",
"mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)",
"addUser": "添加用户",
"displayName": "姓名",
"tunnel": "隧道",
"otpMfa": "OTP / MFA",
"bound": "已绑定",
"unbound": "未绑定",
"locked": "锁定",
"lastOnline": "最近在线",
"downloadConfig": "下载配置",
"otpManagement": "OTP 管理",
"addVpnUserTitle": "添加 VPN 拨号用户",
"dialTunnel": "拨号隧道",
"pickFromUsers": "从已有用户选",
"displayNameLabel": "显示名",
"authSource": "认证来源",
"min8Chars": "至少 8 位",
"ldapDn": "LDAP DN",
"create": "创建",
"pickUserTitle": "选择已有平台用户",
"noPlatformUsers": "暂无平台用户",
"clickRowHint": "点击行即选中并自动填充",
"otpDialogTitle": "双因素认证 — {username}",
"otpHint": "基于 TOTP 的双因素认证(SHA1 / base32 / 30s / 6 位),兼容主流 Authenticator 应用。支持静态挑战二次输入与 PIN+OTP 组合模式。",
"bindStatus": "绑定状态",
"boundTotp": "已绑定 TOTP",
"staticPin": "静态 PIN",
"pinSet": "已设置",
"pinNotSet": "未设置",
"failCount": "失败次数",
"lockStatus": "锁定",
"lockedUntil": "是(至 {time})",
"lastOtpSuccess": "最近 OTP 成功",
"compatible": "兼容",
"bindAuthenticator": "绑定 Authenticator",
"disableMfa": "关闭 MFA",
"regenBackupCodes": "重新生成备用码",
"setClearPin": "设置/清除 PIN",
"unlock": "解锁",
"exportKeys": "导出密钥",
"scanHint": "使用 Google Authenticator / Microsoft Authenticator / FreeOTP 扫描",
"base32Key": "base32 密钥:{key}",
"input6DigitCode": "输入 6 位验证码确认绑定",
"verifyAndEnable": "验证并启用",
"clientConnectHint": "客户端连接方式:密码框填登录密码,第二因子填 6 位动态验证码(若设置了 PIN,则填 PIN+验证码,如 1234123456)。也支持密码后直接附加 6 位码。",
"setPinTitle": "设置静态 PIN",
"pinHint": "连接时第二因子需输入 PIN + 6 位动态验证码(无空格拼接)。留空保存可清除已设置的 PIN。",
"pinPlaceholder": "4-32 位 PIN,留空清除",
"userFilled": "已填充用户「{username}」信息",
"vpnUserCreated": "拨号用户已创建",
"createFailed": "创建失败",
"downloadFailed": "下载失败",
"mfaSetupFailed": "无法开始 MFA 设置",
"inputCode": "请输入验证码",
"mfaEnabledMsg": "双因素认证已启用。\n\n一次性备用码(请妥善保存,丢失手机时用于登录):\n{codes}\n\n客户端连接方式:密码框填登录密码,第二因子填 6 位动态验证码(若设置了 PIN 则填 PIN+验证码)。",
"bindSuccess": "绑定成功",
"verifyFailed": "验证失败",
"disableMfaConfirm": "关闭 {username} 的 MFA?",
"mfaClosed": "MFA 已关闭",
"backupCodesTitle": "备用码",
"newBackupCodes": "新备用码(旧码失效):\n\n{codes}",
"generateFailed": "生成失败",
"pinSetSuccess": "PIN 已设置",
"pinCleared": "PIN 已清除",
"unlocked": "已解锁",
"unlockFailed": "解锁失败",
"exportKeysTitle": "导出密钥",
"otpSecretsMsg": "TOTP 密钥行(敏感信息,请勿泄露):\n\n{line}\n\n格式说明:{format}",
"exportFailed": "导出失败",
"deleteVpnUserConfirm": "删除拨号用户 {username}?"
},
"sessions": {
"title": "在线会话",
"onlineCount": "{count} 个在线",
"publicAddress": "公网地址",
"vpnIp": "VPN IP",
"connectedDuration": "已连接时长",
"kickOffline": "踢下线",
"noOnlineUsers": "当前无在线用户",
"trafficAlert": "⚠ 异常流量告警",
"userTunnel": "用户/隧道",
"alertType": "类型",
"rateAlert": "突发流量",
"quotaExceed": "配额超额",
"auditLog": "连接审计日志",
"event": "事件",
"eventConnect": "连接",
"eventDisconnect": "断开",
"eventAuthFail": "认证失败",
"eventQuotaExceed": "配额超额",
"eventRateAlert": "突发流量",
"seconds": "{n}秒",
"minutes": "{n}分钟",
"hoursMinutes": "{h}小时{m}分",
"loadDataFailed": "加载数据失败",
"kickConfirm": "确定踢用户「{username}」下线?",
"kicked": "已踢下线"
},
"settings": {
"mustChangePassword": "首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能",
"changePassword": "修改密码",
"oldPassword": "原密码",
"newPassword": "新密码",
"min8Chars": "至少 8 位",
"confirmNewPassword": "确认新密码",
"change": "修改",
"mfaTitle": "双因素认证(MFA)",
"mfaHint": "使用 Microsoft Authenticator、Google Authenticator 等应用扫描二维码。备用码可在丢失手机时登录。",
"setupMfa": "设置 MFA",
"disableMfa": "关闭 MFA",
"input6DigitCode": "输入 App 显示的 6 位验证码",
"verifyAndEnable": "验证并启用",
"deployTitle": "部署说明",
"deliveryForm": "交付形态",
"deliveryDesc": "单二进制(前端已内嵌),无需 Docker",
"dataDir": "数据目录",
"dataDirDesc": "./data(可用 VPN_DATA_DIR 覆盖)",
"listenAddr": "监听地址",
"listenAddrDesc": ":8080(VPN_LISTEN_ADDR 覆盖)",
"keyStorage": "密钥存储",
"keyStorageDesc": "PSK / 私钥 AES-GCM 加密落库,配置文件 0600 权限",
"systemDeps": "系统依赖",
"systemDepsDesc": "wireguard-tools / strongswan / xl2tpd / openvpn / frp",
"prodSuggestion": "生产建议",
"prodSuggestionDesc": "经 Nginx/Caddy 反向代理终止 TLS 后暴露",
"auditTitle": "操作审计(最近 100 条)",
"noAuditLogs": "暂无审计日志",
"mfaEnabledTitle": "MFA 已启用 — 请保存备用码",
"backupCodesHint": "这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!",
"copyAll": "复制全部",
"savedConfirm": "我已保存",
"passwordMismatch": "两次新密码不一致",
"passwordMin8": "新密码至少 8 位",
"passwordChanged": "密码已修改",
"changeFailed": "修改失败",
"setupFailed": "设置失败",
"verifyFailed": "验证失败",
"mfaClosed": "MFA 已关闭",
"closeFailed": "关闭失败",
"mustChangeHint": "首次登录或密码已被重置,请先修改密码",
"confirmChange": "确认修改",
"strength": "强度:{level}",
"strengthVeryWeak": "很弱",
"strengthWeak": "弱",
"strengthFair": "一般",
"strengthStrong": "较强",
"strengthVeryStrong": "强"
},
"plugins": {
"title": "自定义协议插件",
"registerPlugin": "注册插件",
"pluginHint": "插件通过<b>命令钩子</b>实现统一协议契约:平台将专线配置 JSON 通过 stdin 传给钩子命令。",
"pluginContract": "契约:<code>Create / Start / Stop / Status / Config</code>;Status 钩子 stdout 输出 <code>running</code> 或 <code>stopped</code>;Config 钩子 stdout 输出客户端配置文本。",
"protocolId": "协议标识",
"enabled": "启用",
"disabled": "停用",
"noPlugins": "还没有自定义协议插件",
"editPlugin": "编辑插件",
"registerPluginTitle": "注册协议插件",
"protocolIdPlaceholder": "如 myproto(字母数字)",
"displayName": "显示名称",
"optional": "可选",
"protocolIdRequired": "协议标识与 Start 钩子必填"
},
"nodes": {
"title": "节点管理",
"addNode": "添加节点",
"nodeHint": "节点用于将专线部署到中继服务器。本机节点为内置默认节点;远程节点支持 SSH 凭证验证(密码/私钥),验证通过后状态显示为「已验证」。",
"type": "类型",
"typeLocal": "本机",
"typeRemote": "远程",
"sshUser": "SSH 用户",
"authMethod": "认证方式",
"authPassword": "密码",
"authKey": "私钥",
"statusReady": "就绪",
"statusVerified": "已验证",
"statusFailed": "验证失败",
"statusUnverified": "未验证",
"verify": "验证",
"noNodes": "还没有远程节点,点击右上角添加",
"editNode": "编辑节点",
"namePlaceholder": "例如:北京中继节点",
"addressPlaceholder": "IP 或域名,如 192.168.1.100",
"sshPort": "SSH 端口",
"remoteNode": "远程节点",
"localNode": "本机节点",
"defaultRoot": "默认 root",
"keepUnchanged": "留空表示不修改",
"sshPasswordPlaceholder": "SSH 登录密码",
"privateKeyPlaceholder": "粘贴 OpenSSH/PEM 格式私钥内容",
"testConnection": "测试连接",
"determine": "确定",
"fillAddressFirst": "请先填写地址",
"requestFailed": "请求失败",
"fillNameAndAddress": "请填写名称与地址",
"added": "已添加",
"updated": "已更新",
"verifySuccess": "验证成功:{msg}",
"verifyFailed": "验证失败:{msg}",
"deleteNodeConfirm": "确定删除节点「{name}」?该操作不可撤销。"
},
"notFound": {
"title": "404",
"subtitle": "您访问的页面不存在或已被移除",
"backHome": "返回首页"
},
"mfaVerify": {
"title": "双因素认证",
"hint": "请输入 Authenticator 应用中显示的 6 位动态验证码",
"verifyAndLogin": "验证并登录",
"backToLogin": "返回登录",
"inputFullCode": "请输入完整的 6 位验证码",
"sessionExpired": "登录会话已过期,请重新登录",
"verifySuccess": "验证成功",
"codeError": "验证码错误,请重试"
},
"ipsecPeer": {
"localParams": "本端 IPSec 参数",
"loadTemplate": "载入设备模板",
"localPublicIp": "本端公网 IP",
"remoteGateway": "对端网关",
"localSubnets": "本端网段",
"remoteSubnets": "对端网段",
"psk": "预共享密钥",
"phase1": "Phase 1(IKE SA)",
"ikeVersion": "IKE 版本",
"proposal": "提议",
"localId": "本端 ID",
"remoteId": "对端 ID",
"phase2": "Phase 2(IPSec SA)",
"espProposal": "ESP 提议",
"pfsGroup": "PFS 组",
"dpdPolicy": "DPD 策略",
"dpdRestart": "restart(断线重连)",
"dpdClear": "clear(清除)",
"dpdTrap": "trap(按需触发)",
"createIpsecTunnel": "创建 IPSec 专线",
"paramConsistency": "双方参数一致性检查",
"peerIkeVersion": "对端 IKE 版本",
"peerPhase1": "对端 Phase1",
"peerPhase2": "对端 Phase2",
"peerPfsGroup": "对端 PFS 组",
"peerLocalSubnets": "对端本端网段",
"peerIkePlaceholder": "对端的 IKE 提议",
"peerEspPlaceholder": "对端的 ESP 提议",
"peerSubnetsPlaceholder": "对端视角的 local(=我方 remote)",
"startCompare": "开始对比检查",
"paramsConsistent": "双方参数一致,可以建立隧道",
"paramsInconsistent": "发现参数不一致:",
"connectivityTest": "一键连通性测试",
"selectIpsecTunnel": "选择已有 IPSec 专线",
"testConnectivity": "测试与对端网关连通性",
"reachable": "可达",
"unreachable": "不可达",
"latency": "耗时",
"templateLoaded": "已载入「{name}」模板:{note}",
"fillRequiredFields": "请填写完整的必填项(网关/网段/PSK)",
"ipsecCreated": "IPSec 专线已创建并启动",
"createFailed": "创建失败",
"testFailed": "测试失败"
},
"router": {
"dashboard": "总览",
"tunnels": "专线列表",
"sessions": "在线会话",
"createTunnel": "创建专线",
"editTunnel": "编辑专线",
"ipsecPeer": "IPSec 第三方对接",
"nodes": "节点管理",
"users": "用户管理",
"vpnUsers": "VPN 拨号用户",
"plugins": "协议插件",
"settings": "系统设置",
"notFound": "页面不存在",
"sidebarOps": "专线运维",
"sidebarResource": "资源管理",
"sidebarSystem": "系统"
},
"protocolIcon": {
"custom": "自定义协议"
}
}
+7 -1
View File
@@ -1,13 +1,19 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import './styles/tokens.css'
import './style.css'
const epLocales = { 'zh-CN': zhCn, 'en-US': en }
const currentLocale = i18n.global.locale.value
const app = createApp(App)
app.use(ElementPlus, { locale: zhCn })
app.use(ElementPlus, { locale: epLocales[currentLocale] || zhCn })
app.use(router)
app.use(i18n)
app.mount('#app')
+12 -12
View File
@@ -9,18 +9,18 @@ const routes = [
component: () => import('./views/Layout.vue'),
redirect: '/dashboard',
children: [
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { title: '总览' } },
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { title: '专线列表' } },
{ path: 'sessions', component: () => import('./views/Sessions.vue'), meta: { title: '在线会话' } },
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { title: '创建专线' } },
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { title: '编辑专线' } },
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { title: 'IPSec 第三方对接' } },
{ path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { title: '节点管理' } },
{ path: 'users', component: () => import('./views/Users.vue'), meta: { title: '用户管理' } },
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { title: 'VPN 拨号用户' } },
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { title: '协议插件' } },
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { title: '系统设置' } },
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { title: '页面不存在' } }
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { titleKey: 'router.dashboard' } },
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { titleKey: 'router.tunnels' } },
{ path: 'sessions', component: () => import('./views/Sessions.vue'), meta: { titleKey: 'router.sessions' } },
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } },
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } },
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
{ path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { titleKey: 'router.nodes' } },
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users' } },
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers' } },
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { titleKey: 'router.plugins' } },
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings' } },
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } }
]
}
]
+33 -31
View File
@@ -5,14 +5,14 @@
<div class="welcome-inner">
<div class="welcome-icon"><el-icon :size="32" color="var(--brand-primary)"><Connection /></el-icon></div>
<div class="welcome-text">
<h3>欢迎使用 VPN 管理平台</h3>
<p>还没有专线,3 步即可创建第一条:选择协议 → 填写参数 → 自动生成配置</p>
<h3>{{ $t('dashboard.welcomeTitle') }}</h3>
<p>{{ $t('dashboard.welcomeDesc') }}</p>
</div>
<el-button type="primary" size="large" :icon="Plus" @click="$router.push('/tunnels/create')">立即创建专线</el-button>
<el-button type="primary" size="large" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('dashboard.createNow') }}</el-button>
</div>
</el-card>
<!-- 统计卡片:渐变 icon 块 + 数值,借鉴 opsx-web-lite/aiops -->
<!-- 统计卡片:渐变 icon 块 + 数值 -->
<el-row :gutter="16">
<el-col :xs="12" :sm="12" :lg="6" v-for="(card, i) in statCards" :key="i">
<div class="stat-card fade-in" :class="{ clickable: card.to }" :style="{ animationDelay: i * 0.05 + 's' }"
@@ -31,7 +31,7 @@
<el-row :gutter="16" style="margin-top:16px">
<el-col :xs="24" :lg="10">
<el-card class="page-card" shadow="never">
<template #header><b>协议分布</b></template>
<template #header><b>{{ $t('dashboard.protocolDistribution') }}</b></template>
<div ref="pieRef" style="height:280px"></div>
</el-card>
</el-col>
@@ -39,30 +39,30 @@
<el-card class="page-card" shadow="never">
<template #header>
<div class="row-between">
<b>协议引擎状态</b>
<span class="dep-hint">系统依赖检测结果</span>
<b>{{ $t('dashboard.protocolEngineStatus') }}</b>
<span class="dep-hint">{{ $t('dashboard.depCheckResult') }}</span>
</div>
</template>
<el-table :data="protocols" size="default" v-loading="loadingProto">
<el-table-column label="协议" width="180">
<el-table-column :label="$t('common.protocol')" width="180">
<template #default="{ row }">
<span class="protocol-tag" :style="{ color: protocolColor(row.name) }">{{ row.display_name }}</span>
</template>
</el-table-column>
<el-table-column label="依赖状态" width="110">
<el-table-column :label="$t('tunnels.trafficPolicy')" width="110">
<template #default="{ row }">
<el-tag :type="row.available ? 'success' : 'warning'" size="small">
{{ row.available ? '已就绪' : '演示模式' }}
{{ row.available ? $t('dashboard.depReady') : $t('dashboard.demoMode') }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="dependency" label="说明" show-overflow-tooltip />
<el-table-column label="操作" width="120">
<el-table-column prop="dependency" :label="$t('common.description')" show-overflow-tooltip />
<el-table-column :label="$t('common.operation')" width="120">
<template #default="{ row }">
<template v-if="!row.available && !row.custom">
<el-button v-if="deployableProtocols.includes(row.name)" size="small" type="primary"
:loading="deploying === row.name" @click="deployProtocol(row.name)">下载部署</el-button>
<el-button v-else size="small" @click="showInstallCmd(row.name)">安装命令</el-button>
:loading="deploying === row.name" @click="deployProtocol(row.name)">{{ $t('dashboard.deploy') }}</el-button>
<el-button v-else size="small" @click="showInstallCmd(row.name)">{{ $t('dashboard.installCmd') }}</el-button>
</template>
</template>
</el-table-column>
@@ -72,13 +72,13 @@
</el-row>
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="ov.expiring?.length">
<template #header><b style="color:var(--color-warning)">即将到期的专线(7 天内)</b></template>
<template #header><b style="color:var(--color-warning)">{{ $t('dashboard.expiringTunnels') }}</b></template>
<el-table :data="ov.expiring" size="small">
<el-table-column prop="name" label="名称" />
<el-table-column label="协议">
<el-table-column prop="name" :label="$t('common.name')" />
<el-table-column :label="$t('common.protocol')">
<template #default="{ row }">{{ protocolDisplay(row.protocol) }}</template>
</el-table-column>
<el-table-column label="到期时间">
<el-table-column :label="$t('dashboard.expiryTime')">
<template #default="{ row }">{{ fmtTime(row.expire_at) }}</template>
</el-table-column>
</el-table>
@@ -89,11 +89,13 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import { useI18n } from 'vue-i18n'
import { Connection, Monitor, Share, DataLine, Plus } from '@element-plus/icons-vue'
import api from '../api'
import { protocolColor, protocolDisplay } from '../protocols'
import { ElMessage, ElMessageBox } from 'element-plus'
const { t } = useI18n()
const ov = ref({})
const protocols = ref([])
const loadingProto = ref(true)
@@ -110,10 +112,10 @@ async function deployProtocol(name) {
deploying.value = name
try {
const { data } = await api.post(`/binaries/install/${pkg}`)
ElMessage.success(`${name} ${data.version} 部署成功`)
ElMessage.success(t('dashboard.deploySuccess', { name, version: data.version }))
await load()
} catch (e) {
ElMessage.error(e.response?.data?.error || `${name} 部署失败`)
ElMessage.error(e.response?.data?.error || t('dashboard.deployFailed', { name }))
} finally { deploying.value = '' }
}
@@ -121,7 +123,7 @@ async function showInstallCmd(name) {
const pkg = protocolToPkg[name] || name
try {
const { data } = await api.get(`/binaries/install-hint/${pkg}`)
const cmd = data.command || '请联系系统管理员安装'
const cmd = data.command || t('dashboard.contactAdmin')
const pmLabel = data.pm ? `(${data.pm})` : '(apt 默认)'
// 构建其他平台的命令列表
let otherCmds = ''
@@ -132,13 +134,13 @@ async function showInstallCmd(name) {
}
}
}
const otherSection = otherCmds ? `<hr style="margin:12px 0;border:none;border-top:1px solid #e8eaee"><div style="font-size:12px;color:#909399;margin-bottom:6px">其他发行版:</div>${otherCmds}` : ''
const otherSection = otherCmds ? `<hr style="margin:12px 0;border:none;border-top:1px solid #e8eaee"><div style="font-size:12px;color:#909399;margin-bottom:6px">${t('dashboard.otherDistros')}</div>${otherCmds}` : ''
ElMessageBox.alert(
`<div style="margin-bottom:8px"><b>推荐命令${pmLabel}</b></div><pre style="background:#f0f9eb;border:1px solid #67c23a;padding:12px;border-radius:6px;font-family:monospace;white-space:pre-wrap;font-size:13px">${cmd}</pre>${otherSection}`,
`${name} 安装命令`, { dangerouslyUseHTMLString: true, confirmButtonText: '复制推荐命令',
`<div style="margin-bottom:8px"><b>${t('dashboard.recommendedCmd')}${pmLabel}</b></div><pre style="background:#f0f9eb;border:1px solid #67c23a;padding:12px;border-radius:6px;font-family:monospace;white-space:pre-wrap;font-size:13px">${cmd}</pre>${otherSection}`,
`${name} ${t('dashboard.installCmd')}`, { dangerouslyUseHTMLString: true, confirmButtonText: t('dashboard.copyRecommended'),
callback: (action) => { if (action === 'confirm') navigator.clipboard.writeText(cmd) } })
} catch {
ElMessage.error('获取安装命令失败')
ElMessage.error(t('dashboard.getInstallCmdFailed'))
}
}
@@ -149,13 +151,13 @@ function fmtBytes(n) {
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
return n.toFixed(1) + ' ' + units[i]
}
function fmtTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' }
const statCards = computed(() => [
{ label: '专线总数', value: ov.value.total ?? '-', icon: Connection, iconBg: 'linear-gradient(135deg,#4a6fa5,#2b4a7a)', to: '/tunnels' },
{ label: '运行中', value: ov.value.running ?? '-', icon: Monitor, iconBg: 'linear-gradient(135deg,#4caf7d,#2e7d5b)', to: '/tunnels?state=running' },
{ label: '在线用户', value: ov.value.online_sessions ?? 0, icon: Share, iconBg: 'linear-gradient(135deg,#e0935a,#c0762b)', to: '/sessions' },
{ label: '累计流量 ↓↑', value: `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}`, icon: DataLine, iconBg: 'linear-gradient(135deg,#7b5ea7,#5a4080)', numStyle: { fontSize: '18px' } },
{ label: t('dashboard.totalTunnels'), value: ov.value.total ?? '-', icon: Connection, iconBg: 'linear-gradient(135deg,#4a6fa5,#2b4a7a)', to: '/tunnels' },
{ label: t('dashboard.running'), value: ov.value.running ?? '-', icon: Monitor, iconBg: 'linear-gradient(135deg,#4caf7d,#2e7d5b)', to: '/tunnels?state=running' },
{ label: t('dashboard.onlineUsers'), value: ov.value.online_sessions ?? 0, icon: Share, iconBg: 'linear-gradient(135deg,#e0935a,#c0762b)', to: '/sessions' },
{ label: t('dashboard.totalTraffic'), value: `${fmtBytes(ov.value.rx_bytes)} / ${fmtBytes(ov.value.tx_bytes)}`, icon: DataLine, iconBg: 'linear-gradient(135deg,#7b5ea7,#5a4080)', numStyle: { fontSize: '18px' } },
])
async function load() {
@@ -183,7 +185,7 @@ function renderPie() {
series: [{
type: 'pie', radius: ['45%', '70%'], center: ['50%', '45%'],
label: { formatter: '{b}: {c}' },
data: dist.length ? dist : [{ name: '暂无专线', value: 1, itemStyle: { color: '#e0e3e8' } }]
data: dist.length ? dist : [{ name: t('dashboard.noTunnels'), value: 1, itemStyle: { color: '#e0e3e8' } }]
}]
})
}
+51 -49
View File
@@ -5,44 +5,44 @@
<el-col :span="12">
<el-card class="page-card" shadow="never">
<template #header>
<b>本端 IPSec 参数</b>
<el-select v-model="selectedTemplate" placeholder="载入设备模板" size="small" style="width:200px;float:right"
<b>{{ $t('ipsecPeer.localParams') }}</b>
<el-select v-model="selectedTemplate" :placeholder="$t('ipsecPeer.loadTemplate')" size="small" style="width:200px;float:right"
@change="applyTemplate">
<el-option v-for="t in templates" :key="t.key" :label="t.name" :value="t.key" />
</el-select>
</template>
<el-alert v-if="templateNote" :title="templateNote" type="info" :closable="false" style="margin-bottom:14px" />
<el-form label-width="130px" size="default">
<el-form-item label="本端公网 IP" required><el-input v-model="local.public_endpoint" /></el-form-item>
<el-form-item label="对端网关" required><el-input v-model="local.remote_gateway" /></el-form-item>
<el-form-item label="本端网段" required><el-input v-model="local.local_subnets" placeholder="192.168.1.0/24" /></el-form-item>
<el-form-item label="对端网段" required><el-input v-model="local.remote_subnets" placeholder="10.0.0.0/24" /></el-form-item>
<el-form-item label="预共享密钥" required><el-input v-model="local.psk" type="password" show-password /></el-form-item>
<el-divider content-position="left">Phase 1(IKE SA)</el-divider>
<el-form-item label="IKE 版本">
<el-form-item :label="$t('ipsecPeer.localPublicIp')" required><el-input v-model="local.public_endpoint" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.remoteGateway')" required><el-input v-model="local.remote_gateway" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.localSubnets')" required><el-input v-model="local.local_subnets" placeholder="192.168.1.0/24" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.remoteSubnets')" required><el-input v-model="local.remote_subnets" placeholder="10.0.0.0/24" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.psk')" required><el-input v-model="local.psk" type="password" show-password /></el-form-item>
<el-divider content-position="left">{{ $t('ipsecPeer.phase1') }}</el-divider>
<el-form-item :label="$t('ipsecPeer.ikeVersion')">
<el-radio-group v-model="local.ike_version">
<el-radio-button value="ikev2">IKEv2</el-radio-button>
<el-radio-button value="ikev1">IKEv1</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="提议">
<el-form-item :label="$t('ipsecPeer.proposal')">
<el-input v-model="local.ike_proposal" placeholder="aes256-sha256-modp2048" />
</el-form-item>
<el-form-item label="本端 ID"><el-input v-model="local.local_id" placeholder="@vpn-local" /></el-form-item>
<el-form-item label="对端 ID"><el-input v-model="local.remote_id" placeholder="@vpn-remote" /></el-form-item>
<el-divider content-position="left">Phase 2(IPSec SA)</el-divider>
<el-form-item label="ESP 提议"><el-input v-model="local.esp_proposal" placeholder="aes256-sha256" /></el-form-item>
<el-form-item label="PFS 组"><el-input v-model="local.pfs_group" placeholder="modp2048" /></el-form-item>
<el-form-item label="DPD 策略">
<el-form-item :label="$t('ipsecPeer.localId')"><el-input v-model="local.local_id" placeholder="@vpn-local" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.remoteId')"><el-input v-model="local.remote_id" placeholder="@vpn-remote" /></el-form-item>
<el-divider content-position="left">{{ $t('ipsecPeer.phase2') }}</el-divider>
<el-form-item :label="$t('ipsecPeer.espProposal')"><el-input v-model="local.esp_proposal" placeholder="aes256-sha256" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.pfsGroup')"><el-input v-model="local.pfs_group" placeholder="modp2048" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.dpdPolicy')">
<el-select v-model="local.dpd_action" style="width:100%">
<el-option label="restart(断线重连)" value="restart" />
<el-option label="clear(清除)" value="clear" />
<el-option label="trap(按需触发)" value="trap" />
<el-option :label="$t('ipsecPeer.dpdRestart')" value="restart" />
<el-option :label="$t('ipsecPeer.dpdClear')" value="clear" />
<el-option :label="$t('ipsecPeer.dpdTrap')" value="trap" />
</el-select>
</el-form-item>
</el-form>
<el-button type="primary" style="width:100%" :loading="creating" @click="createTunnel">
创建 IPSec 专线
{{ $t('ipsecPeer.createIpsecTunnel') }}
</el-button>
</el-card>
</el-col>
@@ -50,24 +50,24 @@
<!-- 右:对端参数对比 + 连通性测试 -->
<el-col :span="12">
<el-card class="page-card" shadow="never">
<template #header><b>双方参数一致性检查</b></template>
<template #header><b>{{ $t('ipsecPeer.paramConsistency') }}</b></template>
<el-form label-width="130px" size="default">
<el-form-item label="对端 IKE 版本">
<el-form-item :label="$t('ipsecPeer.peerIkeVersion')">
<el-radio-group v-model="peer.ike_version">
<el-radio-button value="ikev2">IKEv2</el-radio-button>
<el-radio-button value="ikev1">IKEv1</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="对端 Phase1"><el-input v-model="peer.ike_proposal" placeholder="对端的 IKE 提议" /></el-form-item>
<el-form-item label="对端 Phase2"><el-input v-model="peer.esp_proposal" placeholder="对端的 ESP 提议" /></el-form-item>
<el-form-item label="对端 PFS 组"><el-input v-model="peer.pfs_group" /></el-form-item>
<el-form-item label="对端本端网段"><el-input v-model="peer.local_subnets" placeholder="对端视角的 local(=我方 remote)" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.peerPhase1')"><el-input v-model="peer.ike_proposal" :placeholder="$t('ipsecPeer.peerIkePlaceholder')" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.peerPhase2')"><el-input v-model="peer.esp_proposal" :placeholder="$t('ipsecPeer.peerEspPlaceholder')" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.peerPfsGroup')"><el-input v-model="peer.pfs_group" /></el-form-item>
<el-form-item :label="$t('ipsecPeer.peerLocalSubnets')"><el-input v-model="peer.local_subnets" :placeholder="$t('ipsecPeer.peerSubnetsPlaceholder')" /></el-form-item>
</el-form>
<el-button style="width:100%" :loading="checking" @click="compare">开始对比检查</el-button>
<el-button style="width:100%" :loading="checking" @click="compare">{{ $t('ipsecPeer.startCompare') }}</el-button>
<div v-if="compareResult" style="margin-top:14px">
<el-alert v-if="compareResult.consistent" type="success" title="双方参数一致,可以建立隧道" :closable="false" show-icon />
<el-alert v-if="compareResult.consistent" type="success" :title="$t('ipsecPeer.paramsConsistent')" :closable="false" show-icon />
<div v-else>
<el-alert type="error" title="发现参数不一致:" :closable="false" />
<el-alert type="error" :title="$t('ipsecPeer.paramsInconsistent')" :closable="false" />
<ul class="diff-list">
<li v-for="(d, i) in compareResult.differences" :key="i">{{ d }}</li>
</ul>
@@ -76,22 +76,22 @@
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>一键连通性测试</b></template>
<el-select v-model="testTunnelId" placeholder="选择已有 IPSec 专线" style="width:100%">
<el-option v-for="t in ipsecTunnels" :key="t.id" :label="`${t.name}(${t.state})`" :value="t.id" />
<template #header><b>{{ $t('ipsecPeer.connectivityTest') }}</b></template>
<el-select v-model="testTunnelId" :placeholder="$t('ipsecPeer.selectIpsecTunnel')" style="width:100%">
<el-option v-for="tun in ipsecTunnels" :key="tun.id" :label="`${tun.name}(${tun.state})`" :value="tun.id" />
</el-select>
<el-button type="primary" style="width:100%;margin-top:12px" :loading="testing" :disabled="!testTunnelId"
@click="testConnectivity">测试与对端网关连通性</el-button>
@click="testConnectivity">{{ $t('ipsecPeer.testConnectivity') }}</el-button>
<div v-if="testResult" style="margin-top:14px">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="目标">{{ testResult.target }}</el-descriptions-item>
<el-descriptions-item label="结果">
<el-descriptions-item :label="$t('common.target')">{{ testResult.target }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.status')">
<el-tag :type="testResult.reachable ? 'success' : 'danger'">
{{ testResult.reachable ? '可达' : '不可达' }}
{{ testResult.reachable ? $t('ipsecPeer.reachable') : $t('ipsecPeer.unreachable') }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="耗时">{{ testResult.latency_ms }} ms</el-descriptions-item>
<el-descriptions-item label="说明">{{ testResult.note || testResult.ike_udp500_note || '-' }}</el-descriptions-item>
<el-descriptions-item :label="$t('ipsecPeer.latency')">{{ testResult.latency_ms }} ms</el-descriptions-item>
<el-descriptions-item :label="$t('common.description')">{{ testResult.note || testResult.ike_udp500_note || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
@@ -103,8 +103,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../api'
const { t } = useI18n()
const templates = ref([])
const selectedTemplate = ref('')
const templateNote = ref('')
@@ -119,19 +121,19 @@ const testing = ref(false)
const testResult = ref(null)
function applyTemplate(key) {
const t = templates.value.find(x => x.key === key)
if (!t) return
local.value.ike_version = t.ike_version
local.value.ike_proposal = t.ike_proposal
local.value.esp_proposal = t.esp_proposal
local.value.dpd_action = t.dpd_action
templateNote.value = `已载入「${t.name}」模板:${t.note}`
const tmpl = templates.value.find(x => x.key === key)
if (!tmpl) return
local.value.ike_version = tmpl.ike_version
local.value.ike_proposal = tmpl.ike_proposal
local.value.esp_proposal = tmpl.esp_proposal
local.value.dpd_action = tmpl.dpd_action
templateNote.value = t('ipsecPeer.templateLoaded', { name: tmpl.name, note: tmpl.note })
}
async function createTunnel() {
const l = local.value
if (!l.remote_gateway || !l.local_subnets || !l.remote_subnets || !l.psk) {
return ElMessage.warning('请填写完整的必填项(网关/网段/PSK)')
return ElMessage.warning(t('ipsecPeer.fillRequiredFields'))
}
creating.value = true
try {
@@ -141,10 +143,10 @@ async function createTunnel() {
auto_start: true,
config: l
})
ElMessage.success('IPSec 专线已创建并启动')
ElMessage.success(t('ipsecPeer.ipsecCreated'))
loadTunnels()
} catch (e) {
ElMessage.error(e.response?.data?.error || '创建失败')
ElMessage.error(e.response?.data?.error || t('ipsecPeer.createFailed'))
} finally { creating.value = false }
}
@@ -168,7 +170,7 @@ async function testConnectivity() {
const { data } = await api.post(`/tunnels/${testTunnelId.value}/test`)
testResult.value = data
} catch (e) {
ElMessage.error(e.response?.data?.error || '测试失败')
ElMessage.error(e.response?.data?.error || t('ipsecPeer.testFailed'))
} finally { testing.value = false }
}
+71 -44
View File
@@ -3,44 +3,59 @@
<el-aside width="220px" class="aside">
<div class="logo">
<el-icon :size="22" color="#7dd3fc"><Lock /></el-icon>
<span class="logo-text">VPN 管理平台</span>
<span class="logo-text">{{ $t('common.appName') }}</span>
</div>
<el-menu :default-active="activeMenu" router :background-color="sideBg" text-color="#a8b8d8"
active-text-color="#ffffff" class="menu">
<el-menu-item-group title="专线运维">
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>总览</el-menu-item>
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>专线列表</el-menu-item>
<el-menu-item index="/sessions"><el-icon><Monitor /></el-icon>在线会话</el-menu-item>
<el-menu-item-group :title="$t('router.sidebarOps')">
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>{{ $t('router.dashboard') }}</el-menu-item>
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>{{ $t('router.tunnels') }}</el-menu-item>
<el-menu-item index="/sessions"><el-icon><Monitor /></el-icon>{{ $t('router.sessions') }}</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="auth.isAdmin()" title="资源管理">
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>节点管理</el-menu-item>
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>VPN 拨号用户</el-menu-item>
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>协议插件</el-menu-item>
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>{{ $t('router.nodes') }}</el-menu-item>
<el-menu-item index="/vpn-users"><el-icon><Iphone /></el-icon>{{ $t('router.vpnUsers') }}</el-menu-item>
<el-menu-item index="/plugins"><el-icon><Grid /></el-icon>{{ $t('router.plugins') }}</el-menu-item>
</el-menu-item-group>
<el-menu-item-group title="系统">
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>用户管理</el-menu-item>
<el-menu-item index="/settings"><el-icon><Setting /></el-icon>系统设置</el-menu-item>
<el-menu-item-group :title="$t('router.sidebarSystem')">
<el-menu-item v-if="auth.isAdmin()" index="/users"><el-icon><UserFilled /></el-icon>{{ $t('router.users') }}</el-menu-item>
<el-menu-item index="/settings"><el-icon><Setting /></el-icon>{{ $t('router.settings') }}</el-menu-item>
</el-menu-item-group>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<span class="page-title">{{ $route.meta.title }}</span>
<el-dropdown @command="onCommand">
<span class="user" role="button" aria-label="账户菜单" tabindex="0">
<el-icon><Avatar /></el-icon> {{ auth.username }}
<el-tag size="small" style="margin-left:6px" :type="roleTagType">
{{ roleLabel }}
</el-tag>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="password">修改密码</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="page-title">{{ $route.meta.titleKey ? $t($route.meta.titleKey) : '' }}</span>
<div style="display:flex;align-items:center;gap:12px">
<el-dropdown @command="switchLang" trigger="click">
<span class="lang-switch" role="button">
<el-icon><Promotion /></el-icon>
{{ locale === 'zh-CN' ? '中文' : 'EN' }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN" :class="{ 'is-active': locale === 'zh-CN' }">中文</el-dropdown-item>
<el-dropdown-item command="en-US" :class="{ 'is-active': locale === 'en-US' }">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown @command="onCommand">
<span class="user" role="button" :aria-label="$t('settings.changePassword')" tabindex="0">
<el-icon><Avatar /></el-icon> {{ auth.username }}
<el-tag size="small" style="margin-left:6px" :type="roleTagType">
{{ roleLabel }}
</el-tag>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="password">{{ $t('settings.changePassword') }}</el-dropdown-item>
<el-dropdown-item command="logout" divided>{{ $t('common.logout') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main">
<router-view />
@@ -48,26 +63,26 @@
</el-container>
<!-- 右上角修改密码弹窗(不必跳转整页) -->
<el-dialog v-model="pwdDialog" title="修改密码" width="420px" align-center>
<el-dialog v-model="pwdDialog" :title="$t('settings.changePassword')" width="420px" align-center>
<el-alert v-if="auth.mustChange" type="warning" :closable="false" show-icon
title="首次登录或密码已被重置,请先修改密码" style="margin-bottom:16px" />
:title="$t('settings.mustChangeHint')" style="margin-bottom:16px" />
<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-form-item :label="$t('settings.newPassword')">
<el-input v-model="pwd.new_password" type="password" show-password autocomplete="new-password"
placeholder="至少 8 位" />
<div class="pwd-hint" v-if="pwd.new_password">强度:{{ pwdStrength }}</div>
:placeholder="$t('settings.min8Chars')" />
<div class="pwd-hint" v-if="pwd.new_password">{{ $t('settings.strength', { level: pwdStrength }) }}</div>
</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-form>
<template #footer>
<el-button @click="pwdDialog = false">取消</el-button>
<el-button type="primary" :loading="pwdLoading" @click="changePwd">确认修改</el-button>
<el-button @click="pwdDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="pwdLoading" @click="changePwd">{{ $t('settings.confirmChange') }}</el-button>
</template>
</el-dialog>
</el-container>
@@ -77,14 +92,16 @@
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion } from '@element-plus/icons-vue'
import api, { auth } from '../api'
const sideBg = 'var(--bg-sidebar)'
const router = useRouter()
const route = useRoute()
const { t, locale } = useI18n()
const roleLabel = computed(() => ({ admin: '管理员', operator: '操作员', viewer: '只读' }[auth.role] || auth.role))
const roleLabel = computed(() => ({ admin: t('users.roleAdmin'), operator: t('users.roleOperator'), viewer: t('users.roleViewer') }[auth.role] || auth.role))
const roleTagType = computed(() => auth.role === 'admin' ? 'danger' : auth.role === 'operator' ? 'warning' : 'info')
// 子路由(创建/编辑专线)归一到 /tunnels 保持高亮
@@ -93,6 +110,14 @@ const activeMenu = computed(() => {
return route.path
})
// ---- 语言切换 ----
function switchLang(lang) {
locale.value = lang
localStorage.setItem('vpn_locale', lang)
// Element Plus locale 需要刷新页面才能生效
window.location.reload()
}
// ---- 右上角修改密码弹窗 ----
const pwdDialog = ref(false)
const pwdLoading = ref(false)
@@ -107,7 +132,8 @@ const pwdStrength = computed(() => {
if (/[A-Z]/.test(p)) score++
if (/[0-9]/.test(p)) score++
if (/[^A-Za-z0-9]/.test(p)) score++
return ['很弱', '弱', '一般', '较强', '强'][Math.min(score - 1, 4)] || '很弱'
const keys = ['strengthVeryWeak', 'strengthWeak', 'strengthFair', 'strengthStrong', 'strengthVeryStrong']
return t(`settings.${keys[Math.min(score - 1, 4)]}`) || t('settings.strengthVeryWeak')
})
function onCommand(cmd) {
@@ -116,16 +142,16 @@ function onCommand(cmd) {
}
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'))
pwdDialog.value = false
} catch (e) {
ElMessage.error(e.response?.data?.error || '修改失败')
ElMessage.error(e.response?.data?.error || t('settings.changeFailed'))
} finally { pwdLoading.value = false }
}
</script>
@@ -146,6 +172,7 @@ async function changePwd() {
.page-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.user { cursor: pointer; color: var(--text-regular); display: flex; align-items: center; gap: var(--space-1); }
.user:focus-visible { outline: 2px solid var(--brand-primary); border-radius: var(--radius-sm); }
.lang-switch { cursor: pointer; color: var(--text-regular); display: flex; align-items: center; gap: 4px; font-size: 13px; }
.main { background: var(--bg-page); padding: var(--space-5); overflow-y: auto; }
.pwd-hint { font-size: 12px; color: var(--text-secondary); margin-top: var(--space-1); }
</style>
+26 -24
View File
@@ -6,36 +6,36 @@
<div class="brand-logo">
<el-icon :size="32" color="#7dd3fc"><Lock /></el-icon>
</div>
<h1 class="brand-title">VPN 专线管理平台</h1>
<p class="brand-subtitle">统一管理多协议 VPN,配置简单、安全可控</p>
<p class="brand-desc">WireGuard · IPSec · L2TP · FRP · OpenVPN · 自定义协议插件</p>
<h1 class="brand-title">{{ $t('login.title') }}</h1>
<p class="brand-subtitle">{{ $t('login.subtitle') }}</p>
<p class="brand-desc">{{ $t('login.protocols') }}</p>
<div class="brand-features">
<div class="feature-item">
<el-icon :size="18" color="#67c23a"><CircleCheck /></el-icon>
<div>
<strong>多协议统一管理</strong>
<span>WireGuard / IPSec / L2TP / FRP / OpenVPN</span>
<strong>{{ $t('login.feature1Title') }}</strong>
<span>{{ $t('login.feature1Desc') }}</span>
</div>
</div>
<div class="feature-item">
<el-icon :size="18" color="#67c23a"><CircleCheck /></el-icon>
<div>
<strong>企业级安全</strong>
<span>密钥 AES-GCM 加密 + 双因素认证</span>
<strong>{{ $t('login.feature2Title') }}</strong>
<span>{{ $t('login.feature2Desc') }}</span>
</div>
</div>
<div class="feature-item">
<el-icon :size="18" color="#67c23a"><CircleCheck /></el-icon>
<div>
<strong>一键创建配置</strong>
<span>向导式生成,客户端配置秒级下载</span>
<strong>{{ $t('login.feature3Title') }}</strong>
<span>{{ $t('login.feature3Desc') }}</span>
</div>
</div>
<div class="feature-item">
<el-icon :size="18" color="#67c23a"><CircleCheck /></el-icon>
<div>
<strong>轻量零依赖</strong>
<span>单二进制交付,无需 Docker</span>
<strong>{{ $t('login.feature4Title') }}</strong>
<span>{{ $t('login.feature4Desc') }}</span>
</div>
</div>
</div>
@@ -46,8 +46,8 @@
<div class="login-right">
<div class="login-form-wrapper fade-in">
<div class="form-header">
<h2 class="form-title">欢迎登录</h2>
<p class="form-subtitle">请输入账号密码登录管理平台</p>
<h2 class="form-title">{{ $t('login.welcome') }}</h2>
<p class="form-subtitle">{{ $t('login.welcomeHint') }}</p>
</div>
<!-- 内联错误提示 -->
@@ -56,23 +56,23 @@
<form @submit.prevent="doLogin">
<div class="form-group">
<label class="form-label" for="login-user">用户名</label>
<el-input id="login-user" v-model="username" placeholder="请输入用户名" size="large"
<label class="form-label" for="login-user">{{ $t('login.usernameLabel') }}</label>
<el-input id="login-user" v-model="username" :placeholder="$t('login.usernamePlaceholder')" size="large"
:prefix-icon="User" autocomplete="username" @keyup.enter="doLogin" />
</div>
<div class="form-group">
<label class="form-label" for="login-pass">密码</label>
<el-input id="login-pass" v-model="password" type="password" placeholder="请输入密码"
<label class="form-label" for="login-pass">{{ $t('login.passwordLabel') }}</label>
<el-input id="login-pass" v-model="password" type="password" :placeholder="$t('login.passwordPlaceholder')"
size="large" show-password :prefix-icon="Lock"
autocomplete="current-password" @keyup.enter="doLogin" />
</div>
<el-button type="primary" size="large" native-type="submit" style="width:100%" :loading="loading">
登 录
{{ $t('login.loginButton') }}
</el-button>
</form>
<div class="login-footer">
<p>默认账号 <strong>admin / admin123</strong>,登录后请立即修改</p>
<p v-html="$t('login.defaultAccount')"></p>
</div>
</div>
</div>
@@ -83,9 +83,11 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { User, Lock, CircleCheck } from '@element-plus/icons-vue'
import api, { auth } from '../api'
const { t } = useI18n()
const username = ref('admin')
const password = ref('')
const loading = ref(false)
@@ -95,16 +97,16 @@ const router = useRouter()
async function doLogin() {
loginError.value = ''
if (!username.value || !password.value) {
loginError.value = '请输入用户名和密码'
loginError.value = t('login.errorEmpty')
return
}
loading.value = true
try {
const { data } = await api.post('/login', { username: username.value, password: password.value })
auth.login(data.token, data.username, data.role, data.must_change_password)
ElMessage.success('登录成功')
ElMessage.success(t('login.loginSuccess'))
if (data.must_change_password) {
ElMessage.warning('首次登录请先修改密码')
ElMessage.warning(t('login.changePasswordFirst'))
router.push('/settings')
} else {
router.push('/')
@@ -112,7 +114,7 @@ async function doLogin() {
} catch (e) {
// 检测 MFA 要求:后端返回 mfa_required: true 表示需要双因素验证
if (e.response?.data?.mfa_required) {
// 将凭据暂存 sessionStorage,跳转到独立 MFA 验证页(参考 opsx-web-lite 两步登录)
// 将凭据暂存 sessionStorage,跳转到独立 MFA 验证页
sessionStorage.setItem('pendingLogin', JSON.stringify({
username: username.value,
password: password.value,
@@ -120,7 +122,7 @@ async function doLogin() {
router.push('/mfa-verify')
return
}
loginError.value = e.response?.data?.error || '登录失败,请检查网络或联系管理员'
loginError.value = e.response?.data?.error || t('login.loginFailed')
} finally {
loading.value = false
}
+11 -9
View File
@@ -5,8 +5,8 @@
<div class="mfa-icon">
<el-icon :size="28" color="var(--brand-primary)"><Key /></el-icon>
</div>
<h2>双因素认证</h2>
<p>请输入 Authenticator 应用中显示的 6 位动态验证码</p>
<h2>{{ $t('mfaVerify.title') }}</h2>
<p>{{ $t('mfaVerify.hint') }}</p>
</div>
<el-alert v-if="errorMsg" type="error" :title="errorMsg" show-icon :closable="false" style="margin-bottom:20px" />
@@ -30,11 +30,11 @@
<el-button type="primary" size="large" style="width:100%;margin-top:24px"
:loading="loading" :disabled="code.length < 6" @click="verify">
验证并登录
{{ $t('mfaVerify.verifyAndLogin') }}
</el-button>
<div class="mfa-footer">
<el-button link @click="backToLogin">返回登录</el-button>
<el-button link @click="backToLogin">{{ $t('mfaVerify.backToLogin') }}</el-button>
</div>
</div>
</div>
@@ -44,9 +44,11 @@
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Key } from '@element-plus/icons-vue'
import api, { auth } from '../api'
const { t } = useI18n()
const router = useRouter()
const digits = ref(['', '', '', '', '', ''])
const loading = ref(false)
@@ -96,12 +98,12 @@ function onPaste(e) {
}
async function verify() {
if (code.value.length < 6) return ElMessage.warning('请输入完整的 6 位验证码')
if (code.value.length < 6) return ElMessage.warning(t('mfaVerify.inputFullCode'))
// 从 sessionStorage 读取暂存的登录凭据
const pending = sessionStorage.getItem('pendingLogin')
if (!pending) {
ElMessage.warning('登录会话已过期,请重新登录')
ElMessage.warning(t('mfaVerify.sessionExpired'))
router.push('/login')
return
}
@@ -110,19 +112,19 @@ async function verify() {
loading.value = true
errorMsg.value = ''
try {
// 重新提交完整登录请求(含 mfa_code),参考 opsx-web-lite 两步登录模式
// 重新提交完整登录请求(含 mfa_code)
const { data } = await api.post('/login', { username, password, mfa_code: code.value })
// 验证成功,清除暂存凭据
sessionStorage.removeItem('pendingLogin')
auth.login(data.token, data.username, data.role, data.must_change_password)
ElMessage.success('验证成功')
ElMessage.success(t('mfaVerify.verifySuccess'))
if (data.must_change_password) {
router.push('/settings')
} else {
router.push('/')
}
} catch (e) {
errorMsg.value = e.response?.data?.error || '验证码错误,请重试'
errorMsg.value = e.response?.data?.error || t('mfaVerify.codeError')
// 清空输入,聚焦第一格
digits.value = ['', '', '', '', '', '']
nextTick(() => digitRefs[0]?.focus?.())
+59 -57
View File
@@ -2,110 +2,110 @@
<el-card class="page-card" shadow="never">
<template #header>
<div class="row-between">
<b>节点管理</b>
<el-button type="primary" :icon="Plus" @click="openCreate">添加节点</el-button>
<b>{{ $t('nodes.title') }}</b>
<el-button type="primary" :icon="Plus" @click="openCreate">{{ $t('nodes.addNode') }}</el-button>
</div>
</template>
<el-alert type="info" :closable="false" style="margin-bottom:14px"
title="节点用于将专线部署到中继服务器。本机节点为内置默认节点;远程节点支持 SSH 凭证验证(密码/私钥),验证通过后状态显示为「已验证」。" />
:title="$t('nodes.nodeHint')" />
<el-table :data="nodes" size="default" v-loading="loading">
<el-table-column prop="name" label="名称" min-width="120">
<el-table-column prop="name" :label="$t('common.name')" min-width="120">
<template #default="{ row }"><b>{{ row.name }}</b></template>
</el-table-column>
<el-table-column label="地址" min-width="160">
<el-table-column :label="$t('nodes.address')" min-width="160">
<template #default="{ row }">
<span class="mono-inline">{{ row.address }}:{{ row.port || 22 }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<el-table-column :label="$t('nodes.type')" width="80">
<template #default="{ row }">
<el-tag :type="row.kind === 'local' ? 'success' : 'primary'" size="small">
{{ row.kind === 'local' ? '本机' : '远程' }}
{{ row.kind === 'local' ? $t('nodes.typeLocal') : $t('nodes.typeRemote') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="SSH 用户" width="100" prop="username" />
<el-table-column label="认证方式" width="90">
<el-table-column :label="$t('nodes.sshUser')" width="100" prop="username" />
<el-table-column :label="$t('nodes.authMethod')" width="90">
<template #default="{ row }">
{{ row.kind === 'local' ? '—' : ({ password: '密码', key: '私钥' }[row.auth_type] || '—') }}
{{ row.kind === 'local' ? '—' : ({ password: $t('nodes.authPassword'), key: $t('nodes.authKey') }[row.auth_type] || '—') }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<el-table-column :label="$t('common.status')" width="100">
<template #default="{ row }">
<el-tag v-if="row.kind === 'local'" type="success" size="small">就绪</el-tag>
<el-tag v-else-if="row.status === 'verified'" type="success" size="small">已验证</el-tag>
<el-tag v-else-if="row.status === 'failed'" type="danger" size="small">验证失败</el-tag>
<el-tag v-else type="info" size="small">未验证</el-tag>
<el-tag v-if="row.kind === 'local'" type="success" size="small">{{ $t('nodes.statusReady') }}</el-tag>
<el-tag v-else-if="row.status === 'verified'" type="success" size="small">{{ $t('nodes.statusVerified') }}</el-tag>
<el-tag v-else-if="row.status === 'failed'" type="danger" size="small">{{ $t('nodes.statusFailed') }}</el-tag>
<el-tag v-else type="info" size="small">{{ $t('nodes.statusUnverified') }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" show-overflow-tooltip min-width="120" />
<el-table-column label="操作" width="200" fixed="right">
<el-table-column prop="remark" :label="$t('common.remark')" show-overflow-tooltip min-width="120" />
<el-table-column :label="$t('common.operation')" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.kind !== 'local'" size="small" link :loading="verifyingId === row.id" @click="verify(row)">验证</el-button>
<el-button v-if="row.kind !== 'local'" size="small" link @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.kind !== 'local'" type="danger" size="small" link @click="remove(row)">删除</el-button>
<el-button v-if="row.kind !== 'local'" size="small" link :loading="verifyingId === row.id" @click="verify(row)">{{ $t('nodes.verify') }}</el-button>
<el-button v-if="row.kind !== 'local'" size="small" link @click="openEdit(row)">{{ $t('common.edit') }}</el-button>
<el-button v-if="row.kind !== 'local'" type="danger" size="small" link @click="remove(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="还没有远程节点,点击右上角添加" />
<el-empty :description="$t('nodes.noNodes')" />
</template>
</el-table>
<!-- 添加/编辑节点对话框 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑节点' : '添加节点'" width="520px" align-center>
<el-dialog v-model="dialogVisible" :title="editing ? $t('nodes.editNode') : $t('nodes.addNode')" width="520px" align-center>
<el-form label-width="100px">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="例如:北京中继节点" />
<el-form-item :label="$t('common.name')" required>
<el-input v-model="form.name" :placeholder="$t('nodes.namePlaceholder')" />
</el-form-item>
<el-form-item label="地址" required>
<el-input v-model="form.address" placeholder="IP 或域名,如 192.168.1.100" />
<el-form-item :label="$t('nodes.address')" required>
<el-input v-model="form.address" :placeholder="$t('nodes.addressPlaceholder')" />
</el-form-item>
<el-form-item label="SSH 端口">
<el-form-item :label="$t('nodes.sshPort')">
<el-input-number v-model="form.port" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="类型">
<el-form-item :label="$t('nodes.type')">
<el-radio-group v-model="form.kind">
<el-radio value="remote">远程节点</el-radio>
<el-radio value="local">本机节点</el-radio>
<el-radio value="remote">{{ $t('nodes.remoteNode') }}</el-radio>
<el-radio value="local">{{ $t('nodes.localNode') }}</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="form.kind === 'remote'">
<el-form-item label="SSH 用户">
<el-input v-model="form.username" placeholder="默认 root" />
<el-form-item :label="$t('nodes.sshUser')">
<el-input v-model="form.username" :placeholder="$t('nodes.defaultRoot')" />
</el-form-item>
<el-form-item label="认证方式">
<el-form-item :label="$t('nodes.authMethod')">
<el-radio-group v-model="form.auth_type">
<el-radio value="password">密码</el-radio>
<el-radio value="key">私钥</el-radio>
<el-radio value="password">{{ $t('nodes.authPassword') }}</el-radio>
<el-radio value="key">{{ $t('nodes.authKey') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.auth_type === 'password'" :label="editing ? '新密码' : '密码'">
<el-form-item v-if="form.auth_type === 'password'" :label="editing ? $t('nodes.authPassword') : $t('nodes.authPassword')">
<el-input v-model="form.password" type="password" show-password
:placeholder="editing ? '留空表示不修改' : 'SSH 登录密码'" />
:placeholder="editing ? $t('nodes.keepUnchanged') : $t('nodes.sshPasswordPlaceholder')" />
</el-form-item>
<el-form-item v-if="form.auth_type === 'key'" :label="editing ? '新私钥' : '私钥'">
<el-form-item v-if="form.auth_type === 'key'" :label="editing ? $t('nodes.authKey') : $t('nodes.authKey')">
<el-input v-model="form.private_key" type="textarea" :rows="4"
:placeholder="editing ? '留空表示不修改' : '粘贴 OpenSSH/PEM 格式私钥内容'" />
:placeholder="editing ? $t('nodes.keepUnchanged') : $t('nodes.privateKeyPlaceholder')" />
</el-form-item>
</template>
<el-form-item label="备注">
<el-form-item :label="$t('common.remark')">
<el-input v-model="form.remark" />
</el-form-item>
</el-form>
<!-- 测试连接结果 -->
<el-alert v-if="testResult" :type="testResult.success ? 'success' : 'error'"
:title="testResult.success ? '连接成功' : '连接失败'"
:title="testResult.success ? $t('nodes.testConnection') + ' ✓' : $t('nodes.testConnection') + ' ✗'"
:description="testResult.message" show-icon :closable="false"
style="margin-top:8px" />
<template #footer>
<el-button v-if="form.kind === 'remote'" :loading="testing" @click="testConnection">
测试连接
{{ $t('nodes.testConnection') }}
</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">确定</el-button>
<el-button @click="dialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="save">{{ $t('nodes.determine') }}</el-button>
</template>
</el-dialog>
</el-card>
@@ -114,9 +114,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Plus } from '@element-plus/icons-vue'
import api from '../api'
const { t } = useI18n()
const nodes = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
@@ -150,12 +152,12 @@ async function load() {
const { data } = await api.get('/nodes')
nodes.value = data || []
} catch (e) {
ElMessage.error(e.response?.data?.error || '加载失败')
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
} finally { loading.value = false }
}
async function testConnection() {
if (!form.value.address) return ElMessage.warning('请先填写地址')
if (!form.value.address) return ElMessage.warning(t('nodes.fillAddressFirst'))
testing.value = true
testResult.value = null
try {
@@ -165,25 +167,25 @@ async function testConnection() {
})
testResult.value = data
} catch (e) {
testResult.value = { success: false, message: e.response?.data?.error || '请求失败' }
testResult.value = { success: false, message: e.response?.data?.error || t('nodes.requestFailed') }
} finally { testing.value = false }
}
async function save() {
if (!form.value.name || !form.value.address) return ElMessage.warning('请填写名称与地址')
if (!form.value.name || !form.value.address) return ElMessage.warning(t('nodes.fillNameAndAddress'))
saving.value = true
try {
if (editing.value) {
await api.put(`/nodes/${editing.value.id}`, form.value)
ElMessage.success('已更新')
ElMessage.success(t('nodes.updated'))
} else {
await api.post('/nodes', form.value)
ElMessage.success('已添加')
ElMessage.success(t('nodes.added'))
}
dialogVisible.value = false
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '保存失败')
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
} finally { saving.value = false }
}
@@ -192,24 +194,24 @@ async function verify(row) {
try {
const { data } = await api.post(`/nodes/${row.id}/verify`)
if (data.success) {
ElMessage.success('验证成功:' + (data.message || '').slice(0, 50))
ElMessage.success(t('nodes.verifySuccess', { msg: (data.message || '').slice(0, 50) }))
} else {
ElMessage.error('验证失败:' + (data.message || ''))
ElMessage.error(t('nodes.verifyFailed', { msg: data.message || '' }))
}
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '验证失败')
ElMessage.error(e.response?.data?.error || t('nodes.statusFailed'))
} finally { verifyingId.value = null }
}
async function remove(row) {
try {
await ElMessageBox.confirm(`确定删除节点「${row.name}」?该操作不可撤销。`, '删除确认', { type: 'warning' })
await ElMessageBox.confirm(t('nodes.deleteNodeConfirm', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' })
await api.delete(`/nodes/${row.id}`)
ElMessage.success('已删除')
ElMessage.success(t('common.deleteSuccess'))
load()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || '删除失败')
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
<template>
<div class="not-found">
<el-result icon="warning" title="404" sub-title="您访问的页面不存在或已被移除">
<el-result icon="warning" :title="$t('notFound.title')" :sub-title="$t('notFound.subtitle')">
<template #extra>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
<el-button type="primary" @click="$router.push('/')">{{ $t('notFound.backHome') }}</el-button>
</template>
</el-result>
</div>
+35 -33
View File
@@ -3,54 +3,54 @@
<el-card class="page-card" shadow="never">
<template #header>
<div class="row-between">
<b>自定义协议插件</b>
<el-button type="primary" :icon="Plus" @click="openCreate">注册插件</el-button>
<b>{{ $t('plugins.title') }}</b>
<el-button type="primary" :icon="Plus" @click="openCreate">{{ $t('plugins.registerPlugin') }}</el-button>
</div>
</template>
<el-alert type="info" :closable="false" style="margin-bottom:14px">
<p style="margin:0">插件通过<b>命令钩子</b>实现统一协议契约:平台将专线配置 JSON 通过 stdin 传给钩子命令。</p>
<p style="margin:4px 0 0;font-size:12px">契约:<code>Create / Start / Stop / Status / Config</code>;Status 钩子 stdout 输出 <code>running</code> 或 <code>stopped</code>;Config 钩子 stdout 输出客户端配置文本。</p>
<p style="margin:0" v-html="$t('plugins.pluginHint')"></p>
<p style="margin:4px 0 0;font-size:12px" v-html="$t('plugins.pluginContract')"></p>
</el-alert>
<el-table :data="plugins" size="default" v-loading="loading">
<el-table-column prop="display_name" label="名称" width="160" />
<el-table-column label="协议标识" width="140">
<el-table-column prop="display_name" :label="$t('common.name')" width="160" />
<el-table-column :label="$t('plugins.protocolId')" width="140">
<template #default="{ row }"><code>custom:{{ row.name }}</code></template>
</el-table-column>
<el-table-column prop="description" label="说明" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<el-table-column prop="description" :label="$t('common.description')" show-overflow-tooltip />
<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('plugins.enabled') : $t('plugins.disabled') }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<el-table-column :label="$t('common.operation')" width="140">
<template #default="{ row }">
<el-button size="small" link @click="edit(row)">编辑</el-button>
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
<el-button size="small" link @click="edit(row)">{{ $t('common.edit') }}</el-button>
<el-button type="danger" size="small" link @click="remove(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="还没有自定义协议插件" /></template>
<template #empty><el-empty :description="$t('plugins.noPlugins')" /></template>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑插件' : '注册协议插件'" width="560px">
<el-dialog v-model="dialogVisible" :title="editing ? $t('plugins.editPlugin') : $t('plugins.registerPluginTitle')" width="560px">
<el-form label-width="110px">
<el-form-item label="协议标识" required>
<el-input v-model="form.name" :disabled="!!editing" placeholder="如 myproto(字母数字)" />
<el-form-item :label="$t('plugins.protocolId')" required>
<el-input v-model="form.name" :disabled="!!editing" :placeholder="$t('plugins.protocolIdPlaceholder')" />
</el-form-item>
<el-form-item label="显示名称"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item label="说明"><el-input v-model="form.description" /></el-form-item>
<el-form-item label="Start 钩子" required>
<el-form-item :label="$t('plugins.displayName')"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item :label="$t('common.description')"><el-input v-model="form.description" /></el-form-item>
<el-form-item label="Start Hook" required>
<el-input v-model="form.start_hook" placeholder="/opt/plugins/myproto start" />
</el-form-item>
<el-form-item label="Stop 钩子"><el-input v-model="form.stop_hook" placeholder="/opt/plugins/myproto stop" /></el-form-item>
<el-form-item label="Status 钩子"><el-input v-model="form.status_hook" placeholder="/opt/plugins/myproto status" /></el-form-item>
<el-form-item label="Config 钩子"><el-input v-model="form.config_hook" placeholder="/opt/plugins/myproto config" /></el-form-item>
<el-form-item label="Create 钩子"><el-input v-model="form.create_hook" placeholder="可选" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
<el-form-item label="Stop Hook"><el-input v-model="form.stop_hook" placeholder="/opt/plugins/myproto stop" /></el-form-item>
<el-form-item label="Status Hook"><el-input v-model="form.status_hook" placeholder="/opt/plugins/myproto status" /></el-form-item>
<el-form-item label="Config Hook"><el-input v-model="form.config_hook" placeholder="/opt/plugins/myproto config" /></el-form-item>
<el-form-item label="Create Hook"><el-input v-model="form.create_hook" :placeholder="$t('plugins.optional')" /></el-form-item>
<el-form-item :label="$t('common.enabled')"><el-switch v-model="form.enabled" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
<el-button @click="dialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="save">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
</div>
@@ -59,9 +59,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Plus } from '@element-plus/icons-vue'
import api from '../api'
const { t } = useI18n()
const plugins = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
@@ -80,29 +82,29 @@ async function load() {
const { data } = await api.get('/plugins')
plugins.value = data || []
} catch (e) {
ElMessage.error(e.response?.data?.error || '加载失败')
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
} finally { loading.value = false }
}
async function save() {
if (!form.value.name || !form.value.start_hook) return ElMessage.warning('协议标识与 Start 钩子必填')
if (!form.value.name || !form.value.start_hook) return ElMessage.warning(t('plugins.protocolIdRequired'))
try {
if (editing.value) await api.put(`/plugins/${editing.value.id}`, form.value)
else await api.post('/plugins', form.value)
dialogVisible.value = false
ElMessage.success('已保存')
ElMessage.success(t('common.saveSuccess'))
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '保存失败')
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(`删除插件「${row.display_name}」?`, '确认', { type: 'warning' })
await ElMessageBox.confirm(`${t('common.delete')}「${row.display_name}」?`, t('common.confirm'), { type: 'warning' })
await api.delete(`/plugins/${row.id}`)
ElMessage.success('已删除')
ElMessage.success(t('common.deleteSuccess'))
load()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || '删除失败')
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
onMounted(load)
+39 -37
View File
@@ -5,72 +5,72 @@
<template #header>
<div class="row-between">
<div>
<b>在线会话</b>
<el-tag size="small" type="success" style="margin-left:8px">{{ sessions.length }} 个在线</el-tag>
<b>{{ $t('sessions.title') }}</b>
<el-tag size="small" type="success" style="margin-left:8px">{{ $t('sessions.onlineCount', { count: sessions.length }) }}</el-tag>
</div>
<el-button link @click="load">刷新</el-button>
<el-button link @click="load">{{ $t('common.refresh') }}</el-button>
</div>
</template>
<el-table :data="sessions" size="default" v-loading="loading">
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column label="协议" width="100">
<el-table-column prop="username" :label="$t('common.username')" min-width="120" />
<el-table-column :label="$t('common.protocol')" width="100">
<template #default="{ row }">
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ protocolDisplay(row.protocol) }}</span>
</template>
</el-table-column>
<el-table-column prop="real_address" label="公网地址" min-width="140" />
<el-table-column prop="virtual_address" label="VPN IP" min-width="120" />
<el-table-column label="已连接时长" width="120">
<el-table-column prop="real_address" :label="$t('sessions.publicAddress')" min-width="140" />
<el-table-column prop="virtual_address" :label="$t('sessions.vpnIp')" min-width="120" />
<el-table-column :label="$t('sessions.connectedDuration')" width="120">
<template #default="{ row }">{{ fmtDuration(row.connected_at) }}</template>
</el-table-column>
<el-table-column label="流量 ↓ / ↑" width="140">
<el-table-column :label="$t('common.traffic')" width="140">
<template #default="{ row }">
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<el-table-column :label="$t('common.operation')" width="100">
<template #default="{ row }">
<el-button type="danger" size="small" link @click="disconnect(row)">踢下线</el-button>
<el-button type="danger" size="small" link @click="disconnect(row)">{{ $t('sessions.kickOffline') }}</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="当前无在线用户" /></template>
<template #empty><el-empty :description="$t('sessions.noOnlineUsers')" /></template>
</el-table>
</el-card>
<!-- 异常流量告警 -->
<el-card class="page-card" shadow="never" style="margin-bottom:16px" v-if="alerts.length">
<template #header><b style="color:var(--color-danger)">⚠ 异常流量告警</b></template>
<template #header><b style="color:var(--color-danger)">{{ $t('sessions.trafficAlert') }}</b></template>
<el-table :data="alerts" size="small">
<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="100" />
<el-table-column label="类型" width="100">
<el-table-column prop="username" :label="$t('sessions.userTunnel')" width="100" />
<el-table-column :label="$t('sessions.alertType')" width="100">
<template #default="{ row }">
<el-tag :type="row.event === 'rate_alert' ? 'warning' : 'danger'" size="small">
{{ { rate_alert: '突发流量', quota_exceed: '配额超额' }[row.event] || row.event }}
{{ { rate_alert: $t('sessions.rateAlert'), quota_exceed: $t('sessions.quotaExceed') }[row.event] || row.event }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="detail" label="详情" show-overflow-tooltip />
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
</el-table>
</el-card>
<!-- 连接审计日志 -->
<el-card class="page-card" shadow="never">
<template #header><b>连接审计日志</b></template>
<template #header><b>{{ $t('sessions.auditLog') }}</b></template>
<el-table :data="logs" size="small" max-height="500">
<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="100" />
<el-table-column label="事件" width="100">
<el-table-column prop="username" :label="$t('common.username')" width="100" />
<el-table-column :label="$t('sessions.event')" width="100">
<template #default="{ row }">
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip_address" label="IP 地址" width="140" />
<el-table-column prop="detail" label="详情" show-overflow-tooltip />
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
</el-table>
</el-card>
</div>
@@ -79,9 +79,11 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../api'
import { protocolColor, protocolDisplay } from '../protocols'
const { t } = useI18n()
const sessions = ref([])
const logs = ref([])
const alerts = ref([])
@@ -94,15 +96,15 @@ function fmtBytes(n) {
while (n >= 1024 && i < 4) { n /= 1024; i++ }
return n.toFixed(1) + ' ' + u[i]
}
function fmtDuration(t) {
if (!t) return '-'
const sec = Math.floor((Date.now() - new Date(t).getTime()) / 1000)
if (sec < 60) return `${sec}秒`
if (sec < 3600) return `${Math.floor(sec / 60)}分钟`
return `${Math.floor(sec / 3600)}小时${Math.floor((sec % 3600) / 60)}分`
function fmtDuration(dur) {
if (!dur) return '-'
const sec = Math.floor((Date.now() - new Date(dur).getTime()) / 1000)
if (sec < 60) return t('sessions.seconds', { n: sec })
if (sec < 3600) return t('sessions.minutes', { n: Math.floor(sec / 60) })
return t('sessions.hoursMinutes', { h: Math.floor(sec / 3600), m: Math.floor((sec % 3600) / 60) })
}
function eventLabel(e) {
return { connect: '连接', disconnect: '断开', auth_fail: '认证失败', quota_exceed: '配额超额', rate_alert: '突发流量' }[e] || e
return { connect: t('sessions.eventConnect'), disconnect: t('sessions.eventDisconnect'), auth_fail: t('sessions.eventAuthFail'), quota_exceed: t('sessions.eventQuotaExceed'), rate_alert: t('sessions.eventRateAlert') }[e] || e
}
function eventTagType(e) {
return { connect: 'success', disconnect: 'info', auth_fail: 'danger', quota_exceed: 'warning', rate_alert: 'warning' }[e] || 'info'
@@ -120,17 +122,17 @@ async function load() {
logs.value = l.data || []
alerts.value = a.data || []
} catch (e) {
ElMessage.error('加载数据失败')
ElMessage.error(t('sessions.loadDataFailed'))
} finally { loading.value = false }
}
async function disconnect(row) {
try {
await ElMessageBox.confirm(`确定踢用户「${row.username}」下线?`, '确认', { type: 'warning' })
await ElMessageBox.confirm(t('sessions.kickConfirm', { username: row.username }), t('common.confirm'), { type: 'warning' })
await api.post(`/sessions/${row.id}/disconnect`)
ElMessage.success('已踢下线')
ElMessage.success(t('sessions.kicked'))
load()
} catch (e) { if (e !== 'cancel') ElMessage.error('操作失败') }
} catch (e) { if (e !== 'cancel') ElMessage.error(t('common.operationFailed')) }
}
let timer
+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>
+18 -16
View File
@@ -2,19 +2,19 @@
<el-card class="page-card" shadow="never" v-loading="loading">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>编辑专线</b>
<el-button @click="router.push('/tunnels')">返回列表</el-button>
<b>{{ $t('tunnelEditor.title') }}</b>
<el-button @click="router.push('/tunnels')">{{ $t('tunnelEditor.backToList') }}</el-button>
</div>
</template>
<el-alert type="info" :closable="false" style="margin-bottom:18px"
title="密码、PSK、Token 等敏感字段不会回显;留空表示继续使用原值。" />
:title="$t('tunnelEditor.sensitiveFieldHint')" />
<el-form v-if="form" label-width="170px" label-position="left" style="max-width:720px">
<el-form-item label="专线名称" required>
<el-form-item :label="$t('tunnelEditor.tunnelName')" required>
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="协议类型">
<el-form-item :label="$t('tunnelEditor.protocolType')">
<el-tag :color="protocolColor(protocol)" effect="dark" style="border:none">{{ protocolDisplay(protocol) }}</el-tag>
</el-form-item>
<el-form-item v-for="field in visibleFields" :key="field.key" :label="field.label" :required="field.required">
@@ -24,18 +24,18 @@
<el-option v-for="option in field.options" :key="optionValue(option)" :label="optionLabel(option)" :value="optionValue(option)" />
</el-select>
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
show-password :placeholder="field.placeholder || '留空保持原值'" />
show-password :placeholder="field.placeholder || $t('tunnelEditor.keepOriginal')" />
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
<div v-if="field.tip" class="field-tip">{{ field.tip }}</div>
</el-form-item>
<el-form-item label="到期时间">
<el-date-picker v-model="form.expire_at" type="datetime" clearable placeholder="留空为永久" style="width:100%" />
<el-form-item :label="$t('tunnelEditor.expireTime')">
<el-date-picker v-model="form.expire_at" type="datetime" clearable :placeholder="$t('tunnelEditor.expirePlaceholder')" style="width:100%" />
</el-form-item>
<el-form-item label="自动启动"><el-switch v-model="form.auto_start" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item>
<el-form-item :label="$t('tunnelEditor.autoStart')"><el-switch v-model="form.auto_start" /></el-form-item>
<el-form-item :label="$t('common.remark')"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
<el-button @click="router.push('/tunnels')">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">{{ $t('tunnelEditor.saveConfig') }}</el-button>
<el-button @click="router.push('/tunnels')">{{ $t('common.cancel') }}</el-button>
</el-form-item>
</el-form>
</el-card>
@@ -45,9 +45,11 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../api'
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const loading = ref(true)
@@ -84,7 +86,7 @@ async function load() {
config,
}
} catch (error) {
ElMessage.error(error.response?.data?.error || '加载专线失败')
ElMessage.error(error.response?.data?.error || t('tunnelEditor.loadFailed'))
router.push('/tunnels')
} finally {
loading.value = false
@@ -92,14 +94,14 @@ async function load() {
}
async function save() {
if (!form.value.name) return ElMessage.warning('请填写专线名称')
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
saving.value = true
try {
await api.put(`/tunnels/${route.params.id}`, form.value)
ElMessage.success('配置已保存')
ElMessage.success(t('tunnelEditor.configSaved'))
router.push('/tunnels')
} catch (error) {
ElMessage.error(error.response?.data?.error || '保存失败')
ElMessage.error(error.response?.data?.error || t('common.saveFailed'))
} finally {
saving.value = false
}
+47 -59
View File
@@ -1,9 +1,9 @@
<template>
<el-card class="page-card" shadow="never">
<el-steps :active="step" align-center style="margin-bottom:32px">
<el-step title="选择协议" />
<el-step title="填写参数" />
<el-step title="生成配置" />
<el-step :title="$t('tunnelWizard.step1')" />
<el-step :title="$t('tunnelWizard.step2')" />
<el-step :title="$t('tunnelWizard.step3')" />
</el-steps>
<!-- 第一步:协议选择 -->
@@ -12,20 +12,19 @@
class="proto-card"
:class="{ active: form.protocol === key, disabled: depStatus[key] && !depStatus[key].available }"
@click="selectProtocol(key)">
<el-tag v-if="key === 'wireguard'" type="danger" size="small" effect="dark" class="recommend-badge">推荐</el-tag>
<el-tag v-if="key === 'wireguard'" type="danger" size="small" effect="dark" class="recommend-badge">{{ $t('tunnelWizard.recommend') }}</el-tag>
<div class="proto-icon"><ProtocolIcon :protocol="key" :size="36" /></div>
<div class="proto-name" :style="{ color: meta.color }">{{ meta.name }}</div>
<div class="proto-desc">{{ meta.desc }}</div>
<div class="proto-scene">{{ meta.scenes }}</div>
<div class="dep-badge" v-if="depStatus[key]">
<el-tag :type="depStatus[key].available ? 'success' : 'warning'" size="small">
{{ depStatus[key].available ? '✓ 依赖就绪' : '未部署' }}
{{ depStatus[key].available ? $t('tunnelWizard.depReady') : $t('tunnelWizard.notDeployed') }}
</el-tag>
<!-- 未就绪:显示下载/安装操作 -->
<div v-if="!depStatus[key].available" class="dep-action" @click.stop>
<el-button v-if="canDeploy(key)" size="small" type="primary" :loading="deploying === key"
@click="deployProtocol(key)">下载部署</el-button>
<el-button v-else size="small" @click="showInstallCmd(key)">安装命令</el-button>
@click="deployProtocol(key)">{{ $t('tunnelWizard.oneClickInstall') }}</el-button>
<el-button size="small" @click="showInstallCmd(key)">{{ $t('tunnelWizard.installCmd') }}</el-button>
</div>
</div>
</div>
@@ -33,20 +32,20 @@
@click="selectProtocol(p.name)">
<div class="proto-icon"><ProtocolIcon protocol="custom" :size="36" /></div>
<div class="proto-name" style="color:#7b5ea7">{{ p.display_name }}</div>
<div class="proto-desc">自定义协议插件</div>
<div class="proto-scene">自定义对接</div>
<div class="proto-desc">{{ $t('tunnelWizard.customPlugin') }}</div>
<div class="proto-scene">{{ $t('tunnelWizard.customScene') }}</div>
</div>
</div>
<!-- 第二步:参数表单 -->
<div v-if="step === 1" style="max-width:640px;margin:0 auto">
<el-form label-width="150px" label-position="left">
<el-form-item label="专线名称" required>
<el-input v-model="form.name" placeholder="例如:总部-分支互联" />
<el-form-item :label="$t('tunnelWizard.tunnelName')" required>
<el-input v-model="form.name" :placeholder="$t('tunnelWizard.namePlaceholder')" />
</el-form-item>
<el-form-item label="协议类型">
<el-form-item :label="$t('tunnelWizard.protocolType')">
<el-tag :color="currentColor" effect="dark" style="border:none">{{ currentName }}</el-tag>
<el-button link style="margin-left:10px" @click="step = 0">重新选择</el-button>
<el-button link style="margin-left:10px" @click="step = 0">{{ $t('tunnelWizard.reselect') }}</el-button>
</el-form-item>
<!-- 基础字段 -->
<el-form-item v-for="f in coreFields" :key="f.key" :label="f.label" :required="f.required">
@@ -64,7 +63,7 @@
<!-- 高级字段(可折叠) -->
<template v-if="advancedFields.length">
<el-collapse v-model="advCollapsed" style="margin-top:8px">
<el-collapse-item title="高级选项" name="adv">
<el-collapse-item :title="$t('tunnelWizard.advancedOptions')" name="adv">
<el-form-item v-for="f in advancedFields" :key="f.key" :label="f.label" :required="f.required">
<el-input-number v-if="f.type === 'number'" v-model="form.config[f.key]" :min="0" :max="65535" />
<el-switch v-else-if="f.type === 'switch'" v-model="form.config[f.key]" />
@@ -79,60 +78,58 @@
</el-collapse-item>
</el-collapse>
</template>
<el-form-item label="到期时间">
<el-date-picker v-model="form.expire_at" type="datetime" placeholder="留空为永久" style="width:100%" />
<el-form-item :label="$t('tunnelWizard.expireTime')">
<el-date-picker v-model="form.expire_at" type="datetime" :placeholder="$t('tunnelWizard.expirePlaceholder')" style="width:100%" />
</el-form-item>
<el-form-item label="创建后自动启动">
<el-form-item :label="$t('tunnelWizard.autoStartAfterCreate')">
<el-switch v-model="form.auto_start" />
</el-form-item>
<el-form-item label="备注">
<el-form-item :label="$t('common.remark')">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div style="text-align:center;margin-top:24px">
<el-button @click="step = 0">上一步</el-button>
<el-button type="primary" :loading="creating" @click="create">生成配置</el-button>
<el-button @click="step = 0">{{ $t('tunnelWizard.previousStep') }}</el-button>
<el-button type="primary" :loading="creating" @click="create">{{ $t('tunnelWizard.generateConfig') }}</el-button>
</div>
</div>
<!-- 第三步:结果 -->
<div v-if="step === 2" style="max-width:720px;margin:0 auto">
<el-result icon="success" title="专线创建成功" :sub-title="`「${created?.name}」配置已生成`" />
<h4>客户端配置</h4>
<el-result icon="success" :title="$t('tunnelWizard.createSuccess')" :sub-title="$t('tunnelWizard.configGenerated', { name: created?.name })" />
<h4>{{ $t('tunnelWizard.clientConfig') }}</h4>
<pre class="mono conf-box">{{ clientConfig }}</pre>
<div style="display:flex;gap:12px;justify-content:center;margin-top:16px">
<el-button type="primary" @click="downloadCreated">下载配置文件</el-button>
<el-button v-if="created?.protocol === 'wireguard'" @click="showQr">显示二维码</el-button>
<el-button v-if="created?.protocol === 'ipsec'" @click="$router.push('/ipsec-peer')">前往 IPSec 对接页</el-button>
<el-button @click="$router.push('/tunnels')">返回列表</el-button>
<el-button type="primary" @click="downloadCreated">{{ $t('tunnelWizard.downloadConfigFile') }}</el-button>
<el-button v-if="created?.protocol === 'wireguard'" @click="showQr">{{ $t('tunnelWizard.showQrCode') }}</el-button>
<el-button v-if="created?.protocol === 'ipsec'" @click="$router.push('/ipsec-peer')">{{ $t('tunnelWizard.goIpsecPeer') }}</el-button>
<el-button @click="$router.push('/tunnels')">{{ $t('tunnelWizard.backToList') }}</el-button>
</div>
<canvas ref="qrCanvas" style="display:block;margin:16px auto" v-show="qrShown"></canvas>
</div>
<!-- 安装命令弹窗(系统包协议) -->
<el-dialog v-model="installCmdVisible" :title="`${installCmdTitle} 安装命令`" width="560px" align-center>
<!-- 安装命令弹窗 -->
<el-dialog v-model="installCmdVisible" :title="$t('tunnelWizard.installCmdTitle', { name: installCmdTitle })" width="560px" align-center>
<el-alert :type="installData.detected ? 'success' : 'warning'" :closable="false" style="margin-bottom:16px"
:title="installData.detected
? `检测到当前系统包管理器:${installData.pm},推荐使用以下命令`
: '未检测到系统包管理器,以下是各平台安装命令供参考'" />
<!-- 当前系统推荐命令 -->
? $t('tunnelWizard.pkgManagerDetected', { pm: installData.pm })
: $t('tunnelWizard.noPkgManager')" />
<div v-if="installData.command" style="margin-bottom:16px">
<div class="cmd-label">
<el-tag size="small" type="success">{{ installData.pm || 'apt (默认)' }}</el-tag>
<span style="margin-left:8px;font-size:13px;color:var(--text-secondary)">推荐</span>
<el-tag size="small" type="success">{{ installData.pm || $t('tunnelWizard.aptDefault') }}</el-tag>
<span style="margin-left:8px;font-size:13px;color:var(--text-secondary)">{{ $t('tunnelWizard.recommended') }}</span>
</div>
<pre class="mono conf-box" style="border-color:var(--color-success)">{{ installData.command }}</pre>
<el-button size="small" style="margin-top:4px" @click="copyText(installData.command)">复制此命令</el-button>
<el-button size="small" style="margin-top:4px" @click="copyText(installData.command)">{{ $t('tunnelWizard.copyThisCmd') }}</el-button>
</div>
<!-- 其他平台命令 -->
<el-divider content-position="left"><span style="font-size:12px;color:var(--text-secondary)">其他发行版</span></el-divider>
<el-divider content-position="left"><span style="font-size:12px;color:var(--text-secondary)">{{ $t('tunnelWizard.otherDistros') }}</span></el-divider>
<div v-for="(cmd, pm) in installData.all_commands" :key="pm" class="cmd-row" v-show="cmd !== installData.command">
<el-tag size="small" style="min-width:70px;text-align:center">{{ pm }}</el-tag>
<code class="cmd-code">{{ cmd }}</code>
<el-button link size="small" @click="copyText(cmd)">复制</el-button>
<el-button link size="small" @click="copyText(cmd)">{{ $t('common.copy') }}</el-button>
</div>
<template #footer>
<el-button type="primary" @click="installCmdVisible = false">关闭</el-button>
<el-button type="primary" @click="installCmdVisible = false">{{ $t('common.close') }}</el-button>
</template>
</el-dialog>
</el-card>
@@ -141,11 +138,13 @@
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api from '../api'
import ProtocolIcon from '../components/ProtocolIcon.vue'
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
const { t } = useI18n()
const step = ref(0)
const form = ref({ name: '', protocol: '', config: {}, remark: '', auto_start: true, expire_at: null })
const creating = ref(false)
@@ -168,7 +167,6 @@ const visibleFields = computed(() => currentFields.value.filter(f => {
? v.includes(form.value.config[k])
: form.value.config[k] === v)
}))
// 必填字段为"基础",其余为"高级"(可折叠),超过 6 个字段才拆分
const coreFields = computed(() => {
if (visibleFields.value.length <= 6) return visibleFields.value
return visibleFields.value.filter(f => f.required)
@@ -182,7 +180,6 @@ const advCollapsed = ref([])
function selectProtocol(key) {
form.value.protocol = key
form.value.config = {}
// 填充默认值
for (const f of (PROTOCOL_META[key]?.fields || CUSTOM_FIELDS)) {
if (f.default !== undefined) form.value.config[f.key] = f.default
}
@@ -190,13 +187,12 @@ function selectProtocol(key) {
}
async function create() {
if (!form.value.name) return ElMessage.warning('请填写专线名称')
// 必填字段校验:提示具体缺哪个字段
if (!form.value.name) return ElMessage.warning(t('tunnelWizard.fillName'))
for (const f of visibleFields.value) {
if (f.required) {
const val = form.value.config[f.key]
if (val === undefined || val === '' || val === null) {
return ElMessage.warning(`请填写必填项:${f.label}`)
return ElMessage.warning(t('tunnelWizard.fillRequired', { label: f.label }))
}
}
}
@@ -215,7 +211,7 @@ async function create() {
clientConfig.value = detail.data.client_config || ''
step.value = 2
} catch (e) {
ElMessage.error(e.response?.data?.error || '创建失败')
ElMessage.error(e.response?.data?.error || t('tunnelWizard.createFailed'))
} finally { creating.value = false }
}
@@ -241,31 +237,25 @@ const installCmdVisible = ref(false)
const installCmdTitle = ref('')
const installData = ref({ command: '', pm: '', detected: false, all_commands: {} })
// 协议 → 二进制包名映射(用于判断是否支持平台下载)
const deployableProtocols = ['frp', 'openvpn']
// IPSec/IKEv2/L2TP 归入 strongswan
const protocolToPkg = { wireguard: 'wireguard', ipsec: 'strongswan', ikev2: 'strongswan', l2tp: 'strongswan', openvpn: 'openvpn', frp: 'frp' }
const protocolToPkg = { wireguard: 'wireguard', ipsec: 'ipsec', ikev2: 'ikev2', l2tp: 'l2tp', openvpn: 'openvpn', frp: 'frp' }
function canDeploy(protocol) {
return deployableProtocols.includes(protocol)
return Object.keys(protocolToPkg).includes(protocol)
}
// 下载部署(FRP / OpenVPN 从 GitHub 下载)
async function deployProtocol(protocol) {
const pkg = protocolToPkg[protocol] || protocol
deploying.value = protocol
try {
const { data } = await api.post(`/binaries/install/${pkg}`)
ElMessage.success(`${protocol} ${data.version} 部署成功`)
// 刷新依赖状态
const method = data.method === 'package_manager' ? t('tunnelWizard.methodPackageManager') : data.method === 'download' ? t('tunnelWizard.methodDownload') : ''
ElMessage.success(t('tunnelWizard.installSuccess', { protocol, version: data.version || '', method }))
await refreshDepStatus()
} catch (e) {
ElMessage.error(e.response?.data?.error || `${protocol} 部署失败`)
ElMessage.error(e.response?.data?.error || t('tunnelWizard.installFailed', { protocol }))
} finally { deploying.value = '' }
}
// 显示系统包安装命令
async function showInstallCmd(protocol) {
const pkg = protocolToPkg[protocol] || protocol
installCmdTitle.value = protocol
@@ -284,10 +274,9 @@ async function showInstallCmd(protocol) {
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
navigator.clipboard.writeText(text).then(() => ElMessage.success(t('common.copied')))
}
// 刷新协议依赖状态
async function refreshDepStatus() {
try {
const { data } = await api.get('/protocols')
@@ -299,7 +288,6 @@ async function refreshDepStatus() {
onMounted(async () => {
await refreshDepStatus()
// 加载自定义协议
try {
const { data } = await api.get('/protocols')
for (const p of data) {
+100 -102
View File
@@ -3,28 +3,28 @@
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<el-input v-model="keyword" placeholder="搜索名称" clearable style="width:200px" @change="load" />
<el-select v-model="protocolFilter" placeholder="全部协议" clearable style="width:160px;margin-left:10px" @change="load">
<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-select>
<el-select v-model="stateFilter" placeholder="全部状态" clearable style="width:140px;margin-left:10px" @change="load">
<el-option label="运行中" value="running" />
<el-option label="已停止" value="stopped" />
<el-option label="异常" value="error" />
<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" />
<el-option :label="$t('tunnels.stateStopped')" value="stopped" />
<el-option :label="$t('tunnels.stateError')" value="error" />
</el-select>
</div>
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建专线</el-button>
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
</div>
</template>
<el-table :data="list" v-loading="loading" size="large">
<el-table-column prop="name" label="名称" min-width="140">
<el-table-column prop="name" :label="$t('common.name')" min-width="140">
<template #default="{ row }">
<b>{{ row.name }}</b>
<div class="remark" v-if="row.remark">{{ row.remark }}</div>
</template>
</el-table-column>
<el-table-column label="协议" width="150">
<el-table-column :label="$t('common.protocol')" width="150">
<template #default="{ row }">
<span class="proto-cell">
<ProtocolIcon :protocol="row.protocol" :size="16" :style="{ color: protocolColor(row.protocol) }" />
@@ -32,7 +32,7 @@
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<el-table-column :label="$t('common.status')" width="110">
<template #default="{ row }">
<el-badge is-dot :type="dotType(row)" style="padding-left:12px">
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
@@ -42,42 +42,42 @@
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="流量 ↓ / ↑" width="140">
<el-table-column :label="$t('common.traffic')" width="140">
<template #default="{ row }">
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
</template>
</el-table-column>
<el-table-column label="到期时间" width="150">
<el-table-column :label="$t('tunnels.expireTime')" width="150">
<template #default="{ row }">
<span :style="{ color: expiringSoon(row) ? '#e6a23c' : '' }">{{ row.expire_at ? fmtTime(row.expire_at) : '永久' }}</span>
<span :style="{ color: expiringSoon(row) ? '#e6a23c' : '' }">{{ row.expire_at ? fmtTime(row.expire_at) : $t('common.permanent') }}</span>
</template>
</el-table-column>
<el-table-column label="节点" width="110">
<template #default="{ row }">{{ row.node?.name || '本机节点' }}</template>
<el-table-column :label="$t('tunnels.node')" width="110">
<template #default="{ row }">{{ row.node?.name || $t('tunnels.localNode') }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<el-table-column :label="$t('common.operation')" width="240" fixed="right">
<template #default="{ row }">
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" link @click="start(row)">启动</el-button>
<el-button v-else type="warning" size="small" link @click="stop(row)">停止</el-button>
<el-button size="small" link @click="showDetail(row)">详情</el-button>
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">编辑</el-button>
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" link @click="start(row)">{{ $t('tunnels.start') }}</el-button>
<el-button v-else type="warning" size="small" link @click="stop(row)">{{ $t('tunnels.stop') }}</el-button>
<el-button size="small" link @click="showDetail(row)">{{ $t('tunnels.detail') }}</el-button>
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
<el-button size="small" link>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logs">查看日志</el-dropdown-item>
<el-dropdown-item command="download">下载配置</el-dropdown-item>
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">显示二维码</el-dropdown-item>
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">IPSec 对接测试</el-dropdown-item>
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="danger" size="small" link style="margin-left:12px" @click="remove(row)">删除</el-button>
<el-button type="danger" size="small" link style="margin-left:12px" @click="remove(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="还没有专线">
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建第一条专线</el-button>
<el-empty :description="$t('tunnels.noTunnels')">
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createFirst') }}</el-button>
</el-empty>
</template>
</el-table>
@@ -86,69 +86,69 @@
<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="协议">{{ protocolDisplay(detail.tunnel.protocol) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ stateText(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
<el-descriptions-item label="自动启动">{{ detail.tunnel.auto_start ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.protocol')">{{ 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>
</el-descriptions>
<h4>运行参数</h4>
<h4>{{ $t('tunnels.runningParams') }}</h4>
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
<h4>客户端配置</h4>
<pre class="mono conf-box">{{ detail.client_config || '该协议暂无客户端配置' }}</pre>
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">下载客户端配置</el-button>
<h4>{{ $t('tunnels.clientConfig') }}</h4>
<pre class="mono conf-box">{{ detail.client_config || $t('tunnels.noClientConfig') }}</pre>
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">{{ $t('tunnels.downloadClientConfig') }}</el-button>
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
@click="$router.push('/ipsec-peer')">IPSec 参数检查与连通性测试</el-button>
@click="$router.push('/ipsec-peer')">{{ $t('tunnels.ipsecParamCheck') }}</el-button>
<!-- 流量策略 -->
<h4>流量策略</h4>
<h4>{{ $t('tunnels.trafficPolicy') }}</h4>
<el-form label-width="90px" size="small" v-loading="policyLoading">
<el-form-item label="配额(GB)">
<el-form-item :label="$t('tunnels.quotaGB')">
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">0 = 不限。已用:{{ fmtBytes(policy.used_bytes || 0) }}</span>
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.quotaUnlimited', { used: fmtBytes(policy.used_bytes || 0) }) }}</span>
</el-form-item>
<el-form-item label="限速(Mbps)">
<el-form-item :label="$t('tunnels.rateLimitMbps')">
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">0 = 不限</span>
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.rateUnlimited') }}</span>
</el-form-item>
<el-form-item label="超额动作">
<el-form-item :label="$t('tunnels.exceedAction')">
<el-select v-model="policy.action_on_exceed" style="width:100%">
<el-option value="warn" label="仅告警" />
<el-option value="block" label="自动停止" />
<el-option value="throttle" label="限速至1Mbps" />
<el-option value="warn" :label="$t('tunnels.actionWarn')" />
<el-option value="block" :label="$t('tunnels.actionBlock')" />
<el-option value="throttle" :label="$t('tunnels.actionThrottle')" />
</el-select>
</el-form-item>
<el-form-item label="重置周期">
<el-form-item :label="$t('tunnels.resetCycle')">
<el-select v-model="policy.reset_cycle" style="width:100%">
<el-option value="none" label="不重置" />
<el-option value="daily" label="每天" />
<el-option value="monthly" label="每月" />
<el-option value="none" :label="$t('tunnels.resetNone')" />
<el-option value="daily" :label="$t('tunnels.resetDaily')" />
<el-option value="monthly" :label="$t('tunnels.resetMonthly')" />
</el-select>
</el-form-item>
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">保存策略</el-button>
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">{{ $t('tunnels.savePolicy') }}</el-button>
</el-form>
<!-- WireGuard Peer 管理 -->
<template v-if="detail.tunnel.protocol === 'wireguard'">
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
<h4 style="margin:0">Peer 列表</h4>
<el-button type="primary" size="small" @click="openAddPeer">添加 Peer</el-button>
<h4 style="margin:0">{{ $t('tunnels.peerList') }}</h4>
<el-button type="primary" size="small" @click="openAddPeer">{{ $t('tunnels.addPeer') }}</el-button>
</div>
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="name" label="名称" width="100" />
<el-table-column prop="client_address" label="地址" min-width="110" />
<el-table-column label="状态" width="80">
<el-table-column prop="name" :label="$t('common.name')" width="100" />
<el-table-column prop="client_address" :label="$t('tunnels.address')" min-width="110" />
<el-table-column :label="$t('common.status')" width="80">
<template #default="{ row }">
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
{{ peerOnline(row) ? '在线' : '离线' }}
{{ peerOnline(row) ? $t('tunnels.peerOnline') : $t('tunnels.peerOffline') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<el-table-column :label="$t('common.operation')" width="150">
<template #default="{ row }">
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">配置</el-button>
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">二维码</el-button>
<el-button type="danger" link size="small" @click="removePeer(row)">删除</el-button>
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">{{ $t('tunnels.config') }}</el-button>
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">{{ $t('tunnels.qrCode') }}</el-button>
<el-button type="danger" link size="small" @click="removePeer(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -157,58 +157,59 @@
</el-drawer>
<!-- WireGuard 二维码 -->
<el-dialog v-model="qrVisible" title="扫码导入 WireGuard 客户端" width="360px" align-center>
<el-dialog v-model="qrVisible" :title="$t('tunnels.scanQrTitle')" width="360px" align-center>
<div style="text-align:center">
<canvas ref="qrCanvas"></canvas>
<p style="color:#909399;font-size:12px">使用 WireGuard App「从二维码添加」</p>
<p style="color:#909399;font-size:12px">{{ $t('tunnels.scanQrHint') }}</p>
</div>
</el-dialog>
<!-- 添加 Peer -->
<el-dialog v-model="peerDialog" title="添加 WireGuard Peer" width="440px">
<el-dialog v-model="peerDialog" :title="$t('tunnels.addPeerTitle')" width="440px">
<el-form label-width="110px">
<el-form-item label="Peer ID">
<el-input v-model="peerForm.id" placeholder="留空自动生成 client-N" />
<el-form-item :label="$t('tunnels.peerId')">
<el-input v-model="peerForm.id" :placeholder="$t('tunnels.autoGenId')" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="peerForm.name" placeholder="手机 / 笔记本" />
<el-form-item :label="$t('common.name')">
<el-input v-model="peerForm.name" :placeholder="$t('tunnels.peerNamePlaceholder')" />
</el-form-item>
<el-form-item label="客户端地址" required>
<el-form-item :label="$t('tunnels.clientAddress')" required>
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
</el-form-item>
<el-form-item label="AllowedIPs">
<el-input v-model="peerForm.allowed_ips" placeholder="留空使用隧道默认" />
<el-input v-model="peerForm.allowed_ips" :placeholder="$t('tunnels.defaultAllowedIPs')" />
</el-form-item>
<el-form-item label="外部公钥">
<el-form-item :label="$t('tunnels.externalPublicKey')">
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
placeholder="留空则平台托管生成密钥;填写则为外部设备" />
:placeholder="$t('tunnels.externalKeyHint')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="peerDialog = false">取消</el-button>
<el-button type="primary" :loading="peerSaving" @click="addPeer">添加</el-button>
<el-button @click="peerDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="peerSaving" @click="addPeer">{{ $t('common.add') }}</el-button>
</template>
</el-dialog>
</el-card>
<!-- 日志实时查看弹窗 -->
<el-dialog v-model="logVisible" :title="`实例日志 — ${logTunnel?.name || ''}`" width="780px" align-center
<el-dialog v-model="logVisible" :title="$t('tunnels.logTitle', { name: logTunnel?.name || '' })" width="780px" align-center
@close="closeLogStream">
<div class="log-toolbar">
<el-tag size="small" :type="logStreaming ? 'success' : 'info'">
{{ logStreaming ? '● 实时输出中' : '已停止' }}
{{ logStreaming ? $t('tunnels.liveOutput') : $t('tunnels.logStopped') }}
</el-tag>
<el-button v-if="logStreaming" size="small" @click="stopLogStream">暂停</el-button>
<el-button v-else size="small" type="primary" @click="resumeLogStream">继续</el-button>
<el-button size="small" @click="logContent = ''">清屏</el-button>
<el-button v-if="logStreaming" size="small" @click="stopLogStream">{{ $t('tunnels.pause') }}</el-button>
<el-button v-else size="small" type="primary" @click="resumeLogStream">{{ $t('tunnels.resume') }}</el-button>
<el-button size="small" @click="logContent = ''">{{ $t('tunnels.clearScreen') }}</el-button>
</div>
<div class="log-view mono" ref="logView">{{ logContent || '等待日志输出...' }}</div>
<div class="log-view mono" ref="logView">{{ logContent || $t('tunnels.waitingLog') }}</div>
</el-dialog>
</template>
<script setup>
import { ref, 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'
@@ -216,6 +217,7 @@ import api from '../api'
import ProtocolIcon from '../components/ProtocolIcon.vue'
import { protocolDisplay, protocolColor } from '../protocols'
const { t } = useI18n()
const router = useRouter()
const list = ref([])
const loading = ref(false)
@@ -245,12 +247,12 @@ function fmtBytes(n) {
while (n >= 1024 && i < 4) { n /= 1024; i++ }
return n.toFixed(1) + ' ' + u[i]
}
function fmtTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
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
}
function stateText(row) {
return { running: '运行中', stopped: '已停止', error: '异常', undeployed: '未部署' }[stateOf(row)] || stateOf(row)
return { running: t('tunnels.stateRunning'), stopped: t('tunnels.stateStopped'), error: t('tunnels.stateError'), undeployed: t('tunnels.stateUndeployed') }[stateOf(row)] || stateOf(row)
}
function stateColor(row) {
return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)]
@@ -272,7 +274,6 @@ async function load() {
try {
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
let result = data || []
// 前端状态筛选(后端无 state 参数)
if (stateFilter.value) {
result = result.filter(r => stateOf(r) === stateFilter.value)
}
@@ -307,16 +308,13 @@ function resumeLogStream() {
if (!logTunnel.value) return
stopLogStream()
const token = localStorage.getItem('vpn_token')
// SSE 不支持自定义 header,通过 query 传 token
logEventSource = new EventSource(`/api/tunnels/${logTunnel.value.id}/logs?token=${token}`)
logStreaming.value = true
logEventSource.onmessage = (e) => {
logContent.value += e.data + '\n'
// 自动滚动到底部
nextTick(() => {
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
})
// 限制日志长度防止内存爆炸
if (logContent.value.length > 100000) {
logContent.value = logContent.value.slice(-50000)
}
@@ -364,30 +362,30 @@ async function savePolicy(tunnelId) {
action_on_exceed: policy.value.action_on_exceed,
reset_cycle: policy.value.reset_cycle,
})
ElMessage.success('流量策略已保存')
ElMessage.success(t('tunnels.policySaved'))
} catch (e) {
ElMessage.error(e.response?.data?.error || '保存失败')
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
} finally { policySaving.value = false }
}
async function start(row) {
try {
await api.post(`/tunnels/${row.id}/start`)
ElMessage.success('已启动')
ElMessage.success(t('tunnels.started'))
} catch (e) {
ElMessage.error(e.response?.data?.error || '启动失败')
ElMessage.error(e.response?.data?.error || t('tunnels.startFailed'))
}
load()
}
async function stop(row) {
await api.post(`/tunnels/${row.id}/stop`)
ElMessage.success('已停止')
ElMessage.success(t('tunnels.stopped'))
load()
}
async function remove(row) {
await ElMessageBox.confirm(`确定删除专线「${row.name}」?配置与密钥将一并清除。`, '删除确认', { type: 'warning' })
await ElMessageBox.confirm(t('tunnels.deleteTunnelConfirm', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' })
await api.delete(`/tunnels/${row.id}`)
ElMessage.success('已删除')
ElMessage.success(t('common.deleteSuccess'))
load()
}
async function showDetail(row) {
@@ -422,7 +420,7 @@ function openAddPeer() {
}
async function addPeer() {
if (!peerForm.value.client_address) return ElMessage.warning('请填写客户端地址')
if (!peerForm.value.client_address) return ElMessage.warning(t('tunnels.fillClientAddress'))
peerSaving.value = true
try {
const body = { ...peerForm.value }
@@ -430,28 +428,28 @@ async function addPeer() {
if (!body.public_key) delete body.public_key
if (!body.allowed_ips) delete body.allowed_ips
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
ElMessage.success('Peer 已添加')
ElMessage.success(t('tunnels.peerAdded'))
peerDialog.value = false
await loadPeers(detail.value.tunnel.id)
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
detail.value = data
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '添加失败')
ElMessage.error(e.response?.data?.error || t('tunnels.addFailed'))
} finally {
peerSaving.value = false
}
}
async function removePeer(row) {
await ElMessageBox.confirm(`删除 Peer「${row.id}」?`, '确认', { type: 'warning' })
await ElMessageBox.confirm(t('tunnels.deletePeerConfirm', { id: row.id }), t('common.confirm'), { type: 'warning' })
try {
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
ElMessage.success('已删除')
ElMessage.success(t('common.deleteSuccess'))
await loadPeers(detail.value.tunnel.id)
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '删除失败')
ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
@@ -469,7 +467,7 @@ function downloadPeer(row) {
a.href = URL.createObjectURL(b)
a.download = `wireguard-${row.id}.conf`
a.click()
}).catch(e => ElMessage.error(e.message || '下载失败'))
}).catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function qrPeer(row) {
@@ -486,7 +484,7 @@ async function qrPeer(row) {
await nextTick()
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.message || '生成二维码失败')
ElMessage.error(e.message || t('tunnels.qrFailed'))
}
}
@@ -503,7 +501,7 @@ function download(row) {
}
async function showQr(row) {
const { data } = await api.get(`/tunnels/${row.id}`)
if (!data.client_config) return ElMessage.warning('无客户端配置')
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 })
+83 -82
View File
@@ -4,62 +4,62 @@
<el-card class="page-card" shadow="never">
<template #header>
<div class="row-between">
<b>管理用户</b>
<b>{{ $t('users.title') }}</b>
<div>
<el-button @click="batchVisible = true">批量导入</el-button>
<el-button @click="ldapImportVisible = true">LDAP 导入</el-button>
<el-button type="primary" @click="openCreate">添加用户</el-button>
<el-button @click="batchVisible = true">{{ $t('users.batchImport') }}</el-button>
<el-button @click="ldapImportVisible = true">{{ $t('users.ldapImport') }}</el-button>
<el-button type="primary" @click="openCreate">{{ $t('users.addUser') }}</el-button>
</div>
</div>
</template>
<el-table :data="users" size="small" v-loading="loading">
<el-table-column prop="username" label="用户名" min-width="100" />
<el-table-column prop="email" label="邮箱" min-width="140" />
<el-table-column label="来源" width="80">
<el-table-column prop="username" :label="$t('common.username')" min-width="100" />
<el-table-column prop="email" :label="$t('common.email')" min-width="140" />
<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>
<el-table-column label="角色" width="90">
<el-table-column :label="$t('users.role')" width="90">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'operator' ? 'warning' : 'info'" size="small">
{{ { admin: '管理员', operator: '运维', viewer: '只读' }[row.role] || row.role }}
{{ { admin: $t('users.roleAdmin'), operator: $t('users.roleOperator'), viewer: $t('users.roleViewer') }[row.role] || row.role }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="70">
<el-table-column :label="$t('common.status')" width="70">
<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="140">
<el-table-column :label="$t('common.operation')" width="140">
<template #default="{ row }">
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button>
<el-button link size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.username !== auth.username" type="danger" link size="small" @click="remove(row)">删除</el-button>
<el-button link size="small" @click="toggle(row)">{{ row.enabled ? $t('common.disabled') : $t('common.enabled') }}</el-button>
<el-button link size="small" @click="openEdit(row)">{{ $t('common.edit') }}</el-button>
<el-button v-if="row.username !== auth.username" type="danger" link size="small" @click="remove(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="还没有用户" /></template>
<template #empty><el-empty :description="$t('users.noUsers')" /></template>
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :lg="9">
<el-card class="page-card" shadow="never">
<template #header><b>LDAP 身份源</b></template>
<template #header><b>{{ $t('users.ldapTitle') }}</b></template>
<el-alert type="info" :closable="false"
title="LDAP 用户可首次登录后自动同步,也可使用左侧「LDAP 导入」预先添加。" style="margin-bottom:12px" />
:title="$t('users.ldapHint')" style="margin-bottom:12px" />
<el-form label-width="90px" size="small">
<el-form-item label="启用"><el-switch v-model="ldap.enabled" /></el-form-item>
<el-form-item label="LDAP URL"><el-input v-model="ldap.url" placeholder="ldaps://ldap.example.com:636" /></el-form-item>
<el-form-item label="Bind DN"><el-input v-model="ldap.bind_dn" /></el-form-item>
<el-form-item label="Bind 密码"><el-input v-model="ldap.bind_password" type="password" show-password placeholder="留空保持原值" /></el-form-item>
<el-form-item label="Base DN"><el-input v-model="ldap.base_dn" placeholder="dc=example,dc=com" /></el-form-item>
<el-form-item label="用户过滤"><el-input v-model="ldap.user_filter" placeholder="(objectClass=inetOrgPerson)" /></el-form-item>
<el-form-item label="用户名属性"><el-input v-model="ldap.username_attribute" placeholder="uid" /></el-form-item>
<el-form-item :label="$t('common.enabled')"><el-switch v-model="ldap.enabled" /></el-form-item>
<el-form-item :label="$t('users.ldapUrl')"><el-input v-model="ldap.url" placeholder="ldaps://ldap.example.com:636" /></el-form-item>
<el-form-item :label="$t('users.bindDn')"><el-input v-model="ldap.bind_dn" /></el-form-item>
<el-form-item :label="$t('users.bindPassword')"><el-input v-model="ldap.bind_password" type="password" show-password :placeholder="$t('users.keepOriginal')" /></el-form-item>
<el-form-item :label="$t('users.baseDn')"><el-input v-model="ldap.base_dn" placeholder="dc=example,dc=com" /></el-form-item>
<el-form-item :label="$t('users.userFilter')"><el-input v-model="ldap.user_filter" placeholder="(objectClass=inetOrgPerson)" /></el-form-item>
<el-form-item :label="$t('users.usernameAttr')"><el-input v-model="ldap.username_attribute" placeholder="uid" /></el-form-item>
<div style="text-align:right">
<el-button @click="testLDAP">测试连接</el-button>
<el-button type="primary" @click="saveLDAP">保存</el-button>
<el-button @click="testLDAP">{{ $t('users.testConnection') }}</el-button>
<el-button type="primary" @click="saveLDAP">{{ $t('common.save') }}</el-button>
</div>
</el-form>
</el-card>
@@ -67,80 +67,80 @@
</el-row>
<!-- 单用户新增/编辑对话框 -->
<el-dialog v-model="visible" :title="editing ? '编辑用户' : '添加用户'" width="440px" align-center>
<el-dialog v-model="visible" :title="editing ? $t('users.editUser') : $t('users.addUser')" width="440px" align-center>
<el-form label-width="80px">
<el-form-item label="用户名" required>
<el-input v-model="form.username" :disabled="!!editing" placeholder="登录用户名" />
<el-form-item :label="$t('common.username')" required>
<el-input v-model="form.username" :disabled="!!editing" :placeholder="$t('users.loginUsername')" />
</el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" placeholder="user@example.com" /></el-form-item>
<el-form-item v-if="!editing || form.source === 'local'" label="密码" :required="!editing">
<el-form-item :label="$t('common.email')"><el-input v-model="form.email" placeholder="user@example.com" /></el-form-item>
<el-form-item v-if="!editing || form.source === 'local'" :label="$t('common.password')" :required="!editing">
<el-input v-model="form.password" type="password" show-password
:placeholder="editing ? '留空不修改' : '至少 8 位'" />
:placeholder="editing ? $t('users.keepUnchanged') : $t('users.min8Chars')" />
</el-form-item>
<el-form-item label="角色">
<el-form-item :label="$t('users.role')">
<el-select v-model="form.role" style="width:100%">
<el-option value="admin" label="管理员" />
<el-option value="operator" label="运维" />
<el-option value="viewer" label="只读" />
<el-option value="admin" :label="$t('users.roleAdmin')" />
<el-option value="operator" :label="$t('users.roleOperator')" />
<el-option value="viewer" :label="$t('users.roleViewer')" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
<el-button @click="visible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="save">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
<!-- 批量导入对话框 -->
<el-dialog v-model="batchVisible" title="批量导入用户" width="600px" align-center>
<el-dialog v-model="batchVisible" :title="$t('users.batchImportTitle')" width="600px" align-center>
<el-alert type="info" :closable="false" style="margin-bottom:16px"
title="每行一个用户,格式:用户名,密码 或仅用户名(自动生成密码)。密码至少 8 位。" />
:title="$t('users.batchHint')" />
<el-input v-model="batchText" type="textarea" :rows="8"
placeholder="每行一个:用户名,密码(如 user1,Pass1234);或仅用户名自动生成密码" />
<el-form-item label="默认角色" style="margin-top:16px">
:placeholder="$t('users.batchPlaceholder')" />
<el-form-item :label="$t('users.defaultRole')" style="margin-top:16px">
<el-select v-model="batchRole" style="width:160px">
<el-option value="viewer" label="只读" />
<el-option value="operator" label="运维" />
<el-option value="admin" label="管理员" />
<el-option value="viewer" :label="$t('users.roleViewer')" />
<el-option value="operator" :label="$t('users.roleOperator')" />
<el-option value="admin" :label="$t('users.roleAdmin')" />
</el-select>
</el-form-item>
<template #footer>
<el-button @click="batchVisible = false">取消</el-button>
<el-button type="primary" :loading="batchLoading" @click="doBatchImport">开始导入</el-button>
<el-button @click="batchVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="batchLoading" @click="doBatchImport">{{ $t('users.startImport') }}</el-button>
</template>
</el-dialog>
<!-- LDAP 导入对话框 -->
<el-dialog v-model="ldapImportVisible" title="从 LDAP 导入用户" width="680px" align-center>
<el-dialog v-model="ldapImportVisible" :title="$t('users.ldapImportTitle')" width="680px" align-center>
<el-alert v-if="!ldap.enabled" type="warning" :closable="false" style="margin-bottom:12px"
title="LDAP 未启用,请先在右侧配置并测试连接" />
:title="$t('users.ldapNotEnabled')" />
<div class="row-between" style="margin-bottom:12px">
<span style="font-size:14px;color:var(--text-secondary)">
<el-button v-if="ldap.enabled" link type="primary" :loading="ldapLoading" @click="loadLDAPUsers">刷新用户列表</el-button>
<el-button v-if="ldap.enabled" link type="primary" :loading="ldapLoading" @click="loadLDAPUsers">{{ $t('users.refreshUserList') }}</el-button>
</span>
<span v-if="ldapUsers.length" style="font-size:13px;color:var(--text-secondary)">
共 {{ ldapUsers.length }} 个用户,已选 {{ selectedDNs.length }} 个
{{ $t('users.userCount', { count: ldapUsers.length, selected: selectedDNs.length }) }}
</span>
</div>
<el-table :data="ldapUsers" size="small" max-height="380" v-loading="ldapLoading"
@selection-change="rows => selectedDNs = rows.map(r => r.dn)">
<el-table-column type="selection" width="40" :selectable="row => !row.imported" />
<el-table-column prop="uid" label="用户名" width="120" />
<el-table-column prop="cn" label="姓名" min-width="100" />
<el-table-column prop="mail" label="邮箱" min-width="160" />
<el-table-column label="状态" width="80">
<el-table-column prop="uid" :label="$t('common.username')" width="120" />
<el-table-column prop="cn" :label="$t('users.fullName')" min-width="100" />
<el-table-column prop="mail" :label="$t('common.email')" min-width="160" />
<el-table-column :label="$t('common.status')" width="80">
<template #default="{ row }">
<el-tag :type="row.imported ? 'success' : 'info'" size="small">{{ row.imported ? '已导入' : '待导入' }}</el-tag>
<el-tag :type="row.imported ? 'success' : 'info'" size="small">{{ row.imported ? $t('users.imported') : $t('users.pendingImport') }}</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty :description="ldap.enabled ? '点击上方刷新加载 LDAP 用户' : 'LDAP 未启用'" />
<el-empty :description="ldap.enabled ? $t('users.clickRefreshHint') : $t('users.ldapDisabled')" />
</template>
</el-table>
<template #footer>
<el-button @click="ldapImportVisible = false">取消</el-button>
<el-button @click="ldapImportVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="importLoading" :disabled="!selectedDNs.length" @click="doLDAPImport">
导入选中 ({{ selectedDNs.length }})
{{ $t('users.importSelected', { count: selectedDNs.length }) }}
</el-button>
</template>
</el-dialog>
@@ -149,8 +149,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api, { auth } from '../api'
const { t } = useI18n()
const users = ref([])
const loading = ref(false)
const visible = ref(false)
@@ -184,7 +186,7 @@ async function load() {
ldap.value = { ...ldap.value, ...l.data, bind_password: '' }
}
} catch (e) {
ElMessage.error(e.response?.data?.error || '加载失败')
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
} finally { loading.value = false }
}
@@ -205,17 +207,17 @@ async function save() {
const payload = { email: form.value.email, role: form.value.role }
if (form.value.password) payload.password = form.value.password
await api.put(`/users/${editing.value.id}`, payload)
ElMessage.success('已更新')
ElMessage.success(t('users.updated'))
} else {
if (!form.value.username) return ElMessage.warning('请填写用户名')
if (!form.value.password || form.value.password.length < 8) return ElMessage.warning('密码至少 8 位')
if (!form.value.username) return ElMessage.warning(t('users.fillUsername'))
if (!form.value.password || form.value.password.length < 8) return ElMessage.warning(t('users.passwordMin8'))
await api.post('/users', form.value)
ElMessage.success('用户已创建')
ElMessage.success(t('users.userCreated'))
}
visible.value = false
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '操作失败')
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
}
}
async function toggle(row) {
@@ -224,32 +226,31 @@ async function toggle(row) {
}
async function remove(row) {
try {
await ElMessageBox.confirm(`确定删除用户「${row.username}」?`, '删除确认', { type: 'warning' })
await ElMessageBox.confirm(t('users.deleteUserConfirm', { username: row.username }), t('common.deleteConfirm'), { type: 'warning' })
await api.delete(`/users/${row.id}`)
ElMessage.success('已删除')
ElMessage.success(t('common.deleteSuccess'))
load()
} catch (e) { if (e !== 'cancel') ElMessage.error(e.response?.data?.error || '删除失败') }
} catch (e) { if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed')) }
}
// ---- 批量导入 ----
async function doBatchImport() {
const lines = batchText.value.trim().split('\n').filter(l => l.trim())
if (!lines.length) return ElMessage.warning('请输入至少一行用户数据')
if (!lines.length) return ElMessage.warning(t('users.enterAtLeastOne'))
const usersToCreate = lines.map(line => {
const parts = line.trim().split(/[,,\t]/)
const username = parts[0]?.trim()
let password = parts[1]?.trim()
// 仅用户名时自动生成 8 位随机密码
if (!password) password = Math.random().toString(36).slice(2, 10)
return { username, password, email: `${username}@local`, role: batchRole.value }
})
batchLoading.value = true
try {
const { data } = await api.post('/users/batch', { users: usersToCreate, default_role: batchRole.value })
const msg = `成功 ${data.success},跳过 ${data.skipped},失败 ${data.failed}`
const msg = t('users.importResult', { success: data.success, skipped: data.skipped, failed: data.failed })
if (data.failed > 0 && data.errors?.length) {
const errDetail = data.errors.map(e => `${e.username || '(空)'}: ${e.error}`).join('\n')
ElMessageBox.alert(`${msg}\n\n失败详情:\n${errDetail}`, '导入结果', { type: 'warning' })
ElMessageBox.alert(`${msg}\n\n失败详情:\n${errDetail}`, t('users.importResultTitle'), { type: 'warning' })
} else {
ElMessage.success(msg)
}
@@ -257,7 +258,7 @@ async function doBatchImport() {
batchText.value = ''
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '导入失败')
ElMessage.error(e.response?.data?.error || t('users.importFailed'))
} finally { batchLoading.value = false }
}
@@ -269,7 +270,7 @@ async function loadLDAPUsers() {
const { data } = await api.post('/ldap/users')
ldapUsers.value = data || []
} catch (e) {
ElMessage.error(e.response?.data?.error || 'LDAP 加载失败')
ElMessage.error(e.response?.data?.error || t('users.ldapLoadFailed'))
} finally { ldapLoading.value = false }
}
async function doLDAPImport() {
@@ -277,12 +278,12 @@ async function doLDAPImport() {
importLoading.value = true
try {
const { data } = await api.post('/users/import-ldap', { dns: selectedDNs.value })
ElMessage.success(`成功 ${data.success},跳过 ${data.skipped},失败 ${data.failed}`)
ElMessage.success(t('users.importResult', { success: data.success, skipped: data.skipped, failed: data.failed }))
ldapImportVisible.value = false
selectedDNs.value = []
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '导入失败')
ElMessage.error(e.response?.data?.error || t('users.importFailed'))
} finally { importLoading.value = false }
}
@@ -291,14 +292,14 @@ async function saveLDAP() {
try {
await api.put('/ldap', ldap.value)
ldap.value.bind_password = ''
ElMessage.success('LDAP 配置已保存')
} catch (e) { ElMessage.error(e.response?.data?.error || '保存失败') }
ElMessage.success(t('users.ldapConfigSaved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('common.saveFailed')) }
}
async function testLDAP() {
try {
await api.post('/ldap/test')
ElMessage.success('LDAP 连接成功')
} catch (e) { ElMessage.error(e.response?.data?.error || '连接失败') }
ElMessage.success(t('users.ldapConnected'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('users.ldapConnectFailed')) }
}
onMounted(load)
+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'))
}
}