feat: 新增 Frpc 一键启动与实时日志回显功能

新增一键启动/停止 Frpc 的命令及后端进程管理模块,支持生成配置文件、独立进程拉起、日志实时推送与退出事件通知。新增 Frpc 状态与日志类型定义,同步更新前端仪表盘逻辑以展示运行状态与实时日志。新增中英文启动失败提示文案,完善应用退出时自动终止 Frpc 子进程的逻辑。
This commit is contained in:
Yuhang Wu
2026-10-13 17:35:50 +08:00
parent 85a52dfbe9
commit a6276680cc
13 changed files with 1105 additions and 40 deletions
+26 -1
View File
@@ -1,6 +1,11 @@
import { invoke } from "@tauri-apps/api/core"
import type { ApiResponse } from "@/types/api"
import type { FrpDownload, FrpDownloadPayload, FrpRelease } from "@/types/frp"
import type {
FrpDownload,
FrpDownloadPayload,
FrpRelease,
FrpcStatus,
} from "@/types/frp"
/**
* 调用后端命令并统一解包 ApiResponse。
@@ -42,3 +47,23 @@ export function deleteFrp(id: number) {
export function importFrp() {
return call<FrpDownload[]>("import_frp")
}
/**
* 一键启动 Frpc。
*
* 后端会依据所选版本、已启用的服务器与隧道生成 frpc.toml 并拉起独立进程,
* 随后通过 `frpc-log` / `frpc-exit` 事件回传运行日志与退出信息。
*/
export function startFrpc(downloadId: number) {
return call<FrpcStatus>("start_frpc", { downloadId })
}
/** 停止 Frpc 服务进程 */
export function stopFrpc() {
return call<FrpcStatus>("stop_frpc")
}
/** 获取 Frpc 当前运行状态 */
export function getFrpcStatus() {
return call<FrpcStatus>("get_frpc_status")
}
+2 -1
View File
@@ -50,7 +50,8 @@
"logEmpty": "No logs yet. Live logs will appear here once Frpc starts.",
"logReady": "Frpilot is ready. Waiting for Frpc to start.",
"logStarted": "Frpc {version} started",
"logStopped": "Frpc stopped"
"logStopped": "Frpc stopped",
"logStartFailed": "Failed to start Frpc: {message}"
},
"settings": {
"title": "Settings",
+2 -1
View File
@@ -50,7 +50,8 @@
"logEmpty": "暂无日志,启动 Frpc 后将在此显示实时日志",
"logReady": "Frpilot 已就绪,等待启动 Frpc",
"logStarted": "已启动 Frpc {version}",
"logStopped": "已停止 Frpc"
"logStopped": "已停止 Frpc",
"logStartFailed": "启动 Frpc 失败:{message}"
},
"settings": {
"title": "设置",
+36
View File
@@ -87,3 +87,39 @@ export interface FrpDownloadProgress {
/** 附加信息(错误原因等) */
message: string | null
}
/** Frpc 运行状态 */
export interface FrpcStatus {
/** 是否正在运行 */
running: boolean
/** 当前运行的 Frpc 版本号 */
version: string | null
/** frpc 可执行文件路径 */
frpc_path: string | null
/** 本次运行生成的 frpc.toml 路径 */
config_path: string | null
/** frpc 进程 PID */
pid: number | null
}
/** Frpc 实时日志事件(`frpc-log`) */
export interface FrpcLogEvent {
/** frpc 输出中的时间戳(HH:MM:SS,可能为空) */
time: string | null
/** 日志级别:INFO / WARN / ERROR / DEBUG / TRACE */
level: string
/** 来源:stdout / stderr / system */
source: string
/** 日志内容 */
message: string
}
/** Frpc 进程退出事件(`frpc-exit`) */
export interface FrpcExitEvent {
/** 退出原因:stopped(用户主动停止)/ exited(进程自行退出) */
reason: string
/** 退出码(主动终止时可能为空) */
code: number | null
/** 退出时对应的 Frpc 版本号 */
version: string | null
}
+110 -29
View File
@@ -102,12 +102,19 @@
<a-button
type="primary"
:icon="h(PlayCircleOutlined)"
:loading="starting"
:disabled="isRunning || !selectedId"
@click="start"
>
{{ t("dashboard.start") }}
</a-button>
<a-button danger :icon="h(PoweroffOutlined)" :disabled="!isRunning" @click="stop">
<a-button
danger
:icon="h(PoweroffOutlined)"
:loading="stopping"
:disabled="!isRunning"
@click="stop"
>
{{ t("dashboard.stop") }}
</a-button>
</a-space>
@@ -120,7 +127,7 @@
>
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold">{{ t("dashboard.logs") }}</h3>
<a-tag class="!me-0">{{ t("dashboard.mock") }}</a-tag>
<a-tag v-if="runningVersion" class="!me-0">{{ runningVersion }}</a-tag>
<a-tag :color="statusTagColor" class="!me-0">{{ statusText }}</a-tag>
</div>
@@ -155,14 +162,16 @@ import {
PlayCircleOutlined,
PoweroffOutlined,
} from "@ant-design/icons-vue"
import { message } from "ant-design-vue"
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
import { useI18n } from "vue-i18n"
import { getFrpDownloads } from "@/api/frp"
import { getFrpDownloads, getFrpcStatus, startFrpc, stopFrpc } from "@/api/frp"
import { getServerList } from "@/api/server"
import { getSystemDetails } from "@/api/system"
import { getTunnelList } from "@/api/tunnel"
import { useAppStore } from "@/stores/app"
import { useSystemStats } from "@/composables/useSystemStats"
import type { FrpDownload } from "@/types/frp"
import type { FrpDownload, FrpcExitEvent, FrpcLogEvent } from "@/types/frp"
import type { SystemDetails } from "@/types/system"
import { isTauri } from "@/utils/window"
@@ -210,6 +219,12 @@ const selectedVersion = computed(() => selectedDownload.value?.version ?? "-")
const isRunning = computed(() => appStore.runStatus === "running")
/** 当前运行的 Frpc 版本(用于日志区标签) */
const runningVersion = ref("")
/** 启动 / 停止按钮的 loading 状态 */
const starting = ref(false)
const stopping = ref(false)
type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG"
interface LogLine {
@@ -222,8 +237,8 @@ const logs = ref<LogLine[]>([])
const logBox = ref<HTMLElement | null>(null)
/** 追加一行日志并自动滚动到底部(最多保留 500 行) */
function pushLog(level: LogLevel, message: string) {
logs.value.push({ time: dayjs().format("HH:mm:ss"), level, message })
function pushLog(level: LogLevel, message: string, time?: string) {
logs.value.push({ time: time || dayjs().format("HH:mm:ss"), level, message })
if (logs.value.length > 500) {
logs.value.splice(0, logs.value.length - 500)
}
@@ -237,33 +252,76 @@ function clearLogs() {
logs.value = []
}
function start() {
if (isRunning.value) return
appStore.runStatus = "running"
pushLog("INFO", t("dashboard.logStarted", { version: selectedVersion.value }))
function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e)
}
function stop() {
if (!isRunning.value) return
appStore.runStatus = "stopped"
pushLog("WARN", t("dashboard.logStopped"))
/** 将后端日志级别归一化为 UI 级别 */
function normalizeLevel(level: string): LogLevel {
switch (level.toUpperCase()) {
case "WARN":
case "WARNING":
return "WARN"
case "ERROR":
case "FATAL":
return "ERROR"
case "DEBUG":
case "TRACE":
return "DEBUG"
default:
return "INFO"
}
}
/** 模拟实时日志:运行中定时追加 */
const MOCK_LOGS: { level: LogLevel; message: string }[] = [
{ level: "INFO", message: "login to server success" },
{ level: "INFO", message: "start proxy success" },
{ level: "DEBUG", message: "heartbeat sent to server" },
{ level: "INFO", message: "new connection established" },
{ level: "WARN", message: "connection timeout, retrying..." },
{ level: "DEBUG", message: "connection closed by remote" },
]
/** 后端实时日志事件(frpc-log) */
function onBackendLog(event: FrpcLogEvent) {
pushLog(normalizeLevel(event.level), event.message, event.time ?? undefined)
}
useIntervalFn(() => {
if (!isRunning.value) return
const item = MOCK_LOGS[Math.floor(Math.random() * MOCK_LOGS.length)]
pushLog(item.level, item.message)
}, 1500)
/** 后端进程退出事件(frpc-exit) */
function onBackendExit(event: FrpcExitEvent) {
runningVersion.value = ""
if (event.reason === "stopped") {
appStore.runStatus = "stopped"
return
}
appStore.runStatus = event.code != null && event.code !== 0 ? "error" : "stopped"
}
/** 一键启动:由后端生成 frpc.toml 并拉起独立进程 */
async function start() {
if (isRunning.value || starting.value) return
if (!selectedId.value) return
starting.value = true
try {
const status = await startFrpc(Number(selectedId.value))
appStore.runStatus = "running"
runningVersion.value = status.version ?? selectedVersion.value
} catch (e) {
appStore.runStatus = "error"
runningVersion.value = ""
pushLog("ERROR", t("dashboard.logStartFailed", { message: errMsg(e) }))
message.error(errMsg(e))
} finally {
starting.value = false
}
}
/** 停止 Frpc 服务进程 */
async function stop() {
if (!isRunning.value || stopping.value) return
stopping.value = true
try {
await stopFrpc()
appStore.runStatus = "stopped"
runningVersion.value = ""
} catch (e) {
message.error(errMsg(e))
} finally {
stopping.value = false
}
}
/** 拉取服务器 / 隧道数量 */
async function loadCounts() {
@@ -304,11 +362,34 @@ async function loadFrpDownloads() {
}
}
onMounted(() => {
let unlistenLog: UnlistenFn | null = null
let unlistenExit: UnlistenFn | null = null
onMounted(async () => {
pushLog("INFO", t("dashboard.logReady"))
loadCounts()
loadSystemDetails()
loadFrpDownloads()
if (!isTauri()) return
// 订阅后端实时日志与进程退出事件
unlistenLog = await listen<FrpcLogEvent>("frpc-log", (event) => onBackendLog(event.payload))
unlistenExit = await listen<FrpcExitEvent>("frpc-exit", (event) => onBackendExit(event.payload))
// 同步当前运行状态(避免页面切换后状态丢失)
try {
const status = await getFrpcStatus()
appStore.runStatus = status.running ? "running" : "stopped"
runningVersion.value = status.running ? status.version ?? "" : ""
} catch {
// 读取失败时保持默认状态
}
})
onUnmounted(() => {
unlistenLog?.()
unlistenExit?.()
})
/** 进度条颜色:低=蓝,中=橙,高=红 */