feat(frontend): align console IA with integrations sidebar

Add 对接与扩展 (IPSec, Feishu/OIDC, Plugins, Nodes), move identity
out of settings onto /identity, make sessions/logs tab-deep-linkable,
and rename ops audit / dial-in / admin labels to match the locked nav.
This commit is contained in:
Codex committed 2026-10-23 16:12:02 +08:00
1 parent 7f80f0f7ce
commit f01d3dc5ea
12 files changed
+584 -262

No files matched your search

+2 -2
View File
@@ -87,8 +87,8 @@ const externalDraft = ref(null)
function openIdentitySettings() { function openIdentitySettings() {
const domain = readiness.value?.management_domain const domain = readiness.value?.management_domain
if (window.location.protocol !== 'https:' && readiness.value?.https_management_ready && /^[a-z0-9.-]+$/i.test(domain || '')) { if (window.location.protocol !== 'https:' && readiness.value?.https_management_ready && /^[a-z0-9.-]+$/i.test(domain || '')) {
window.location.assign(`https://${domain}/settings?tab=identity`) window.location.assign(`https://${domain}/identity`)
} else router.push({ path: '/settings', query: { tab: 'identity' } }) } else router.push({ path: '/identity' })
} }
function useBuiltIn() { function useBuiltIn() {
if (!readiness.value?.built_in_enabled) return if (!readiness.value?.built_in_enabled) return
+219 -56
View File
@@ -229,27 +229,34 @@
"title": "Feishu / OIDC browser authentication", "title": "Feishu / OIDC browser authentication",
"externalService": "Requires openvpn-auth-oauth2", "externalService": "Requires openvpn-auth-oauth2",
"componentReady": "Authentication component installed v{version}", "componentReady": "Authentication component installed v{version}",
"authEntryPending": "HTTPS is ready at {domain}; sync the authentication route in Settings → Feishu Identity after starting the tunnel.", "authEntryPending": "HTTPS is ready at {domain}; sync the authentication route on the Feishu / OIDC Identity page after starting the tunnel.",
"app": "Create app", "app": "Create app",
"appShort": "Feishu developer console", "appShort": "Feishu developer console",
"identity": "Enter identity settings", "identity": "Enter identity settings",
"identityShort": "OIDC and credentials", "identityShort": "OIDC and credentials",
"builtinIdentityShort": "Enter Feishu credentials in Settings", "builtinIdentityShort": "Enter Feishu credentials on the Feishu / OIDC Identity page",
"callback": "Set public callback", "callback": "Set public callback",
"callbackShort": "HTTPS reverse proxy", "callbackShort": "HTTPS reverse proxy",
"test": "Test client", "test": "Test client",
"testShort": "Browser sign-in", "testShort": "Browser sign-in",
"appTip": "Ask a Feishu administrator to create and publish an app with the appropriate sign-in capability.", "appTip": "Ask a Feishu administrator to create and publish an app with the appropriate sign-in capability.",
"builtinAppTip": "Manage Feishu App ID and App Secret in Settings → Feishu Identity, not in each tunnel.", "builtinAppTip": "Manage Feishu App ID and App Secret on the Feishu / OIDC Identity page, not in each tunnel.",
"openConsole": "Open Feishu app console", "openConsole": "Open Feishu app console",
"appInstruction1": "Create an enterprise app, obtain its credentials, and enable the web sign-in capability.", "appInstruction1": "Create an enterprise app, obtain its credentials, and enable the web sign-in capability.",
"appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.", "appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.",
"builtinAppInstruction2": "Register the Feishu app callback shown below in the app's redirect settings, not the VPN client callback.", "builtinAppInstruction2": "Register the Feishu app callback shown below in the app's redirect settings, not the VPN client callback.",
"appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.", "appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.",
"scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.", "scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.",
"providerTip": "Use the built-in Feishu issuer configured in Settings, or provide an external issuer with OIDC discovery and ID tokens. A Feishu app URL alone is not an issuer.", "providerTip": "Use the built-in Feishu issuer configured on the Feishu / OIDC Identity page, or provide an external issuer with OIDC discovery and ID tokens. A Feishu app URL alone is not an issuer.",
"source": "Identity source", "builtinSource": "Built-in Feishu", "externalSource": "External OIDC", "configureBuiltIn": "The built-in Feishu issuer is not enabled. Save app credentials and enable it in Settings first.", "manageFeishu": "Open Settings → Feishu Identity", "builtinSummary": "The Feishu App ID, App Secret, and tenant are managed in Settings; this tunnel references the issuer and its own public authentication entry.", "source": "Identity source",
"useBuiltIn": "Use built-in Feishu issuer", "builtInSecret": "The server supplies the VPN Client Secret. Do not enter the Feishu App Secret here.", "builtInGatewayPending": "The public Nginx route for the built-in issuer is pending; sync it in Settings before testing.", "builtinSource": "Built-in Feishu",
"externalSource": "External OIDC",
"configureBuiltIn": "The built-in Feishu issuer is not enabled. Save app credentials and enable it on the Feishu / OIDC Identity page first.",
"manageFeishu": "Open Feishu / OIDC Identity",
"builtinSummary": "The Feishu App ID, App Secret, and tenant are managed on the Feishu / OIDC Identity page; this tunnel references the issuer and its own public authentication entry.",
"useBuiltIn": "Use built-in Feishu issuer",
"builtInSecret": "The server supplies the VPN Client Secret. Do not enter the Feishu App Secret here.",
"builtInGatewayPending": "The public Nginx route for the built-in issuer is pending; sync it on the Feishu / OIDC Identity page before testing.",
"issuer": "OIDC issuer URL", "issuer": "OIDC issuer URL",
"issuerTip": "Use an HTTPS issuer that provides standard discovery (/.well-known/openid-configuration) and ID tokens, not an authorization page URL.", "issuerTip": "Use an HTTPS issuer that provides standard discovery (/.well-known/openid-configuration) and ID tokens, not an authorization page URL.",
"clientId": "OIDC Client ID", "clientId": "OIDC Client ID",
@@ -260,14 +267,17 @@
"secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.", "secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.",
"baseUrl": "Public sign-in URL", "baseUrl": "Public sign-in URL",
"baseTip": "Automatic filling reuses the domain and certificate only. The management site does not yet proxy OAuth2 paths to the auth component. Sign-in needs that Nginx route. Recheck saved tunnel callbacks if the domain changes.", "baseTip": "Automatic filling reuses the domain and certificate only. The management site does not yet proxy OAuth2 paths to the auth component. Sign-in needs that Nginx route. Recheck saved tunnel callbacks if the domain changes.",
"builtinBaseTip": "Uses the system HTTPS domain automatically. The auth component listens locally; sync public routes in Settings after starting the tunnel.", "builtinBaseTip": "Uses the system HTTPS domain automatically. The auth component listens locally; sync public routes on the Feishu / OIDC Identity page after starting the tunnel.",
"reuseDomain": "Use current public domain", "linkedDomain": "Using the HTTPS domain from system settings; you can edit it.", "reuseDomain": "Use current public domain",
"linkedDomain": "Using the HTTPS domain from system settings; you can edit it.",
"listenPort": "Local auth service port", "listenPort": "Local auth service port",
"portTip": "Listens only on 127.0.0.1; no public access needed. New tunnels increment from 19090; +/- and manual entry skip configured and locally occupied ports.", "portTip": "Listens only on 127.0.0.1; no public access needed. New tunnels increment from 19090; +/- and manual entry skip configured and locally occupied ports.",
"portLookupFailed": "Could not find an available port. Retry or enter one manually.", "portLookupFailed": "Could not find an available port. Retry or enter one manually.",
"callbackTitle": "Register this callback with the final OIDC provider", "callbackTitle": "Register this callback with the final OIDC provider",
"callbackTip": "With an identity broker, register this URL in the broker's OIDC client, and register the broker's upstream callback in Feishu. Reverse proxy the public endpoint to 127.0.0.1:{port}; :18080 does not handle this callback.", "callbackTip": "With an identity broker, register this URL in the broker's OIDC client, and register the broker's upstream callback in Feishu. Reverse proxy the public endpoint to 127.0.0.1:{port}; :18080 does not handle this callback.",
"builtinCallbackTitle": "The VPN client and Feishu app use different callbacks", "builtinCallbackTip": "The VPN client callback above is handled by openvpn-auth-oauth2. Register only the Feishu app callback below in Feishu. After starting the tunnel, sync /oauth2/ to local port 127.0.0.1:{port} in Settings.", "feishuCallbackLabel": "Feishu app callback", "builtinCallbackTitle": "The VPN client and Feishu app use different callbacks",
"builtinCallbackTip": "The VPN client callback above is handled by openvpn-auth-oauth2. Register only the Feishu app callback below in Feishu. After starting the tunnel, sync /oauth2/ to local port 127.0.0.1:{port} on the Feishu / OIDC Identity page.",
"feishuCallbackLabel": "Feishu app callback",
"testTip": "Install openvpn-auth-oauth2 and OpenVPN ≥ 2.6.2, then test with a WebAuth-capable client (e.g. OpenVPN Community for Windows or Tunnelblick/Viscosity for macOS). OpenVPN Connect v3 needs individual testing.", "testTip": "Install openvpn-auth-oauth2 and OpenVPN ≥ 2.6.2, then test with a WebAuth-capable client (e.g. OpenVPN Community for Windows or Tunnelblick/Viscosity for macOS). OpenVPN Connect v3 needs individual testing.",
"startFailed": "Configuration generated, but the tunnel did not start. Resolve the error below and retry from the tunnel list.", "startFailed": "Configuration generated, but the tunnel did not start. Resolve the error below and retry from the tunnel list.",
"fillRequired": "Configure an identity source, OIDC issuer, VPN client credentials, and public sign-in URL" "fillRequired": "Configure an identity source, OIDC issuer, VPN client credentials, and public sign-in URL"
@@ -349,8 +359,8 @@
"installFailed": "Failed to install {protocol}" "installFailed": "Failed to install {protocol}"
}, },
"users": { "users": {
"title": "User Management", "title": "Admins",
"clientLoginHint": "These accounts sign in to the admin console. Desktop VPN clients use VPN dial-in users assigned to a WireGuard line.", "clientLoginHint": "These accounts sign in to the admin console. Desktop VPN clients use dial-in accounts assigned to a WireGuard line.",
"batchImport": "Batch Import", "batchImport": "Batch Import",
"ldapImport": "LDAP Import", "ldapImport": "LDAP Import",
"addUser": "Add User", "addUser": "Add User",
@@ -428,7 +438,7 @@
"empty": "No client devices registered" "empty": "No client devices registered"
}, },
"vpnUsers": { "vpnUsers": {
"title": "VPN Dial-in Users", "title": "Dial-in Accounts",
"mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)", "mfaHint": "OpenVPN Two-Factor Auth (TOTP / Static Challenge / PIN+OTP)",
"addUser": "Add User", "addUser": "Add User",
"bulkImport": "Bulk Import", "bulkImport": "Bulk Import",
@@ -454,7 +464,7 @@
"lastOnline": "Last Online", "lastOnline": "Last Online",
"downloadConfig": "Download Config", "downloadConfig": "Download Config",
"otpManagement": "OTP Management", "otpManagement": "OTP Management",
"addVpnUserTitle": "Add VPN Dial-in User", "addVpnUserTitle": "Add Dial-in Account",
"dialTunnel": "Dial-in Tunnel", "dialTunnel": "Dial-in Tunnel",
"pickFromUsers": "Pick from Users", "pickFromUsers": "Pick from Users",
"displayNameLabel": "Display Name", "displayNameLabel": "Display Name",
@@ -576,18 +586,65 @@
"noAlerts": "No traffic alerts" "noAlerts": "No traffic alerts"
}, },
"identity": { "identity": {
"tab": "Feishu identity", "title": "Built-in Feishu identity service", "description": "Issue OIDC identities for VPN using your existing HTTPS domain. Feishu app credentials stay upstream; this platform creates separate VPN client credentials.", "tab": "Feishu identity",
"guideTitle": "Feishu app setup", "guideApp": "Enter the enterprise app ID and secret; grant the three scopes below as needed.", "guidePermission": "", "guidePublish": "Publish the app and include VPN users in its availability scope.", "guideCallback": "Security Settings → Redirect URL:", "guideCallbackPending": "Configure an HTTPS domain first.", "guideVerify": "", "guideManual": "", "guideScope": "", "title": "Built-in Feishu identity service",
"tenantPermission": "tenant:tenant:readonly · enterprise lookup", "userPermission": "contact:user.base:readonly · browser sign-in", "basicProfilePermission": "contact:user.basic_profile:readonly · name lookup by open_id (app identity)", "permissionSteps": "Grant the app identity scope and publish the app; after a member signs in, check their name below.", "openFeishuPermission": "Open Feishu permissions ↗", "checkNamePending": "Check name below", "checking": "Checking", "checked": "Verified", "checkFailed": "Check failed", "checkPending": "Not checked", "checkedByLogin": "Login verified", "loginPending": "Verify on first login", "description": "Issue OIDC identities for VPN using your existing HTTPS domain. Feishu app credentials stay upstream; this platform creates separate VPN client credentials.",
"appId": "Feishu App ID", "appSecret": "Feishu App Secret", "secretPlaceholder": "Leave blank if saved; enter a new secret to rotate", "tenant": "Allowed Feishu tenant_key", "tenantPlaceholder": "Fetched automatically or enter manually", "fetchTenant": "Check again", "fetchingTenant": "Checking ({seconds}s)…", "tenantFetched": "Enterprise permission verified", "tenantFailed": "Enterprise permission check failed", "tenantNotEnabled": "", "enabled": "Enable built-in issuer", "httpsOnly": "Use HTTPS to enter app credentials.", "openHttps": "Open HTTPS management site", "guideTitle": "Feishu app setup",
"httpsRequired": "Configure and activate your public HTTPS site and certificate first.", "gatewayPending": "The identity provider is saved but Nginx does not yet route /id/; sync the gateway.", "guideApp": "Enter the enterprise app ID and secret; grant the three scopes below as needed.",
"addresses": "Addresses and client credentials", "issuer": "OIDC issuer", "feishuCallback": "Callback registered in Feishu", "vpnCallback": "VPN client callback", "clientId": "VPN Client ID", "guidePermission": "",
"oneTimeSecret": "New VPN Client Secret is displayed only once. Store it safely; rotation of active tunnel credentials requires separate planning.", "routeHint": "Sync identity routes first. After starting the one OpenVPN OIDC tunnel using this domain, sync again to route /oauth2/ to its local auth component.", "guidePublish": "Publish the app and include VPN users in its availability scope.",
"syncGateway": "Sync identity gateway", "gatewaySynced": "Gateway routes updated", "gatewayFailed": "Gateway update failed", "loadFailed": "Could not load identity settings", "saved": "Identity settings saved", "saveFailed": "Could not save identity settings" "guideCallback": "Security Settings → Redirect URL:",
"guideCallbackPending": "Configure an HTTPS domain first.",
"guideVerify": "",
"guideManual": "",
"guideScope": "",
"tenantPermission": "tenant:tenant:readonly · enterprise lookup",
"userPermission": "contact:user.base:readonly · browser sign-in",
"basicProfilePermission": "contact:user.basic_profile:readonly · name lookup by open_id (app identity)",
"permissionSteps": "Grant the app identity scope and publish the app; after a member signs in, check their name below.",
"openFeishuPermission": "Open Feishu permissions ↗",
"checkNamePending": "Check name below",
"checking": "Checking",
"checked": "Verified",
"checkFailed": "Check failed",
"checkPending": "Not checked",
"checkedByLogin": "Login verified",
"loginPending": "Verify on first login",
"appId": "Feishu App ID",
"appSecret": "Feishu App Secret",
"secretPlaceholder": "Leave blank if saved; enter a new secret to rotate",
"tenant": "Allowed Feishu tenant_key",
"tenantPlaceholder": "Fetched automatically or enter manually",
"fetchTenant": "Check again",
"fetchingTenant": "Checking ({seconds}s)…",
"tenantFetched": "Enterprise permission verified",
"tenantFailed": "Enterprise permission check failed",
"tenantNotEnabled": "",
"enabled": "Enable built-in issuer",
"httpsOnly": "Use HTTPS to enter app credentials.",
"openHttps": "Open HTTPS management site",
"httpsRequired": "Configure and activate your public HTTPS site and certificate first.",
"gatewayPending": "The identity provider is saved but Nginx does not yet route /id/; sync the gateway.",
"addresses": "Addresses and client credentials",
"issuer": "OIDC issuer",
"feishuCallback": "Callback registered in Feishu",
"vpnCallback": "VPN client callback",
"clientId": "VPN Client ID",
"oneTimeSecret": "New VPN Client Secret is displayed only once. Store it safely; rotation of active tunnel credentials requires separate planning.",
"routeHint": "Sync identity routes first. After starting the one OpenVPN OIDC tunnel using this domain, sync again to route /oauth2/ to its local auth component.",
"syncGateway": "Sync identity gateway",
"gatewaySynced": "Gateway routes updated",
"gatewayFailed": "Gateway update failed",
"loadFailed": "Could not load identity settings",
"saved": "Identity settings saved",
"saveFailed": "Could not save identity settings",
"pageDescription": "Configure Feishu or OIDC identity (app credentials, tenant, callbacks, and directory sync). Per-tunnel admission stays on the tunnel editor."
}, },
"settings": { "settings": {
"pageDescription": "Manage account security, the public gateway, and server setup.", "pageDescription": "Manage account security, the public gateway, and server setup.",
"securityTab": "Account Security", "gatewayTab": "Public Gateway", "hostTab": "Server Setup", "securityTab": "Account Security",
"gatewayTab": "Public Gateway",
"hostTab": "Server Setup",
"mustChangePassword": "First login or password has been reset by admin. You must change your password before using other features.", "mustChangePassword": "First login or password has been reset by admin. You must change your password before using other features.",
"changePassword": "Change Password", "changePassword": "Change Password",
"oldPassword": "Current Password", "oldPassword": "Current Password",
@@ -615,7 +672,7 @@
"prodSuggestion": "Production Tip", "prodSuggestion": "Production Tip",
"prodSuggestionDesc": "Expose via Nginx/Caddy reverse proxy with TLS termination", "prodSuggestionDesc": "Expose via Nginx/Caddy reverse proxy with TLS termination",
"auditTitle": "Audit Log (Latest 100)", "auditTitle": "Audit Log (Latest 100)",
"auditMigrated": "Audit logs have been moved to Log Center with pagination, filtering, and CSV export", "auditMigrated": "Audit logs have been moved to Operation Audit with pagination, filtering, and CSV export",
"noAuditLogs": "No audit logs yet", "noAuditLogs": "No audit logs yet",
"mfaEnabledTitle": "MFA Enabled — Please Save Backup Codes", "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!", "backupCodesHint": "These backup codes are shown only once. Use them to log in if you lose your phone. Copy and save them now!",
@@ -636,10 +693,12 @@
"strengthWeak": "Weak", "strengthWeak": "Weak",
"strengthFair": "Fair", "strengthFair": "Fair",
"strengthStrong": "Strong", "strengthStrong": "Strong",
"strengthVeryStrong": "Very Strong" "strengthVeryStrong": "Very Strong",
"identityMovedHint": "Feishu / OIDC platform settings moved to a dedicated sidebar page; they are no longer configured here.",
"identityMovedLink": "Open Feishu / OIDC Identity"
}, },
"plugins": { "plugins": {
"title": "Custom Protocol Plugins", "title": "Custom Protocol Plugins (Preview)",
"registerPlugin": "Register Plugin", "registerPlugin": "Register Plugin",
"commandHooks": "command hooks", "commandHooks": "command hooks",
"pluginHint": "Plugins implement the unified protocol contract via {hooks}: the platform pipes tunnel config JSON to hook commands via stdin.", "pluginHint": "Plugins implement the unified protocol contract via {hooks}: the platform pipes tunnel config JSON to hook commands via stdin.",
@@ -653,10 +712,11 @@
"protocolIdPlaceholder": "e.g., myproto (alphanumeric)", "protocolIdPlaceholder": "e.g., myproto (alphanumeric)",
"displayName": "Display Name", "displayName": "Display Name",
"optional": "Optional", "optional": "Optional",
"protocolIdRequired": "Protocol ID and Start hook are required" "protocolIdRequired": "Protocol ID and Start hook are required",
"soonHint": "Protocol plugins are an early preview; contracts and stability are still evolving."
}, },
"nodes": { "nodes": {
"title": "Node Management", "title": "Node Management (Preview)",
"addNode": "Add Node", "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.", "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", "type": "Type",
@@ -691,7 +751,8 @@
"updated": "Updated", "updated": "Updated",
"verifySuccess": "Verification successful: {msg}", "verifySuccess": "Verification successful: {msg}",
"verifyFailed": "Verification failed: {msg}", "verifyFailed": "Verification failed: {msg}",
"deleteNodeConfirm": "Delete node \"{name}\"? This action cannot be undone." "deleteNodeConfirm": "Delete node \"{name}\"? This action cannot be undone.",
"soonHint": "Node management is an early multi-relay entry point; use with caution in production."
}, },
"notFound": { "notFound": {
"title": "404", "title": "404",
@@ -760,20 +821,26 @@
"createTunnel": "Create Tunnel", "createTunnel": "Create Tunnel",
"editTunnel": "Edit Tunnel", "editTunnel": "Edit Tunnel",
"ipsecPeer": "IPSec Peer Test", "ipsecPeer": "IPSec Peer Test",
"nodes": "Nodes", "nodes": "Nodes (Preview)",
"users": "Users", "users": "Admins",
"vpnUsers": "VPN Dial-in Users", "vpnUsers": "Dial-in Accounts",
"clientDevices": "Client Devices", "clientDevices": "Client Devices",
"plugins": "Protocol Plugins", "plugins": "Protocol Plugins (Preview)",
"settings": "Settings", "settings": "Settings",
"logs": "Log Center", "logs": "Operation Audit",
"notFound": "Page Not Found", "notFound": "Page Not Found",
"sidebarOps": "Tunnel Ops", "sidebarOps": "Tunnels",
"sidebarResource": "Identity & Devices", "sidebarResource": "Identity & Devices",
"sidebarSystem": "System", "sidebarSystem": "System",
"sidebarWorkspace": "Workspace", "console": "Admin Console", "sidebarWorkspace": "Workspace",
"navigation": "Main navigation", "openNavigation": "Open navigation", "closeNavigation": "Close navigation", "console": "Admin Console",
"collapseNavigation": "Collapse sidebar", "expandNavigation": "Expand sidebar" "navigation": "Main navigation",
"openNavigation": "Open navigation",
"closeNavigation": "Close navigation",
"collapseNavigation": "Collapse sidebar",
"expandNavigation": "Expand sidebar",
"identityAccess": "Feishu / OIDC Identity",
"sidebarIntegration": "Integrations"
}, },
"protocolIcon": { "protocolIcon": {
"custom": "Custom Protocol" "custom": "Custom Protocol"
@@ -781,20 +848,93 @@
"tls": { "tls": {
"title": "Domain & certificate", "title": "Domain & certificate",
"description": "Configure a public domain and use Nginx / Certbot for HTTP-01 issuance and renewal.", "description": "Configure a public domain and use Nginx / Certbot for HTTP-01 issuance and renewal.",
"domain": "Configured domain", "activeDomain": "Active domain", "saveDomain": "Save domain", "restartRequired": "Domain saved; restart OpsX VPN to enable ports 80/443 and automatic issuance", "saveFailed": "Could not save domain", "dns": "DNS", "certificate": "Certificate", "valid": "Valid", "pending": "Pending first HTTPS handshake", "domain": "Configured domain",
"notConfigured": "Disabled", "saved": "Domain configuration saved", "issuer": "Issuer", "expires": "Expires", "renewal": "Renewal", "automatic": "Automatic (autocert)", "activeDomain": "Active domain",
"ports": "HTTP / HTTPS ports", "externalPorts": "Managed by external Nginx; listener status not checked", "setupHint": "Restart to apply a saved domain or mode. Before switching to Nginx, follow the deployment guide to install it, issue a certificate, and validate the configuration. Go mode caches certificates in data/acme; saving settings alone does not issue a certificate.", "saveDomain": "Save domain",
"refresh": "Refresh status", "openHttps": "Open HTTPS health check", "loadFailed": "Could not load certificate status", "restartRequired": "Domain saved; restart OpsX VPN to enable ports 80/443 and automatic issuance",
"mode": "Public entry mode", "goMode": "Go automatic certificate (binds 80/443)", "nginxMode": "Nginx proxy (external 80/443)", "saveMode": "Save entry mode", "saveFailed": "Could not save domain",
"dns": "DNS",
"certificate": "Certificate",
"valid": "Valid",
"pending": "Pending first HTTPS handshake",
"notConfigured": "Disabled",
"saved": "Domain configuration saved",
"issuer": "Issuer",
"expires": "Expires",
"renewal": "Renewal",
"automatic": "Automatic (autocert)",
"ports": "HTTP / HTTPS ports",
"externalPorts": "Managed by external Nginx; listener status not checked",
"setupHint": "Restart to apply a saved domain or mode. Before switching to Nginx, follow the deployment guide to install it, issue a certificate, and validate the configuration. Go mode caches certificates in data/acme; saving settings alone does not issue a certificate.",
"refresh": "Refresh status",
"openHttps": "Open HTTPS health check",
"loadFailed": "Could not load certificate status",
"mode": "Public entry mode",
"goMode": "Go automatic certificate (binds 80/443)",
"nginxMode": "Nginx proxy (external 80/443)",
"saveMode": "Save entry mode",
"nginxHint": "Save the Nginx mode and domain, restart the service, then enable the site and request a certificate. Public ports 80/443 must be open.", "nginxHint": "Save the Nginx mode and domain, restart the service, then enable the site and request a certificate. Public ports 80/443 must be open.",
"externalRenewal": "Managed by Certbot", "previewNginx": "Preview Nginx configuration", "previewOnly": "Configuration preview; HTTP handles issuance and HTTPS starts after the certificate exists.", "httpSite": "HTTP-01 site", "httpsSite": "HTTPS management site", "externalRenewal": "Managed by Certbot",
"siteStage": "Site stage", "stage_none": "Inactive", "stage_http": "HTTP-01 ready; certificate pending", "stage_https": "HTTPS configuration enabled", "activateSite": "Enable site and issue certificate", "activateHint": "Checks existing sites and ports first. Certbot registration uses no email address, so renewal failures cannot be reported by email.", "siteReady": "HTTPS site and certificate are ready", "siteFailed": "Gateway or certificate setup failed; see output", "previewNginx": "Preview Nginx configuration",
"previewOnly": "Configuration preview; HTTP handles issuance and HTTPS starts after the certificate exists.",
"httpSite": "HTTP-01 site",
"httpsSite": "HTTPS management site",
"siteStage": "Site stage",
"stage_none": "Inactive",
"stage_http": "HTTP-01 ready; certificate pending",
"stage_https": "HTTPS configuration enabled",
"activateSite": "Enable site and issue certificate",
"activateHint": "Checks existing sites and ports first. Certbot registration uses no email address, so renewal failures cannot be reported by email.",
"siteReady": "HTTPS site and certificate are ready",
"siteFailed": "Gateway or certificate setup failed; see output",
"directPortWarning": "The management service still listens publicly on port 18080, bypassing Nginx path rules. Migrate clients using the old HTTP address, then bind the service to 127.0.0.1:18080.", "directPortWarning": "The management service still listens publicly on port 18080, bypassing Nginx path rules. Migrate clients using the old HTTP address, then bind the service to 127.0.0.1:18080.",
"setupTitle": "Optional HTTPS public gateway", "setupStepCheck": "Detect system", "setupStepInstall": "Install dependencies", "setupStepConfigure": "Configure gateway and certificate", "detectedSystem": "Server OS", "setupUnsupported": "Automatic setup requires root on Ubuntu or TencentOS Server 4", "setupScope": "Use only if you choose the Nginx gateway. Installs Nginx / Certbot and prepares the validation directory. It does not install VPN protocol dependencies, enable ports, or issue certificates.", "setupButton": "Prepare Nginx / Certbot", "setupComplete": "Dependencies are ready; configure the site and certificate next", "setupFailed": "Initialization failed; inspect the output", "setupInterrupted": "Log connection interrupted; refresh status. Installation may still be running on the server", "setupTitle": "Optional HTTPS public gateway",
"commonSetupTitle": "First-time shared network setup", "commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.", "hostRoot": "Privileges", "hostRoute": "Default route", "hostForward": "IPv4 forwarding", "enableForwarding": "Enable IPv4 forwarding", "forwardingEnabled": "IPv4 forwarding enabled" "setupStepCheck": "Detect system",
"setupStepInstall": "Install dependencies",
"setupStepConfigure": "Configure gateway and certificate",
"detectedSystem": "Server OS",
"setupUnsupported": "Automatic setup requires root on Ubuntu or TencentOS Server 4",
"setupScope": "Use only if you choose the Nginx gateway. Installs Nginx / Certbot and prepares the validation directory. It does not install VPN protocol dependencies, enable ports, or issue certificates.",
"setupButton": "Prepare Nginx / Certbot",
"setupComplete": "Dependencies are ready; configure the site and certificate next",
"setupFailed": "Initialization failed; inspect the output",
"setupInterrupted": "Log connection interrupted; refresh status. Installation may still be running on the server",
"commonSetupTitle": "First-time shared network setup",
"commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.",
"hostRoot": "Privileges",
"hostRoute": "Default route",
"hostForward": "IPv4 forwarding",
"enableForwarding": "Enable IPv4 forwarding",
"forwardingEnabled": "IPv4 forwarding enabled"
}, },
"feishuSync": { "feishuSync": {
"title": "Feishu directory", "publishAll": "Publish the Feishu app, set availability to All Employees, and include departments in directory visibility.", "hint": "Sync manually or every 30 minutes; imports do not grant VPN access.", "permissions": "Grant department and user-list access; Feishu error codes show missing permissions.", "liveCheck": "Access check", "recheck": "Check again", "checking": "Checking…", "checkFailed": "Access check failed", "liveVisibility": "App visibility: {departments} departments · {users} individual users (not the enterprise total)", "scopeMissing": "Departments are not visible. Check department access and directory scope, then publish.", "permissionMissing": "Departments: {department}; users: {users}. Grant scopes indicated by the error codes.", "apiReady": "Endpoints work. Confirm publication and All Employees in Feishu.", "ok": "Available", "auto": "Auto sync (30 min)", "run": "Sync now", "lastSuccess": "Last success", "counts": "Departments {departments} · members {members}", "visibility": "Last scope: {departments} departments · {users} individual users", "noDepartments": "Departments are not visible yet. Check the app's access and directory scope.", "numberName": "Feishu still returns a 'User + number' name. Sync saved that value; if you just renamed them, check app visibility and retry later.", "name": "Name", "departments": "Department IDs", "firstMembers": "Showing the first 500 members", "saved": "Sync settings saved", "started": "Sync started", "done": "Directory sync complete", "failed": "Directory sync failed" "title": "Feishu directory",
"publishAll": "Publish the Feishu app, set availability to All Employees, and include departments in directory visibility.",
"hint": "Sync manually or every 30 minutes; imports do not grant VPN access.",
"permissions": "Grant department and user-list access; Feishu error codes show missing permissions.",
"liveCheck": "Access check",
"recheck": "Check again",
"checking": "Checking…",
"checkFailed": "Access check failed",
"liveVisibility": "App visibility: {departments} departments · {users} individual users (not the enterprise total)",
"scopeMissing": "Departments are not visible. Check department access and directory scope, then publish.",
"permissionMissing": "Departments: {department}; users: {users}. Grant scopes indicated by the error codes.",
"apiReady": "Endpoints work. Confirm publication and All Employees in Feishu.",
"ok": "Available",
"auto": "Auto sync (30 min)",
"run": "Sync now",
"lastSuccess": "Last success",
"counts": "Departments {departments} · members {members}",
"visibility": "Last scope: {departments} departments · {users} individual users",
"noDepartments": "Departments are not visible yet. Check the app's access and directory scope.",
"numberName": "Feishu still returns a 'User + number' name. Sync saved that value; if you just renamed them, check app visibility and retry later.",
"name": "Name",
"departments": "Department IDs",
"firstMembers": "Showing the first 500 members",
"saved": "Sync settings saved",
"started": "Sync started",
"done": "Directory sync complete",
"failed": "Directory sync failed"
}, },
"feishuDirectory": { "feishuDirectory": {
"title": "Feishu directory lookup", "title": "Feishu directory lookup",
@@ -1000,15 +1140,38 @@
"desc": "Standard IP protocol 47 tunnel for point-to-point Layer 3 connectivity between Linux, routers, and firewalls", "desc": "Standard IP protocol 47 tunnel for point-to-point Layer 3 connectivity between Linux, routers, and firewalls",
"scenes": "Network Device Interconnect / Site-to-Site Routing", "scenes": "Network Device Interconnect / Site-to-Site Routing",
"fields": { "fields": {
"local_endpoint": { "label": "Local Public IP" }, "local_endpoint": {
"remote_endpoint": { "label": "Remote Public IP" }, "label": "Local Public IP"
"local_tunnel_ip": { "label": "Local Tunnel IP", "tip": "Use a /30, for example 10.255.0.1/30" }, },
"remote_tunnel_ip": { "label": "Remote Tunnel IP", "tip": "Must share the local tunnel IP subnet" }, "remote_endpoint": {
"remote_subnet": { "label": "Remote LAN Subnet", "tip": "Adds a static route through the GRE peer" }, "label": "Remote Public IP"
"enable_nat": { "label": "NAT Remote Traffic", "tip": "Enable only when the remote subnet needs Internet egress via this host" }, },
"outbound_interface": { "label": "NAT Egress Interface" }, "local_tunnel_ip": {
"ttl": { "label": "TTL" }, "label": "Local Tunnel IP",
"mtu": { "label": "GRE MTU", "tip": "1476 is typical over public Internet GRE; lower it for additional encapsulation" } "tip": "Use a /30, for example 10.255.0.1/30"
},
"remote_tunnel_ip": {
"label": "Remote Tunnel IP",
"tip": "Must share the local tunnel IP subnet"
},
"remote_subnet": {
"label": "Remote LAN Subnet",
"tip": "Adds a static route through the GRE peer"
},
"enable_nat": {
"label": "NAT Remote Traffic",
"tip": "Enable only when the remote subnet needs Internet egress via this host"
},
"outbound_interface": {
"label": "NAT Egress Interface"
},
"ttl": {
"label": "TTL"
},
"mtu": {
"label": "GRE MTU",
"tip": "1476 is typical over public Internet GRE; lower it for additional encapsulation"
}
} }
}, },
"frp": { "frp": {
+219 -56
View File
@@ -229,27 +229,34 @@
"title": "飞书 / OIDC 浏览器认证配置", "title": "飞书 / OIDC 浏览器认证配置",
"externalService": "需要 openvpn-auth-oauth2", "externalService": "需要 openvpn-auth-oauth2",
"componentReady": "认证组件已安装 v{version}", "componentReady": "认证组件已安装 v{version}",
"authEntryPending": "{domain} 的 HTTPS 已就绪;专线启动后还需在“设置 → 飞书身份”同步认证入口路由。", "authEntryPending": "{domain} 的 HTTPS 已就绪;专线启动后还需在「飞书 / OIDC 身份」页同步认证入口路由。",
"app": "创建应用", "app": "创建应用",
"appShort": "飞书开放平台", "appShort": "飞书开放平台",
"identity": "填写身份信息", "identity": "填写身份信息",
"identityShort": "OIDC 和应用凭据", "identityShort": "OIDC 和应用凭据",
"builtinIdentityShort": "在系统设置中填写飞书凭据", "builtinIdentityShort": "在「飞书 / OIDC 身份」页填写飞书凭据",
"callback": "设置公网回调", "callback": "设置公网回调",
"callbackShort": "HTTPS 与反向代理", "callbackShort": "HTTPS 与反向代理",
"test": "客户端试连", "test": "客户端试连",
"testShort": "浏览器登录验证", "testShort": "浏览器登录验证",
"appTip": "先由飞书管理员在开放平台创建应用,启用身份登录所需能力,并发布应用。", "appTip": "先由飞书管理员在开放平台创建应用,启用身份登录所需能力,并发布应用。",
"builtinAppTip": "飞书 App ID 和 App Secret 统一在“设置 → 飞书身份”填写,不在专线里填写。", "builtinAppTip": "飞书 App ID 和 App Secret 统一在「飞书 / OIDC 身份」页填写,不在专线里填写。",
"openConsole": "打开飞书开放平台的应用管理", "openConsole": "打开飞书开放平台的应用管理",
"appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。", "appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。",
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。", "appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
"builtinAppInstruction2": "在飞书应用的安全/重定向地址中登记下方“飞书应用回调”,不要登记 VPN 客户端回调。", "builtinAppInstruction2": "在飞书应用的安全/重定向地址中登记下方“飞书应用回调”,不要登记 VPN 客户端回调。",
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。", "appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
"scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。", "scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。",
"providerTip": "可使用设置页配置的内置飞书身份服务;使用外部发行者时仍需标准 OIDC discovery 与 ID Token。飞书应用地址本身不是发行者。", "providerTip": "可使用「飞书 / OIDC 身份」页配置的内置飞书身份服务;使用外部发行者时仍需标准 OIDC discovery 与 ID Token。飞书应用地址本身不是发行者。",
"source": "身份来源", "builtinSource": "内置飞书身份", "externalSource": "外部 OIDC", "configureBuiltIn": "尚未启用内置飞书身份服务;请先到设置页保存应用凭据并启用。", "manageFeishu": "前往设置 → 飞书身份", "builtinSummary": "飞书 App ID、App Secret 与租户标识在设置页统一管理;专线只引用 OIDC 发行者和专属认证入口。", "source": "身份来源",
"useBuiltIn": "使用内置飞书身份服务", "builtInSecret": "VPN Client Secret 由服务端自动关联,无需复制或填写飞书 App Secret。", "builtInGatewayPending": "内置身份入口尚未同步 Nginx;到“设置 → 飞书身份”同步后再联调。", "builtinSource": "内置飞书身份",
"externalSource": "外部 OIDC",
"configureBuiltIn": "尚未启用内置飞书身份服务;请先到「飞书 / OIDC 身份」页保存应用凭据并启用。",
"manageFeishu": "前往「飞书 / OIDC 身份」",
"builtinSummary": "飞书 App ID、App Secret 与租户标识在「飞书 / OIDC 身份」页统一管理;专线只引用 OIDC 发行者和专属认证入口。",
"useBuiltIn": "使用内置飞书身份服务",
"builtInSecret": "VPN Client Secret 由服务端自动关联,无需复制或填写飞书 App Secret。",
"builtInGatewayPending": "内置身份入口尚未同步 Nginx;到「飞书 / OIDC 身份」页同步后再联调。",
"issuer": "OIDC 发行者地址", "issuer": "OIDC 发行者地址",
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。", "issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
"clientId": "OIDC Client ID", "clientId": "OIDC Client ID",
@@ -260,14 +267,17 @@
"secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。", "secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。",
"baseUrl": "认证入口公网地址", "baseUrl": "认证入口公网地址",
"baseTip": "自动填写仅复用域名和证书;当前管理站点尚未转发认证组件的 OAuth2 路径。接通 Nginx 回调路由前不能完成登录。修改域名后需核对已保存专线的回调地址。", "baseTip": "自动填写仅复用域名和证书;当前管理站点尚未转发认证组件的 OAuth2 路径。接通 Nginx 回调路由前不能完成登录。修改域名后需核对已保存专线的回调地址。",
"builtinBaseTip": "自动使用系统 HTTPS 域名;认证组件仅监听本机,启动专线后到设置页同步公网路由。", "builtinBaseTip": "自动使用系统 HTTPS 域名;认证组件仅监听本机,启动专线后到「飞书 / OIDC 身份」页同步公网路由。",
"reuseDomain": "使用当前公网域名", "linkedDomain": "已引用系统设置中的 HTTPS 域名;可手动修改。", "reuseDomain": "使用当前公网域名",
"linkedDomain": "已引用系统设置中的 HTTPS 域名;可手动修改。",
"listenPort": "本机认证服务端口", "listenPort": "本机认证服务端口",
"portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。", "portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。",
"portLookupFailed": "无法获取可用端口,请稍后重试或手动选择", "portLookupFailed": "无法获取可用端口,请稍后重试或手动选择",
"callbackTitle": "在最终 OIDC 身份提供方的客户端中登记此回调地址", "callbackTitle": "在最终 OIDC 身份提供方的客户端中登记此回调地址",
"callbackTip": "如果通过身份代理接入飞书,本地址登记在代理的 OIDC 客户端中,飞书应用应登记代理的上游回调。将公网入口的所有路径反向代理至本机 127.0.0.1:{port};不由 :18080 处理。", "callbackTip": "如果通过身份代理接入飞书,本地址登记在代理的 OIDC 客户端中,飞书应用应登记代理的上游回调。将公网入口的所有路径反向代理至本机 127.0.0.1:{port};不由 :18080 处理。",
"builtinCallbackTitle": "专线与飞书使用不同的回调地址", "builtinCallbackTip": "上方是 VPN 客户端回调,由 openvpn-auth-oauth2 处理;飞书应用中只登记下方的飞书应用回调。启动专线后在设置页同步 /oauth2/ 路由至本机 127.0.0.1:{port}。", "feishuCallbackLabel": "飞书应用回调", "builtinCallbackTitle": "专线与飞书使用不同的回调地址",
"builtinCallbackTip": "上方是 VPN 客户端回调,由 openvpn-auth-oauth2 处理;飞书应用中只登记下方的飞书应用回调。启动专线后在「飞书 / OIDC 身份」页同步 /oauth2/ 路由至本机 127.0.0.1:{port}。",
"feishuCallbackLabel": "飞书应用回调",
"testTip": "安装 openvpn-auth-oauth2 与 OpenVPN ≥ 2.6.2 后启动专线;使用支持 WebAuth 的客户端试连(如 Windows OpenVPN Community、macOS Tunnelblick/Viscosity)。OpenVPN Connect v3 部分兼容,须实测。", "testTip": "安装 openvpn-auth-oauth2 与 OpenVPN ≥ 2.6.2 后启动专线;使用支持 WebAuth 的客户端试连(如 Windows OpenVPN Community、macOS Tunnelblick/Viscosity)。OpenVPN Connect v3 部分兼容,须实测。",
"startFailed": "配置已生成,但专线未成功启动;处理下方错误后从专线列表重新启动", "startFailed": "配置已生成,但专线未成功启动;处理下方错误后从专线列表重新启动",
"fillRequired": "请先配置身份来源,并填写 OIDC 地址、VPN 客户端凭据和公网认证入口" "fillRequired": "请先配置身份来源,并填写 OIDC 地址、VPN 客户端凭据和公网认证入口"
@@ -349,8 +359,8 @@
"installFailed": "{protocol} 安装失败" "installFailed": "{protocol} 安装失败"
}, },
"users": { "users": {
"title": "管理用户", "title": "管理员",
"clientLoginHint": "此处账号用于登录管理后台;桌面 VPN 客户端请使用“VPN 拨号用户”中的账号,并分配 WireGuard 专线。", "clientLoginHint": "此处账号用于登录管理后台;桌面 VPN 客户端请使用「拨号账号」中的账号,并分配 WireGuard 专线。",
"batchImport": "批量导入", "batchImport": "批量导入",
"ldapImport": "LDAP 导入", "ldapImport": "LDAP 导入",
"addUser": "添加用户", "addUser": "添加用户",
@@ -428,7 +438,7 @@
"empty": "暂无客户端设备登记" "empty": "暂无客户端设备登记"
}, },
"vpnUsers": { "vpnUsers": {
"title": "VPN 拨号用户", "title": "拨号账号",
"mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)", "mfaHint": "OpenVPN 双因素认证(TOTP / 静态挑战 / PIN+OTP)",
"addUser": "添加用户", "addUser": "添加用户",
"bulkImport": "批量导入", "bulkImport": "批量导入",
@@ -454,7 +464,7 @@
"lastOnline": "最近在线", "lastOnline": "最近在线",
"downloadConfig": "下载配置", "downloadConfig": "下载配置",
"otpManagement": "OTP 管理", "otpManagement": "OTP 管理",
"addVpnUserTitle": "添加 VPN 拨号用户", "addVpnUserTitle": "添加拨号账号",
"dialTunnel": "拨号隧道", "dialTunnel": "拨号隧道",
"pickFromUsers": "从已有用户选", "pickFromUsers": "从已有用户选",
"displayNameLabel": "显示名", "displayNameLabel": "显示名",
@@ -576,18 +586,65 @@
"noAlerts": "暂无流量告警" "noAlerts": "暂无流量告警"
}, },
"identity": { "identity": {
"tab": "飞书身份", "title": "内置飞书身份服务", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。", "tab": "飞书身份",
"guideTitle": "飞书应用准备", "guideApp": "填入企业自建应用的 App ID 和 App Secret;按需开通下方三项权限。", "guidePermission": "", "guidePublish": "发布应用,并将 VPN 用户加入应用可用范围。", "guideCallback": "安全设置 → 重定向 URL:", "guideCallbackPending": "请先配置 HTTPS 域名。", "guideVerify": "", "guideManual": "", "guideScope": "", "title": "内置飞书身份服务",
"tenantPermission": "tenant:tenant:readonly · 查询企业信息", "userPermission": "contact:user.base:readonly · 浏览器登录用户", "basicProfilePermission": "contact:user.basic_profile:readonly · 按 open_id 查询姓名(应用身份)", "permissionSteps": "在飞书开通应用身份权限并发布应用;成员登录后,到下方“飞书姓名查询”检查。", "openFeishuPermission": "打开飞书权限管理 ↗", "checkNamePending": "待检查姓名", "checking": "检测中", "checked": "已验证", "checkFailed": "检测失败", "checkPending": "待检测", "checkedByLogin": "登录已验证", "loginPending": "首次登录后验证", "description": "使用现有 HTTPS 域名签发 VPN 所需 OIDC 身份令牌。飞书 App 凭据仅用于飞书上游,VPN 客户端凭据由本平台生成。",
"appId": "飞书 App ID", "appSecret": "飞书 App Secret", "secretPlaceholder": "已保存可留空;更换密钥请填写", "tenant": "允许的飞书 tenant_key", "tenantPlaceholder": "自动读取,也可手填", "fetchTenant": "重新检测", "fetchingTenant": "正在检测({seconds} 秒)…", "tenantFetched": "企业信息权限已验证", "tenantFailed": "检测企业信息权限失败", "tenantNotEnabled": "", "enabled": "启用内置发行者", "httpsOnly": "请通过 HTTPS 管理入口填写密钥。", "openHttps": "打开 HTTPS 管理入口", "guideTitle": "飞书应用准备",
"httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。", "gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。", "guideApp": "填入企业自建应用的 App ID 和 App Secret;按需开通下方三项权限。",
"addresses": "接入地址与客户端凭据", "issuer": "OIDC 发行者", "feishuCallback": "登记到飞书应用的回调", "vpnCallback": "VPN 客户端回调", "clientId": "VPN Client ID", "guidePermission": "",
"oneTimeSecret": "新 VPN Client Secret 只显示这一次,请立即妥善保存;现有专线的密钥轮换需另行安排。", "routeHint": "先同步身份路由;启动唯一一条使用当前域名的 OpenVPN OIDC 专线后,再同步一次以转发 /oauth2/ 到本机认证组件。", "guidePublish": "发布应用,并将 VPN 用户加入应用可用范围。",
"syncGateway": "同步身份公网路由", "gatewaySynced": "公网路由已更新", "gatewayFailed": "公网路由更新失败", "loadFailed": "读取身份配置失败", "saved": "身份配置已保存", "saveFailed": "保存身份配置失败" "guideCallback": "安全设置 → 重定向 URL:",
"guideCallbackPending": "请先配置 HTTPS 域名。",
"guideVerify": "",
"guideManual": "",
"guideScope": "",
"tenantPermission": "tenant:tenant:readonly · 查询企业信息",
"userPermission": "contact:user.base:readonly · 浏览器登录用户",
"basicProfilePermission": "contact:user.basic_profile:readonly · 按 open_id 查询姓名(应用身份)",
"permissionSteps": "在飞书开通应用身份权限并发布应用;成员登录后,到下方“飞书姓名查询”检查。",
"openFeishuPermission": "打开飞书权限管理 ↗",
"checkNamePending": "待检查姓名",
"checking": "检测中",
"checked": "已验证",
"checkFailed": "检测失败",
"checkPending": "待检测",
"checkedByLogin": "登录已验证",
"loginPending": "首次登录后验证",
"appId": "飞书 App ID",
"appSecret": "飞书 App Secret",
"secretPlaceholder": "已保存可留空;更换密钥请填写",
"tenant": "允许的飞书 tenant_key",
"tenantPlaceholder": "自动读取,也可手填",
"fetchTenant": "重新检测",
"fetchingTenant": "正在检测({seconds} 秒)…",
"tenantFetched": "企业信息权限已验证",
"tenantFailed": "检测企业信息权限失败",
"tenantNotEnabled": "",
"enabled": "启用内置发行者",
"httpsOnly": "请通过 HTTPS 管理入口填写密钥。",
"openHttps": "打开 HTTPS 管理入口",
"httpsRequired": "请先完成公网域名 HTTPS 证书和站点配置,再启用身份服务。",
"gatewayPending": "身份服务已保存,但 Nginx 尚未转发 /id/;请同步公网入口。",
"addresses": "接入地址与客户端凭据",
"issuer": "OIDC 发行者",
"feishuCallback": "登记到飞书应用的回调",
"vpnCallback": "VPN 客户端回调",
"clientId": "VPN Client ID",
"oneTimeSecret": "新 VPN Client Secret 只显示这一次,请立即妥善保存;现有专线的密钥轮换需另行安排。",
"routeHint": "先同步身份路由;启动唯一一条使用当前域名的 OpenVPN OIDC 专线后,再同步一次以转发 /oauth2/ 到本机认证组件。",
"syncGateway": "同步身份公网路由",
"gatewaySynced": "公网路由已更新",
"gatewayFailed": "公网路由更新失败",
"loadFailed": "读取身份配置失败",
"saved": "身份配置已保存",
"saveFailed": "保存身份配置失败",
"pageDescription": "配置飞书或 OIDC 身份源(App 凭据、租户、回调与通讯录同步)。专线准入名单仍在对应专线编辑页配置。"
}, },
"settings": { "settings": {
"pageDescription": "管理个人安全、公网认证入口与服务器环境。", "pageDescription": "管理个人安全、公网认证入口与服务器环境。",
"securityTab": "个人安全", "gatewayTab": "公网入口", "hostTab": "服务器环境", "securityTab": "个人安全",
"gatewayTab": "公网入口",
"hostTab": "服务器环境",
"mustChangePassword": "首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能", "mustChangePassword": "首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能",
"changePassword": "修改密码", "changePassword": "修改密码",
"oldPassword": "原密码", "oldPassword": "原密码",
@@ -615,7 +672,7 @@
"prodSuggestion": "生产建议", "prodSuggestion": "生产建议",
"prodSuggestionDesc": "经 Nginx/Caddy 反向代理终止 TLS 后暴露", "prodSuggestionDesc": "经 Nginx/Caddy 反向代理终止 TLS 后暴露",
"auditTitle": "操作审计(最近 100 条)", "auditTitle": "操作审计(最近 100 条)",
"auditMigrated": "审计日志已迁移至「日志中心」,支持分页、筛选和导出", "auditMigrated": "审计日志已迁移至「操作审计」,支持分页、筛选和导出",
"noAuditLogs": "暂无审计日志", "noAuditLogs": "暂无审计日志",
"mfaEnabledTitle": "MFA 已启用 — 请保存备用码", "mfaEnabledTitle": "MFA 已启用 — 请保存备用码",
"backupCodesHint": "这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!", "backupCodesHint": "这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!",
@@ -636,10 +693,12 @@
"strengthWeak": "弱", "strengthWeak": "弱",
"strengthFair": "一般", "strengthFair": "一般",
"strengthStrong": "较强", "strengthStrong": "较强",
"strengthVeryStrong": "强" "strengthVeryStrong": "强",
"identityMovedHint": "飞书 / OIDC 平台级配置已迁至侧栏独立页,设置里不再重复配置。",
"identityMovedLink": "打开飞书 / OIDC 身份"
}, },
"plugins": { "plugins": {
"title": "自定义协议插件", "title": "自定义协议插件(即将)",
"registerPlugin": "注册插件", "registerPlugin": "注册插件",
"commandHooks": "命令钩子", "commandHooks": "命令钩子",
"pluginHint": "插件通过 {hooks} 实现统一协议契约:平台将专线配置 JSON 通过 stdin 传给钩子命令。", "pluginHint": "插件通过 {hooks} 实现统一协议契约:平台将专线配置 JSON 通过 stdin 传给钩子命令。",
@@ -653,10 +712,11 @@
"protocolIdPlaceholder": "如 myproto(字母数字)", "protocolIdPlaceholder": "如 myproto(字母数字)",
"displayName": "显示名称", "displayName": "显示名称",
"optional": "可选", "optional": "可选",
"protocolIdRequired": "协议标识与 Start 钩子必填" "protocolIdRequired": "协议标识与 Start 钩子必填",
"soonHint": "协议插件为扩展能力,当前为早期可用版本,契约与稳定性仍在完善。"
}, },
"nodes": { "nodes": {
"title": "节点管理", "title": "节点管理(即将)",
"addNode": "添加节点", "addNode": "添加节点",
"nodeHint": "节点用于将专线部署到中继服务器。本机节点为内置默认节点;远程节点支持 SSH 凭证验证(密码/私钥),验证通过后状态显示为「已验证」。", "nodeHint": "节点用于将专线部署到中继服务器。本机节点为内置默认节点;远程节点支持 SSH 凭证验证(密码/私钥),验证通过后状态显示为「已验证」。",
"type": "类型", "type": "类型",
@@ -691,7 +751,8 @@
"updated": "已更新", "updated": "已更新",
"verifySuccess": "验证成功:{msg}", "verifySuccess": "验证成功:{msg}",
"verifyFailed": "验证失败:{msg}", "verifyFailed": "验证失败:{msg}",
"deleteNodeConfirm": "确定删除节点「{name}」?该操作不可撤销。" "deleteNodeConfirm": "确定删除节点「{name}」?该操作不可撤销。",
"soonHint": "节点管理为多中继能力的早期入口,当前为早期可用版本,请谨慎用于生产。"
}, },
"notFound": { "notFound": {
"title": "404", "title": "404",
@@ -760,20 +821,26 @@
"createTunnel": "创建专线", "createTunnel": "创建专线",
"editTunnel": "编辑专线", "editTunnel": "编辑专线",
"ipsecPeer": "IPSec 第三方对接", "ipsecPeer": "IPSec 第三方对接",
"nodes": "节点管理", "nodes": "节点管理(即将)",
"users": "用户管理", "users": "管理员",
"vpnUsers": "VPN 拨号用户", "vpnUsers": "拨号账号",
"clientDevices": "客户端设备", "clientDevices": "客户端设备",
"plugins": "协议插件", "plugins": "协议插件(即将)",
"settings": "系统设置", "settings": "系统设置",
"logs": "日志中心", "logs": "操作审计",
"notFound": "页面不存在", "notFound": "页面不存在",
"sidebarOps": "专线运维", "sidebarOps": "专线",
"sidebarResource": "身份与设备", "sidebarResource": "身份与设备",
"sidebarSystem": "系统", "sidebarSystem": "系统",
"sidebarWorkspace": "工作台", "console": "管理控制台", "sidebarWorkspace": "工作台",
"navigation": "主导航", "openNavigation": "打开导航", "closeNavigation": "关闭导航", "console": "管理控制台",
"collapseNavigation": "收起侧栏", "expandNavigation": "展开侧栏" "navigation": "主导航",
"openNavigation": "打开导航",
"closeNavigation": "关闭导航",
"collapseNavigation": "收起侧栏",
"expandNavigation": "展开侧栏",
"identityAccess": "飞书 / OIDC 身份",
"sidebarIntegration": "对接与扩展"
}, },
"protocolIcon": { "protocolIcon": {
"custom": "自定义协议" "custom": "自定义协议"
@@ -781,20 +848,93 @@
"tls": { "tls": {
"title": "域名与证书", "title": "域名与证书",
"description": "配置公网域名,使用 Nginx / Certbot 申请并自动续签 HTTP-01 证书。", "description": "配置公网域名,使用 Nginx / Certbot 申请并自动续签 HTTP-01 证书。",
"domain": "配置域名", "activeDomain": "当前监听域名", "saveDomain": "保存域名", "restartRequired": "域名已保存,重启 opsx-vpn 后才会监听 80/443 并启用自动签发", "saveFailed": "保存域名失败", "dns": "DNS 解析", "certificate": "证书", "valid": "有效", "pending": "等待首次 HTTPS 请求签发", "domain": "配置域名",
"notConfigured": "未启用", "saved": "域名配置已保存", "issuer": "颁发者", "expires": "到期时间", "renewal": "续签", "automatic": "自动(autocert)", "activeDomain": "当前监听域名",
"ports": "HTTP / HTTPS 端口", "externalPorts": "由外部 Nginx 管理,尚未检测监听状态", "setupHint": "保存域名或入口模式后需重启生效;切换为 Nginx 前须按部署文档完成安装、证书签发和配置校验。Go 模式证书缓存于 data/acme;保存配置本身不会签发证书。", "saveDomain": "保存域名",
"refresh": "刷新状态", "openHttps": "打开 HTTPS 健康检查", "loadFailed": "读取证书状态失败", "restartRequired": "域名已保存,重启 opsx-vpn 后才会监听 80/443 并启用自动签发",
"mode": "公网入口模式", "goMode": "Go 自动证书(进程监听 80/443)", "nginxMode": "Nginx 代理(外部监听 80/443)", "saveMode": "保存入口模式", "saveFailed": "保存域名失败",
"dns": "DNS 解析",
"certificate": "证书",
"valid": "有效",
"pending": "等待首次 HTTPS 请求签发",
"notConfigured": "未启用",
"saved": "域名配置已保存",
"issuer": "颁发者",
"expires": "到期时间",
"renewal": "续签",
"automatic": "自动(autocert)",
"ports": "HTTP / HTTPS 端口",
"externalPorts": "由外部 Nginx 管理,尚未检测监听状态",
"setupHint": "保存域名或入口模式后需重启生效;切换为 Nginx 前须按部署文档完成安装、证书签发和配置校验。Go 模式证书缓存于 data/acme;保存配置本身不会签发证书。",
"refresh": "刷新状态",
"openHttps": "打开 HTTPS 健康检查",
"loadFailed": "读取证书状态失败",
"mode": "公网入口模式",
"goMode": "Go 自动证书(进程监听 80/443)",
"nginxMode": "Nginx 代理(外部监听 80/443)",
"saveMode": "保存入口模式",
"nginxHint": "选择 Nginx 模式并保存域名后重启服务,再点击启用站点申请证书。端口 80/443 需允许公网访问。", "nginxHint": "选择 Nginx 模式并保存域名后重启服务,再点击启用站点申请证书。端口 80/443 需允许公网访问。",
"externalRenewal": "由 Certbot 管理", "previewNginx": "预览 Nginx 配置", "previewOnly": "以下为配置预览;HTTP 站点用于签发,HTTPS 站点在证书存在后启用。", "httpSite": "HTTP-01 站点", "httpsSite": "HTTPS 管理站点", "externalRenewal": "由 Certbot 管理",
"siteStage": "站点阶段", "stage_none": "未启用", "stage_http": "HTTP-01 已准备,证书待签发", "stage_https": "HTTPS 配置已启用", "activateSite": "启用站点并申请证书", "activateHint": "启用前会检查现有站点及端口;冲突时停止并提示。证书采用 Certbot 无邮箱注册,续签失败时不会收到邮件通知。", "siteReady": "HTTPS 站点与证书已就绪", "siteFailed": "站点或证书配置失败,请查看输出", "previewNginx": "预览 Nginx 配置",
"previewOnly": "以下为配置预览;HTTP 站点用于签发,HTTPS 站点在证书存在后启用。",
"httpSite": "HTTP-01 站点",
"httpsSite": "HTTPS 管理站点",
"siteStage": "站点阶段",
"stage_none": "未启用",
"stage_http": "HTTP-01 已准备,证书待签发",
"stage_https": "HTTPS 配置已启用",
"activateSite": "启用站点并申请证书",
"activateHint": "启用前会检查现有站点及端口;冲突时停止并提示。证书采用 Certbot 无邮箱注册,续签失败时不会收到邮件通知。",
"siteReady": "HTTPS 站点与证书已就绪",
"siteFailed": "站点或证书配置失败,请查看输出",
"directPortWarning": "管理服务仍直接监听公网 18080,访问该端口会绕过 Nginx 的路径限制。请先迁移使用旧 HTTP 地址的客户端,再将管理服务改为仅监听 127.0.0.1:18080。", "directPortWarning": "管理服务仍直接监听公网 18080,访问该端口会绕过 Nginx 的路径限制。请先迁移使用旧 HTTP 地址的客户端,再将管理服务改为仅监听 127.0.0.1:18080。",
"setupTitle": "可选 · HTTPS 公网入口", "setupStepCheck": "检测系统", "setupStepInstall": "安装依赖", "setupStepConfigure": "配置入口与证书", "detectedSystem": "服务器系统", "setupUnsupported": "自动初始化仅支持以 root 运行的 Ubuntu 或 TencentOS Server 4", "setupScope": "仅在选择 Nginx 入口时点击:按需安装 Nginx / Certbot 并准备验证目录;不会安装 VPN 协议依赖、启用端口或签发证书。", "setupButton": "准备 Nginx / Certbot", "setupComplete": "依赖已准备,继续配置站点与证书", "setupFailed": "初始化失败,请查看输出", "setupInterrupted": "日志连接中断,请刷新状态;服务器上的安装可能仍在继续", "setupTitle": "可选 · HTTPS 公网入口",
"commonSetupTitle": "首次引导 · 通用网络环境", "commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。", "hostRoot": "运行权限", "hostRoute": "默认出口", "hostForward": "IPv4 转发", "enableForwarding": "开启 IPv4 转发", "forwardingEnabled": "IPv4 转发已开启" "setupStepCheck": "检测系统",
"setupStepInstall": "安装依赖",
"setupStepConfigure": "配置入口与证书",
"detectedSystem": "服务器系统",
"setupUnsupported": "自动初始化仅支持以 root 运行的 Ubuntu 或 TencentOS Server 4",
"setupScope": "仅在选择 Nginx 入口时点击:按需安装 Nginx / Certbot 并准备验证目录;不会安装 VPN 协议依赖、启用端口或签发证书。",
"setupButton": "准备 Nginx / Certbot",
"setupComplete": "依赖已准备,继续配置站点与证书",
"setupFailed": "初始化失败,请查看输出",
"setupInterrupted": "日志连接中断,请刷新状态;服务器上的安装可能仍在继续",
"commonSetupTitle": "首次引导 · 通用网络环境",
"commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。",
"hostRoot": "运行权限",
"hostRoute": "默认出口",
"hostForward": "IPv4 转发",
"enableForwarding": "开启 IPv4 转发",
"forwardingEnabled": "IPv4 转发已开启"
}, },
"feishuSync": { "feishuSync": {
"title": "飞书组织与成员", "publishAll": "请发布飞书应用,将可用范围设为“全体员工”,并开放通讯录部门范围。", "hint": "手动或每 30 分钟同步;导入不自动授予 VPN 权限。", "permissions": "需部门和成员列表读取权限;不足时显示飞书错误码。", "liveCheck": "权限检查", "recheck": "重新检测", "checking": "检测中…", "checkFailed": "权限检查失败", "liveVisibility": "应用可见:部门 {departments} · 单独成员 {users}(非企业总人数)", "scopeMissing": "部门不可见:检查部门读取权限及通讯录范围,发布后重试。", "permissionMissing": "部门:{department};成员:{users}。请按错误码补全权限。", "apiReady": "接口可用;请在飞书确认已发布和“全体员工”。", "ok": "可用", "auto": "自动同步(30 分钟)", "run": "立即同步", "lastSuccess": "上次成功", "counts": "部门 {departments} · 成员 {members}", "visibility": "上次可见:部门 {departments} · 单独成员 {users}", "noDepartments": "部门尚不可见;请核对飞书应用权限和通讯录范围。", "numberName": "飞书接口仍返回“用户+编号”。同步已保存该值;若刚改名,请核对应用可见范围并稍后重试。", "name": "姓名", "departments": "部门 ID", "firstMembers": "仅显示前 500 位成员", "saved": "同步设置已保存", "started": "已开始同步", "done": "通讯录同步完成", "failed": "通讯录同步失败" "title": "飞书组织与成员",
"publishAll": "请发布飞书应用,将可用范围设为“全体员工”,并开放通讯录部门范围。",
"hint": "手动或每 30 分钟同步;导入不自动授予 VPN 权限。",
"permissions": "需部门和成员列表读取权限;不足时显示飞书错误码。",
"liveCheck": "权限检查",
"recheck": "重新检测",
"checking": "检测中…",
"checkFailed": "权限检查失败",
"liveVisibility": "应用可见:部门 {departments} · 单独成员 {users}(非企业总人数)",
"scopeMissing": "部门不可见:检查部门读取权限及通讯录范围,发布后重试。",
"permissionMissing": "部门:{department};成员:{users}。请按错误码补全权限。",
"apiReady": "接口可用;请在飞书确认已发布和“全体员工”。",
"ok": "可用",
"auto": "自动同步(30 分钟)",
"run": "立即同步",
"lastSuccess": "上次成功",
"counts": "部门 {departments} · 成员 {members}",
"visibility": "上次可见:部门 {departments} · 单独成员 {users}",
"noDepartments": "部门尚不可见;请核对飞书应用权限和通讯录范围。",
"numberName": "飞书接口仍返回“用户+编号”。同步已保存该值;若刚改名,请核对应用可见范围并稍后重试。",
"name": "姓名",
"departments": "部门 ID",
"firstMembers": "仅显示前 500 位成员",
"saved": "同步设置已保存",
"started": "已开始同步",
"done": "通讯录同步完成",
"failed": "通讯录同步失败"
}, },
"feishuDirectory": { "feishuDirectory": {
"title": "飞书姓名查询", "title": "飞书姓名查询",
@@ -1000,15 +1140,38 @@
"desc": "标准 IP 协议 47 隧道,用于 Linux 与路由器、防火墙的点对点三层互联", "desc": "标准 IP 协议 47 隧道,用于 Linux 与路由器、防火墙的点对点三层互联",
"scenes": "网络设备互联 / 跨站点路由", "scenes": "网络设备互联 / 跨站点路由",
"fields": { "fields": {
"local_endpoint": { "label": "本端公网 IP" }, "local_endpoint": {
"remote_endpoint": { "label": "对端公网 IP" }, "label": "本端公网 IP"
"local_tunnel_ip": { "label": "本端隧道 IP", "tip": "建议使用 /30,例如 10.255.0.1/30" }, },
"remote_tunnel_ip": { "label": "对端隧道 IP", "tip": "需与本端隧道 IP 位于同一子网" }, "remote_endpoint": {
"remote_subnet": { "label": "对端业务网段", "tip": "填写后自动添加经 GRE 对端的静态路由" }, "label": "对端公网 IP"
"enable_nat": { "label": "对端流量 NAT 出网", "tip": "仅当对端业务网段需要经本机访问互联网时启用" }, },
"outbound_interface": { "label": "NAT 出站网卡" }, "local_tunnel_ip": {
"ttl": { "label": "TTL" }, "label": "本端隧道 IP",
"mtu": { "label": "GRE MTU", "tip": "公网 GRE 常用 1476;链路有额外封装时可继续降低" } "tip": "建议使用 /30,例如 10.255.0.1/30"
},
"remote_tunnel_ip": {
"label": "对端隧道 IP",
"tip": "需与本端隧道 IP 位于同一子网"
},
"remote_subnet": {
"label": "对端业务网段",
"tip": "填写后自动添加经 GRE 对端的静态路由"
},
"enable_nat": {
"label": "对端流量 NAT 出网",
"tip": "仅当对端业务网段需要经本机访问互联网时启用"
},
"outbound_interface": {
"label": "NAT 出站网卡"
},
"ttl": {
"label": "TTL"
},
"mtu": {
"label": "GRE MTU",
"tip": "公网 GRE 常用 1476;链路有额外封装时可继续降低"
}
} }
}, },
"frp": { "frp": {
+10 -3
View File
@@ -15,10 +15,13 @@ const routes = [
{ path: 'logs', component: () => import('./views/Logs.vue'), meta: { titleKey: 'router.logs', navGroup: 'ops', navOrder: 3, navIcon: 'logs' } }, { path: 'logs', component: () => import('./views/Logs.vue'), meta: { titleKey: 'router.logs', navGroup: 'ops', navOrder: 3, navIcon: 'logs' } },
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel', parentPath: '/tunnels' } }, { path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel', parentPath: '/tunnels' } },
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel', parentPath: '/tunnels' } }, { path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel', parentPath: '/tunnels' } },
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer', navGroup: 'ops', navOrder: 4, navIcon: 'peer' } },
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true, navGroup: 'resource', navOrder: 3, navIcon: 'users' } },
{ path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true, navGroup: 'resource', navOrder: 1, navIcon: 'vpnUsers' } }, { path: 'vpn-users', component: () => import('./views/VPNUsers.vue'), meta: { titleKey: 'router.vpnUsers', requiresAdmin: true, navGroup: 'resource', navOrder: 1, navIcon: 'vpnUsers' } },
{ path: 'client-devices', component: () => import('./views/ClientDevices.vue'), meta: { titleKey: 'router.clientDevices', requiresAdmin: true, navGroup: 'resource', navOrder: 2, navIcon: 'devices' } }, { path: 'client-devices', component: () => import('./views/ClientDevices.vue'), meta: { titleKey: 'router.clientDevices', requiresAdmin: true, navGroup: 'resource', navOrder: 2, navIcon: 'devices' } },
{ path: 'users', component: () => import('./views/Users.vue'), meta: { titleKey: 'router.users', requiresAdmin: true, navGroup: 'resource', navOrder: 3, navIcon: 'users' } },
{ path: 'identity', component: () => import('./views/Identity.vue'), meta: { titleKey: 'router.identityAccess', requiresAdmin: true, navGroup: 'integration', navOrder: 2, navIcon: 'identity' } },
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer', requiresAdmin: true, navGroup: 'integration', navOrder: 1, navIcon: 'peer' } },
{ path: 'plugins', component: () => import('./views/Plugins.vue'), meta: { titleKey: 'router.plugins', requiresAdmin: true, navGroup: 'integration', navOrder: 3, navIcon: 'plugins' } },
{ path: 'nodes', component: () => import('./views/Nodes.vue'), meta: { titleKey: 'router.nodes', requiresAdmin: true, navGroup: 'integration', navOrder: 4, navIcon: 'nodes' } },
{ path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings', navGroup: 'system', navOrder: 1, navIcon: 'settings' } }, { path: 'settings', component: () => import('./views/Settings.vue'), meta: { titleKey: 'router.settings', navGroup: 'system', navOrder: 1, navIcon: 'settings' } },
{ path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } } { path: ':pathMatch(.*)*', component: () => import('./views/NotFound.vue'), meta: { titleKey: 'router.notFound' } }
] ]
@@ -28,7 +31,7 @@ const routes = [
const router = createRouter({ history: createWebHistory(), routes }) const router = createRouter({ history: createWebHistory(), routes })
// The same route metadata drives menu visibility, order, title and child breadcrumbs. // The same route metadata drives menu visibility, order, title and child breadcrumbs.
export const navigationGroups = ['workspace', 'ops', 'resource', 'system'] export const navigationGroups = ['workspace', 'ops', 'resource', 'integration', 'system']
export function menuFor(group, isAdmin) { export function menuFor(group, isAdmin) {
return router.getRoutes() return router.getRoutes()
.filter(r => r.meta.navGroup === group && (!r.meta.requiresAdmin || isAdmin)) .filter(r => r.meta.navGroup === group && (!r.meta.requiresAdmin || isAdmin))
@@ -39,6 +42,10 @@ router.beforeEach(to => {
if (!to.meta.public && !auth.token) return '/login' if (!to.meta.public && !auth.token) return '/login'
if (to.path === '/login' && auth.token) return '/' if (to.path === '/login' && auth.token) return '/'
if (to.meta.requiresAdmin && !auth.isAdmin()) return '/' if (to.meta.requiresAdmin && !auth.isAdmin()) return '/'
// Legacy deep link: settings?tab=identity → /identity
if (to.path === '/settings' && to.query.tab === 'identity') {
return { path: '/identity', query: {} }
}
}) })
export default router export default router
+18
View File
@@ -0,0 +1,18 @@
<template>
<section class="identity-page">
<div class="identity-intro">
<p>{{ $t('identity.pageDescription') }}</p>
</div>
<IdentitySettings />
</section>
</template>
<script setup>
import IdentitySettings from './settings/components/IdentitySettings.vue'
</script>
<style scoped>
.identity-page { max-width: 1160px; margin: 0 auto; padding-bottom: 32px; }
.identity-intro { padding: 0 4px var(--space-4); }
.identity-intro p { margin: 0; color: var(--text-regular); line-height: 1.6; font-size: 13px; }
</style>
+3 -3
View File
@@ -96,7 +96,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { DataAnalysis, Connection, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document, Link, Expand, Fold, Close, Operation } from '@element-plus/icons-vue' import { DataAnalysis, Connection, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document, Link, Expand, Fold, Close, Operation, Key, Box, Cpu, OfficeBuilding } from '@element-plus/icons-vue'
import api, { auth } from '../api' import api, { auth } from '../api'
import { menuFor, navigationGroups } from '../router' import { menuFor, navigationGroups } from '../router'
@@ -105,8 +105,8 @@ const route = useRoute()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const collapsed = ref(localStorage.getItem('vpn_nav_collapsed') === '1') const collapsed = ref(localStorage.getItem('vpn_nav_collapsed') === '1')
const mobileOpen = ref(false) const mobileOpen = ref(false)
const menuIcons = { dashboard: DataAnalysis, tunnels: Connection, sessions: Monitor, logs: Document, peer: Link, users: UserFilled, vpnUsers: Iphone, devices: Monitor, settings: Setting } const menuIcons = { dashboard: DataAnalysis, tunnels: Connection, sessions: Monitor, logs: Document, peer: Link, users: UserFilled, vpnUsers: Iphone, devices: Cpu, settings: Setting, identity: Key, plugins: Box, nodes: OfficeBuilding }
const groupTitles = { workspace: 'sidebarWorkspace', ops: 'sidebarOps', resource: 'sidebarResource', system: 'sidebarSystem' } const groupTitles = { workspace: 'sidebarWorkspace', ops: 'sidebarOps', resource: 'sidebarResource', integration: 'sidebarIntegration', system: 'sidebarSystem' }
const visibleGroups = computed(() => navigationGroups.map(key => ({ key, titleKey: groupTitles[key], items: menuFor(key, auth.isAdmin()) })).filter(group => group.items.length)) const visibleGroups = computed(() => navigationGroups.map(key => ({ key, titleKey: groupTitles[key], items: menuFor(key, auth.isAdmin()) })).filter(group => group.items.length))
const parentRoute = computed(() => route.meta.parentPath && router.getRoutes().find(r => r.path === route.meta.parentPath)) const parentRoute = computed(() => route.meta.parentPath && router.getRoutes().find(r => r.path === route.meta.parentPath))
function toggleCollapsed() { function toggleCollapsed() {
+26 -122
View File
@@ -48,59 +48,6 @@
</el-card> </el-card>
</el-tab-pane> </el-tab-pane>
<!-- 连接日志 Tab -->
<el-tab-pane :label="$t('logs.connTitle')" name="connection">
<el-card class="page-card" shadow="never">
<template #header>
<div class="row-between">
<div class="filter-bar">
<el-input v-model="connFilters.username" :placeholder="$t('common.username')" clearable size="small" style="width:120px" @clear="loadConn" @keyup.enter="loadConn" />
<el-select v-model="connFilters.event" :placeholder="$t('logs.eventType')" clearable multiple size="small" style="width:200px" @change="loadConn">
<el-option :label="$t('sessions.eventConnect')" value="connect" />
<el-option :label="$t('sessions.eventDisconnect')" value="disconnect" />
<el-option :label="$t('sessions.eventAuthFail')" value="auth_fail" />
<el-option :label="$t('sessions.eventQuotaExceed')" value="quota_exceed" />
<el-option :label="$t('sessions.eventRateAlert')" value="rate_alert" />
</el-select>
<el-date-picker v-model="connDateRange" type="daterange" :start-placeholder="$t('logs.startDate')" :end-placeholder="$t('logs.endDate')" size="small" value-format="YYYY-MM-DD" style="width:260px" @change="loadConn" clearable />
<el-button type="primary" size="small" @click="loadConn">{{ $t('common.search') }}</el-button>
</div>
<div style="display:flex;gap:8px">
<el-button size="small" :loading="connExporting" @click="exportConn">{{ $t('logs.exportCsv') }}</el-button>
</div>
</div>
</template>
<el-alert v-if="connError" type="error" :closable="false" :title="connError" class="log-error"><el-button size="small" @click="loadConn">{{ $t('common.refresh') }}</el-button></el-alert>
<el-table :data="connLogs" size="default" v-loading="connLoading" max-height="560">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="tunnel_id" :label="$t('logs.tunnelId')" width="80" />
<el-table-column prop="username" :label="$t('common.username')" width="100" />
<el-table-column :label="$t('sessions.event')" width="110">
<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="$t('common.ipAddress')" width="140" />
<el-table-column prop="detail" :label="$t('common.detail')" min-width="200" show-overflow-tooltip />
<template #empty><el-empty :description="$t('common.noData')" /></template>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="connPage"
v-model:page-size="connPageSize"
:total="connTotal"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadConn"
@size-change="loadConn"
/>
</div>
</el-card>
</el-tab-pane>
<!-- 流量告警 Tab --> <!-- 流量告警 Tab -->
<el-tab-pane :label="$t('logs.alertTitle')" name="alerts"> <el-tab-pane :label="$t('logs.alertTitle')" name="alerts">
<el-card class="page-card" shadow="never"> <el-card class="page-card" shadow="never">
@@ -160,14 +107,34 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import api, { auth } from '../api' import api, { auth } from '../api'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const TAB_NAMES = ['audit', 'alerts']
const activeTab = ref(TAB_NAMES.includes(route.query.tab) ? route.query.tab : 'audit')
const activeTab = ref('audit') watch(() => route.query.tab, (name) => {
// 连接审计已迁到 Sessions;旧链接 ?tab=connection 跳过去
if (name === 'connection') {
router.replace({ path: '/sessions', query: { tab: 'audit' } })
return
}
const next = TAB_NAMES.includes(name) ? name : 'audit'
if (activeTab.value !== next) activeTab.value = next
}, { immediate: true })
watch(activeTab, (tab) => {
const query = tab === 'audit' ? {} : { tab }
if ((route.query.tab || undefined) !== query.tab) {
router.replace({ path: '/logs', query })
}
})
// ---- 审计日志 ---- // ---- 审计日志 ----
const auditLogs = ref([]) const auditLogs = ref([])
@@ -226,56 +193,6 @@ async function exportAudit() {
finally { auditExporting.value = false } finally { auditExporting.value = false }
} }
// ---- 连接日志 ----
const connLogs = ref([])
const connLoading = ref(false)
const connError = ref('')
const connExporting = ref(false)
const connPage = ref(1)
const connPageSize = ref(20)
const connTotal = ref(0)
const connFilters = reactive({ username: '', event: [], keyword: '' })
const connDateRange = ref(null)
async function loadConn() {
connLoading.value = true
try {
const params = {
page: connPage.value,
page_size: connPageSize.value,
username: connFilters.username,
keyword: connFilters.keyword,
}
if (connFilters.event && connFilters.event.length > 0) {
params.event = connFilters.event.join(',')
}
if (connDateRange.value) {
params.start = connDateRange.value[0]
params.end = connDateRange.value[1]
}
const { data } = await api.get('/connection-logs', { params })
connLogs.value = data.items || []
connTotal.value = data.total || 0
connError.value = ''
} catch (e) {
connError.value = e.response?.data?.error || t('logs.loadFailed')
} finally { connLoading.value = false }
}
async function exportConn() {
connExporting.value = true
const params = { username: connFilters.username, keyword: connFilters.keyword }
if (connFilters.event && connFilters.event.length > 0) {
params.event = connFilters.event.join(',')
}
if (connDateRange.value) {
params.start = connDateRange.value[0]
params.end = connDateRange.value[1]
}
try { await downloadCSV('/connection-logs/export', params, 'connection-logs') }
catch (e) { ElMessage.error(t('logs.exportFailed')) }
finally { connExporting.value = false }
}
// ---- 流量告警 ---- // ---- 流量告警 ----
const alerts = ref([]) const alerts = ref([])
@@ -314,14 +231,14 @@ async function cleanupLogs() {
ElMessage.success(t('logs.cleanupDone', { audit: data.audit_deleted, conn: data.conn_deleted, session: data.session_deleted })) ElMessage.success(t('logs.cleanupDone', { audit: data.audit_deleted, conn: data.conn_deleted, session: data.session_deleted }))
loadStats() loadStats()
if (activeTab.value === 'audit') loadAudit() if (activeTab.value === 'audit') loadAudit()
if (activeTab.value === 'connection') loadConn() if (activeTab.value === 'alerts') loadAlerts()
} catch { /* cancel */ } } catch { /* cancel */ }
} }
// ---- 通用 ---- // ---- 通用 ----
function onTabChange(tab) { function onTabChange(tab) {
activeTab.value = tab
if (tab === 'audit') loadAudit() if (tab === 'audit') loadAudit()
if (tab === 'connection') loadConn()
if (tab === 'alerts') loadAlerts() if (tab === 'alerts') loadAlerts()
} }
@@ -330,20 +247,6 @@ function fmtTime(d) {
return new Date(d).toLocaleString() return new Date(d).toLocaleString()
} }
function eventLabel(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'
}
function actionTagType(a) { function actionTagType(a) {
if (a.includes('login')) return 'primary' if (a.includes('login')) return 'primary'
if (a.includes('create') || a.includes('add')) return 'success' if (a.includes('create') || a.includes('add')) return 'success'
@@ -353,7 +256,8 @@ function actionTagType(a) {
} }
onMounted(() => { onMounted(() => {
loadAudit() if (activeTab.value === 'alerts') loadAlerts()
else loadAudit()
loadStats() loadStats()
}) })
</script> </script>
+1
View File
@@ -6,6 +6,7 @@
<el-button type="primary" :icon="Plus" @click="openCreate">{{ $t('nodes.addNode') }}</el-button> <el-button type="primary" :icon="Plus" @click="openCreate">{{ $t('nodes.addNode') }}</el-button>
</div> </div>
</template> </template>
<el-alert type="warning" :closable="false" :title="$t('nodes.soonHint')" style="margin-bottom:10px" />
<el-alert type="info" :closable="false" style="margin-bottom:14px" <el-alert type="info" :closable="false" style="margin-bottom:14px"
:title="$t('nodes.nodeHint')" /> :title="$t('nodes.nodeHint')" />
+1
View File
@@ -7,6 +7,7 @@
<el-button type="primary" :icon="Plus" @click="openCreate">{{ $t('plugins.registerPlugin') }}</el-button> <el-button type="primary" :icon="Plus" @click="openCreate">{{ $t('plugins.registerPlugin') }}</el-button>
</div> </div>
</template> </template>
<el-alert type="warning" :closable="false" :title="$t('plugins.soonHint')" style="margin-bottom:10px" />
<el-alert type="info" :closable="false" style="margin-bottom:14px"> <el-alert type="info" :closable="false" style="margin-bottom:14px">
<i18n-t keypath="plugins.pluginHint" tag="p" style="margin:0"> <i18n-t keypath="plugins.pluginHint" tag="p" style="margin:0">
<template #hooks><b>{{ $t('plugins.commandHooks') }}</b></template> <template #hooks><b>{{ $t('plugins.commandHooks') }}</b></template>
+75 -14
View File
@@ -1,6 +1,8 @@
<template> <template>
<div> <div>
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="session-error"><el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button></el-alert> <el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane :label="$t('sessions.title')" name="online">
<el-alert v-if="sessionError" type="error" :closable="false" :title="sessionError" class="session-error"><el-button size="small" @click="loadOnline">{{ $t('common.refresh') }}</el-button></el-alert>
<!-- 在线会话 --> <!-- 在线会话 -->
<el-card class="page-card" shadow="never" style="margin-bottom:16px"> <el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header> <template #header>
@@ -9,7 +11,7 @@
<b>{{ $t('sessions.title') }}</b> <b>{{ $t('sessions.title') }}</b>
<el-tag size="small" type="success" style="margin-left:8px">{{ $t('sessions.onlineCount', { count: sessions.length }) }}</el-tag> <el-tag size="small" type="success" style="margin-left:8px">{{ $t('sessions.onlineCount', { count: sessions.length }) }}</el-tag>
</div> </div>
<el-button link @click="load">{{ $t('common.refresh') }}</el-button> <el-button link @click="loadOnline">{{ $t('common.refresh') }}</el-button>
</div> </div>
</template> </template>
<el-table :data="sessions" size="default" v-loading="loading"> <el-table :data="sessions" size="default" v-loading="loading">
@@ -59,10 +61,18 @@
</el-table> </el-table>
</el-card> </el-card>
</el-tab-pane>
<el-tab-pane :label="$t('sessions.auditLog')" name="audit">
<el-alert v-if="auditError" type="error" :closable="false" :title="auditError" class="session-error"><el-button size="small" @click="loadAudit">{{ $t('common.refresh') }}</el-button></el-alert>
<!-- 连接审计日志 --> <!-- 连接审计日志 -->
<el-card class="page-card" shadow="never"> <el-card class="page-card" shadow="never">
<template #header><b>{{ $t('sessions.auditLog') }}</b></template> <template #header>
<el-table :data="logs" size="small" max-height="500"> <div class="row-between">
<b>{{ $t('sessions.auditLog') }}</b>
<el-button link @click="loadAudit">{{ $t('common.refresh') }}</el-button>
</div>
</template>
<el-table :data="logs" size="small" max-height="500" v-loading="auditLoading">
<el-table-column prop="created_at" :label="$t('common.time')" width="170"> <el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template> <template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
</el-table-column> </el-table-column>
@@ -78,11 +88,14 @@
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip /> <el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
</el-table> </el-table>
</el-card> </el-card>
</el-tab-pane>
</el-tabs>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import api from '../api' import api from '../api'
@@ -90,11 +103,37 @@ import { fmtBytes } from '../utils/format'
import { protocolColor, protocolDisplay } from '../protocols' import { protocolColor, protocolDisplay } from '../protocols'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const sessions = ref([]) const sessions = ref([])
const logs = ref([]) const logs = ref([])
const alerts = ref([]) const alerts = ref([])
const loading = ref(false) const loading = ref(false)
const loadError = ref('') /** 规范名 online / audit;兼容 ?tab=sessions|connection */
function normalizeTab(name) {
if (name === 'sessions' || name === 'online') return 'online'
if (name === 'audit' || name === 'connection') return 'audit'
return 'online'
}
const activeTab = ref(normalizeTab(route.query.tab))
const sessionError = ref('')
const auditError = ref('')
const auditLoading = ref(false)
watch(() => route.query.tab, (name) => {
const next = normalizeTab(name)
if (activeTab.value !== next) activeTab.value = next
}, { immediate: true })
watch(activeTab, (tab) => {
// 写入规范 query:在线清空;审计固定 tab=audit(别名落地后会改写)
const query = tab === 'online' ? {} : { tab: 'audit' }
const cur = route.query.tab
const needsReplace = normalizeTab(cur) !== tab
|| (tab === 'online' && cur != null && cur !== '')
|| (tab === 'audit' && cur !== 'audit')
if (needsReplace) router.replace({ path: '/sessions', query })
})
function displayUsername(value) { function displayUsername(value) {
return typeof value === 'string' && value.trim() && value.trim().toUpperCase() !== 'UNDEF' return typeof value === 'string' && value.trim() && value.trim().toUpperCase() !== 'UNDEF'
@@ -116,34 +155,56 @@ function eventTagType(e) {
return { connect: 'success', disconnect: 'info', auth_fail: 'danger', quota_exceed: 'warning', rate_alert: 'warning' }[e] || 'info' return { connect: 'success', disconnect: 'info', auth_fail: 'danger', quota_exceed: 'warning', rate_alert: 'warning' }[e] || 'info'
} }
async function load() { async function loadOnline() {
loading.value = true loading.value = true
try { try {
const [s, l, a] = await Promise.all([ const [s, a] = await Promise.all([
api.get('/sessions'), api.get('/sessions'),
api.get('/sessions/log'),
api.get('/traffic/alerts'), api.get('/traffic/alerts'),
]) ])
sessions.value = s.data || [] sessions.value = s.data || []
logs.value = l.data || []
alerts.value = a.data || [] alerts.value = a.data || []
loadError.value = '' sessionError.value = ''
} catch (e) { } catch (e) {
loadError.value = e.response?.data?.error || t('sessions.loadDataFailed') sessionError.value = e.response?.data?.error || t('sessions.loadDataFailed')
} finally { loading.value = false } } finally { loading.value = false }
} }
async function loadAudit() {
auditLoading.value = true
try {
const response = await api.get('/sessions/log')
logs.value = response.data || []
auditError.value = ''
} catch (e) {
auditError.value = e.response?.data?.error || t('sessions.loadDataFailed')
} finally { auditLoading.value = false }
}
function onTabChange(tab) {
activeTab.value = tab
if (tab === 'audit') loadAudit()
else loadOnline()
}
async function disconnect(row) { async function disconnect(row) {
try { try {
await ElMessageBox.confirm(t('sessions.kickConfirm', { username: row.display_name || displayUsername(row.username) }), t('common.confirm'), { type: 'warning' }) await ElMessageBox.confirm(t('sessions.kickConfirm', { username: row.display_name || displayUsername(row.username) }), t('common.confirm'), { type: 'warning' })
await api.post(`/sessions/${row.id}/disconnect`) await api.post(`/sessions/${row.id}/disconnect`)
ElMessage.success(t('sessions.kicked')) ElMessage.success(t('sessions.kicked'))
load() loadOnline()
} catch (e) { if (e !== 'cancel') ElMessage.error(t('common.operationFailed')) } } catch (e) { if (e !== 'cancel') ElMessage.error(t('common.operationFailed')) }
} }
let timer let timer
onMounted(() => { load(); timer = setInterval(load, 15000) }) onMounted(() => {
if (activeTab.value === 'audit') loadAudit()
else loadOnline()
timer = setInterval(() => {
if (activeTab.value === 'online') loadOnline()
else loadAudit()
}, 15000)
})
onUnmounted(() => clearInterval(timer)) onUnmounted(() => clearInterval(timer))
</script> </script>
+9 -5
View File
@@ -2,6 +2,12 @@
<section class="settings-page"> <section class="settings-page">
<div class="settings-intro"> <div class="settings-intro">
<p>{{ $t('settings.pageDescription') }}</p> <p>{{ $t('settings.pageDescription') }}</p>
<el-alert v-if="auth.isAdmin()" type="info" :closable="false" show-icon class="identity-moved">
<template #title>
<span>{{ $t('settings.identityMovedHint') }}</span>
<el-button link type="primary" @click="$router.push('/identity')">{{ $t('settings.identityMovedLink') }}</el-button>
</template>
</el-alert>
</div> </div>
<el-tabs v-model="activeTab" class="settings-tabs" @tab-change="changeTab"> <el-tabs v-model="activeTab" class="settings-tabs" @tab-change="changeTab">
<el-tab-pane :label="$t('settings.securityTab')" name="security"> <el-tab-pane :label="$t('settings.securityTab')" name="security">
@@ -37,9 +43,6 @@
</el-card> </el-card>
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('identity.tab')" name="identity">
<IdentitySettings />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway"> <el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
<GatewaySettings /> <GatewaySettings />
</el-tab-pane> </el-tab-pane>
@@ -91,7 +94,6 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import IdentitySettings from './settings/components/IdentitySettings.vue'
import GatewaySettings from './settings/components/GatewaySettings.vue' import GatewaySettings from './settings/components/GatewaySettings.vue'
import api, { auth } from '../api' import api, { auth } from '../api'
@@ -99,7 +101,7 @@ const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const activeTab = ref('security') const activeTab = ref('security')
const visibleTab = (name) => auth.isAdmin() && ['identity', 'gateway', 'host'].includes(name) ? name : 'security' const visibleTab = (name) => auth.isAdmin() && ['gateway', 'host'].includes(name) ? name : 'security'
watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true }) watch(() => route.query.tab, name => { activeTab.value = visibleTab(name) }, { immediate: true })
function changeTab(name) { function changeTab(name) {
if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } }) if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } })
@@ -193,6 +195,8 @@ function copyBackup() {
.settings-page { max-width: 1160px; margin: 0 auto; padding-bottom: 32px; } .settings-page { max-width: 1160px; margin: 0 auto; padding-bottom: 32px; }
.settings-intro { padding: 0 4px var(--space-4); } .settings-intro { padding: 0 4px var(--space-4); }
.settings-intro p { margin: 0; color: var(--text-regular); line-height: 1.6; font-size: 13px; } .settings-intro p { margin: 0; color: var(--text-regular); line-height: 1.6; font-size: 13px; }
.identity-moved { margin-top: 12px; }
.identity-moved :deep(.el-alert__title) { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.settings-tabs { background: white; border: 1px solid var(--border-base); border-radius: 14px; box-shadow: var(--shadow-sm); padding: 0 22px 22px; min-height: 380px; } .settings-tabs { background: white; border: 1px solid var(--border-base); border-radius: 14px; box-shadow: var(--shadow-sm); padding: 0 22px 22px; min-height: 380px; }
.settings-tabs :deep(.el-tabs__header) { margin-bottom: 22px; } .settings-tabs :deep(.el-tabs__header) { margin-bottom: 22px; }
.settings-tabs :deep(.el-tabs__item) { height: 55px; font-weight: 600; } .settings-tabs :deep(.el-tabs__item) { height: 55px; font-weight: 600; }
@@ -109,7 +109,7 @@ const { t } = useI18n()
const identityStatus = ref(null) const identityStatus = ref(null)
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' }) const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
const identitySecure = window.location.protocol === 'https:' const identitySecure = window.location.protocol === 'https:'
const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/settings?tab=identity` : '') const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/identity` : '')
const feishuPermissionUrl = computed(() => /^cli_[a-zA-Z0-9]+$/.test(identityForm.value.app_id) const feishuPermissionUrl = computed(() => /^cli_[a-zA-Z0-9]+$/.test(identityForm.value.app_id)
? `https://open.feishu.cn/app/${identityForm.value.app_id}/auth?q=contact:user.basic_profile:readonly&op_from=openapi&token_type=tenant` ? `https://open.feishu.cn/app/${identityForm.value.app_id}/auth?q=contact:user.basic_profile:readonly&op_from=openapi&token_type=tenant`
: '') : '')