feat: 前端多语言支持(中/英)与品牌 Logo 接入

多语言:
- 新增 vue-i18n,创建 src/locales/zh.ts、en.ts 语言包,按功能模块组织 key
- 新增 src/i18n.ts,语言检测优先级:localStorage > navigator.language(zh→中文,其他→英文)> 兜底英文
- 导航栏新增语言切换按钮(中/EN 一键切换),选择持久化到 localStorage
- 全部 View/Component 硬编码中文替换为 t() 调用,stores/auth.ts 错误消息同样国际化
- index.html lang 属性运行时动态更新

Logo:
- 替换 Vue 默认 logo 为 LmVPN 品牌 Logo(盾牌+网络节点)
- Header 导航栏:Logo 图标 + "LmVPN" 文字
- 首页标题区:大尺寸 Logo 展示
- Favicon 替换为 SVG 格式
This commit is contained in:
2026-07-07 15:05:53 +08:00
parent 5bce78cdb4
commit 25c3ad685a
20 changed files with 721 additions and 185 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LmVPN</title>
</head>
+89
View File
@@ -10,6 +10,7 @@
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.38",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
"devDependencies": {
@@ -518,6 +519,67 @@
"tslib": "^2.4.0"
}
},
"node_modules/@intlify/core-base": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.6.tgz",
"integrity": "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.6",
"@intlify/message-compiler": "11.4.6",
"@intlify/shared": "11.4.6"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.6.tgz",
"integrity": "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.6",
"@intlify/shared": "11.4.6"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.6.tgz",
"integrity": "sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.6",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.6.tgz",
"integrity": "sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==",
"license": "MIT",
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -3343,6 +3405,33 @@
}
}
},
"node_modules/vue-i18n": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
"integrity": "sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.6",
"@intlify/devtools-types": "11.4.6",
"@intlify/shared": "11.4.6",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-i18n/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-router": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz",
+1
View File
@@ -13,6 +13,7 @@
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.38",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
"devDependencies": {
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<rect width="200" height="200" fill="#ffffff"/>
<rect id="pot1" x="70" y="160" width="40" height="40" rx="20" ry="20" fill="#33BEFF"/>
<rect id="pot2" x="150" y="130" width="40" height="40" rx="20" ry="20" fill="#33BEFF"/>
<g id="shield">
<path d="M 72 50 L 108 50 L 108 74 Q 108 88 90 96 Q 72 88 72 74 Z" fill="#33BEFF"/>
<path d="M 86.5 66 A 3.5 3.5 0 1 1 93.5 66 L 91 80 L 89 80 Z" fill="#ffffff"/>
</g>
<rect id="line1" x="132" y="60" width="14" height="120" rx="7" ry="7" fill="#33BEFF" transform="rotate(-45 139 120)"/>
<rect id="line2" x="105" y="105" width="14" height="80" rx="7" ry="7" fill="#33BEFF" transform="rotate(30 112 145)"/>
<rect id="line3" x="45" y="80" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(45 52 105)"/>
<rect id="line4" x="30" y="45" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(90 37 70)"/>
<rect id="line5" x="43" y="8" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(135 50 33)"/>
<rect id="line6" x="81" y="-8" width="14" height="50" rx="7" ry="7" fill="#B1B1B1"/>
<rect id="line7" x="120" y="6" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(45 127 31)"/>
<rect id="line8" x="135" y="45" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(90 142 70)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+20 -8
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { toggleLocale } from '@/i18n'
import logo from '@/assets/logo.svg'
const router = useRouter()
const authStore = useAuthStore()
const { t, locale } = useI18n()
function handleLogout() {
authStore.logout()
@@ -11,8 +15,8 @@ function handleLogout() {
}
const navLinks = [
{ to: '/', label: '首页' },
{ to: '/about', label: '关于' },
{ to: '/', label: 'nav.home' },
{ to: '/about', label: 'nav.about' },
]
</script>
@@ -20,7 +24,8 @@ const navLinks = [
<div class="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
<header class="bg-sky-600 text-white shadow-md">
<div class="max-w-6xl mx-auto px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<RouterLink to="/" class="text-xl font-bold tracking-wide hover:text-sky-200 transition-colors">
<RouterLink to="/" class="flex items-center gap-2 text-xl font-bold tracking-wide hover:text-sky-200 transition-colors">
<img :src="logo" alt="LmVPN" class="h-8 w-8 rounded-lg" />
LmVPN
</RouterLink>
<nav class="flex items-center gap-1 flex-wrap">
@@ -32,7 +37,7 @@ const navLinks = [
hover:bg-sky-500/40"
active-class="bg-sky-700/60"
>
{{ link.label }}
{{ t(link.label) }}
</RouterLink>
<template v-if="authStore.isLoggedIn">
<RouterLink
@@ -41,7 +46,7 @@ const navLinks = [
hover:bg-sky-500/40"
active-class="bg-sky-700/60"
>
用户信息
{{ t('nav.profile') }}
</RouterLink>
<template v-if="authStore.user?.role === 'admin'">
<RouterLink
@@ -50,7 +55,7 @@ const navLinks = [
hover:bg-sky-500/40"
active-class="bg-sky-700/60"
>
管理后台
{{ t('nav.admin') }}
</RouterLink>
</template>
<a
@@ -59,7 +64,7 @@ const navLinks = [
hover:bg-red-500/40"
@click.prevent="handleLogout"
>
退出
{{ t('nav.logout') }}
</a>
</template>
<template v-else>
@@ -69,9 +74,16 @@ const navLinks = [
hover:bg-sky-500/40"
active-class="bg-sky-700/60"
>
登录
{{ t('nav.login') }}
</RouterLink>
</template>
<button
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
hover:bg-sky-500/40 border border-white/20"
@click="toggleLocale"
>
{{ locale === 'zh' ? 'EN' : '中' }}
</button>
</nav>
</div>
</header>
+18 -1
View File
@@ -1 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<rect width="200" height="200" fill="#ffffff"/>
<rect id="pot1" x="70" y="160" width="40" height="40" rx="20" ry="20" fill="#33BEFF"/>
<rect id="pot2" x="150" y="130" width="40" height="40" rx="20" ry="20" fill="#33BEFF"/>
<g id="shield">
<path d="M 72 50 L 108 50 L 108 74 Q 108 88 90 96 Q 72 88 72 74 Z" fill="#33BEFF"/>
<path d="M 86.5 66 A 3.5 3.5 0 1 1 93.5 66 L 91 80 L 89 80 Z" fill="#ffffff"/>
</g>
<rect id="line1" x="132" y="60" width="14" height="120" rx="7" ry="7" fill="#33BEFF" transform="rotate(-45 139 120)"/>
<rect id="line2" x="105" y="105" width="14" height="80" rx="7" ry="7" fill="#33BEFF" transform="rotate(30 112 145)"/>
<rect id="line3" x="45" y="80" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(45 52 105)"/>
<rect id="line4" x="30" y="45" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(90 37 70)"/>
<rect id="line5" x="43" y="8" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(135 50 33)"/>
<rect id="line6" x="81" y="-8" width="14" height="50" rx="7" ry="7" fill="#B1B1B1"/>
<rect id="line7" x="120" y="6" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(45 127 31)"/>
<rect id="line8" x="135" y="45" width="14" height="50" rx="7" ry="7" fill="#B1B1B1" transform="rotate(90 142 70)"/>
</svg>

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 1.4 KiB

+7 -5
View File
@@ -1,17 +1,19 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
import { useI18n } from 'vue-i18n'
import logo from '@/assets/logo.svg'
const { t } = useI18n()
</script>
<template>
<div class="py-16 sm:py-24 text-center">
<div class="max-w-3xl mx-auto px-4">
<img :src="logo" alt="LmVPN" class="h-20 w-20 rounded-2xl mx-auto mb-6 shadow-sm" />
<h1 class="text-4xl sm:text-5xl font-extrabold text-sky-600 dark:text-sky-400 mb-4">
{{ msg }}
LmVPN
</h1>
<p class="text-lg sm:text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
安全快速可靠的 VPN 隧道管理系统
{{ t('home.tagline') }}
</p>
</div>
</div>
+11 -8
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import WelcomeItem from './WelcomeItem.vue'
const { t } = useI18n()
</script>
<template>
@@ -10,8 +13,8 @@ import WelcomeItem from './WelcomeItem.vue'
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</template>
<template #heading>安全隧道</template>
基于 WireGuard/OpenVPN 协议端到端加密传输保障数据安全无泄漏
<template #heading>{{ t('home.secureTunnel') }}</template>
{{ t('home.secureTunnelDesc') }}
</WelcomeItem>
<WelcomeItem>
@@ -20,8 +23,8 @@ import WelcomeItem from './WelcomeItem.vue'
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</template>
<template #heading>多设备支持</template>
同时连接多台设备手机电脑路由器全平台覆盖
<template #heading>{{ t('home.multiDevice') }}</template>
{{ t('home.multiDeviceDesc') }}
</WelcomeItem>
<WelcomeItem>
@@ -30,8 +33,8 @@ import WelcomeItem from './WelcomeItem.vue'
<path stroke-linecap="round" stroke-linejoin="round" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
</template>
<template #heading>流量监控</template>
实时流量统计与带宽分析随时掌握网络使用状况
<template #heading>{{ t('home.trafficMonitor') }}</template>
{{ t('home.trafficMonitorDesc') }}
</WelcomeItem>
<WelcomeItem>
@@ -41,8 +44,8 @@ import WelcomeItem from './WelcomeItem.vue'
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
<template #heading>简易配置</template>
一键部署自动配置无需复杂网络知识即可上线
<template #heading>{{ t('home.easyConfig') }}</template>
{{ t('home.easyConfigDesc') }}
</WelcomeItem>
</div>
</template>
+32
View File
@@ -0,0 +1,32 @@
import { createI18n } from 'vue-i18n'
import { messages, type Locale } from './locales'
const STORAGE_KEY = 'lang'
function detectLocale(): Locale {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'zh' || saved === 'en') return saved
const navLang = navigator.language?.toLowerCase() ?? ''
return navLang.startsWith('zh') ? 'zh' : 'en'
}
const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: 'en',
messages,
})
export function setLocale(locale: Locale) {
i18n.global.locale.value = locale
localStorage.setItem(STORAGE_KEY, locale)
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en'
}
export function toggleLocale() {
setLocale(i18n.global.locale.value === 'zh' ? 'en' : 'zh')
}
document.documentElement.lang = i18n.global.locale.value === 'zh' ? 'zh-CN' : 'en'
export default i18n
+167
View File
@@ -0,0 +1,167 @@
export default {
common: {
cancel: 'Cancel',
confirm: 'OK',
save: 'Save',
saving: 'Saving...',
delete: 'Delete',
deleting: 'Deleting...',
edit: 'Edit',
loading: 'Loading...',
loadFailed: 'Failed to load',
saveFailed: 'Failed to save',
createFailed: 'Failed to create',
deleteFailed: 'Failed to delete',
role: 'Role',
status: 'Status',
admin: 'Admin',
normalUser: 'User',
enabled: 'Enabled',
disabled: 'Disabled',
username: 'Username',
password: 'Password',
createdAt: 'Created At',
actions: 'Actions',
id: 'ID',
},
nav: {
home: 'Home',
about: 'About',
profile: 'Profile',
admin: 'Admin',
logout: 'Logout',
login: 'Login',
},
language: {
label: 'Language',
zh: '中文',
en: 'English',
},
login: {
title: 'LmVPN Login',
enterUsernamePassword: 'Please enter username and password',
enterUsername: 'Enter username',
enterPassword: 'Enter password',
loginFailed: 'Login failed',
loggingIn: 'Logging in...',
loginButton: 'Login',
},
profile: {
title: 'Profile',
basicInfo: 'Basic Info',
usernameLabel: 'Username: ',
roleLabel: 'Role: ',
changePassword: 'Change Password',
oldPassword: 'Current Password',
newPassword: 'New Password',
confirmNewPassword: 'Confirm New Password',
enterOldAndNewPassword: 'Please enter current and new password',
passwordsDoNotMatch: 'The two passwords do not match',
passwordChangeFailed: 'Failed to change password',
},
about: {
title: 'About LmVPN',
description:
'LmVPN is a lightweight VPN tunnel management system built with Go + Vue 3. It supports multiple VPN protocols and provides secure and reliable point-to-point and point-to-site networking solutions.',
},
admin: {
title: 'Admin Dashboard',
uptime: 'Uptime',
activeDevices: 'Active Devices',
todayTraffic: "Today's Traffic",
onlineNodes: 'Online Nodes',
totalUsers: 'Total Users',
vpnManage: 'VPN Management',
config: 'Config',
userInfo: 'User Info',
usernameLabel: 'Username: ',
roleLabel: 'Role: ',
logoutButton: 'Logout',
},
userManage: {
title: 'User Management',
addUser: 'Add User',
fillUsernamePassword: 'Please enter username and password',
creating: 'Creating...',
editUser: 'Edit User - {username}',
newPasswordOptional: 'New Password (leave empty to skip)',
newPasswordPlaceholder: 'Leave empty to keep current password',
confirmDelete: 'Confirm Delete',
confirmDeleteMessage:
'Are you sure you want to delete user {username}? This action cannot be undone.',
confirmDeleteButton: 'Confirm Delete',
},
vpn: {
title: 'VPN Management',
refresh: 'Refresh',
serviceStatus: 'Service Status',
running: 'Running',
stopped: 'Stopped',
onlineClients: 'Online Clients',
ipUsage: 'IP Usage',
systemCheck: 'System Diagnostics',
tunDevice: 'TUN Device',
tunRunning: 'Running',
rootPermission: 'Root Permission',
runningAsRoot: 'Running as root',
notRunningAsRoot: 'Not running as root',
capNetAdmin: 'CAP_NET_ADMIN',
authorized: 'Authorized',
unauthorized: 'Not authorized',
ipForward: 'IP Forward (ip_forward)',
notEnabled: 'Not enabled',
natMasqueradeV4: 'NAT MASQUERADE (IPv4)',
configured: 'Configured',
notConfigured: 'Not configured',
ipv6Forward: 'IPv6 Forward (forwarding)',
natMasqueradeV6: 'NAT MASQUERADE (IPv6)',
platform: 'Platform',
nonLinuxNote:
'Note: CAP_NET_ADMIN / ip_forward / MASQUERADE checks only apply to Linux. For other platforms, verify network configuration manually.',
tunnelSettings: 'Tunnel Settings',
enableVpn: 'Enable VPN Service',
enable: 'Enable',
stop: 'Stop',
subnet: 'Subnet (CIDR)',
ipv6Subnet: 'IPv6 Subnet (CIDR, optional)',
mtu: 'MTU',
interfaceName: 'Interface Name (auto if empty)',
allowClientToClient: 'Allow Client-to-Client',
allow: 'Allow',
deny: 'Deny',
serverConfigTunIp: 'Server Configures TUN IP',
autoConfig: 'Auto',
manual: 'Manual',
saveSettings: 'Save Settings',
saveSuccess: 'Saved successfully',
user: 'User',
ipv4: 'IPv4',
ipv6: 'IPv6',
connectTime: 'Connected At',
noOnlineClients: 'No online clients',
staticIpReservation: 'Static IP Reservation',
addReservation: 'Add Reservation',
noReservations: 'No reservations',
addIpReservation: 'Add IP Reservation',
selectUser: 'Select user',
ipv4Address: 'IPv4 Address (optional)',
ipv6Address: 'IPv6 Address (optional)',
selectUserAndIp: 'Please select a user and fill in at least one IP address',
confirmDeleteReservation: 'Delete this reservation?',
},
home: {
tagline: 'Secure, fast, and reliable VPN tunnel management system',
secureTunnel: 'Secure Tunnel',
secureTunnelDesc:
'Based on WireGuard/OpenVPN protocols with end-to-end encryption to keep your data safe.',
multiDevice: 'Multi-Device Support',
multiDeviceDesc:
'Connect multiple devices simultaneously — phones, computers, and routers all supported.',
trafficMonitor: 'Traffic Monitoring',
trafficMonitorDesc:
'Real-time traffic statistics and bandwidth analysis to stay on top of network usage.',
easyConfig: 'Easy Configuration',
easyConfigDesc:
'One-click deployment with auto-configuration — no advanced networking knowledge required.',
},
}
+11
View File
@@ -0,0 +1,11 @@
import zh from './zh'
import en from './en'
export type MessageSchema = typeof zh
export const messages = {
zh,
en,
} as const
export type Locale = keyof typeof messages
+163
View File
@@ -0,0 +1,163 @@
export default {
common: {
cancel: '取消',
confirm: '确定',
save: '保存',
saving: '保存中...',
delete: '删除',
deleting: '删除中...',
edit: '编辑',
loading: '加载中...',
loadFailed: '加载失败',
saveFailed: '保存失败',
createFailed: '创建失败',
deleteFailed: '删除失败',
role: '角色',
status: '状态',
admin: '管理员',
normalUser: '普通用户',
enabled: '启用',
disabled: '禁用',
username: '用户名',
password: '密码',
createdAt: '创建时间',
actions: '操作',
id: 'ID',
},
nav: {
home: '首页',
about: '关于',
profile: '用户信息',
admin: '管理后台',
logout: '退出',
login: '登录',
},
language: {
label: '语言',
zh: '中文',
en: 'English',
},
login: {
title: 'LmVPN 登录',
enterUsernamePassword: '请输入用户名和密码',
enterUsername: '请输入用户名',
enterPassword: '请输入密码',
loginFailed: '登录失败',
loggingIn: '登录中...',
loginButton: '登录',
},
profile: {
title: '用户信息',
basicInfo: '基本信息',
usernameLabel: '用户名:',
roleLabel: '角色:',
changePassword: '修改密码',
oldPassword: '原密码',
newPassword: '新密码',
confirmNewPassword: '确认新密码',
enterOldAndNewPassword: '请填写原密码和新密码',
passwordsDoNotMatch: '两次输入的新密码不一致',
passwordChangeFailed: '密码修改失败',
},
about: {
title: '关于 LmVPN',
description:
'LmVPN 是一款轻量级 VPN 隧道管理系统,采用 Go + Vue 3 技术栈构建。支持多种 VPN 协议,提供安全可靠的点对点与点对站点网络连接方案。',
},
admin: {
title: '管理后台',
uptime: '运行时长',
activeDevices: '活跃设备',
todayTraffic: '今日流量',
onlineNodes: '在线节点',
totalUsers: '用户总数',
vpnManage: 'VPN 管理',
config: '配置',
userInfo: '用户信息',
usernameLabel: '用户名:',
roleLabel: '角色:',
logoutButton: '退出登录',
},
userManage: {
title: '用户管理',
addUser: '新增用户',
fillUsernamePassword: '请填写用户名和密码',
creating: '创建中...',
editUser: '编辑用户 - {username}',
newPasswordOptional: '新密码(留空不修改)',
newPasswordPlaceholder: '留空则不修改密码',
confirmDelete: '确认删除',
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
confirmDeleteButton: '确认删除',
},
vpn: {
title: 'VPN 管理',
refresh: '刷新',
serviceStatus: '服务状态',
running: '运行中',
stopped: '已停止',
onlineClients: '在线客户端',
ipUsage: 'IP 用量',
systemCheck: '系统环境检测',
tunDevice: 'TUN 设备',
tunRunning: '运行中',
rootPermission: 'Root 权限',
runningAsRoot: '以 root 运行',
notRunningAsRoot: '非 root 运行',
capNetAdmin: 'CAP_NET_ADMIN',
authorized: '已授权',
unauthorized: '未授权',
ipForward: 'IP 转发 (ip_forward)',
notEnabled: '未开启',
natMasqueradeV4: 'NAT MASQUERADE (IPv4)',
configured: '已配置',
notConfigured: '未配置',
ipv6Forward: 'IPv6 转发 (forwarding)',
natMasqueradeV6: 'NAT MASQUERADE (IPv6)',
platform: '平台',
nonLinuxNote:
'注:CAP_NET_ADMIN / ip_forward / MASQUERADE 检测仅适用于 Linux,其他平台需手动确认网络配置。',
tunnelSettings: '隧道设置',
enableVpn: '启用 VPN 服务',
enable: '启用',
stop: '停止',
subnet: '子网 (CIDR)',
ipv6Subnet: 'IPv6 子网 (CIDR, 可选)',
mtu: 'MTU',
interfaceName: '接口名 (留空自动)',
allowClientToClient: '允许客户端互通',
allow: '允许',
deny: '禁止',
serverConfigTunIp: '服务端配置 TUN IP',
autoConfig: '自动配置',
manual: '手动',
saveSettings: '保存设置',
saveSuccess: '保存成功',
user: '用户',
ipv4: 'IPv4',
ipv6: 'IPv6',
connectTime: '连接时间',
noOnlineClients: '暂无在线客户端',
staticIpReservation: '静态 IP 预留',
addReservation: '新增预留',
noReservations: '暂无预留',
addIpReservation: '新增 IP 预留',
selectUser: '选择用户',
ipv4Address: 'IPv4 地址 (可选)',
ipv6Address: 'IPv6 地址 (可选)',
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
confirmDeleteReservation: '确认删除该预留?',
},
home: {
tagline: '安全、快速、可靠的 VPN 隧道管理系统',
secureTunnel: '安全隧道',
secureTunnelDesc:
'基于 WireGuard/OpenVPN 协议,端到端加密传输,保障数据安全无泄漏。',
multiDevice: '多设备支持',
multiDeviceDesc: '同时连接多台设备,手机、电脑、路由器全平台覆盖。',
trafficMonitor: '流量监控',
trafficMonitorDesc: '实时流量统计与带宽分析,随时掌握网络使用状况。',
easyConfig: '简易配置',
easyConfigDesc: '一键部署、自动配置,无需复杂网络知识即可上线。',
},
}
+2
View File
@@ -5,10 +5,12 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
const app = createApp(App)
app.use(createPinia())
app.use(i18n)
app.use(router)
app.mount('#app')
+2 -1
View File
@@ -1,5 +1,6 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import i18n from '@/i18n'
interface UserInfo {
id: number
@@ -33,7 +34,7 @@ export const useAuthStore = defineStore('auth', () => {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || '登录失败')
throw new Error(data.error || i18n.global.t('login.loginFailed'))
}
const data = await res.json()
setAuth(data.token, data.user)
+8 -3
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div class="max-w-3xl mx-auto px-4 py-16 text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">关于 LmVPN</h1>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">{{ t('about.title') }}</h1>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed max-w-xl mx-auto">
LmVPN 是一款轻量级 VPN 隧道管理系统采用 Go + Vue 3 技术栈构建
支持多种 VPN 协议提供安全可靠的点对点与点对站点网络连接方案
{{ t('about.description') }}
</p>
</div>
</template>
+16 -14
View File
@@ -1,17 +1,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const { t } = useI18n()
const stats = ref([
{ label: '运行时长', value: '--', unit: '', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', route: '' },
{ label: '活跃设备', value: '--', unit: '', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', route: '' },
{ label: '今日流量', value: '--', unit: 'GB', icon: 'M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z', route: '' },
{ label: '在线节点', value: '--', unit: '', icon: 'M5 12h14M12 5l7 7-7 7', route: '' },
{ label: '用户总数', value: '--', unit: '', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z', route: '/admin/users' },
{ label: 'VPN 管理', value: '配置', unit: '', icon: 'M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 4.05A12.884 12.884 0 0015 11a4 4 0 10-8 0c0 1.017.07 2.019.203 3M3 3l18 18', route: '/admin/vpn' },
{ label: 'admin.uptime', value: '--', unit: '', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', route: '' },
{ label: 'admin.activeDevices', value: '--', unit: '', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', route: '' },
{ label: 'admin.todayTraffic', value: '--', unit: 'GB', icon: 'M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z', route: '' },
{ label: 'admin.onlineNodes', value: '--', unit: '', icon: 'M5 12h14M12 5l7 7-7 7', route: '' },
{ label: 'admin.totalUsers', value: '--', unit: '', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z', route: '/admin/users' },
{ label: 'admin.vpnManage', value: 'admin.config', unit: '', icon: 'M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 4.05A12.884 12.884 0 0015 11a4 4 0 10-8 0c0 1.017.07 2.019.203 3M3 3l18 18', route: '/admin/vpn' },
])
const userCount = ref<number | null>(null)
@@ -24,7 +26,7 @@ async function fetchUserCount() {
if (res.ok) {
const data = await res.json()
userCount.value = data.count
const stat = stats.value.find(s => s.label === '用户总数')
const stat = stats.value.find(s => s.label === 'admin.totalUsers')
if (stat) stat.value = String(data.count)
}
} catch {}
@@ -47,7 +49,7 @@ function handleLogout() {
<template>
<div class="max-w-4xl mx-auto px-4 py-12">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">管理后台</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">{{ t('admin.title') }}</h2>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-5 mb-8">
<div
@@ -64,9 +66,9 @@ function handleLogout() {
</svg>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ stat.label }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t(stat.label) }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
{{ stat.value }}<span class="text-sm font-normal text-gray-500">{{ stat.unit }}</span>
{{ stat.label === 'admin.vpnManage' ? t(stat.value) : stat.value }}<span class="text-sm font-normal text-gray-500">{{ stat.unit }}</span>
</p>
</div>
</div>
@@ -74,10 +76,10 @@ function handleLogout() {
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">用户信息</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.userInfo') }}</h3>
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<p><span class="font-medium text-gray-900 dark:text-white">用户名</span>{{ authStore.user?.username }}</p>
<p><span class="font-medium text-gray-900 dark:text-white">角色</span>{{ authStore.user?.role === 'admin' ? '管理员' : '普通用户' }}</p>
<p><span class="font-medium text-gray-900 dark:text-white">{{ t('admin.usernameLabel') }}</span>{{ authStore.user?.username }}</p>
<p><span class="font-medium text-gray-900 dark:text-white">{{ t('admin.roleLabel') }}</span>{{ authStore.user?.role === 'admin' ? t('common.admin') : t('common.normalUser') }}</p>
</div>
</div>
@@ -85,7 +87,7 @@ function handleLogout() {
class="px-6 py-2.5 rounded-lg font-medium text-white bg-red-500 hover:bg-red-600 transition-colors"
@click="handleLogout"
>
退出登录
{{ t('admin.logoutButton') }}
</button>
</div>
</template>
+10 -8
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const { t } = useI18n()
const username = ref('')
const password = ref('')
@@ -14,7 +16,7 @@ const loading = ref(false)
async function handleLogin() {
error.value = ''
if (!username.value || !password.value) {
error.value = '请输入用户名和密码'
error.value = t('login.enterUsernamePassword')
return
}
loading.value = true
@@ -22,7 +24,7 @@ async function handleLogin() {
await authStore.login(username.value, password.value)
router.push('/admin')
} catch (e: any) {
error.value = e.message || '登录失败'
error.value = e.message || t('login.loginFailed')
} finally {
loading.value = false
}
@@ -33,18 +35,18 @@ async function handleLogin() {
<div class="min-h-[calc(100vh-160px)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-sm bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8">
<h2 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">
LmVPN 登录
{{ t('login.title') }}
</h2>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
用户名
{{ t('common.username') }}
</label>
<input
id="username"
v-model="username"
type="text"
placeholder="请输入用户名"
:placeholder="t('login.enterUsername')"
autocomplete="username"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
@@ -54,13 +56,13 @@ async function handleLogin() {
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
密码
{{ t('common.password') }}
</label>
<input
id="password"
v-model="password"
type="password"
placeholder="请输入密码"
:placeholder="t('login.enterPassword')"
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
@@ -75,7 +77,7 @@ async function handleLogin() {
class="w-full py-2.5 rounded-lg font-medium text-white transition-colors
bg-sky-600 hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ loading ? '登录中...' : '登录' }}
{{ loading ? t('login.loggingIn') : t('login.loginButton') }}
</button>
</form>
</div>
+17 -15
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { t } = useI18n()
onMounted(async () => {
await authStore.fetchUser()
@@ -22,11 +24,11 @@ function openPasswordModal() {
async function handleChangePassword() {
passwordError.value = ''
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
passwordError.value = '请填写原密码和新密码'
passwordError.value = t('profile.enterOldAndNewPassword')
return
}
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
passwordError.value = '两次输入的新密码不一致'
passwordError.value = t('profile.passwordsDoNotMatch')
return
}
changingPassword.value = true
@@ -44,11 +46,11 @@ async function handleChangePassword() {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || '密码修改失败')
throw new Error(data.error || t('profile.passwordChangeFailed'))
}
showPasswordModal.value = false
} catch (e: any) {
passwordError.value = e.message || '密码修改失败'
passwordError.value = e.message || t('profile.passwordChangeFailed')
} finally {
changingPassword.value = false
}
@@ -57,13 +59,13 @@ async function handleChangePassword() {
<template>
<div class="max-w-4xl mx-auto px-4 py-12">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">用户信息</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">{{ t('profile.title') }}</h2>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">基本信息</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('profile.basicInfo') }}</h3>
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<p><span class="font-medium text-gray-900 dark:text-white">用户名</span>{{ authStore.user?.username }}</p>
<p><span class="font-medium text-gray-900 dark:text-white">角色</span>{{ authStore.user?.role === 'admin' ? '管理员' : '普通用户' }}</p>
<p><span class="font-medium text-gray-900 dark:text-white">{{ t('profile.usernameLabel') }}</span>{{ authStore.user?.username }}</p>
<p><span class="font-medium text-gray-900 dark:text-white">{{ t('profile.roleLabel') }}</span>{{ authStore.user?.role === 'admin' ? t('common.admin') : t('common.normalUser') }}</p>
</div>
</div>
@@ -71,15 +73,15 @@ async function handleChangePassword() {
class="px-6 py-2.5 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
@click="openPasswordModal"
>
修改密码
{{ t('profile.changePassword') }}
</button>
<div v-if="showPasswordModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showPasswordModal = false">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">修改密码</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('profile.changePassword') }}</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">原密码</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('profile.oldPassword') }}</label>
<input
v-model="passwordForm.old_password"
type="password"
@@ -87,7 +89,7 @@ async function handleChangePassword() {
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">新密码</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('profile.newPassword') }}</label>
<input
v-model="passwordForm.new_password"
type="password"
@@ -95,7 +97,7 @@ async function handleChangePassword() {
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">确认新密码</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('profile.confirmNewPassword') }}</label>
<input
v-model="passwordForm.confirm_password"
type="password"
@@ -109,14 +111,14 @@ async function handleChangePassword() {
class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
@click="showPasswordModal = false"
>
取消
{{ t('common.cancel') }}
</button>
<button
class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 disabled:opacity-50 transition-colors"
:disabled="changingPassword"
@click="handleChangePassword"
>
{{ changingPassword ? '保存中...' : '确定' }}
{{ changingPassword ? t('common.saving') : t('common.confirm') }}
</button>
</div>
</div>
+50 -48
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { t } = useI18n()
const users = ref<any[]>([])
const loading = ref(false)
const error = ref('')
@@ -25,12 +27,12 @@ async function fetchUsers() {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || '加载失败')
throw new Error(data.error || t('common.loadFailed'))
}
const data = await res.json()
users.value = data.users
} catch (e: any) {
error.value = e.message || '加载失败'
error.value = e.message || t('common.loadFailed')
} finally {
loading.value = false
}
@@ -44,7 +46,7 @@ const creating = ref(false)
async function handleCreate() {
createError.value = ''
if (!createForm.value.username || !createForm.value.password) {
createError.value = '请填写用户名和密码'
createError.value = t('userManage.fillUsernamePassword')
return
}
creating.value = true
@@ -64,13 +66,13 @@ async function handleCreate() {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || '创建失败')
throw new Error(data.error || t('common.createFailed'))
}
showCreateModal.value = false
createForm.value = { username: '', password: '', role: 'user', status: 1 }
await fetchUsers()
} catch (e: any) {
createError.value = e.message || '创建失败'
createError.value = e.message || t('common.createFailed')
} finally {
creating.value = false
}
@@ -108,12 +110,12 @@ async function handleUpdate() {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || '保存失败')
throw new Error(data.error || t('common.saveFailed'))
}
showEditModal.value = false
await fetchUsers()
} catch (e: any) {
editError.value = e.message || '保存失败'
editError.value = e.message || t('common.saveFailed')
} finally {
saving.value = false
}
@@ -141,12 +143,12 @@ async function handleDelete() {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || '删除失败')
throw new Error(data.error || t('common.deleteFailed'))
}
showDeleteConfirm.value = false
await fetchUsers()
} catch (e: any) {
deleteError.value = e.message || '删除失败'
deleteError.value = e.message || t('common.deleteFailed')
} finally {
deleting.value = false
}
@@ -160,29 +162,29 @@ onMounted(() => {
<template>
<div class="max-w-6xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">用户管理</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('userManage.title') }}</h2>
<button
class="px-4 py-2 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
@click="showCreateModal = true"
>
新增用户
{{ t('userManage.addUser') }}
</button>
</div>
<p v-if="error" class="text-red-500 mb-4">{{ error }}</p>
<div v-if="loading" class="text-gray-500 dark:text-gray-400 py-8 text-center">加载中...</div>
<div v-if="loading" class="text-gray-500 dark:text-gray-400 py-8 text-center">{{ t('common.loading') }}</div>
<div v-else class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">ID</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">用户名</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">角色</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">状态</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">创建时间</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">操作</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.id') }}</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.username') }}</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.role') }}</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.status') }}</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.createdAt') }}</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
@@ -198,7 +200,7 @@ onMounted(() => {
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
:class="user.role === 'admin' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
{{ user.role === 'admin' ? t('common.admin') : t('common.normalUser') }}
</span>
</td>
<td class="px-4 py-3">
@@ -206,7 +208,7 @@ onMounted(() => {
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
:class="user.status === 1 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{ user.status === 1 ? '启用' : '禁用' }}
{{ user.status === 1 ? t('common.enabled') : t('common.disabled') }}
</span>
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{{ user.created_at }}</td>
@@ -216,13 +218,13 @@ onMounted(() => {
class="px-3 py-1 text-xs rounded-md font-medium text-sky-700 bg-sky-50 hover:bg-sky-100 dark:text-sky-400 dark:bg-sky-900/20 dark:hover:bg-sky-900/40 transition-colors"
@click="openEditModal(user)"
>
编辑
{{ t('common.edit') }}
</button>
<button
class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 dark:hover:bg-red-900/40 transition-colors"
@click="confirmDelete(user)"
>
删除
{{ t('common.delete') }}
</button>
</div>
</td>
@@ -233,10 +235,10 @@ onMounted(() => {
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showCreateModal = false">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">新增用户</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('userManage.addUser') }}</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">用户名</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('common.username') }}</label>
<input
v-model="createForm.username"
type="text"
@@ -244,7 +246,7 @@ onMounted(() => {
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">密码</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('common.password') }}</label>
<input
v-model="createForm.password"
type="password"
@@ -252,23 +254,23 @@ onMounted(() => {
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">角色</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('common.role') }}</label>
<select
v-model="createForm.role"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="user">{{ t('common.normalUser') }}</option>
<option value="admin">{{ t('common.admin') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">状态</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('common.status') }}</label>
<select
v-model.number="createForm.status"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
>
<option :value="1">启用</option>
<option :value="0">禁用</option>
<option :value="1">{{ t('common.enabled') }}</option>
<option :value="0">{{ t('common.disabled') }}</option>
</select>
</div>
<p v-if="createError" class="text-sm text-red-500">{{ createError }}</p>
@@ -278,14 +280,14 @@ onMounted(() => {
class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
@click="showCreateModal = false"
>
取消
{{ t('common.cancel') }}
</button>
<button
class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 disabled:opacity-50 transition-colors"
:disabled="creating"
@click="handleCreate"
>
{{ creating ? '创建中...' : '确定' }}
{{ creating ? t('userManage.creating') : t('common.confirm') }}
</button>
</div>
</div>
@@ -293,35 +295,35 @@ onMounted(() => {
<div v-if="showEditModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showEditModal = false">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">编辑用户 - {{ editingUser?.username }}</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('userManage.editUser', { username: editingUser?.username }) }}</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">角色</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('common.role') }}</label>
<select
v-model="editForm.role"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="user">{{ t('common.normalUser') }}</option>
<option value="admin">{{ t('common.admin') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">状态</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('common.status') }}</label>
<select
v-model.number="editForm.status"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
>
<option :value="1">启用</option>
<option :value="0">禁用</option>
<option :value="1">{{ t('common.enabled') }}</option>
<option :value="0">{{ t('common.disabled') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">新密码留空不修改</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('userManage.newPasswordOptional') }}</label>
<input
v-model="editForm.password"
type="password"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500 placeholder-gray-400 dark:placeholder-gray-500"
placeholder="留空则不修改密码"
:placeholder="t('userManage.newPasswordPlaceholder')"
/>
</div>
<p v-if="editError" class="text-sm text-red-500">{{ editError }}</p>
@@ -331,14 +333,14 @@ onMounted(() => {
class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
@click="showEditModal = false"
>
取消
{{ t('common.cancel') }}
</button>
<button
class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 disabled:opacity-50 transition-colors"
:disabled="saving"
@click="handleUpdate"
>
{{ saving ? '保存中...' : '保存' }}
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
@@ -346,9 +348,9 @@ onMounted(() => {
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showDeleteConfirm = false">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">确认删除</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ t('userManage.confirmDelete') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
确定要删除用户 <span class="font-medium text-gray-900 dark:text-white">{{ deletingUser?.username }}</span> 此操作不可撤销
{{ t('userManage.confirmDeleteMessage', { username: deletingUser?.username }) }}
</p>
<p v-if="deleteError" class="text-sm text-red-500 mb-4">{{ deleteError }}</p>
<div class="flex justify-end gap-3">
@@ -356,14 +358,14 @@ onMounted(() => {
class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
@click="showDeleteConfirm = false"
>
取消
{{ t('common.cancel') }}
</button>
<button
class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 transition-colors"
:disabled="deleting"
@click="handleDelete"
>
{{ deleting ? '删除中...' : '确认删除' }}
{{ deleting ? t('common.deleting') : t('userManage.confirmDeleteButton') }}
</button>
</div>
</div>
+77 -72
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { t } = useI18n()
const authHeader = () => ({ Authorization: `Bearer ${authStore.token}` })
interface Settings {
@@ -69,6 +71,7 @@ const loading = ref(false)
const error = ref('')
const saving = ref(false)
const saveMsg = ref('')
const saveOk = ref(false)
const form = ref<Settings>({
enabled: false,
@@ -84,7 +87,7 @@ const form = ref<Settings>({
async function fetchSettings() {
try {
const res = await fetch('/api/admin/vpn/settings', { headers: authHeader() })
if (!res.ok) throw new Error('加载失败')
if (!res.ok) throw new Error(t('common.loadFailed'))
const data = await res.json()
form.value = { ...data }
settings.value = { ...data }
@@ -96,7 +99,7 @@ async function fetchSettings() {
async function fetchStatus() {
try {
const res = await fetch('/api/admin/vpn/status', { headers: authHeader() })
if (!res.ok) throw new Error('加载失败')
if (!res.ok) throw new Error(t('common.loadFailed'))
status.value = await res.json()
} catch (e: any) {
error.value = e.message
@@ -106,7 +109,7 @@ async function fetchStatus() {
async function fetchReservations() {
try {
const res = await fetch('/api/admin/vpn/reservations', { headers: authHeader() })
if (!res.ok) throw new Error('加载失败')
if (!res.ok) throw new Error(t('common.loadFailed'))
const data = await res.json()
reservations.value = data.reservations
} catch (e: any) {
@@ -117,7 +120,7 @@ async function fetchReservations() {
async function fetchUsers() {
try {
const res = await fetch('/api/admin/users', { headers: authHeader() })
if (!res.ok) throw new Error('加载失败')
if (!res.ok) throw new Error(t('common.loadFailed'))
const data = await res.json()
users.value = data.users
} catch (e: any) {
@@ -128,7 +131,7 @@ async function fetchUsers() {
async function fetchDiag() {
try {
const res = await fetch('/api/admin/vpn/diag', { headers: authHeader() })
if (!res.ok) throw new Error('加载失败')
if (!res.ok) throw new Error(t('common.loadFailed'))
diag.value = await res.json()
} catch (e: any) {
error.value = e.message
@@ -145,11 +148,13 @@ async function handleSave() {
body: JSON.stringify(form.value),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '保存失败')
saveMsg.value = '保存成功'
if (!res.ok) throw new Error(data.error || t('common.saveFailed'))
saveMsg.value = t('vpn.saveSuccess')
saveOk.value = true
await Promise.all([fetchSettings(), fetchStatus(), fetchDiag()])
} catch (e: any) {
saveMsg.value = e.message
saveOk.value = false
} finally {
saving.value = false
}
@@ -162,7 +167,7 @@ const resvError = ref('')
async function handleAddResv() {
resvError.value = ''
if (!resvForm.value.user_id || (!resvForm.value.ip_address && !resvForm.value.ip_address6)) {
resvError.value = '请选择用户并至少填写一个 IP 地址'
resvError.value = t('vpn.selectUserAndIp')
return
}
try {
@@ -172,7 +177,7 @@ async function handleAddResv() {
body: JSON.stringify(resvForm.value),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '创建失败')
if (!res.ok) throw new Error(data.error || t('common.createFailed'))
showAddResv.value = false
resvForm.value = { user_id: 0, ip_address: '', ip_address6: '' }
await fetchReservations()
@@ -182,14 +187,14 @@ async function handleAddResv() {
}
async function handleDeleteResv(id: number) {
if (!confirm('确认删除该预留?')) return
if (!confirm(t('vpn.confirmDeleteReservation'))) return
try {
const res = await fetch(`/api/admin/vpn/reservations/${id}`, {
method: 'DELETE',
headers: authHeader(),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '删除失败')
if (!res.ok) throw new Error(data.error || t('common.deleteFailed'))
await fetchReservations()
} catch (e: any) {
error.value = e.message
@@ -216,12 +221,12 @@ onMounted(() => {
<template>
<div class="max-w-6xl mx-auto px-4 py-8 space-y-8">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">VPN 管理</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('vpn.title') }}</h2>
<button
class="px-4 py-2 rounded-lg font-medium text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
@click="refreshAll"
>
刷新
{{ t('vpn.refresh') }}
</button>
</div>
@@ -230,24 +235,24 @@ onMounted(() => {
<!-- 状态 -->
<div class="grid gap-4 sm:grid-cols-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs text-gray-500 dark:text-gray-400">服务状态</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('vpn.serviceStatus') }}</p>
<p class="text-xl font-bold mt-1" :class="status?.enabled ? 'text-green-600' : 'text-gray-400'">
{{ status?.enabled ? '运行中' : '已停止' }}
{{ status?.enabled ? t('vpn.running') : t('vpn.stopped') }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs text-gray-500 dark:text-gray-400">在线客户端</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('vpn.onlineClients') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white mt-1">{{ status?.online ?? '--' }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs text-gray-500 dark:text-gray-400">IP 用量</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('vpn.ipUsage') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white mt-1">{{ status?.used_ips ?? '--' }} / {{ status?.capacity ?? '--' }}</p>
</div>
</div>
<!-- 系统环境检测 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">系统环境检测</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('vpn.systemCheck') }}</h3>
<div class="grid gap-3 sm:grid-cols-2">
<!-- TUN 创建状态 -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
@@ -255,9 +260,9 @@ onMounted(() => {
<span v-else-if="checkTunCreate()" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">TUN 设备</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.tunDevice') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.tun_running">运行中 ({{ diag.tun_name }})</template>
<template v-if="diag?.tun_running">{{ t('vpn.tunRunning') }} ({{ diag.tun_name }})</template>
<template v-else>{{ diag?.tun_create }}</template>
</p>
</div>
@@ -267,8 +272,8 @@ onMounted(() => {
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span :class="diag?.is_root ? 'text-green-500' : 'text-red-500'" class="mt-0.5">{{ diag?.is_root ? '✓' : '✗' }}</span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Root 权限</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ diag?.is_root ? '以 root 运行' : '非 root 运行' }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.rootPermission') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ diag?.is_root ? t('vpn.runningAsRoot') : t('vpn.notRunningAsRoot') }}</p>
</div>
</div>
@@ -278,10 +283,10 @@ onMounted(() => {
<span v-else-if="diag?.has_cap_net_admin" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">CAP_NET_ADMIN</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.capNetAdmin') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.has_cap_net_admin === null">{{ diag?.cap_net_admin_note }}</template>
<template v-else>{{ diag?.has_cap_net_admin ? '已授权' : diag?.cap_net_admin_note || '未授权' }}</template>
<template v-else>{{ diag?.has_cap_net_admin ? t('vpn.authorized') : diag?.cap_net_admin_note || t('vpn.unauthorized') }}</template>
</p>
</div>
</div>
@@ -292,10 +297,10 @@ onMounted(() => {
<span v-else-if="diag?.ip_forward" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">IP 转发 (ip_forward)</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.ipForward') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.ip_forward === null">{{ diag?.ip_forward_note }}</template>
<template v-else>{{ diag?.ip_forward ? '已开启' : diag?.ip_forward_note || '未开启' }}</template>
<template v-else>{{ diag?.ip_forward ? t('common.enabled') : diag?.ip_forward_note || t('vpn.notEnabled') }}</template>
</p>
</div>
</div>
@@ -306,10 +311,10 @@ onMounted(() => {
<span v-else-if="diag?.masquerade" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">NAT MASQUERADE (IPv4)</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.natMasqueradeV4') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.masquerade === null">{{ diag?.masquerade_note }}</template>
<template v-else>{{ diag?.masquerade ? '已配置' : diag?.masquerade_note || '未配置' }}</template>
<template v-else>{{ diag?.masquerade ? t('vpn.configured') : diag?.masquerade_note || t('vpn.notConfigured') }}</template>
</p>
</div>
</div>
@@ -320,10 +325,10 @@ onMounted(() => {
<span v-else-if="diag?.ip6_forward" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">IPv6 转发 (forwarding)</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.ipv6Forward') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.ip6_forward === null">{{ diag?.ip6_forward_note }}</template>
<template v-else>{{ diag?.ip6_forward ? '已开启' : diag?.ip6_forward_note || '未开启' }}</template>
<template v-else>{{ diag?.ip6_forward ? t('common.enabled') : diag?.ip6_forward_note || t('vpn.notEnabled') }}</template>
</p>
</div>
</div>
@@ -334,10 +339,10 @@ onMounted(() => {
<span v-else-if="diag?.masquerade6" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">NAT MASQUERADE (IPv6)</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.natMasqueradeV6') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.masquerade6 === null">{{ diag?.masquerade6_note }}</template>
<template v-else>{{ diag?.masquerade6 ? '已配置' : diag?.masquerade6_note || '未配置' }}</template>
<template v-else>{{ diag?.masquerade6 ? t('vpn.configured') : diag?.masquerade6_note || t('vpn.notConfigured') }}</template>
</p>
</div>
</div>
@@ -346,55 +351,55 @@ onMounted(() => {
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span class="text-gray-400 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">平台</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.platform') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ diag?.platform }}</p>
</div>
</div>
</div>
<p v-if="diag && diag.platform !== 'linux'" class="text-xs text-gray-400 mt-3">
CAP_NET_ADMIN / ip_forward / MASQUERADE 检测仅适用于 Linux其他平台需手动确认网络配置
{{ t('vpn.nonLinuxNote') }}
</p>
</div>
<!-- 设置 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">隧道设置</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('vpn.tunnelSettings') }}</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">启用 VPN 服务</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.enableVpn') }}</label>
<select v-model="form.enabled" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="true">启用</option>
<option :value="false">停止</option>
<option :value="true">{{ t('vpn.enable') }}</option>
<option :value="false">{{ t('vpn.stop') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">子网 (CIDR)</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.subnet') }}</label>
<input v-model="form.subnet" type="text" placeholder="192.168.77.0/24" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IPv6 子网 (CIDR, 可选)</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.ipv6Subnet') }}</label>
<input v-model="form.subnet6" type="text" placeholder="fd00:dead:beef::/112" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">MTU</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.mtu') }}</label>
<input v-model.number="form.mtu" type="number" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">接口名 (留空自动)</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.interfaceName') }}</label>
<input v-model="form.interface_name" type="text" placeholder="tun0" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">允许客户端互通</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.allowClientToClient') }}</label>
<select v-model="form.allow_client_to_client" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="false">禁止</option>
<option :value="true">允许</option>
<option :value="false">{{ t('vpn.deny') }}</option>
<option :value="true">{{ t('vpn.allow') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">服务端配置 TUN IP</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.serverConfigTunIp') }}</label>
<select v-model="form.do_local_ip_config" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="true">自动配置</option>
<option :value="false">手动</option>
<option :value="true">{{ t('vpn.autoConfig') }}</option>
<option :value="false">{{ t('vpn.manual') }}</option>
</select>
</div>
</div>
@@ -404,27 +409,27 @@ onMounted(() => {
:disabled="saving"
@click="handleSave"
>
{{ saving ? '保存中...' : '保存设置' }}
{{ saving ? t('common.saving') : t('vpn.saveSettings') }}
</button>
<span v-if="saveMsg" :class="saveMsg === '保存成功' ? 'text-green-500' : 'text-red-500'" class="text-sm">{{ saveMsg }}</span>
<span v-if="saveMsg" :class="saveOk ? 'text-green-500' : 'text-red-500'" class="text-sm">{{ saveMsg }}</span>
</div>
</div>
<!-- 在线客户端 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">在线客户端</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('vpn.onlineClients') }}</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">用户</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IPv4</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IPv6</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">连接时间</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.user') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!status?.clients?.length">
<td colspan="4" class="px-6 py-6 text-center text-gray-400">暂无在线客户端</td>
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
</tr>
<tr v-for="(c, i) in status?.clients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
@@ -439,22 +444,22 @@ onMounted(() => {
<!-- 静态预留 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="flex items-center justify-between p-6 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">静态 IP 预留</h3>
<button class="px-3 py-1.5 text-xs rounded-md font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors" @click="showAddResv = true">新增预留</button>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('vpn.staticIpReservation') }}</h3>
<button class="px-3 py-1.5 text-xs rounded-md font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors" @click="showAddResv = true">{{ t('vpn.addReservation') }}</button>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">用户</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IPv4</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IPv6</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">创建时间</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">操作</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.user') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.createdAt') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!reservations.length">
<td colspan="5" class="px-6 py-6 text-center text-gray-400">暂无预留</td>
<td colspan="5" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noReservations') }}</td>
</tr>
<tr v-for="r in reservations" :key="r.id" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ r.username }}</td>
@@ -462,7 +467,7 @@ onMounted(() => {
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ r.ip_address6 || '—' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ r.created_at }}</td>
<td class="px-6 py-3">
<button class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors" @click="handleDeleteResv(r.id)">删除</button>
<button class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors" @click="handleDeleteResv(r.id)">{{ t('common.delete') }}</button>
</td>
</tr>
</tbody>
@@ -472,28 +477,28 @@ onMounted(() => {
<!-- 新增预留弹窗 -->
<div v-if="showAddResv" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showAddResv = false">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">新增 IP 预留</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('vpn.addIpReservation') }}</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">用户</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.user') }}</label>
<select v-model.number="resvForm.user_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="0" disabled>选择用户</option>
<option :value="0" disabled>{{ t('vpn.selectUser') }}</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IPv4 地址 (可选)</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.ipv4Address') }}</label>
<input v-model="resvForm.ip_address" type="text" placeholder="192.168.77.10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">IPv6 地址 (可选)</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.ipv6Address') }}</label>
<input v-model="resvForm.ip_address6" type="text" placeholder="fd00:dead:beef::10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<p v-if="resvError" class="text-sm text-red-500">{{ resvError }}</p>
</div>
<div class="flex justify-end gap-3 mt-6">
<button class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" @click="showAddResv = false">取消</button>
<button class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors" @click="handleAddResv">确定</button>
<button class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" @click="showAddResv = false">{{ t('common.cancel') }}</button>
<button class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors" @click="handleAddResv">{{ t('common.confirm') }}</button>
</div>
</div>
</div>