This commit is contained in:
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.6.1] - 2026-09-28
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **Cloudflare 隧道弹窗完善**:编辑弹窗回填已配置的暴露域名与回源 service(后端从 `config.yml` 解析);按「基本信息 / 暴露规则 / 网络调优」分组,增加模式说明;云端规则模式隐藏本地域名配置并提示规则由 Cloudflare 面板管理
|
||||||
|
- **详情弹窗**:改为状态卡 + `config.yml` / 日志分页,日志每 3 秒自动刷新,支持一键复制配置,刷新按钮带加载状态
|
||||||
|
- **二进制弹窗**:显示当前版本与目标文件;镜像列表为空时回退官方源
|
||||||
|
- **保存校验**:提交前校验名称、token 与本地规则模式的暴露域名
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 版本号提升到 1.6.1;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
|
||||||
|
|
||||||
## [v1.6.0] - 2026-09-28
|
## [v1.6.0] - 2026-09-28
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Fonu 文档索引
|
# Fonu 文档索引
|
||||||
|
|
||||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||||
> 最近更新:2026-09-28(v1.6.0)
|
> 最近更新:2026-09-28(v1.6.1)
|
||||||
|
|
||||||
## 方案与设计(本目录)
|
## 方案与设计(本目录)
|
||||||
|
|
||||||
|
|||||||
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
|
|||||||
|
|
||||||
## 10. 在线升级验证
|
## 10. 在线升级验证
|
||||||
|
|
||||||
示例页面用于验证升级结果。v1.6.0 的验证流程:
|
示例页面用于验证升级结果。v1.6.1 的验证流程:
|
||||||
|
|
||||||
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
||||||
2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.0`。
|
2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.1`。
|
||||||
3. 在侧栏看到「一键升级」后点击并确认。
|
3. 在侧栏看到「一键升级」后点击并确认。
|
||||||
4. 等待升级弹窗显示成功,页面自动刷新。
|
4. 等待升级弹窗显示成功,页面自动刷新。
|
||||||
5. 打开侧栏「示例页面」,确认:
|
5. 打开侧栏「示例页面」,确认:
|
||||||
- 页面路由为 `/example`;
|
- 页面路由为 `/example`;
|
||||||
- 页面显示的版本为 `1.6.0`;
|
- 页面显示的版本为 `1.6.1`;
|
||||||
- 页面显示验证标识 `v1.6.0-example-page`;
|
- 页面显示验证标识 `v1.6.1-example-page`;
|
||||||
- 页面显示独立的「本次升级验证」区块;
|
- 页面显示独立的「本次升级验证」区块;
|
||||||
- 「升级验证」显示为「通过」。
|
- 「升级验证」显示为「通过」。
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ type Tunnel struct {
|
|||||||
LastError string `json:"last_error"`
|
LastError string `json:"last_error"`
|
||||||
MetricsPort int `json:"metrics_port"`
|
MetricsPort int `json:"metrics_port"`
|
||||||
AutoStart bool `json:"auto_start"`
|
AutoStart bool `json:"auto_start"`
|
||||||
|
Hostnames []string `json:"hostnames"`
|
||||||
|
OriginService string `json:"origin_service"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,11 +48,23 @@ func (s *Service) EnsureDirs() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
|
func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
|
||||||
return s.store.List(ctx)
|
items, err := s.store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
s.attachConfig(&items[i])
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Get(ctx context.Context, id int64) (Tunnel, error) {
|
func (s *Service) Get(ctx context.Context, id int64) (Tunnel, error) {
|
||||||
return s.store.Get(ctx, id)
|
item, err := s.store.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return Tunnel{}, err
|
||||||
|
}
|
||||||
|
s.attachConfig(&item)
|
||||||
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
|
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
|
||||||
@@ -158,13 +170,13 @@ func (s *Service) Start(ctx context.Context, id int64) (Tunnel, error) {
|
|||||||
return Tunnel{}, err
|
return Tunnel{}, err
|
||||||
}
|
}
|
||||||
_ = s.store.SetRuntime(ctx, id, "starting", "", 0)
|
_ = s.store.SetRuntime(ctx, id, "starting", "", 0)
|
||||||
return s.store.Get(ctx, id)
|
return s.Get(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Stop(ctx context.Context, id int64) (Tunnel, error) {
|
func (s *Service) Stop(ctx context.Context, id int64) (Tunnel, error) {
|
||||||
_ = s.manager.Stop(id)
|
_ = s.manager.Stop(id)
|
||||||
_ = s.store.SetRuntime(ctx, id, "stopped", "", 0)
|
_ = s.store.SetRuntime(ctx, id, "stopped", "", 0)
|
||||||
return s.store.Get(ctx, id)
|
return s.Get(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
|
func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
|
||||||
@@ -179,6 +191,33 @@ func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, e
|
|||||||
return s.manager.Status(id), nil
|
return s.manager.Status(id), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
|
||||||
|
func (s *Service) attachConfig(item *Tunnel) {
|
||||||
|
data, err := os.ReadFile(item.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for i, raw := range lines {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
host, found := strings.CutPrefix(line, "- hostname:")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.Hostnames = append(item.Hostnames, host)
|
||||||
|
if i+1 >= len(lines) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if service, ok := strings.CutPrefix(strings.TrimSpace(lines[i+1]), "service:"); ok && item.OriginService == "" {
|
||||||
|
item.OriginService = strings.TrimSpace(service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Logs(ctx context.Context, id int64, lines int) (string, error) {
|
func (s *Service) Logs(ctx context.Context, id int64, lines int) (string, error) {
|
||||||
tunnel, err := s.store.Get(ctx, id)
|
tunnel, err := s.store.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.11.1",
|
"@codemirror/commands": "^6.11.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"packageManager": "npm@10.9.4",
|
"packageManager": "npm@10.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1064,6 +1064,8 @@ export interface CfTunnel {
|
|||||||
last_error?: string
|
last_error?: string
|
||||||
metrics_port?: number
|
metrics_port?: number
|
||||||
auto_start: boolean
|
auto_start: boolean
|
||||||
|
hostnames?: string[]
|
||||||
|
origin_service?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,32 +38,39 @@
|
|||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
<n-modal v-model:show="editorVisible" preset="card" class="cf-modal" :title="editing ? '编辑隧道' : '新建隧道'">
|
<n-modal v-model:show="editorVisible" preset="card" class="cf-modal" :title="editing ? '编辑隧道' : '新建隧道'">
|
||||||
|
<div class="cf-modal__body">
|
||||||
|
<n-alert :type="form.mode === 'token-remote' ? 'warning' : 'info'" :bordered="false" class="cf-mode-alert">
|
||||||
|
{{ modeHint }}
|
||||||
|
</n-alert>
|
||||||
|
<n-divider title-placement="left">基本信息</n-divider>
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
|
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
|
||||||
<n-form-item label="模式">
|
<n-form-item label="模式"><n-select v-model:value="form.mode" :options="modeOptions" /></n-form-item>
|
||||||
<n-select v-model:value="form.mode" :options="modeOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item :label="editing ? 'Token(留空保留原值)' : 'Token'">
|
<n-form-item :label="editing ? 'Token(留空保留原值)' : 'Token'">
|
||||||
<n-input v-model:value="form.token" type="password" show-password-on="click" placeholder="Cloudflare 面板生成的隧道 token" />
|
<n-input v-model:value="form.token" type="password" show-password-on="click" placeholder="Cloudflare 面板生成的隧道 token" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<n-divider title-placement="left">暴露规则</n-divider>
|
||||||
|
<n-form v-if="form.mode === 'token-local'" label-placement="top">
|
||||||
<n-form-item label="暴露域名">
|
<n-form-item label="暴露域名">
|
||||||
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
|
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="回源 service">
|
<n-form-item label="回源 service">
|
||||||
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
|
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="传输协议">
|
</n-form>
|
||||||
<n-select v-model:value="form.network.transport_protocol" :options="protocolOptions" />
|
<n-alert v-else type="warning" :bordered="false" class="cf-mode-alert">
|
||||||
</n-form-item>
|
云端规则模式下,本地域名与回源配置不生效,请在 Cloudflare 面板管理 ingress。
|
||||||
<n-form-item label="边缘地址族">
|
</n-alert>
|
||||||
<n-select v-model:value="form.network.edge_ip_version" :options="edgeOptions" />
|
<n-divider title-placement="left">网络调优</n-divider>
|
||||||
</n-form-item>
|
<n-form label-placement="top">
|
||||||
|
<n-form-item label="传输协议"><n-select v-model:value="form.network.transport_protocol" :options="protocolOptions" /></n-form-item>
|
||||||
|
<n-form-item label="边缘地址族"><n-select v-model:value="form.network.edge_ip_version" :options="edgeOptions" /></n-form-item>
|
||||||
<n-form-item label="HA 连接数"><n-input-number v-model:value="form.network.ha_connections" :min="1" :max="4" /></n-form-item>
|
<n-form-item label="HA 连接数"><n-input-number v-model:value="form.network.ha_connections" :min="1" :max="4" /></n-form-item>
|
||||||
<n-form-item label="代理模式">
|
<n-form-item label="代理模式"><n-select v-model:value="form.network.proxy_mode" :options="proxyOptions" /></n-form-item>
|
||||||
<n-select v-model:value="form.network.proxy_mode" :options="proxyOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
|
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="cf-modal__footer">
|
<div class="cf-modal__footer">
|
||||||
<n-button @click="editorVisible = false">取消</n-button>
|
<n-button @click="editorVisible = false">取消</n-button>
|
||||||
@@ -72,35 +79,48 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<n-modal v-model:show="detailVisible" preset="card" class="cf-modal" :title="detailTunnel?.name || '隧道详情'">
|
<n-modal v-model:show="detailVisible" preset="card" class="cf-modal cf-modal--wide" :title="detailTunnel?.name || '隧道详情'">
|
||||||
<div v-if="detailTunnel" class="cf-detail">
|
<div v-if="detailTunnel" class="cf-detail">
|
||||||
<div class="cf-detail__row">
|
<div class="cf-detail__stats">
|
||||||
<StatusBadge :kind="statusKind(detailStatus?.status || detailTunnel.status)" :text="statusText(detailStatus?.status || detailTunnel.status)" />
|
<div class="cf-stat"><label>状态</label><StatusBadge :kind="statusKind(detailStatus?.status || detailTunnel.status)" :text="statusText(detailStatus?.status || detailTunnel.status)" /></div>
|
||||||
<span class="cf-meta mono">连接数 {{ detailStatus?.connections ?? 0 }}</span>
|
<div class="cf-stat"><label>连接数</label><strong>{{ detailStatus?.connections ?? 0 }}</strong></div>
|
||||||
<span class="cf-meta mono">延迟 {{ (detailStatus?.latency_ms ?? 0).toFixed(0) }} ms</span>
|
<div class="cf-stat"><label>延迟</label><strong>{{ (detailStatus?.latency_ms ?? 0).toFixed(0) }} ms</strong></div>
|
||||||
<span class="cf-meta mono">{{ detailStatus?.transport || '—' }}</span>
|
<div class="cf-stat"><label>传输协议</label><strong>{{ detailStatus?.transport || '—' }}</strong></div>
|
||||||
|
<div class="cf-stat"><label>metrics 端口</label><strong>{{ detailStatus?.metrics_port || '—' }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="detailStatus?.bandwidth_notice" class="cf-hint">{{ detailStatus.bandwidth_notice }}</p>
|
<p v-if="detailStatus?.bandwidth_notice" class="cf-hint">{{ detailStatus.bandwidth_notice }}</p>
|
||||||
<h4>config.yml</h4>
|
<n-tabs type="line" animated>
|
||||||
|
<n-tab-pane name="config" tab="config.yml">
|
||||||
|
<div class="cf-code-toolbar"><n-button size="tiny" @click="copyConfig">复制</n-button></div>
|
||||||
<pre class="cf-pre">{{ detailConfig || '暂无配置' }}</pre>
|
<pre class="cf-pre">{{ detailConfig || '暂无配置' }}</pre>
|
||||||
<h4>日志</h4>
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="logs" tab="日志">
|
||||||
|
<div class="cf-code-toolbar"><n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button></div>
|
||||||
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
|
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="cf-modal__footer">
|
<div class="cf-modal__footer">
|
||||||
<n-button @click="refreshDetail">刷新</n-button>
|
<n-button @click="refreshDetail">刷新全部</n-button>
|
||||||
<n-button type="primary" @click="detailVisible = false">关闭</n-button>
|
<n-button type="primary" @click="detailVisible = false">关闭</n-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<n-modal v-model:show="binaryVisible" preset="card" class="cf-modal" title="cloudflared 二进制">
|
<n-modal v-model:show="binaryVisible" preset="card" class="cf-modal" title="cloudflared 二进制">
|
||||||
|
<div class="cf-modal__body">
|
||||||
|
<div class="cf-binary">
|
||||||
|
<div><label>当前版本</label><strong class="mono">{{ binaryVersion || '未安装' }}</strong></div>
|
||||||
|
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
|
||||||
|
</div>
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
<n-form-item label="当前版本"><span class="mono">{{ binaryVersion || '未安装' }}</span></n-form-item>
|
|
||||||
<n-form-item label="下载镜像">
|
<n-form-item label="下载镜像">
|
||||||
<n-select v-model:value="selectedMirror" :options="mirrorOptions" />
|
<n-select v-model:value="selectedMirror" :options="mirrorOptions" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
|
||||||
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="cf-modal__footer">
|
<div class="cf-modal__footer">
|
||||||
<n-button @click="binaryVisible = false">取消</n-button>
|
<n-button @click="binaryVisible = false">取消</n-button>
|
||||||
@@ -112,13 +132,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { NAlert, NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
|
||||||
import { api, asList } from '../api/client'
|
import { api, asList } from '../api/client'
|
||||||
import type { CfMirror, CfRuntimeStatus, CfTunnel, CfTunnelPayload } from '../api/types'
|
import type { CfMirror, CfRuntimeStatus, CfTunnel, CfTunnelPayload } from '../api/types'
|
||||||
import FonuCard from '../components/FonuCard.vue'
|
import FonuCard from '../components/FonuCard.vue'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
import StatusBadge from '../components/StatusBadge.vue'
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
||||||
|
import { copyText } from '../utils/clipboard'
|
||||||
import type { StatusKind } from '../utils/status'
|
import type { StatusKind } from '../utils/status'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -138,6 +159,7 @@ const detailTunnel = ref<CfTunnel | null>(null)
|
|||||||
const detailStatus = ref<CfRuntimeStatus | null>(null)
|
const detailStatus = ref<CfRuntimeStatus | null>(null)
|
||||||
const detailConfig = ref('')
|
const detailConfig = ref('')
|
||||||
const detailLogs = ref('')
|
const detailLogs = ref('')
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
|
||||||
const form = ref<CfTunnelPayload>(emptyForm())
|
const form = ref<CfTunnelPayload>(emptyForm())
|
||||||
|
|
||||||
@@ -160,7 +182,13 @@ const proxyOptions = [
|
|||||||
{ label: '禁用代理', value: 'disabled' },
|
{ label: '禁用代理', value: 'disabled' },
|
||||||
]
|
]
|
||||||
const hostnameOptions = computed(() => candidates.value.map((host) => ({ label: host, value: host })))
|
const hostnameOptions = computed(() => candidates.value.map((host) => ({ label: host, value: host })))
|
||||||
const mirrorOptions = computed(() => mirrors.value.map((item) => ({ label: item.name, value: item.id })))
|
const mirrorOptions = computed(() => {
|
||||||
|
const list = mirrors.value.length ? mirrors.value : [{ id: 'official', name: 'GitHub 官方源', base: '' }]
|
||||||
|
return list.map((item) => ({ label: item.name, value: item.id }))
|
||||||
|
})
|
||||||
|
const modeHint = computed(() => (form.value.mode === 'token-remote'
|
||||||
|
? '云端规则模式:cloudflared 用 --token 启动,ingress 由 Cloudflare 面板管理,Fonu 只负责进程、日志与健康。'
|
||||||
|
: '本地规则模式:Fonu 解码 token 并生成本地 config.yml,域名与回源由下方配置决定。'))
|
||||||
|
|
||||||
function emptyForm(): CfTunnelPayload {
|
function emptyForm(): CfTunnelPayload {
|
||||||
return {
|
return {
|
||||||
@@ -215,8 +243,8 @@ function openEdit(item: CfTunnel) {
|
|||||||
name: item.name,
|
name: item.name,
|
||||||
mode: item.mode,
|
mode: item.mode,
|
||||||
token: '',
|
token: '',
|
||||||
hostnames: [],
|
hostnames: [...(item.hostnames || [])],
|
||||||
origin_service: 'http://127.0.0.1:80',
|
origin_service: item.origin_service || 'http://127.0.0.1:80',
|
||||||
network: { ...item.network },
|
network: { ...item.network },
|
||||||
auto_start: item.auto_start,
|
auto_start: item.auto_start,
|
||||||
}
|
}
|
||||||
@@ -224,6 +252,18 @@ function openEdit(item: CfTunnel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveTunnel() {
|
async function saveTunnel() {
|
||||||
|
if (!form.value.name.trim()) {
|
||||||
|
message.warning('请填写隧道名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editing.value && !(form.value.token || '').trim()) {
|
||||||
|
message.warning('请填写 Cloudflare 隧道 token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.value.mode === 'token-local' && !form.value.hostnames.length) {
|
||||||
|
message.warning('本地规则模式至少需要一个暴露域名')
|
||||||
|
return
|
||||||
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
if (editing.value) {
|
if (editing.value) {
|
||||||
@@ -282,6 +322,7 @@ async function openDetail(item: CfTunnel) {
|
|||||||
async function refreshDetail() {
|
async function refreshDetail() {
|
||||||
const item = detailTunnel.value
|
const item = detailTunnel.value
|
||||||
if (!item) return
|
if (!item) return
|
||||||
|
detailLoading.value = true
|
||||||
try {
|
try {
|
||||||
const [status, config, logs] = await Promise.all([
|
const [status, config, logs] = await Promise.all([
|
||||||
api.getCfTunnelStatus(item.id),
|
api.getCfTunnelStatus(item.id),
|
||||||
@@ -293,6 +334,18 @@ async function refreshDetail() {
|
|||||||
detailLogs.value = logs.output || ''
|
detailLogs.value = logs.output || ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '读取隧道详情失败')
|
message.error(error instanceof Error ? error.message : '读取隧道详情失败')
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyConfig() {
|
||||||
|
if (!detailConfig.value) return
|
||||||
|
try {
|
||||||
|
await copyText(detailConfig.value)
|
||||||
|
message.success('已复制 config.yml')
|
||||||
|
} catch {
|
||||||
|
message.error('复制失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +396,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useVisibilityPolling(() => load(), 5000)
|
useVisibilityPolling(() => load(), 5000)
|
||||||
|
useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -360,9 +414,19 @@ useVisibilityPolling(() => load(), 5000)
|
|||||||
.cf-row__main { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
.cf-row__main { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
.cf-row__actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
.cf-row__actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.cf-meta { font-size: 12px; color: var(--fonu-text-secondary); }
|
.cf-meta { font-size: 12px; color: var(--fonu-text-secondary); }
|
||||||
.cf-modal { width: 620px; max-width: 94vw; }
|
.cf-modal { width: 720px; max-width: 94vw; }
|
||||||
|
.cf-modal--wide { width: 780px; }
|
||||||
|
.cf-modal__body { max-height: 68vh; overflow: auto; padding-right: 4px; }
|
||||||
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
|
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
.cf-detail__row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
.cf-mode-alert { margin-bottom: 4px; }
|
||||||
|
.cf-detail__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 12px; }
|
||||||
|
.cf-stat { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg); }
|
||||||
|
.cf-stat label { font-size: 12px; color: var(--fonu-text-muted); }
|
||||||
|
.cf-stat strong { font-size: 15px; }
|
||||||
|
.cf-code-toolbar { display: flex; justify-content: flex-end; margin-bottom: 6px; }
|
||||||
|
.cf-binary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px; }
|
||||||
|
.cf-binary > div { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg); }
|
||||||
|
.cf-binary label { font-size: 12px; color: var(--fonu-text-muted); }
|
||||||
.cf-hint { font-size: 12px; color: var(--fonu-text-muted); }
|
.cf-hint { font-size: 12px; color: var(--fonu-text-muted); }
|
||||||
.cf-pre {
|
.cf-pre {
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.0 示例页面验证标识已生效">
|
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.1 示例页面验证标识已生效">
|
||||||
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
|
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
|
||||||
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
|
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
|
||||||
</n-alert>
|
</n-alert>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<strong class="example-proof__value">{{ verificationRound }}</strong>
|
<strong class="example-proof__value">{{ verificationRound }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="example-proof__hint">
|
<div class="example-proof__hint">
|
||||||
看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.0。
|
看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.1。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@
|
|||||||
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
|
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
|
||||||
<template #icon><n-icon :component="FlashOutline" /></template>
|
<template #icon><n-icon :component="FlashOutline" /></template>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
<StatCard label="页面状态" value="已更新" sub="v1.6.0 前端资源已加载" tone="teal">
|
<StatCard label="页面状态" value="已更新" sub="v1.6.1 前端资源已加载" tone="teal">
|
||||||
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
|
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
|
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
|
||||||
<ul class="example-changes">
|
<ul class="example-changes">
|
||||||
<li>页面版本提示更新为 v1.6.0。</li>
|
<li>页面版本提示更新为 v1.6.1。</li>
|
||||||
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.0。</li>
|
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.1。</li>
|
||||||
<li>验证标识更新为 <span class="mono">v1.6.0-example-page</span>。</li>
|
<li>验证标识更新为 <span class="mono">v1.6.1-example-page</span>。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ const message = useMessage()
|
|||||||
const version = ref(__APP_VERSION__)
|
const version = ref(__APP_VERSION__)
|
||||||
const checkedAt = ref('')
|
const checkedAt = ref('')
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const verificationRound = 'v1.6.0'
|
const verificationRound = 'v1.6.1'
|
||||||
const verificationMarker = 'v1.6.0-example-page'
|
const verificationMarker = 'v1.6.1-example-page'
|
||||||
|
|
||||||
async function refreshVersion() {
|
async function refreshVersion() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user