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:
@@ -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