189 lines
9.3 KiB
Vue
189 lines
9.3 KiB
Vue
<template>
|
|
<el-card class="page-card" shadow="never" v-loading="loading">
|
|
<template #header>
|
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
<b>{{ $t('tunnelEditor.title') }}</b>
|
|
<el-button @click="router.push('/tunnels')">{{ $t('tunnelEditor.backToList') }}</el-button>
|
|
</div>
|
|
</template>
|
|
|
|
<el-alert type="info" :closable="false" style="margin-bottom:18px"
|
|
:title="$t('tunnelEditor.sensitiveFieldHint')" />
|
|
|
|
<el-form v-if="form" label-width="170px" label-position="left" style="max-width:720px">
|
|
<el-form-item :label="$t('tunnelEditor.tunnelName')" required>
|
|
<el-input v-model="form.name" />
|
|
</el-form-item>
|
|
<el-form-item :label="$t('tunnelEditor.protocolType')">
|
|
<el-tag :color="protocolColor(protocol)" effect="dark" style="border:none">{{ $t(protocolDisplay(protocol)) }}</el-tag>
|
|
</el-form-item>
|
|
<el-divider v-if="isOpenVPN" content-position="left" class="config-section-divider">{{ $t('tunnelEditor.coreOptions') }}</el-divider>
|
|
<el-form-item v-for="field in coreFields" :key="field.key" :label="$t(field.label)" :required="field.required">
|
|
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
|
|
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
|
|
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
|
|
<el-option v-for="option in field.options" :key="optionValue(option)" :label="$t(optionLabel(option))" :value="optionValue(option)" :disabled="option.disabled" />
|
|
</el-select>
|
|
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
|
|
show-password :placeholder="field.placeholder || $t('tunnelEditor.keepOriginal')" />
|
|
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
|
|
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
|
|
</el-form-item>
|
|
<OpenVPNOIDCGuide v-if="isOpenVPN && form.config.auth_mode === 'oidc'" :config="form.config" :editing="originalAuthMode === 'oidc'" />
|
|
<template v-if="isOpenVPN">
|
|
<el-form-item :label="$t('tunnelEditor.trafficMode')">
|
|
<el-radio-group v-model="trafficMode">
|
|
<el-radio-button value="gateway">{{ $t('tunnelEditor.trafficGateway') }}</el-radio-button>
|
|
<el-radio-button value="static">{{ $t('tunnelEditor.trafficStatic') }}</el-radio-button>
|
|
</el-radio-group>
|
|
<div class="field-tip">{{ $t(`tunnelEditor.traffic${trafficMode[0].toUpperCase()}${trafficMode.slice(1)}Tip`) }}</div>
|
|
</el-form-item>
|
|
<el-form-item v-if="trafficMode === 'static'" :label="$t('protocols.openvpn.fields.push_routes.label')">
|
|
<RouteListInput ref="routeListRef" v-model="form.config.push_routes" />
|
|
<div class="field-tip">{{ $t('protocols.openvpn.fields.push_routes.tip') }}</div>
|
|
</el-form-item>
|
|
</template>
|
|
<el-collapse v-if="advancedFields.length" v-model="advancedOpen" class="advanced-options">
|
|
<el-collapse-item :title="$t('tunnelEditor.advancedOptions')" name="advanced">
|
|
<el-form-item v-for="field in advancedFields" :key="field.key" :label="$t(field.label)" :required="field.required">
|
|
<el-input-number v-if="field.type === 'number'" v-model="form.config[field.key]" :min="0" :max="65535" />
|
|
<el-switch v-else-if="field.type === 'switch'" v-model="form.config[field.key]" />
|
|
<el-select v-else-if="field.type === 'select'" v-model="form.config[field.key]" style="width:100%">
|
|
<el-option v-for="option in field.options" :key="optionValue(option)" :label="$t(optionLabel(option))" :value="optionValue(option)" :disabled="option.disabled" />
|
|
</el-select>
|
|
<el-input v-else-if="field.type === 'password'" v-model="form.config[field.key]" type="password"
|
|
show-password :placeholder="field.placeholder || $t('tunnelEditor.keepOriginal')" />
|
|
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
|
|
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
|
|
</el-form-item>
|
|
</el-collapse-item>
|
|
</el-collapse>
|
|
<el-form-item :label="$t('tunnelEditor.expireTime')">
|
|
<el-date-picker v-model="form.expire_at" type="datetime" clearable :placeholder="$t('tunnelEditor.expirePlaceholder')" style="width:100%" />
|
|
</el-form-item>
|
|
<el-form-item :label="$t('tunnelEditor.autoStart')"><el-switch v-model="form.auto_start" /></el-form-item>
|
|
<el-form-item :label="$t('common.remark')"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item>
|
|
<el-form-item>
|
|
<el-button type="primary" :loading="saving" @click="save">{{ $t('tunnelEditor.saveConfig') }}</el-button>
|
|
<el-button @click="router.push('/tunnels')">{{ $t('common.cancel') }}</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { ElMessage } from 'element-plus'
|
|
import { useI18n } from 'vue-i18n'
|
|
import api from '../api'
|
|
import RouteListInput from '../components/RouteListInput.vue'
|
|
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
|
|
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
|
|
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const protocol = ref('')
|
|
const originalAuthMode = ref('')
|
|
const form = ref(null)
|
|
const advancedOpen = ref([])
|
|
const routeListRef = ref(null)
|
|
|
|
const fields = computed(() => PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)
|
|
const visibleFields = computed(() => fields.value.filter(field => {
|
|
if (!field.showIf) return true
|
|
return Object.entries(field.showIf).every(([key, value]) => Array.isArray(value)
|
|
? value.includes(form.value.config[key])
|
|
: form.value.config[key] === value)
|
|
}))
|
|
const isOpenVPN = computed(() => protocol.value === 'openvpn')
|
|
const openVPNCoreKeys = new Set(['public_endpoint', 'port', 'proto', 'auth_mode', 'mfa_mode'])
|
|
const openVPNTrafficKeys = new Set(['redirect_gateway', 'push_routes', 'enable_nat'])
|
|
const trafficMode = computed({
|
|
get() {
|
|
const cfg = form.value?.config || {}
|
|
return cfg.redirect_gateway === false ? 'static' : 'gateway'
|
|
},
|
|
set(mode) {
|
|
if (!form.value) return
|
|
if (mode === 'gateway') {
|
|
form.value.config.redirect_gateway = true
|
|
form.value.config.enable_nat = true
|
|
form.value.config.push_routes = ''
|
|
} else if (mode === 'static') {
|
|
form.value.config.redirect_gateway = false
|
|
form.value.config.enable_nat = false
|
|
}
|
|
},
|
|
})
|
|
const coreFields = computed(() => isOpenVPN.value
|
|
? visibleFields.value.filter(field => openVPNCoreKeys.has(field.key))
|
|
: visibleFields.value.filter(field => field.group !== 'advanced' && field.group !== 'crypto'))
|
|
const advancedFields = computed(() => {
|
|
if (!isOpenVPN.value) return visibleFields.value.filter(field => field.group === 'advanced' || field.group === 'crypto')
|
|
return visibleFields.value.filter(field => !openVPNCoreKeys.has(field.key) && !openVPNTrafficKeys.has(field.key))
|
|
})
|
|
|
|
function parseConfig(raw) {
|
|
try { return JSON.parse(raw || '{}') } catch { return {} }
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
const { data } = await api.get(`/tunnels/${route.params.id}`)
|
|
const tunnel = data.tunnel
|
|
protocol.value = tunnel.protocol
|
|
const config = parseConfig(tunnel.config)
|
|
originalAuthMode.value = config.auth_mode || ''
|
|
for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) {
|
|
if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default
|
|
}
|
|
form.value = {
|
|
name: tunnel.name,
|
|
remark: tunnel.remark,
|
|
auto_start: tunnel.auto_start,
|
|
expire_at: tunnel.expire_at,
|
|
config,
|
|
}
|
|
} catch (error) {
|
|
ElMessage.error(error.response?.data?.error || t('tunnelEditor.loadFailed'))
|
|
router.push('/tunnels')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
|
|
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
|
|
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' ? [] : ['oidc_client_secret'])]) {
|
|
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
|
|
}
|
|
if (!form.value.config.oidc_http_port) return ElMessage.warning(t('oidcGuide.portLookupFailed'))
|
|
}
|
|
trafficMode.value = trafficMode.value
|
|
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
|
|
saving.value = true
|
|
try {
|
|
await api.put(`/tunnels/${route.params.id}`, form.value)
|
|
ElMessage.success(t('tunnelEditor.configSaved'))
|
|
router.push('/tunnels')
|
|
} catch (error) {
|
|
ElMessage.error(error.response?.data?.error || t('common.saveFailed'))
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.field-tip { margin-top: 4px; color: #909399; font-size: 12px; line-height: 1.4; }
|
|
.config-section-divider { margin: 4px 0 18px; }
|
|
.advanced-options { margin: 12px 0 18px; }
|
|
</style>
|