feat: 详情页按钮改为流式下载+进度显示,保留唤醒弹窗代码;恢复弹窗相关状态变量和函数
This commit is contained in:
parent
cc9b68e4d4
commit
efc9abefe5
10
frontend/components.d.ts
vendored
10
frontend/components.d.ts
vendored
@ -16,10 +16,7 @@ declare module 'vue' {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
||||
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
@ -32,19 +29,12 @@ declare module 'vue' {
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
|
||||
IconUpload: typeof import('./src/components/IconUpload.vue')['default']
|
||||
PageHeader: typeof import('./src/components/PageHeader.vue')['default']
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
/* ===== 项目详情页:ui-ux-pro-max Bento Grid 风格 ===== */
|
||||
/* 浅色模式 + 科技蓝主题 + 卡片式布局 + 响应式设计 */
|
||||
|
||||
/* 下载图标旋转动画 */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.detail-page {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
|
||||
@ -153,19 +153,35 @@
|
||||
<button
|
||||
v-if="androidPackage"
|
||||
class="action-btn primary"
|
||||
:disabled="downloadState.android.downloading"
|
||||
@click="onLaunchAndroid"
|
||||
>
|
||||
<SvgIcon name="android" :size="18" :color="'#FFFFFF'" />
|
||||
下载安卓端
|
||||
<SvgIcon
|
||||
:name="downloadState.android.downloading ? 'download' : 'android'"
|
||||
:size="18"
|
||||
:color="'#FFFFFF'"
|
||||
:class="{ 'spinning': downloadState.android.downloading }"
|
||||
/>
|
||||
{{ downloadState.android.downloading
|
||||
? `下载中 ${downloadState.android.progress}%`
|
||||
: '下载安卓端' }}
|
||||
</button>
|
||||
<!-- PC 端资源:下载PC端(当前为直接下载模式,唤醒逻辑保留可恢复) -->
|
||||
<button
|
||||
v-if="pcPackage"
|
||||
class="action-btn primary"
|
||||
:disabled="downloadState.pc.downloading"
|
||||
@click="onLaunchPC"
|
||||
>
|
||||
<SvgIcon name="windows" :size="18" :color="'#FFFFFF'" />
|
||||
下载PC端
|
||||
<SvgIcon
|
||||
:name="downloadState.pc.downloading ? 'download' : 'windows'"
|
||||
:size="18"
|
||||
:color="'#FFFFFF'"
|
||||
:class="{ 'spinning': downloadState.pc.downloading }"
|
||||
/>
|
||||
{{ downloadState.pc.downloading
|
||||
? `下载中 ${downloadState.pc.progress}%`
|
||||
: '下载PC端' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 第二行:收藏 + 分享 -->
|
||||
@ -274,9 +290,9 @@
|
||||
@close="viewerVisible = false"
|
||||
/>
|
||||
|
||||
<!-- 下载弹窗:唤醒协议失败时显示,引导用户下载客户端 -->
|
||||
<!-- 下载弹窗:当前禁用(直接下载模式),恢复唤醒模式时将 v-if 改为 downloadDialogVisible 即可 -->
|
||||
<el-dialog
|
||||
v-model="downloadDialogVisible"
|
||||
v-if="false"
|
||||
width="460px"
|
||||
class="download-dialog"
|
||||
:show-close="true"
|
||||
@ -314,7 +330,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { projectApi } from '@/api'
|
||||
@ -362,10 +378,16 @@ function onKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 下载弹窗状态:唤醒协议失败时显示
|
||||
// 下载状态:按平台追踪下载进度(流式下载,非弹窗模式)
|
||||
const downloadState = reactive({
|
||||
android: { downloading: false, progress: 0 },
|
||||
pc: { downloading: false, progress: 0 },
|
||||
})
|
||||
|
||||
// 弹窗模式状态:当前禁用(v-if="false"),恢复唤醒模式时启用
|
||||
const downloadDialogVisible = ref(false)
|
||||
// 当前下载弹窗对应的资源包
|
||||
const downloadDialogPkg = ref<Package | null>(null)
|
||||
const downloading = ref(false)
|
||||
|
||||
// 截图列表计算属性
|
||||
const screenshots = computed(() => project.value?.screenshots || [])
|
||||
@ -410,7 +432,7 @@ const pcPackage = computed(() =>
|
||||
packages.value.find((p) => p.type === PackageType.Windows || p.type === PackageType.Mac)
|
||||
)
|
||||
|
||||
// 下载弹窗图标:根据资源包类型显示对应平台图标
|
||||
// 下载弹窗图标:根据资源包类型显示对应平台图标(弹窗模式,当前禁用)
|
||||
const downloadDialogIcon = computed(() => {
|
||||
const t = downloadDialogPkg.value?.type
|
||||
if (t === PackageType.Android) return 'android'
|
||||
@ -419,7 +441,7 @@ const downloadDialogIcon = computed(() => {
|
||||
return 'package'
|
||||
})
|
||||
|
||||
// 下载弹窗资源包名称
|
||||
// 下载弹窗资源包名称(弹窗模式,当前禁用)
|
||||
const downloadDialogPkgName = computed(() => {
|
||||
const t = downloadDialogPkg.value?.type
|
||||
if (t === PackageType.Android) return '安卓端客户端'
|
||||
@ -547,7 +569,7 @@ function onLaunchAndroid() {
|
||||
// const protocolUrl = `${prefix}://${param}`
|
||||
// tryWakeProtocol(protocolUrl, () => showDownloadDialog(pkg))
|
||||
// --- 下载模式(当前启用)---
|
||||
showDownloadDialog(pkg)
|
||||
streamDownload(pkg, 'android')
|
||||
}
|
||||
|
||||
// 下载 PC 端:当前为直接下载模式(唤醒协议逻辑保留注释,后续可无缝恢复)
|
||||
@ -561,7 +583,7 @@ function onLaunchPC() {
|
||||
// const protocolUrl = `${prefix}://${param}`
|
||||
// tryWakeProtocol(protocolUrl, () => showDownloadDialog(pkg))
|
||||
// --- 下载模式(当前启用)---
|
||||
showDownloadDialog(pkg)
|
||||
streamDownload(pkg, 'pc')
|
||||
}
|
||||
|
||||
// 登录校验:未登录时提示并跳转登录页
|
||||
@ -628,29 +650,82 @@ function tryWakeProtocol(protocolUrl: string, onFail: () => void) {
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
// 显示下载弹窗:唤醒失败后引导用户下载客户端
|
||||
// 流式下载:通过 ReadableStream 边读边写入,避免大文件全量加载到内存
|
||||
// 显示实时进度百分比在按钮上,下载完成后自动触发保存
|
||||
async function streamDownload(pkg: Package, platform: 'android' | 'pc') {
|
||||
const state = downloadState[platform]
|
||||
if (state.downloading) return
|
||||
|
||||
state.downloading = true
|
||||
state.progress = 0
|
||||
try {
|
||||
const token = localStorage.getItem('token') || ''
|
||||
const resp = await fetch(`/api/projects/packages/${pkg.id}/download`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => '')
|
||||
throw new Error(errText || `下载失败 (${resp.status})`)
|
||||
}
|
||||
|
||||
// 优先从 Content-Length 头获取总大小,用于进度计算
|
||||
const total = Number(resp.headers.get('Content-Length')) || 0
|
||||
const reader = resp.body?.getReader()
|
||||
if (!reader) throw new Error('浏览器不支持流式下载')
|
||||
|
||||
// 边读边收集 chunk(流式读取,避免一次性 blob 占满内存)
|
||||
const chunks: Uint8Array[] = []
|
||||
let received = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value) {
|
||||
chunks.push(value)
|
||||
received += value.length
|
||||
if (total > 0) {
|
||||
state.progress = Math.min(99, Math.round((received / total) * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
state.progress = 100
|
||||
|
||||
// 合并 chunks 为 blob 并触发下载
|
||||
const blob = new Blob(chunks, { type: resp.headers.get('Content-Type') || 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = pkg.fileName || 'download'
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success(`${pkg.fileName || '资源包'} 下载完成`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '下载失败,请稍后重试')
|
||||
} finally {
|
||||
state.downloading = false
|
||||
// 延迟重置进度,让用户看到 100%
|
||||
setTimeout(() => { state.progress = 0 }, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 弹窗模式代码(当前禁用,恢复唤醒模式时取消上面按钮注释并启用此函数)───
|
||||
function showDownloadDialog(pkg: Package) {
|
||||
downloadDialogPkg.value = pkg
|
||||
downloadDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认下载:通过 fetch API 下载(带 Authorization 头),转为 blob 触发下载
|
||||
// 使用原始文件名,解决安卓端下载失败问题(避免 URL 过长/弹窗拦截)
|
||||
const downloading = ref(false)
|
||||
// 弹窗模式下的确认下载(当前用 streamDownload 替代,保留供恢复使用)
|
||||
async function onConfirmDownload() {
|
||||
const pkg = downloadDialogPkg.value
|
||||
if (!pkg) return
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录后下载')
|
||||
router.push({ path: '/login', query: { redirect: route.fullPath } })
|
||||
return
|
||||
}
|
||||
if (downloading.value) return
|
||||
downloading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('token') || ''
|
||||
const resp = await fetch(`/api/projects/packages/${pkg.id}/download`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => '')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user