Files
Frpilot/src/views/Dashboard.vue
T
Yuhang Wu a6276680cc feat: 新增 Frpc 一键启动与实时日志回显功能
新增一键启动/停止 Frpc 的命令及后端进程管理模块,支持生成配置文件、独立进程拉起、日志实时推送与退出事件通知。新增 Frpc 状态与日志类型定义,同步更新前端仪表盘逻辑以展示运行状态与实时日志。新增中英文启动失败提示文案,完善应用退出时自动终止 Frpc 子进程的逻辑。
2026-10-13 17:35:50 +08:00

448 lines
14 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="flex h-full flex-col gap-3">
<!-- 顶部统计卡片 -->
<div class="grid shrink-0 grid-cols-1 gap-3 sm:grid-cols-3">
<!-- 服务器 / 隧道数量 -->
<div class="glass-panel flex flex-col justify-center gap-2 px-5 py-4">
<div class="flex items-center gap-3">
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-500/10 text-base text-indigo-500"
>
<CloudServerOutlined />
</span>
<a-statistic
:title="t('dashboard.serverCount')"
:value="serverCount"
:value-style="statValueStyle"
/>
</div>
<div class="flex items-center gap-3">
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-sky-500/10 text-base text-sky-500"
>
<ApiOutlined />
</span>
<a-statistic
:title="t('dashboard.tunnelCount')"
:value="tunnelCount"
:value-style="statValueStyle"
/>
</div>
</div>
<!-- 系统信息 -->
<div class="glass-panel flex items-center gap-4 px-5 py-4">
<span
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/10 text-xl text-emerald-500"
>
<DesktopOutlined />
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1.5 text-xs">
<div class="flex items-center justify-between gap-2">
<span class="shrink-0 opacity-70">{{ t("dashboard.hostname") }}</span>
<span class="truncate font-medium" :title="details?.hostname">
{{ details?.hostname || "-" }}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="shrink-0 opacity-70">{{ t("dashboard.os") }}</span>
<span class="truncate font-medium" :title="details?.os">{{ details?.os || "-" }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="shrink-0 opacity-70">{{ t("dashboard.arch") }}</span>
<span class="truncate font-medium">{{ details?.arch || "-" }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="shrink-0 opacity-70">{{ t("dashboard.ip") }}</span>
<span class="truncate font-medium" :title="ipFull">{{ ipText }}</span>
</div>
</div>
</div>
<!-- CPU / 内存 / 运行状态 -->
<div class="glass-panel flex flex-col justify-center gap-2 px-5 py-4">
<div class="flex items-center justify-between text-xs">
<span class="opacity-70">{{ t("system.cpu") }}</span>
<span class="font-medium tabular-nums">{{ cpu }}%</span>
</div>
<a-progress :percent="cpu" :show-info="false" :stroke-color="barColor(cpu)" size="small" />
<div class="flex items-center justify-between text-xs">
<span class="opacity-70">{{ t("system.memory") }}</span>
<span class="font-medium tabular-nums">{{ memory }}%</span>
</div>
<a-progress :percent="memory" :show-info="false" :stroke-color="barColor(memory)" size="small" />
<div class="mt-1 flex items-center gap-2 text-xs">
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
<span class="opacity-70">{{ t("system.status") }}</span>
<span class="font-medium">{{ statusText }}</span>
</div>
</div>
</div>
<!-- 快速操作 -->
<div
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
>
<div class="flex items-center gap-3">
<span class="shrink-0 text-sm opacity-70">{{ t("dashboard.frpcVersion") }}</span>
<a-select
v-model:value="selectedId"
:options="versionOptions"
:loading="versionsLoading"
:placeholder="t('dashboard.frpcVersionEmpty')"
class="w-64"
show-search
option-filter-prop="label"
/>
</div>
<a-space>
<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)"
:loading="stopping"
:disabled="!isRunning"
@click="stop"
>
{{ t("dashboard.stop") }}
</a-button>
</a-space>
</div>
<!-- 实时日志 -->
<div class="glass-panel glass-panel-flat flex min-h-0 flex-1 flex-col overflow-hidden">
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-black/5 px-5 py-3 dark:border-white/10"
>
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold">{{ t("dashboard.logs") }}</h3>
<a-tag v-if="runningVersion" class="!me-0">{{ runningVersion }}</a-tag>
<a-tag :color="statusTagColor" class="!me-0">{{ statusText }}</a-tag>
</div>
<a-tooltip :title="t('dashboard.clear')">
<a-button size="small" :icon="h(ClearOutlined)" @click="clearLogs" />
</a-tooltip>
</div>
<div
ref="logBox"
class="min-h-0 flex-1 overflow-y-auto px-5 py-3 font-mono text-xs leading-relaxed"
>
<p v-if="!logs.length" class="opacity-50">{{ t("dashboard.logEmpty") }}</p>
<div v-for="(line, index) in logs" :key="index" class="flex gap-2">
<span class="shrink-0 opacity-50">{{ line.time }}</span>
<span class="shrink-0" :class="levelClass(line.level)">[{{ line.level }}]</span>
<span class="break-all">{{ line.message }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { h } from "vue"
import dayjs from "dayjs"
import {
ApiOutlined,
ClearOutlined,
CloudServerOutlined,
DesktopOutlined,
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, 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, FrpcExitEvent, FrpcLogEvent } from "@/types/frp"
import type { SystemDetails } from "@/types/system"
import { isTauri } from "@/utils/window"
const { t } = useI18n()
const appStore = useAppStore()
const { cpu, memory } = useSystemStats()
/** 服务器 / 隧道数量(来自后端,未连接 Tauri 时保持 0) */
const serverCount = ref(0)
const tunnelCount = ref(0)
/** 系统信息(主机名 / 系统 / 架构 / IP) */
const details = ref<SystemDetails | null>(null)
/** 卡片内展示的主 IP(多个时追加数量,完整列表在 tooltip 中显示) */
const ipText = computed(() => {
const ips = details.value?.ips ?? []
if (!ips.length) return "-"
return ips.length > 1 ? `${ips[0]} +${ips.length - 1}` : ips[0]
})
const ipFull = computed(() => (details.value?.ips?.length ? details.value.ips.join(", ") : "-"))
/** 合并卡片中两个统计数值的样式(更紧凑) */
const statValueStyle = { fontSize: "20px", fontWeight: 600, lineHeight: "1.2" }
/** 已下载 / 已导入的 Frp 版本(来自 frp_downloads) */
const frpDownloads = ref<FrpDownload[]>([])
const versionsLoading = ref(false)
/** 当前选中的版本记录 ID */
const selectedId = ref<string | undefined>(undefined)
/** 仅展示具备 frpc 可执行文件的记录 */
const versionOptions = computed(() =>
frpDownloads.value
.filter((item) => item.frpc_path)
.map((item) => ({ label: `${item.version} · ${item.platform}`, value: String(item.id) })),
)
const selectedDownload = computed(
() => frpDownloads.value.find((item) => String(item.id) === selectedId.value) ?? null,
)
/** 选中的版本号(用于日志展示) */
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 {
time: string
level: LogLevel
message: string
}
const logs = ref<LogLine[]>([])
const logBox = ref<HTMLElement | null>(null)
/** 追加一行日志并自动滚动到底部(最多保留 500 行) */
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)
}
nextTick(() => {
const el = logBox.value
if (el) el.scrollTop = el.scrollHeight
})
}
function clearLogs() {
logs.value = []
}
function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e)
}
/** 将后端日志级别归一化为 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"
}
}
/** 后端实时日志事件(frpc-log) */
function onBackendLog(event: FrpcLogEvent) {
pushLog(normalizeLevel(event.level), event.message, event.time ?? undefined)
}
/** 后端进程退出事件(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() {
if (!isTauri()) return
try {
const [servers, tunnels] = await Promise.all([getServerList(), getTunnelList()])
serverCount.value = servers?.length ?? 0
tunnelCount.value = tunnels?.length ?? 0
} catch {
// 读取失败时保持 0,不影响仪表盘其它区域展示
}
}
/** 拉取系统信息(主机名 / 系统 / 架构 / IP) */
async function loadSystemDetails() {
if (!isTauri()) return
try {
details.value = await getSystemDetails()
} catch {
// 读取失败时保持占位符
}
}
/** 拉取已下载 / 已导入的 Frp 版本,供版本下拉选择 */
async function loadFrpDownloads() {
if (!isTauri()) return
versionsLoading.value = true
try {
frpDownloads.value = (await getFrpDownloads()) ?? []
// 默认选中最新一个可用版本
if (!selectedId.value && versionOptions.value.length) {
selectedId.value = versionOptions.value[0].value
}
} catch {
// 读取失败时保持空列表
} finally {
versionsLoading.value = false
}
}
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?.()
})
/** 进度条颜色:低=蓝,中=橙,高=红 */
function barColor(value: number) {
if (value >= 85) return "#ef4444"
if (value >= 60) return "#f59e0b"
return "#6366f1"
}
const statusDotClass = computed(() => {
switch (appStore.runStatus) {
case "running":
return "bg-green-500 shadow-[0_0_6px_#22c55e]"
case "error":
return "bg-red-500 shadow-[0_0_6px_#ef4444]"
default:
return "bg-yellow-400"
}
})
const statusText = computed(() => {
switch (appStore.runStatus) {
case "running":
return t("system.statusRunning")
case "error":
return t("system.statusError")
default:
return t("system.statusStopped")
}
})
const statusTagColor = computed(() => {
switch (appStore.runStatus) {
case "running":
return "success"
case "error":
return "error"
default:
return "default"
}
})
function levelClass(level: LogLevel) {
switch (level) {
case "WARN":
return "text-amber-500"
case "ERROR":
return "text-red-500"
case "DEBUG":
return "text-slate-400"
default:
return "text-indigo-500"
}
}
</script>