feat: 新增 Frp 下载管理功能,支持版本拉取、下载、导入和删除

新增 Frp 下载相关的后端命令、前端组件和类型定义,包括:
- 后端:添加 frp 模块,实现版本拉取、下载、导入、删除逻辑,新增数据库表
- 前端:新增 FrpCard 组件、下载页面,集成版本选择和下载进度展示
- 国际化:新增中英双语的 Frp 相关文案
- 依赖:添加 reqwest、futures-util、tokio 等网络和异步处理依赖
This commit is contained in:
Yuhang Wu
2026-10-13 16:39:29 +08:00
parent 50bf5f7e36
commit db7b1096d1
16 changed files with 1608 additions and 19 deletions
+44
View File
@@ -0,0 +1,44 @@
import { invoke } from "@tauri-apps/api/core"
import type { ApiResponse } from "@/types/api"
import type { FrpDownload, FrpDownloadPayload, FrpRelease } from "@/types/frp"
/**
* 调用后端命令并统一解包 ApiResponse。
*
* 后端所有 Frp 下载相关命令均返回 `ApiResponse<T>`,业务失败时
* `success === false`,此处统一抛出异常,交由调用方提示。
*/
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const res = await invoke<ApiResponse<T>>(command, args)
if (!res?.success) {
throw new Error(res?.msg || "操作失败")
}
return res.data as T
}
/**
* 获取 Frp 版本列表(返回所有架构,前端按开关过滤)
*/
export function getFrpReleases() {
return call<FrpRelease[]>("get_frp_releases")
}
/** 下载并解压指定 Frp 发行包 */
export function downloadFrp(payload: FrpDownloadPayload) {
return call<FrpDownload>("download_frp", { payload })
}
/** 获取本机已下载 / 已导入的 Frp 版本列表(仪表盘选择 frpc 版本用) */
export function getFrpDownloads() {
return call<FrpDownload[]>("get_frp_downloads")
}
/** 删除已下载 / 已导入的版本 */
export function deleteFrp(id: number) {
return call<null>("delete_frp", { id })
}
/** 导入本地已下载的 frpc / frps(返回导入后的记录) */
export function importFrp() {
return call<FrpDownload[]>("import_frp")
}
+175
View File
@@ -0,0 +1,175 @@
<template>
<a-card class="frp-card" :body-style="{ padding: '16px' }" hoverable>
<!-- 头部:版本 + 状态标签 -->
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate text-base font-semibold">{{ release.version }}</span>
<a-tag v-if="release.downloaded" color="green" class="!me-0">{{ t("frp.downloaded") }}</a-tag>
<a-tag v-if="release.local_only" color="purple" class="!me-0">{{ t("frp.local") }}</a-tag>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<a-tag color="blue" class="!me-0">{{ release.platform }}</a-tag>
<a-tag class="!me-0">frpc</a-tag>
<a-tag v-if="showFrps" color="cyan" class="!me-0">frps</a-tag>
</div>
</div>
</div>
<!-- 元信息:大小 / 下载次数 / 发布日期 -->
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs">
<div class="flex flex-col gap-1">
<span class="opacity-60">{{ t("frp.size") }}</span>
<span class="font-medium tabular-nums">{{ formatSize(release.size) }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="opacity-60">{{ t("frp.downloadCount") }}</span>
<span class="font-medium tabular-nums">{{ formatCount(release.download_count) }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="opacity-60">{{ t("frp.publishedAt") }}</span>
<span class="font-medium">{{ formatDate(release.published_at) }}</span>
</div>
</div>
<a-divider class="!my-3" />
<!-- 操作按钮 -->
<div class="flex items-center justify-end gap-2">
<a-button
v-if="!release.local_only"
type="primary"
size="small"
:icon="h(DownloadOutlined)"
:loading="downloading"
:disabled="release.downloaded"
@click="emit('download')"
>
{{ release.downloaded ? t("frp.downloaded") : t("frp.download") }}
</a-button>
<a-tooltip v-if="!release.local_only" :title="t('frp.copyLink')">
<a-button size="small" :icon="h(LinkOutlined)" @click="emit('copy')" />
</a-tooltip>
<a-popconfirm
v-if="release.downloaded"
:title="t('frp.deleteConfirm', { version: release.version, platform: release.platform })"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
@confirm="emit('delete')"
>
<a-button size="small" danger :icon="h(DeleteOutlined)" />
</a-popconfirm>
</div>
<!-- 下载进度(卡片下方) -->
<div v-if="progress" class="mt-3">
<div class="mb-1 flex items-center justify-between text-xs">
<span :class="statusClass">{{ statusText }}</span>
<span class="tabular-nums opacity-70">{{ progress.percent }}%</span>
</div>
<a-progress
:percent="progress.percent"
:status="progressStatus"
:stroke-color="progressColor"
:show-info="false"
size="small"
/>
<p v-if="progress.status === 'error' && progress.message" class="mt-1 line-clamp-2 text-xs text-red-500">
{{ progress.message }}
</p>
</div>
</a-card>
</template>
<script setup lang="ts">
import dayjs from "dayjs"
import { h } from "vue"
import { DeleteOutlined, DownloadOutlined, LinkOutlined } from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
import type { FrpDownloadProgress, FrpRelease } from "@/types/frp"
const props = defineProps<{
release: FrpRelease
progress?: FrpDownloadProgress | null
showFrps?: boolean
}>()
const emit = defineEmits<{
download: []
copy: []
delete: []
}>()
const { t } = useI18n()
const downloading = computed(
() => props.progress?.status === "downloading" || props.progress?.status === "extracting",
)
const progressStatus = computed(() => {
switch (props.progress?.status) {
case "error":
return "exception"
case "done":
return "success"
default:
return "normal"
}
})
const progressColor = computed(() => {
switch (props.progress?.status) {
case "error":
return "#ef4444"
case "done":
return "#22c55e"
default:
return "#6366f1"
}
})
const statusClass = computed(() =>
props.progress?.status === "error" ? "text-red-500" : "opacity-70",
)
const statusText = computed(() =>
props.progress ? t(`frp.status.${props.progress.status}`) : "",
)
/** 字节数格式化 */
function formatSize(bytes: number) {
if (!bytes) return "-"
const units = ["B", "KB", "MB", "GB"]
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
/** 下载次数格式化(过万显示为 w) */
function formatCount(count: number) {
if (!count) return "-"
return count >= 10000 ? `${(count / 10000).toFixed(1)}w` : String(count)
}
/** 发布日期格式化 */
function formatDate(value: string) {
return value ? dayjs(value).format("YYYY-MM-DD") : "-"
}
</script>
<style scoped lang="scss">
.frp-card {
border-color: rgba(15, 23, 42, 0.08);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.dark .frp-card {
border-color: rgba(255, 255, 255, 0.1);
}
</style>
+29
View File
@@ -41,6 +41,7 @@
"arch": "Architecture",
"ip": "IP address",
"frpcVersion": "Frpc version",
"frpcVersionEmpty": "No downloaded version yet. Download one from the Frpc download page first.",
"start": "One-click start",
"stop": "Stop",
"logs": "Live logs",
@@ -180,6 +181,34 @@
"portRange": "Port must be between 1 and 65535"
}
},
"frp": {
"refresh": "Refresh",
"import": "Import local",
"showAllArchFrpc": "All-arch Frpc",
"showAllArchFrps": "All-arch Frps",
"empty": "No Frp release data. Click Refresh to retry.",
"downloaded": "Downloaded",
"local": "Local",
"download": "Download",
"copyLink": "Copy download link",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete {version} ({platform})?",
"size": "Size",
"downloadCount": "Downloads",
"publishedAt": "Published",
"downloadSuccess": "Download completed",
"deleteSuccess": "Deleted successfully",
"copySuccess": "Download link copied",
"copyFailed": "Copy failed, please copy manually",
"importSuccess": "Imported {count} file(s)",
"importEmpty": "No file selected",
"status": {
"downloading": "Downloading",
"extracting": "Extracting",
"done": "Done",
"error": "Download failed"
}
},
"common": {
"developing": "This feature is under construction",
"confirm": "OK",
+29
View File
@@ -41,6 +41,7 @@
"arch": "系统架构",
"ip": "IP 地址",
"frpcVersion": "Frpc 版本",
"frpcVersionEmpty": "暂无已下载版本,请先前往 Frpc 下载页面下载",
"start": "一键启动",
"stop": "停止",
"logs": "实时日志",
@@ -180,6 +181,34 @@
"portRange": "端口号必须在 1 - 65535 之间"
}
},
"frp": {
"refresh": "刷新",
"import": "导入本地",
"showAllArchFrpc": "所有架构 Frpc",
"showAllArchFrps": "所有架构 Frps",
"empty": "暂无 Frp 版本数据,点击刷新重试",
"downloaded": "已下载",
"local": "本地",
"download": "下载",
"copyLink": "复制下载链接",
"delete": "删除",
"deleteConfirm": "确定要删除已下载的 {version}({platform})吗?",
"size": "大小",
"downloadCount": "下载次数",
"publishedAt": "发布日期",
"downloadSuccess": "下载完成",
"deleteSuccess": "删除成功",
"copySuccess": "下载链接已复制",
"copyFailed": "复制失败,请手动复制",
"importSuccess": "已导入 {count} 个文件",
"importEmpty": "未选择任何文件",
"status": {
"downloading": "下载中",
"extracting": "解压中",
"done": "已完成",
"error": "下载失败"
}
},
"common": {
"developing": "功能开发中,敬请期待",
"confirm": "确定",
+1 -1
View File
@@ -30,7 +30,6 @@ declare module 'vue' {
AModal: typeof import('ant-design-vue/es')['Modal']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AProgress: typeof import('ant-design-vue/es')['Progress']
AResult: typeof import('ant-design-vue/es')['Result']
ARow: typeof import('ant-design-vue/es')['Row']
ASegmented: typeof import('ant-design-vue/es')['Segmented']
ASelect: typeof import('ant-design-vue/es')['Select']
@@ -41,6 +40,7 @@ declare module 'vue' {
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+89
View File
@@ -0,0 +1,89 @@
/**
* Frp 下载相关的类型定义,字段与 Rust 端 `models::FrpRelease` / `models::FrpDownload`
* 等一一对应。
*/
/** GitHub 上的 Frp 发行包(卡片展示用) */
export interface FrpRelease {
/** 版本号,如 v0.71.0 */
version: string
/** 操作系统,如 windows / darwin / linux */
os: string
/** CPU 架构,如 amd64 / arm64 */
arch: string
/** 平台标识(os_arch),如 windows_amd64 */
platform: string
/** 发行包文件名 */
file_name: string
/** 文件大小(字节) */
size: number
/** GitHub 下载次数 */
download_count: number
/** 发布日期(ISO 8601) */
published_at: string
/** GitHub 原始下载地址 */
download_url: string
/** 镜像加速后的下载地址 */
mirror_url: string
/** 是否已下载 / 已导入 */
downloaded: boolean
/** 是否匹配当前运行平台(用于前端默认过滤) */
is_current: boolean
/** 是否为仅本机存在的记录(未匹配到发行包) */
local_only: boolean
/** 本机记录 ID(用于删除) */
local_id: number | null
/** 本机 frpc 可执行文件路径 */
frpc_path: string | null
/** 本机 frps 可执行文件路径 */
frps_path: string | null
}
/** 已下载 / 已导入的 Frp 版本 */
export interface FrpDownload {
id: number
version: string
os: string
arch: string
platform: string
file_name: string
archive_path: string | null
frpc_path: string | null
frps_path: string | null
file_size: number
/** 来源:download(应用内下载)/ import(本地导入) */
source: string
created_at: string
updated_at: string
}
/** 下载请求 */
export interface FrpDownloadPayload {
version: string
os: string
arch: string
platform: string
file_name: string
/** GitHub 原始下载地址(后端会拼接镜像前缀) */
download_url: string
}
/** 下载进度状态 */
export type FrpDownloadStatus = "downloading" | "extracting" | "done" | "error"
/** Frp 下载进度事件 */
export interface FrpDownloadProgress {
/** 事件键,与发行包文件名一致 */
key: string
version: string
platform: string
/** 已下载字节数 */
downloaded: number
/** 总字节数(0 表示未知) */
total: number
/** 进度百分比(0 - 100) */
percent: number
status: FrpDownloadStatus
/** 附加信息(错误原因等) */
message: string | null
}
+49 -9
View File
@@ -87,14 +87,22 @@
>
<div class="flex items-center gap-3">
<span class="shrink-0 text-sm opacity-70">{{ t("dashboard.frpcVersion") }}</span>
<a-select v-model:value="selectedVersion" :options="versionOptions" class="w-56" />
<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)"
:disabled="isRunning"
:disabled="isRunning || !selectedId"
@click="start"
>
{{ t("dashboard.start") }}
@@ -148,11 +156,13 @@ import {
PoweroffOutlined,
} from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
import { getFrpDownloads } 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 { SystemDetails } from "@/types/system"
import { isTauri } from "@/utils/window"
@@ -178,13 +188,25 @@ const ipFull = computed(() => (details.value?.ips?.length ? details.value.ips.jo
/** 合并卡片中两个统计数值的样式(更紧凑) */
const statValueStyle = { fontSize: "20px", fontWeight: 600, lineHeight: "1.2" }
/** 模拟:已下载的 Frpc 版本列表(下载功能尚未实现) */
const versionOptions = [
{ label: "v0.61.1 · windows_amd64", value: "v0.61.1" },
{ label: "v0.60.0 · windows_amd64", value: "v0.60.0" },
{ label: "v0.58.2 · windows_amd64", value: "v0.58.2" },
]
const selectedVersion = ref(versionOptions[0].value)
/** 已下载 / 已导入的 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")
@@ -265,10 +287,28 @@ async function loadSystemDetails() {
}
}
/** 拉取已下载 / 已导入的 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
}
}
onMounted(() => {
pushLog("INFO", t("dashboard.logReady"))
loadCounts()
loadSystemDetails()
loadFrpDownloads()
})
/** 进度条颜色:低=蓝,中=橙,高=红 */
+195 -4
View File
@@ -1,19 +1,210 @@
<template>
<div class="flex h-full flex-col gap-3">
<!-- 顶部操作栏(独立磨砂卡片) -->
<div class="glass-panel glass-panel-flat flex shrink-0 items-center justify-between gap-3 px-5 py-3">
<h2 class="text-lg font-semibold">{{ t("menu.download") }}</h2>
<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-2">
<h2 class="text-lg font-semibold">{{ t("menu.download") }}</h2>
<a-tag v-if="releases.length" class="!me-0">{{ releases.length }}</a-tag>
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<label class="flex cursor-pointer items-center gap-2 text-xs">
<a-switch v-model:checked="showAllArchFrpc" size="small" />
<span>{{ t("frp.showAllArchFrpc") }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-xs">
<a-switch v-model:checked="showAllArchFrps" size="small" />
<span>{{ t("frp.showAllArchFrps") }}</span>
</label>
<a-space>
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("frp.refresh") }}
</a-button>
<a-button :loading="importing" :icon="h(ImportOutlined)" @click="onImport">
{{ t("frp.import") }}
</a-button>
</a-space>
</div>
</div>
<!-- 内容区(独立磨砂卡片) -->
<!-- 内容区(独立磨砂卡片,内部滚动) -->
<div class="glass-panel glass-panel-flat min-h-0 flex-1 overflow-y-auto p-5">
<a-result status="info" :sub-title="t('common.developing')" />
<a-spin :spinning="loading">
<a-empty v-if="!releases.length" class="mt-20" :description="t('frp.empty')" />
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<FrpCard
v-for="item in releases"
:key="`${item.version}-${item.platform}-${item.file_name}`"
:release="item"
:progress="progressMap[item.file_name]"
:show-frps="showAllArchFrps"
@download="onDownload(item)"
@copy="onCopy(item)"
@delete="onDelete(item)"
/>
</div>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { h } from "vue"
import { ImportOutlined, ReloadOutlined } 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 { deleteFrp, downloadFrp, getFrpReleases, importFrp } from "@/api/frp"
import FrpCard from "@/components/frp/FrpCard.vue"
import type { FrpDownloadProgress, FrpRelease } from "@/types/frp"
import { isTauri } from "@/utils/window"
const { t } = useI18n()
const loading = ref(false)
const importing = ref(false)
/** 是否展示所有架构的 Frpc */
const showAllArchFrpc = useStorage("frpilot:frp:allArchFrpc", false)
/** 是否作为 frp 下载器展示所有架构的 Frps */
const showAllArchFrps = useStorage("frpilot:frp:allArchFrps", false)
/** 后端返回的全部发行包(含所有架构) */
const allReleases = ref<FrpRelease[]>([])
/**
* 按开关过滤后的展示列表:
* 默认仅展示当前运行平台(以及已下载的记录);开启任一开关后展示所有架构。
*/
const releases = computed(() => {
if (showAllArchFrpc.value || showAllArchFrps.value) return allReleases.value
return allReleases.value.filter((item) => item.is_current || item.downloaded)
})
/** 各发行包的下载进度(以文件名为键) */
const progressMap = ref<Record<string, FrpDownloadProgress>>({})
function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e)
}
/** 拉取 Frp 版本列表,silent 为 true 时静默刷新(不显示 loading) */
async function refresh(silent = false) {
if (!isTauri()) return
if (!silent) loading.value = true
try {
allReleases.value = (await getFrpReleases()) ?? []
} catch (e) {
message.error(errMsg(e))
} finally {
if (!silent) loading.value = false
}
}
async function onDownload(item: FrpRelease) {
if (!item.download_url) return
progressMap.value[item.file_name] = {
key: item.file_name,
version: item.version,
platform: item.platform,
downloaded: 0,
total: item.size,
percent: 0,
status: "downloading",
message: null,
}
try {
const row = await downloadFrp({
version: item.version,
os: item.os,
arch: item.arch,
platform: item.platform,
file_name: item.file_name,
download_url: item.download_url,
})
// 直接更新本地状态,避免重复请求 GitHub API
item.downloaded = true
item.local_id = row.id
item.frpc_path = row.frpc_path
item.frps_path = row.frps_path
message.success(t("frp.downloadSuccess"))
} catch (e) {
message.error(errMsg(e))
}
}
async function onCopy(item: FrpRelease) {
const link = item.mirror_url || item.download_url
if (!link) return
try {
await navigator.clipboard.writeText(link)
message.success(t("frp.copySuccess"))
} catch {
message.error(t("frp.copyFailed"))
}
}
async function onDelete(item: FrpRelease) {
if (item.local_id == null) return
try {
await deleteFrp(item.local_id)
message.success(t("frp.deleteSuccess"))
if (item.local_only) {
// 仅本机的记录直接移除
allReleases.value = allReleases.value.filter((it) => it !== item)
} else {
item.downloaded = false
item.local_id = null
item.frpc_path = null
item.frps_path = null
}
} catch (e) {
message.error(errMsg(e))
}
}
async function onImport() {
importing.value = true
try {
const rows = await importFrp()
if (rows?.length) {
message.success(t("frp.importSuccess", { count: rows.length }))
} else {
message.info(t("frp.importEmpty"))
}
await refresh(true)
} catch (e) {
message.error(errMsg(e))
} finally {
importing.value = false
}
}
/** 下载进度事件:更新对应卡片的进度条 */
function onProgress(p: FrpDownloadProgress) {
progressMap.value[p.key] = p
if (p.status === "done") {
setTimeout(() => {
delete progressMap.value[p.key]
}, 1500)
}
}
let unlisten: UnlistenFn | null = null
onMounted(async () => {
refresh()
if (isTauri()) {
unlisten = await listen<FrpDownloadProgress>("frp-download-progress", (event) =>
onProgress(event.payload),
)
}
})
onUnmounted(() => unlisten?.())
</script>