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:
+2
-2
@@ -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>
|
||||
|
||||
Generated
+89
@@ -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",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.38",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: '一键部署、自动配置,无需复杂网络知识即可上线。',
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user