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
+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>