feat: 上游 1.0.3-1.0.5 同步第 1-8 项(v1.2.2)

剪贴板:新增 web/src/utils/clipboard.ts,5 处调用点支持非 HTTPS 环境(对齐 1.0.4)

日志中心:新增「FRP 日志」Tab,解析 frpc 日志为 时间/级别/来源/消息(对齐 1.0.3)

通知:单通道密文解密失败不再拖垮整份配置(对齐 1.0.5);容器补 TZ + tzdata(对齐 1.0.4);Telegram 代理自动补 http:// 并校验 scheme(对齐 1.0.3)

反向代理:复制不再追加「-复制」(对齐 1.0.5);监听占用校验合并表单规则与手动 Nginx 配置两张表(迁移 024 + endpoints 解析器),冲突提示写明占用规则;改端口的并发冲突改为可读提示

FRP:frps 弹窗补「VPS 防火墙放行 TCP 远程端口」提示(对齐 1.0.3);反代配置弹窗补齐内容区内边距与来源说明

文档:新增 docs/upstream-sync-plan.md(逐版本对照 + 第 5 项详细整理);VERSION 与 CHANGELOG 记 v1.2.2
This commit is contained in:
2026-09-23 17:46:21 +08:00
parent 1e915fe7c1
commit fd1661622e
23 changed files with 1008 additions and 53 deletions
+3 -4
View File
@@ -253,6 +253,7 @@ import StatusBadge from '../components/StatusBadge.vue'
import { api } from '../api/client'
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
import { formatRelativeTime } from '../utils/format'
import { copyText } from '../utils/clipboard'
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
type Action =
@@ -470,10 +471,8 @@ async function viewConfig(target: FrpServer) {
}
async function copyConfig() {
try {
await navigator.clipboard.writeText(configContent.value)
message.success('已复制 frps 配置')
} catch { message.error('复制失败,请手动选择文本复制') }
if (await copyText(configContent.value)) message.success('已复制 frps 配置')
else message.error('复制失败,请手动选择文本复制')
}
function installFrps(target: FrpServer) {
+5 -6
View File
@@ -878,13 +878,12 @@ async function clearCustomIP() {
await saveCustomIP()
}
// 复用统一剪贴板工具(本页原有同名函数,故用别名导入避免冲突)
import { copyText as copyToClipboard } from '../utils/clipboard'
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success('已复制')
} catch {
message.error('复制失败')
}
if (await copyToClipboard(text)) message.success('已复制')
else message.error('复制失败')
}
async function refreshPublicIP() {
+8
View File
@@ -585,6 +585,10 @@
</div>
<div class="proxy-modal__scroll">
<p class="form-hint">与该服务端的 frpc 配置同源生成(bindPort / 认证 / 管理接口一致);复制到 VPS 保存为 frps.toml 后执行 ./frps -c frps.toml。包含真实 Token,请勿外传。</p>
<p v-if="tcpRemotePorts.length" class="form-hint">
已启用的 TCP/UDP 隧道远程端口:<span class="mono">{{ tcpRemotePorts.join('、') }}</span>
—— 请确认 VPS 防火墙 / 安全组已放行这些端口(frps 侧 allowPorts 已按此生成,未放行时隧道会连不上)。
</p>
<pre class="logs-box">{{ frpsConfigContent || '生成中…' }}</pre>
</div>
<div class="modal-footer"><n-button @click="showFRPSConfig = false">关闭</n-button></div>
@@ -911,6 +915,10 @@ const dialog = useDialog()
const status = ref<FrpStatus>({ status: 'unknown', configured: false, enabled_proxies: 0 })
const servers = ref<FrpServer[]>([])
const proxies = ref<FrpProxy[]>([])
// 已启用的 TCP/UDP 隧道远程端口:用于提示在 VPS 防火墙放行(与 frps 的 allowPorts 同源)
const tcpRemotePorts = computed(() => proxies.value
.filter((proxy) => proxy.enabled && (proxy.type === 'tcp' || proxy.type === 'udp') && proxy.remote_port)
.map((proxy) => proxy.remote_port as number))
const proxyTraffic = ref<Record<number, FrpProxyTraffic>>({})
const proxyTrafficErrors = ref<Record<number, string>>({})
const serverDiagnostics = ref<Record<number, FrpServerDiagnostics>>({})
+87 -1
View File
@@ -37,6 +37,35 @@
/>
</n-tab-pane>
<n-tab-pane name="frp" tab="FRP 日志">
<LogToolbar
v-model:keyword="frpKeyword"
v-model:level="frpLevel"
:show-level="true"
@refresh="loadFrpLogs"
/>
<p class="tab-hint">frpc 运行日志:解析为 时间 / 级别 / 来源 / 消息,用于排查隧道登录、注册与连接错误。</p>
<n-data-table
v-if="filteredFrpLogs.length > 0"
class="log-table"
:columns="frpColumns"
:data="pagedFrpLogs"
:loading="loadingFrp"
:bordered="false"
:row-class-name="frpRowClassName"
/>
<LogPagination
v-if="filteredFrpLogs.length > 0"
v-model:page="frpPage"
:item-count="filteredFrpLogs.length"
/>
<EmptyState
v-if="!loadingFrp && frpLogs.length === 0"
title="暂无 FRP 日志"
description="启动 frpc 后,登录、隧道注册与连接错误会记录在这里。"
/>
</n-tab-pane>
<n-tab-pane name="access" tab="访问日志">
<LogToolbar
v-model:keyword="accessKeyword"
@@ -335,6 +364,63 @@ function systemRowClassName(row: SystemLogEntry) {
return 'log-row'
}
// FRP 日志(frpc):后端返回原始行,前端解析为 时间 / 级别 / 来源 / 消息 四列
interface FrpLogEntry { time: string; level: string; source: string; message: string }
const frpLogs = ref<FrpLogEntry[]>([])
const frpKeyword = ref('')
const frpLevel = ref('')
const frpPage = ref(1)
const loadingFrp = ref(false)
const frpPageSize = 50
const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错误', D: '调试' }
// frpc 行形如:2026-09-23 15:20:31.123 [I] [service.go:295] login to server success
function parseFrpLogLine(line: string): FrpLogEntry {
const matched = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?)\s*\[([A-Z])\]\s*(?:\[([^\]]+)\])?\s*(.*)$/)
if (!matched) return { time: '', level: '', source: '', message: line }
return { time: matched[1], level: matched[2], source: matched[3] ?? '', message: matched[4] ?? '' }
}
const filteredFrpLogs = computed(() => {
const keyword = frpKeyword.value.trim().toLowerCase()
return frpLogs.value.filter((log) => {
if (frpLevel.value && frpLevelText[log.level] !== frpLevel.value) return false
if (keyword && !`${log.source} ${log.message}`.toLowerCase().includes(keyword)) return false
return true
})
})
const pagedFrpLogs = computed(() => {
const start = (frpPage.value - 1) * frpPageSize
return filteredFrpLogs.value.slice(start, start + frpPageSize)
})
const frpColumns: DataTableColumns<FrpLogEntry> = [
{ title: '时间', key: 'time', width: 168, render: (row) => (row.time ? logTimeCell(row.time) : '—') },
{ title: '级别', key: 'level', width: 80, render: (row) => frpLevelText[row.level] ?? (row.level || '—') },
{ title: '来源', key: 'source', width: 170, render: (row) => row.source || '—' },
{ title: '消息', key: 'message', render: (row) => row.message },
]
function frpRowClassName(row: FrpLogEntry) {
if (row.level === 'E') return 'log-row log-row--error'
if (row.level === 'W') return 'log-row log-row--warn'
return 'log-row'
}
async function loadFrpLogs() {
loadingFrp.value = true
try {
const raw = asList(await api.getFrpLogs(500))
frpLogs.value = raw.map((line) => parseFrpLogLine(String(line)))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载 FRP 日志失败')
} finally {
loadingFrp.value = false
}
}
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
{
@@ -491,7 +577,7 @@ async function loadSystemLogs() {
async function loadAll() {
pageError.value = ''
try {
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs()])
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs()])
} catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
}
+17 -10
View File
@@ -300,7 +300,14 @@
<h3 class="modal-title">反代配置 — {{ confDomain }}</h3>
<n-button size="small" quaternary @click="confModal = false">关闭</n-button>
</div>
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
<div class="proxy-modal__scroll conf-scroll">
<p class="field-hint">
由 VPS 上的 fonu-agent 生成,位于 Nginx 的 vhost 配置目录(文件名
<span class="mono">fonu-{{ confDomain }}.conf</span>)。请勿在 VPS 上手工修改:
下次部署会被重新生成覆盖,改动请到「部署设置」或穿透规则里调整。
</p>
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
</div>
<div class="modal-footer">
<n-button :disabled="!confContent" @click="copyConf">复制</n-button>
<n-button type="primary" @click="confModal = false">关闭</n-button>
@@ -327,6 +334,7 @@ import type {
FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer,
} from '../api/types'
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
import { copyText } from '../utils/clipboard'
type Action =
| `deploy:${number}` | `remove:${number}` | `conf:${number}` | `save-settings:${number}` | 'install-nginx'
@@ -513,10 +521,8 @@ function isHealthy(candidate: FrpProxy) {
// 域名快捷操作:复制 / 新窗口打开(http 规则走 http,https 规则走 https)
async function copyDomain(domain: string) {
try {
await navigator.clipboard.writeText(domain)
message.success(`已复制 ${domain}`)
} catch { message.error('复制失败,请手动选择文本复制') }
if (await copyText(domain)) message.success(`已复制 ${domain}`)
else message.error('复制失败,请手动选择文本复制')
}
function openDomain(candidate: FrpProxy, domain: string) {
@@ -733,10 +739,8 @@ async function viewConf(candidate: FrpProxy) {
}
async function copyConf() {
try {
await navigator.clipboard.writeText(confContent.value)
message.success('已复制反代配置')
} catch { message.error('复制失败,请手动选择文本复制') }
if (await copyText(confContent.value)) message.success('已复制反代配置')
else message.error('复制失败,请手动选择文本复制')
}
// 切换服务端必须重置,避免上一台的健康 / 流量 / agent 状态串台
@@ -862,7 +866,10 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
font-family: var(--fonu-mono); font-size: 12.5px; line-height: 1.65; color: var(--fonu-text);
overflow: auto; white-space: pre-wrap; word-break: break-all;
}
.conf-view { max-height: 60vh; }
/* 反代配置查看弹窗:内容区与头部同样的 24px 内边距,代码块自身滚动 */
.conf-scroll { padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
.conf-scroll .field-hint { margin-bottom: var(--fonu-space-3); line-height: 1.65; }
.conf-view { max-height: 62vh; }
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); font-size: 13px; }
.mono-wrap { white-space: normal; word-break: break-all; }
+7 -11
View File
@@ -1361,12 +1361,10 @@ function ruleName(rule: ProxyRule): string {
return name || primaryHost(rule)
}
// 复制:保留原名称与域名,由用户在表单里确认修改(不再自动追加「-复制」后缀,避免误建同名规则)
function duplicateName(rule: ProxyRule): string {
const base = rule.name?.trim() || primaryHost(rule)
const suffix = '-复制'
const maxBase = 100 - suffix.length
const trimmedBase = base.length > maxBase ? base.slice(0, maxBase) : base
return `${trimmedBase}${suffix}`
return base.length > 100 ? base.slice(0, 100) : base
}
function hostsToText(rule: ProxyRule): string {
@@ -1405,13 +1403,11 @@ function ruleAccessUrls(rule: ProxyRule): string[] {
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
}
import { copyText } from '../utils/clipboard'
async function copyAccessUrl(url: string) {
try {
await navigator.clipboard.writeText(url)
message.success('链接已复制')
} catch {
message.error('复制失败')
}
if (await copyText(url)) message.success('链接已复制')
else message.error('复制失败')
}
function renderLinkAction(icon: Component, title: string, onClick: () => void): VNode {
@@ -2040,7 +2036,7 @@ function openDuplicate(rule: ProxyRule) {
syncSecurityExpanded()
formTab.value = 'basic'
showModal.value = true
message.info('已填入复制内容,请修改域名后保存')
message.info('已填入复制内容,请确认名称与域名后保存')
}
function openEdit(rule: ProxyRule, tab: 'basic' | 'security' | 'nginx' = 'basic') {