feat(cloudflare): 完成隧道接入、运维与 P0 体验优化

This commit is contained in:
2026-10-01 11:32:54 +08:00
parent 36d8b1e464
commit 83cc9d504b
48 changed files with 5383 additions and 201 deletions
+201 -17
View File
@@ -104,7 +104,7 @@
:data="tableRules"
:bordered="false"
size="small"
:scroll-x="canReorder ? 1460 : 1420"
:scroll-x="canReorder ? 1610 : 1570"
:row-key="(r: ProxyRule) => r.id"
:row-props="rowProps"
/>
@@ -130,6 +130,28 @@
</div>
</template>
<n-modal v-model:show="discoveryVisible" preset="card" :style="{ width: 'min(680px, 96vw)' }" title="发现本机服务">
<div class="discovery-list">
<div v-for="item in discoveryResults" :key="item.platform + item.port" class="discovery-item">
<div class="discovery-item__main">
<strong>{{ item.name }}</strong>
<span class="mono">{{ item.upstream }}</span>
<n-tag size="small" :type="item.detected ? 'success' : 'default'" :bordered="false">
{{ item.detected ? '检测到服务' : '端口未开放' }}
</n-tag>
</div>
<n-button size="small" type="primary" :disabled="!item.detected" @click="publishDiscovered(item)">
发布
</n-button>
</div>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="discoveryVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="showDetailPanel" :mask-closable="true" transform-origin="center">
<div v-if="selectedRule" class="proxy-detail-modal">
<div class="proxy-detail-modal__header">
@@ -206,6 +228,10 @@
</n-tag>
</span>
</div>
<div class="overview-strip__item">
<span class="overview-strip__label">出口</span>
<span class="overview-strip__value">{{ exitLabel(selectedRule) }}</span>
</div>
</div>
<div class="overview-security">
@@ -453,6 +479,33 @@
</div>
</n-form-item>
<n-form-item label="出口">
<div class="field-stack">
<div class="exit-options">
<n-checkbox v-model:checked="form.exit_local">本机 Nginx</n-checkbox>
<n-checkbox v-model:checked="form.exit_cloudflare">Cloudflare 隧道</n-checkbox>
</div>
<p class="field-hint">可多选;关闭本机出口后,该规则只通过 Cloudflare 隧道访问。</p>
</div>
</n-form-item>
<n-form-item v-if="form.exit_cloudflare" label="Cloudflare 隧道">
<div class="field-stack">
<n-select
v-model:value="form.cf_tunnel_id"
:options="cfTunnelOptions"
placeholder="选择自动托管隧道"
/>
<p v-if="cfTunnelOptions.length === 0" class="field-hint field-hint--warning">
还没有自动托管隧道,请先到
<router-link to="/cloudflare">Cloudflare 隧道</router-link>
新建并开启「自动托管」。
</p>
<p class="field-hint">域名的根 Zone 必须已加入当前 Cloudflare 账号,且 API Token 的 Zone Resources 包含该域名。</p>
<p v-if="!form.exit_local" class="field-hint">仅走 Cloudflare 出口时,HTTPS 由 Cloudflare 边缘提供,不需要在 Fonu 申请本机证书。</p>
</div>
</n-form-item>
<div class="form-switch-list form-switch-list--compact">
<div class="form-switch-row">
<div class="form-switch-row__text">
@@ -859,7 +912,7 @@ import {
WarningOutline,
} from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
@@ -876,6 +929,9 @@ import { renderTableRowActions } from '../utils/tableActions'
const message = useMessage()
const dialog = useDialog()
const rules = ref<ProxyRule[]>([])
const cfTunnels = ref<CfTunnel[]>([])
const discoveryVisible = ref(false)
const discoveryResults = ref<DiscoveredService[]>([])
const loading = ref(false)
const saving = ref(false)
const loadError = ref('')
@@ -930,6 +986,15 @@ const httpsOptions = [
{ label: 'HTTP', value: 'off' },
]
const cfTunnelOptions = computed(() =>
cfTunnels.value
.filter((tunnel) => tunnel.managed)
.map((tunnel) => ({
label: `${tunnel.name}${tunnel.status === 'connected' ? '(已连接)' : ''}`,
value: tunnel.id,
})),
)
const defaultSecurityForm = () => ({
ip_blacklist_text: '',
ip_blacklist_mode: false,
@@ -960,6 +1025,9 @@ const form = reactive({
http_redirect: true,
enabled: true,
name: '',
exit_local: true,
exit_cloudflare: false,
cf_tunnel_id: null as number | null,
...defaultSecurityForm(),
})
@@ -1374,6 +1442,17 @@ function ruleTitle(rule: ProxyRule): string {
return ruleName(rule)
}
function ruleExits(rule: ProxyRule): string[] {
return rule.exits?.length ? rule.exits : ['local']
}
function exitLabel(rule: ProxyRule): string {
const labels: string[] = []
if (ruleExits(rule).includes('local')) labels.push('本机 Nginx')
if (ruleExits(rule).includes('cloudflare')) labels.push('Cloudflare')
return labels.join(' + ') || '未设置'
}
function listenLabel(rule: ProxyRule): string {
const stacks = []
if (rule.listen_ipv4) stacks.push('IPv4')
@@ -1399,7 +1478,14 @@ function hostAccessUrl(rule: ProxyRule, hostPart: string): string {
}
function ruleAccessUrls(rule: ProxyRule): string[] {
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
const exits = ruleExits(rule)
if (exits.includes('local')) {
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
}
if (exits.includes('cloudflare')) {
return ruleHosts(rule).map((host) => `https://${host.replace(/:\d+$/, '')}`)
}
return []
}
import { copyText } from '../utils/clipboard'
@@ -1548,6 +1634,26 @@ function renderProtocol(row: ProxyRule): VNode {
return h('div', { class: 'proto-tags' }, tags)
}
function renderExits(row: ProxyRule): VNode {
const exits = ruleExits(row)
return h(
'div',
{ class: 'proto-tags' },
exits.map((exit) =>
h(
NTag,
{
size: 'small',
type: exit === 'cloudflare' ? 'info' : 'default',
bordered: false,
round: true,
},
() => (exit === 'cloudflare' ? 'Cloudflare' : '本机'),
),
),
)
}
function rowProps(row: ProxyRule) {
return {
class: showDetailPanel.value && selectedRuleId.value === row.id ? 'proxy-row--active' : '',
@@ -1599,6 +1705,12 @@ const columns = computed<DataTableColumns<ProxyRule>>(() => {
width: 88,
render: (row) => renderProtocol(row),
},
{
title: '出口',
key: 'exits',
width: 150,
render: (row) => renderExits(row),
},
{
title: '安全',
key: 'security',
@@ -1825,6 +1937,11 @@ async function load() {
loadError.value = ''
try {
rules.value = asList(await api.listProxies())
try {
cfTunnels.value = asList(await api.listCfTunnels())
} catch {
cfTunnels.value = []
}
} catch (error) {
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
} finally {
@@ -1842,9 +1959,19 @@ function resetForm() {
form.http_redirect = true
form.enabled = true
form.name = ''
form.exit_local = true
form.exit_cloudflare = false
form.cf_tunnel_id = null
Object.assign(form, defaultSecurityForm())
}
function loadExitsToForm(rule?: ProxyRule | null) {
const exits = rule?.exits?.length ? rule.exits : ['local']
form.exit_local = exits.includes('local')
form.exit_cloudflare = exits.includes('cloudflare')
form.cf_tunnel_id = rule?.cf_tunnel_id || null
}
function loadSecurityToForm(rule?: ProxyRule | null) {
const sec = rule?.security ?? {}
form.ip_blacklist_text = (sec.ip_blacklist ?? []).join('\n')
@@ -1923,6 +2050,16 @@ function buildPayload(): ProxySavePayload {
if (!form.listen_ipv4 && !form.listen_ipv6) {
throw new Error('至少需要启用 IPv4 或 IPv6 监听')
}
if (!form.exit_local && !form.exit_cloudflare) {
throw new Error('至少需要选择一个出口')
}
if (form.exit_cloudflare && !form.cf_tunnel_id) {
throw new Error('请选择 Cloudflare 隧道')
}
const exits = [
...(form.exit_local ? ['local'] : []),
...(form.exit_cloudflare ? ['cloudflare'] : []),
]
return {
upstream: form.upstream,
listen_port: form.listen_port,
@@ -1934,6 +2071,8 @@ function buildPayload(): ProxySavePayload {
enabled: form.enabled,
name: form.name.trim(),
security: buildSecurityPayload(),
exits,
cf_tunnel_id: form.exit_cloudflare ? form.cf_tunnel_id! : 0,
}
}
@@ -1941,15 +2080,9 @@ async function scanServices() {
scanning.value = true
try {
const items = await api.scanDiscovery('127.0.0.1')
const found = items.find((i) => i.detected)
if (found) {
resetForm()
form.upstream = found.upstream
showModal.value = true
message.success(`检测到 ${found.name},请填写前端域名`)
} else {
message.info('未检测到常见服务')
}
discoveryResults.value = asList(items)
discoveryVisible.value = true
if (!items.some((item) => item.detected)) message.info('未检测到常见服务,可检查端口或手动填写目标地址')
} catch (error) {
message.error(error instanceof Error ? error.message : '扫描失败')
} finally {
@@ -1957,6 +2090,18 @@ async function scanServices() {
}
}
function publishDiscovered(item: DiscoveredService) {
discoveryVisible.value = false
openCreate()
form.name = item.name
form.upstream = item.upstream
if (cfTunnelOptions.value.length > 0) {
form.exit_cloudflare = true
form.cf_tunnel_id = Number(cfTunnelOptions.value[0].value)
}
message.info('已填入服务信息,请填写域名后保存')
}
function openCreate() {
editing.value = null
formTab.value = 'basic'
@@ -1973,7 +2118,11 @@ async function toggleRuleEnabled(row: ProxyRule, enabled: boolean) {
try {
const updated = await api.updateProxy(row.id, { enabled })
rules.value = rules.value.map((rule) => (rule.id === updated.id ? updated : rule))
message.success(enabled ? '已启用' : '已停用')
if (updated.sync_warning) {
message.warning(updated.sync_warning)
} else {
message.success(enabled ? '已启用' : '已停用')
}
} catch (error) {
rules.value = rules.value.map((rule) => (rule.id === row.id ? { ...rule, enabled: prev } : rule))
const msg = error instanceof Error ? error.message : '更新失败'
@@ -2002,6 +2151,7 @@ function openDuplicate(rule: ProxyRule) {
enabled: rule.enabled,
name: duplicateName(rule),
})
loadExitsToForm(rule)
loadSecurityToForm(rule)
syncSecurityExpanded()
formTab.value = 'basic'
@@ -2024,6 +2174,7 @@ function openEdit(rule: ProxyRule, tab: 'basic' | 'security' | 'nginx' = 'basic'
enabled: rule.enabled,
name: rule.name ?? '',
})
loadExitsToForm(rule)
loadSecurityToForm(rule)
syncSecurityExpanded()
if (tab === 'nginx') {
@@ -2097,11 +2248,13 @@ async function save() {
return
}
if (editing.value) {
await api.updateProxy(editing.value.id, payload)
message.success('规则已保存')
const result = await api.updateProxy(editing.value.id, payload)
if (result.sync_warning) message.warning(result.sync_warning)
else message.success('规则已保存')
} else {
await api.createProxy(payload)
message.success('规则已创建')
const result = await api.createProxy(payload)
if (result.sync_warning) message.warning(result.sync_warning)
else message.success('规则已创建')
}
showModal.value = false
await load()
@@ -3287,6 +3440,37 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
line-height: 1.5;
}
.field-hint--warning { color: var(--fonu-warning); }
.exit-options {
display: flex;
flex-wrap: wrap;
gap: 18px;
}
.discovery-list {
display: grid;
gap: 8px;
}
.discovery-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
}
.discovery-item__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.proxy-modal__pane :deep(.n-form-item .n-form-item-blank) {
display: block;
width: 100%;