Files
nginxserver/nginx-manager-frontend/src/views/NginxForwarding.vue
2026-07-16 13:03:11 +08:00

401 lines
15 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="nginx-forwarding-container">
<!-- 顶部标题栏 -->
<div class="page-header">
<h1>Nginx转发管理</h1>
<p class="subtitle">管理 Nginx 反向代理规则与 HTTPS 证书</p>
</div>
<!-- 操作区域 -->
<div class="toolbar">
<div class="toolbar-left">
<el-tag type="warning" size="large">
<el-icon style="margin-right: 4px"><InfoFilled /></el-icon>
新增/修改/删除请在宝塔面板上操作
</el-tag>
</div>
<div class="toolbar-right">
<el-input
v-model="searchKeyword"
placeholder="搜索域名..."
clearable
style="width: 240px"
:prefix-icon="Search"
/>
<el-select v-model="protocolFilter" placeholder="协议筛选" clearable style="width: 120px; margin-left: 10px">
<el-option label="全部" value="" />
<el-option label="HTTP" value="http" />
<el-option label="HTTPS" value="https" />
</el-select>
<el-button @click="fetchRules" :loading="loading" style="margin-left: 10px">
<el-icon><Refresh /></el-icon>
</el-button>
</div>
</div>
<!-- 桌面端表格视图 -->
<div v-if="!isMobile" class="table-container">
<el-table
:data="filteredRules"
v-loading="loading"
stripe
border
style="width: 100%"
empty-text="暂无转发规则点击 [新增规则] 添加"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="domain" label="域名" min-width="200">
<template #default="{ row }">
<span class="domain-text">{{ row.domain }}</span>
</template>
</el-table-column>
<el-table-column prop="protocol" label="协议类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.protocol === 'https' ? 'success' : 'info'" effect="light">
{{ row.protocol.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="target" label="转发目标地址" min-width="280">
<template #default="{ row }">
<code class="target-address">{{ row.target }}</code>
</template>
</el-table-column>
<el-table-column prop="sslCert" label="SSL证书" min-width="120" align="center">
<template #default="{ row }">
<template v-if="row.sslCert">
<el-tooltip :content="row.sslCert" placement="top">
<el-tag type="warning" effect="plain" size="small">已配置</el-tag>
</el-tooltip>
</template>
<template v-else>
<span style="color: #c0c4cc">-</span>
</template>
</template>
</el-table-column>
<el-table-column prop="createdBy" label="创建人" width="100" align="center">
<template #default="{ row }">
<span>{{ row.createdBy || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160" align="center">
<template #default="{ row }">
<span>{{ formatDate(row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<template v-if="isSystemDomain(row.domain)">
<el-tag type="info" size="small">系统域名</el-tag>
</template>
<template v-else>
<el-button type="primary" link size="small" @click="showDetailDialog(row)">
<el-icon><View /></el-icon> 查看
</el-button>
</template>
</template>
</el-table-column>
</el-table>
</div>
<!-- 移动端卡片视图 -->
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
<div v-if="filteredRules.length === 0" class="mobile-empty">暂无转发规则</div>
<div v-for="row in filteredRules" :key="row.id" class="mobile-card">
<div class="card-top">
<el-tag :type="row.protocol === 'https' ? 'success' : 'info'" size="small" effect="dark">
{{ row.protocol.toUpperCase() }}
</el-tag>
<span class="card-domain">{{ row.domain }}</span>
<el-tag v-if="row.sslCert" type="warning" size="small" effect="plain">SSL</el-tag>
</div>
<div class="card-body">
<div class="card-row">
<span class="card-label">转发目标</span>
<code class="card-value">{{ row.target }}</code>
</div>
<div class="card-row">
<span class="card-label">创建人</span>
<span style="font-size:13px;color:#606266;">{{ row.createdBy || '-' }}</span>
</div>
<div class="card-row">
<span class="card-label">创建时间</span>
<span style="font-size:12px;color:#909399;">{{ formatDate(row.createdAt) }}</span>
</div>
</div>
<div class="card-actions">
<template v-if="!isSystemDomain(row.domain)">
<el-button type="primary" size="small" @click="showDetailDialog(row)">
<el-icon><View /></el-icon> 查看详情
</el-button>
</template>
</div>
</div>
</div>
<!-- 详情查看对话框只读 -->
<el-dialog
v-model="dialogVisible"
:title="'转发规则详情 — ' + detailData.domain"
width="900px"
:close-on-click-modal="false"
destroy-on-close
>
<div v-loading="detailLoading" style="min-height: 300px">
<!-- 基本信息 -->
<el-descriptions :column="2" border size="default" label-width="100px" style="margin-bottom: 16px">
<el-descriptions-item label="域名">
<span class="detail-domain">{{ detailData.domain }}</span>
</el-descriptions-item>
<el-descriptions-item label="协议类型">
<el-tag :type="detailData.protocol === 'https' ? 'success' : 'info'" effect="light">
{{ detailData.protocol?.toUpperCase() }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="转发目标" :span="2">
<code class="target-address">{{ detailData.target }}</code>
</el-descriptions-item>
</el-descriptions>
<!-- SSL证书信息 -->
<template v-if="detailData.protocol === 'https' || detailData.sslStatus">
<el-divider content-position="left">
<el-icon><Lock /></el-icon> SSL 证书
</el-divider>
<el-descriptions :column="2" border size="small" style="margin-bottom: 16px">
<el-descriptions-item label="证书状态">
<el-tag v-if="detailData.sslStatus === 'valid'" type="success" size="small">有效</el-tag>
<el-tag v-else-if="detailData.sslStatus === 'none'" type="info" size="small">未配置</el-tag>
<el-tag v-else-if="detailData.sslStatus === 'error'" type="danger" size="small">获取失败</el-tag>
<el-tag v-else type="warning" size="small">{{ detailData.sslStatus || '未知' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="到期时间">
<span v-if="detailData.sslExpireDate">{{ formatDate(detailData.sslExpireDate) }}</span>
<span v-else style="color:#C0C4CC">-</span>
</el-descriptions-item>
<el-descriptions-item label="剩余天数" v-if="detailData.sslDaysLeft !== undefined">
<span :style="{ color: detailData.sslDaysLeft <= 15 ? '#F56C6C' : detailData.sslDaysLeft <= 30 ? '#E6A23C' : '#67C23A', fontWeight: 'bold' }">
{{ detailData.sslDaysLeft }}
</span>
</el-descriptions-item>
<el-descriptions-item label="验证方式" v-if="detailData.sslAuthType">
<el-tag size="small" effect="plain">{{ detailData.sslAuthType?.toUpperCase() }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</template>
<!-- Nginx 配置文件内容 -->
<el-divider content-position="left">
<el-icon><Document /></el-icon> 配置文件
</el-divider>
<el-input
:model-value="detailData.configContent || '加载中...'"
type="textarea"
:rows="16"
readonly
style="font-family: 'Consolas', 'Courier New', monospace; font-size: 13px;"
/>
</div>
<template #footer>
<el-button @click="dialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, View, InfoFilled, Lock, Document } from '@element-plus/icons-vue'
import { getRules, getRuleConfig } from '../api/nginxApi'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
// ─── 状态 ───────────────────────────────────
const loading = ref(false)
const dialogVisible = ref(false)
const detailLoading = ref(false)
const rules = ref([])
const searchKeyword = ref('')
const protocolFilter = ref('')
const detailData = reactive({
domain: '',
protocol: '',
target: '',
configContent: '',
sslStatus: '',
sslExpireDate: '',
sslDaysLeft: null,
sslAuthType: ''
})
// ─── 域名判断 ────────────────────────────────
const SYSTEM_DOMAINS = ['zgitm.com', 'www.zgitm.com']
function isSystemDomain(domain) {
return SYSTEM_DOMAINS.includes(domain?.toLowerCase())
}
/** 格式化日期为本地字符串 */
function formatDate(dateStr) {
if (!dateStr) return '-'
try {
// 处理 ISO 格式或时间戳
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return d.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
})
} catch {
return dateStr || '-'
}
}
// ─── 计算属性 ────────────────────────────────
const filteredRules = computed(() => {
let result = rules.value
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(r => r.domain.toLowerCase().includes(keyword))
}
if (protocolFilter.value) {
result = result.filter(r => r.protocol === protocolFilter.value)
}
return result
})
// ─── 方法 ───────────────────────────────────
/** 获取规则列表 */
async function fetchRules() {
loading.value = true
try {
const res = await getRules()
rules.value = res.data || []
} catch (e) {
ElMessage.error('获取规则列表失败:' + e.message)
} finally {
loading.value = false
}
}
/** 显示详情对话框 — 调用后端获取配置文件 + SSL信息 */
async function showDetailDialog(row) {
dialogVisible.value = true
detailLoading.value = true
// 先填充基本信息
detailData.domain = row.domain
detailData.protocol = row.protocol
detailData.target = row.target
detailData.configContent = ''
detailData.sslStatus = ''
detailData.sslExpireDate = ''
detailData.sslDaysLeft = null
detailData.sslAuthType = ''
try {
const res = await getRuleConfig(row.id)
const data = res.data?.data || res.data
if (data) {
detailData.configContent = data.configContent || ''
detailData.sslStatus = data.sslStatus || ''
detailData.sslExpireDate = data.sslExpireDate || ''
detailData.sslDaysLeft = data.sslDaysLeft
detailData.sslAuthType = data.sslAuthType || ''
}
} catch (e) {
ElMessage.warning('获取详情失败:' + e.message)
detailData.configContent = '获取配置文件失败'
} finally {
detailLoading.value = false
}
}
// ─── 生命周期 ────────────────────────────────
onMounted(() => {
fetchRules()
})
</script>
<style scoped>
.nginx-forwarding-container { max-width: 1200px; margin: 0 auto; padding: 24px; }
.page-header { margin-bottom: 20px; }
.page-header h1 { margin: 0; font-size: 24px; color: #303133; }
.page-header .subtitle { margin: 4px 0 0; color: #909399; font-size: 14px; }
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 10px;
}
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.table-container {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.domain-text { font-weight: 500; color: #303133; }
.target-address { background: #f5f7fa; padding: 2px 8px; border-radius: 4px; font-size: 13px; color: #e6a23c; }
.detail-domain { font-size: 16px; font-weight: 600; color: #303133; }
/* ════════════════════════════════════════════════ */
/* 移动端 (≤768px) */
/* ════════════════════════════════════════════════ */
@media (max-width: 768px) {
.nginx-forwarding-container { padding: 8px; }
.page-header h1 { font-size: 18px; }
.page-header .subtitle { font-size: 12px; }
.toolbar { flex-direction: column; align-items: stretch; }
.toolbar-left, .toolbar-right { width: 100%; }
.toolbar-right :deep(.el-input) { width: 100% !important; }
.toolbar-right :deep(.el-select) { width: 100% !important; margin-left: 0 !important; }
/* 隐藏桌面表格,改用卡片 */
.table-container { display: none; }
/* 移动端卡片列表 */
.mobile-card-list { display: flex; flex-direction: column; gap: 10px; }
.mobile-empty { text-align: center; color: #C0C4CC; padding: 40px 0; font-size: 14px; }
.mobile-card {
background: #fff; border-radius: 10px; padding: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.card-top {
display: flex; align-items: center; gap: 10px;
padding-bottom: 10px; border-bottom: 1px solid #f0f0f0;
}
.card-domain { font-size: 15px; font-weight: 600; color: #303133; flex:1; word-break: break-all; }
.card-body { padding: 10px 0; }
.card-row { display: flex; align-items: flex-start; margin-bottom: 6px; }
.card-label { color: #909399; font-size: 12px; width: 56px; flex-shrink: 0; padding-top: 1px; }
.card-value { font-size: 13px; color: #E6A23C; background: #fdf6ec; padding: 2px 6px; border-radius: 4px; word-break: break-all; }
.card-actions {
display: flex; gap: 8px; padding-top: 10px;
border-top: 1px solid #f0f0f0; justify-content: flex-end;
}
.card-actions .el-button { min-height: 36px; min-width: 64px; }
/* 对话框全屏 */
:deep(.el-dialog) { width: 95% !important; margin: 2vh auto !important; }
:deep(.el-dialog__body) { padding: 12px 8px; }
:deep(.el-form-item__label) { width: 70px !important; font-size: 13px; }
}
</style>