Files
opsx-vpn/frontend/src/views/settings/components/GatewaySettings.vue
T
Codex df3e119099 fix(frontend): align gateway fields with public_base API
Read configured_scheme/port and management_public_base; PUT domain
with scheme/port only as backend expects.
2026-10-26 11:37:00 +08:00

466 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="settings-column settings-column-wide">
<!-- 公网入口契约 -->
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header>
<div class="card-head">
<b>{{ $t('tls.publicEntryTitle') }}</b>
<el-button link :loading="tlsLoading" @click="refreshAll">{{ $t('tls.refresh') }}</el-button>
</div>
</template>
<el-alert type="info" :closable="false" :title="$t('tls.publicEntryDesc')" style="margin-bottom:16px" />
<el-form label-position="top" @submit.prevent="savePublicEntry">
<el-form-item :label="$t('tls.scheme')">
<el-radio-group v-model="entry.scheme" class="scheme-group" @change="onSchemeChange">
<el-radio-button value="https">HTTPS</el-radio-button>
<el-radio-button value="http">HTTP</el-radio-button>
</el-radio-group>
</el-form-item>
<el-alert
v-if="entry.scheme === 'http'"
type="warning"
:closable="false"
:title="$t('tls.httpRisk')"
style="margin-bottom:12px"
/>
<el-row :gutter="12">
<el-col :xs="24" :sm="16">
<el-form-item :label="$t('tls.host')" required>
<el-input v-model.trim="entry.host" placeholder="vpn.example.com" maxlength="253" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('tls.port')">
<el-input-number v-model="entry.port" :min="1" :max="65535" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('tls.publicBase')">
<el-input :model-value="publicBase" readonly>
<template #append>
<el-button @click="copyBase">{{ $t('common.copy') }}</el-button>
</template>
</el-input>
<div class="tls-hint">{{ $t('tls.publicBaseHint') }}</div>
</el-form-item>
<el-form-item :label="$t('tls.mode')">
<el-select v-model="entry.mode" style="width:100%">
<el-option value="autocert" :label="$t('tls.goMode')" />
<el-option value="nginx" :label="$t('tls.nginxMode')" />
</el-select>
</el-form-item>
<el-button type="primary" :loading="tlsSaving" @click="savePublicEntry">{{ $t('tls.saveEntry') }}</el-button>
</el-form>
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin-top:12px" />
<el-alert v-if="tlsStatus?.management_direct_public" type="warning" :closable="false" :title="$t('tls.directPortWarning')" style="margin-top:12px" />
</el-card>
<!-- 证书列表 -->
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header>
<div class="card-head">
<b>{{ $t('tls.certListTitle') }}</b>
<el-button type="primary" @click="openAddCert">{{ $t('tls.addCert') }}</el-button>
</div>
</template>
<el-table :data="certRows" size="default" v-loading="tlsLoading">
<el-table-column :label="$t('tls.host')" min-width="160">
<template #default="{ row }">{{ row.host || '—' }}</template>
</el-table-column>
<el-table-column :label="$t('tls.certSource')" width="120">
<template #default="{ row }">
<el-tag size="small" :type="row.source === 'upload' ? 'info' : 'success'">{{ $t(row.source === 'upload' ? 'tls.sourceUpload' : 'tls.sourceHttp01') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('tls.certificate')" width="110">
<template #default="{ row }">
<el-tag size="small" :type="row.valid ? 'success' : 'warning'">{{ row.valid ? $t('tls.valid') : row.pending ? $t('tls.pending') : $t('tls.notConfigured') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('tls.expires')" width="170">
<template #default="{ row }">{{ row.expires ? new Date(row.expires).toLocaleString() : '—' }}</template>
</el-table-column>
<el-table-column :label="$t('tls.renewal')" min-width="140">
<template #default="{ row }">
<span>{{ row.renewalLabel }}</span>
<el-tag v-if="row.renewalFailed" type="danger" size="small" style="margin-left:6px">{{ $t('tls.renewalFailed') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="120" fixed="right">
<template #default>
<el-button link type="primary" @click="openAddCert">{{ $t('tls.replaceCert') }}</el-button>
</template>
</el-table-column>
<template #empty><el-empty :description="$t('tls.noCerts')" /></template>
</el-table>
</el-card>
<!-- Nginx 准备(折叠) -->
<el-collapse v-if="entry.mode === 'nginx'">
<el-collapse-item :title="$t('tls.setupTitle')" name="setup">
<el-steps :active="nginxSetup?.site_stage === 'https' ? 3 : nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
<el-step :title="$t('tls.setupStepCheck')" />
<el-step :title="$t('tls.setupStepInstall')" />
<el-step :title="$t('tls.setupStepConfigure')" />
</el-steps>
<template v-if="nginxSetup">
<p class="tls-hint">{{ $t('tls.detectedSystem') }}:{{ nginxSetup.os }} {{ nginxSetup.version }} · {{ nginxSetup.package_manager || '—' }}</p>
<p class="tls-hint">Nginx:{{ nginxSetup.nginx_installed ? '✓' : '—' }} · Certbot:{{ nginxSetup.certbot_installed ? '✓' : '—' }} · HTTP-01 webroot:{{ nginxSetup.webroot_ready ? '✓' : '—' }}</p>
<p class="tls-hint">{{ $t('tls.siteStage') }}:{{ $t(`tls.stage_${nginxSetup.site_stage || 'none'}`) }}</p>
</template>
<el-alert type="info" :closable="false" :title="$t('tls.setupScope')" style="margin-bottom:12px" />
<el-button :loading="nginxSetupLoading" @click="loadNginxSetup">{{ $t('tls.refresh') }}</el-button>
<el-button type="primary" :loading="nginxInstalling" :disabled="!nginxSetup?.supported || !nginxSetup?.root || nginxSetup?.running || (nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready)" @click="initializeNginx">{{ $t('tls.setupButton') }}</el-button>
<pre v-if="nginxInstallLog" ref="nginxLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxInstallLog }}</pre>
<el-button style="margin-top:12px" :loading="nginxLoading" :disabled="!entry.host" @click="loadNginxPreview">{{ $t('tls.previewNginx') }}</el-button>
<template v-if="nginxPreview">
<p class="tls-hint">{{ $t('tls.httpSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.http_site }}</pre>
<p class="tls-hint">{{ $t('tls.httpsSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.https_site }}</pre>
</template>
</el-collapse-item>
</el-collapse>
<!-- 添加证书弹窗 -->
<el-dialog v-model="certDialog" :title="$t('tls.addCert')" width="640px" destroy-on-close @closed="resetCertForm">
<div class="method-cards">
<div class="method-card" :class="{ active: certForm.method === 'upload' }" @click="certForm.method = 'upload'">
<el-radio v-model="certForm.method" value="upload">{{ $t('tls.methodUpload') }}</el-radio>
</div>
<div class="method-card" :class="{ active: certForm.method === 'http01' }" @click="certForm.method = 'http01'">
<el-radio v-model="certForm.method" value="http01">{{ $t('tls.methodHttp01') }}</el-radio>
</div>
</div>
<template v-if="certForm.method === 'upload'">
<el-form label-position="top" style="margin-top:16px">
<el-form-item :label="$t('tls.certPem')" required>
<el-input v-model="certForm.certPem" type="textarea" :rows="6" :placeholder="$t('tls.certPemPlaceholder')" />
<el-upload :show-file-list="false" accept=".crt,.pem,.cer" :before-upload="(f) => readPemFile(f, 'certPem')">
<el-button class="upload-btn" style="width:100%;margin-top:8px">{{ $t('tls.uploadCertFile') }}</el-button>
</el-upload>
</el-form-item>
<el-form-item :label="$t('tls.keyPem')" required>
<el-input v-model="certForm.keyPem" type="textarea" :rows="6" :placeholder="$t('tls.keyPemPlaceholder')" />
<el-upload :show-file-list="false" accept=".key,.pem" :before-upload="(f) => readPemFile(f, 'keyPem')">
<el-button class="upload-btn" style="width:100%;margin-top:8px">{{ $t('tls.uploadKeyFile') }}</el-button>
</el-upload>
</el-form-item>
</el-form>
</template>
<template v-else>
<el-form label-position="top" style="margin-top:16px">
<el-form-item :label="$t('tls.host')" required>
<el-input v-model.trim="certForm.host" :placeholder="$t('tls.multiDomainHint')" />
</el-form-item>
<el-form-item :label="$t('tls.applyEmail')">
<el-input v-model.trim="certForm.email" placeholder="admin@example.com" />
</el-form-item>
<el-alert type="info" :closable="false" :title="$t('tls.http01Hint')" />
</el-form>
</template>
<template #footer>
<el-button @click="certDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="certSubmitting" @click="submitCert">{{ $t('common.submit') }}</el-button>
</template>
<pre v-if="nginxActivateLog" ref="nginxActivateLogView" class="nginx-preview" role="log" aria-live="polite" style="margin-top:12px">{{ nginxActivateLog }}</pre>
</el-dialog>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api, { auth } from '../../../api'
import { buildPublicBase, defaultPort } from '../../../utils/publicBase'
const { t } = useI18n()
const tlsStatus = ref(null)
const tlsLoading = ref(false)
const tlsSaving = ref(false)
const nginxLoading = ref(false)
const nginxPreview = ref(null)
const nginxSetup = ref(null)
const nginxSetupLoading = ref(false)
const nginxInstalling = ref(false)
const nginxInstallLog = ref('')
const nginxLogView = ref(null)
const nginxActivateLog = ref('')
const nginxActivateLogView = ref(null)
const certDialog = ref(false)
const certSubmitting = ref(false)
const entry = reactive({
scheme: 'https',
host: '',
port: 443,
mode: 'autocert',
})
const certForm = reactive({
method: 'upload',
certPem: '',
keyPem: '',
host: '',
email: '',
})
const publicBase = computed(() => {
const local = buildPublicBase(entry)
const remote = tlsStatus.value?.configured_public_base || tlsStatus.value?.public_base || ''
// Prefer live form preview while editing; fall back to server after load.
return local || remote
})
const certRows = computed(() => {
const s = tlsStatus.value
if (!s) return []
const host = s.configured_domain || s.domain || entry.host
if (!host && !s.certificate_valid && !s.enabled && !s.certificate_cached) return []
const source = s.cert_source || (s.mode === 'nginx' || s.enabled ? 'http01' : (s.certificate_cached ? 'upload' : 'http01'))
return [{
host,
source,
valid: !!s.certificate_valid,
pending: !!s.enabled && !s.certificate_valid,
expires: s.not_after || null,
renewalFailed: !!s.renewal_failed,
renewalLabel: s.enabled ? t('tls.automatic') : s.mode === 'nginx' ? t('tls.externalRenewal') : '—',
}]
})
function onSchemeChange() {
entry.port = defaultPort(entry.scheme)
}
function resetCertForm() {
certForm.method = 'upload'
certForm.certPem = ''
certForm.keyPem = ''
certForm.host = entry.host
certForm.email = ''
nginxActivateLog.value = ''
}
function openAddCert() {
resetCertForm()
certDialog.value = true
}
function readPemFile(file, field) {
const reader = new FileReader()
reader.onload = () => { certForm[field] = String(reader.result || '') }
reader.readAsText(file)
return false
}
async function copyBase() {
if (!publicBase.value) return
try {
await navigator.clipboard.writeText(publicBase.value)
ElMessage.success(t('common.copied'))
} catch { ElMessage.error(t('common.copyFailed')) }
}
async function refreshAll() {
await Promise.all([loadTLS(), loadNginxSetup()])
}
async function loadNginxSetup() {
nginxSetupLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-setup')
nginxSetup.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxSetupLoading.value = false }
}
async function initializeNginx() {
nginxInstalling.value = true
nginxInstallLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-setup', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` },
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxInstallLog.value = (nginxInstallLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxLogView.value) nginxLogView.value.scrollTop = nginxLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.setupComplete'))
} catch (e) {
nginxInstallLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.setupFailed'))
} finally {
nginxInstalling.value = false
await loadNginxSetup()
}
}
async function loadTLS() {
tlsLoading.value = true
try {
const { data } = await api.get('/system/tls')
tlsStatus.value = data
entry.host = data.configured_domain || data.domain || data.host || ''
const scheme = data.configured_scheme || data.scheme || 'https'
entry.scheme = scheme === 'http' ? 'http' : 'https'
const port = Number(data.configured_port || data.port)
entry.port = port > 0 ? port : defaultPort(entry.scheme)
entry.mode = data.configured_mode || data.mode || 'autocert'
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { tlsLoading.value = false }
}
async function savePublicEntry() {
if (!entry.host) return ElMessage.warning(t('tls.hostRequired'))
tlsSaving.value = true
try {
const { data: domainRes } = await api.put('/system/tls/domain', {
domain: entry.host,
scheme: entry.scheme,
port: entry.port,
})
tlsStatus.value = domainRes.status
const { data: modeRes } = await api.put('/system/tls/mode', { mode: entry.mode })
tlsStatus.value = modeRes.status || domainRes.status
nginxPreview.value = null
ElMessage.success((domainRes.restart_required || modeRes.restart_required) ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSaving.value = false }
}
async function loadNginxPreview() {
nginxLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-preview')
nginxPreview.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxLoading.value = false }
}
async function streamActivate() {
nginxActivateLog.value = ''
let completed = false
const response = await fetch('/api/system/tls/nginx-activate', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` },
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxActivateLog.value = (nginxActivateLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxActivateLogView.value) nginxActivateLogView.value.scrollTop = nginxActivateLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
}
async function submitCert() {
certSubmitting.value = true
try {
if (certForm.method === 'upload') {
if (!certForm.certPem.trim() || !certForm.keyPem.trim()) {
ElMessage.warning(t('tls.certKeyRequired'))
return
}
await api.post('/system/tls/certificate', {
certificate_pem: certForm.certPem,
private_key_pem: certForm.keyPem,
host: entry.host || certForm.host,
})
ElMessage.success(t('tls.certUploaded'))
certDialog.value = false
await loadTLS()
return
}
// HTTP-01: ensure domain saved, nginx mode, then activate
const host = (certForm.host || entry.host || '').trim()
if (!host) return ElMessage.warning(t('tls.hostRequired'))
entry.host = host.split(/[\s,]+/)[0]
await api.put('/system/tls/domain', {
domain: entry.host, scheme: entry.scheme, port: entry.port,
})
if (entry.mode !== 'nginx') {
await api.put('/system/tls/mode', { mode: 'nginx' })
entry.mode = 'nginx'
}
await streamActivate()
ElMessage.success(t('tls.siteReady'))
certDialog.value = false
await refreshAll()
} catch (e) {
const msg = e.response?.data?.error || e.message || t('tls.siteFailed')
if (certForm.method === 'upload' && (e.response?.status === 404 || e.response?.status === 405)) {
ElMessage.error(t('tls.uploadApiPending'))
} else {
ElMessage.error(msg)
}
} finally { certSubmitting.value = false }
}
onMounted(() => { if (!auth.mustChange) refreshAll() })
</script>
<style scoped>
.settings-column { max-width: 920px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; margin-top: 6px; }
.nginx-preview { max-height: 280px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; margin-top: 8px; }
.scheme-group { width: 100%; }
.scheme-group :deep(.el-radio-button) { flex: 1; }
.scheme-group :deep(.el-radio-button__inner) { width: 100%; }
.method-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.method-card {
border: 1px solid var(--el-border-color); border-radius: 10px; padding: 14px 16px; cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.method-card.active { border-color: var(--el-color-primary); box-shadow: 0 0 0 1px var(--el-color-primary) inset; }
.upload-btn { color: var(--el-color-primary); border-style: dashed; }
</style>