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']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
ElCarousel: typeof import('element-plus/es')['ElCarousel']
|
||||||
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
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']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
@ -32,19 +29,12 @@ declare module 'vue' {
|
|||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
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']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
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']
|
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
|
||||||
IconUpload: typeof import('./src/components/IconUpload.vue')['default']
|
IconUpload: typeof import('./src/components/IconUpload.vue')['default']
|
||||||
PageHeader: typeof import('./src/components/PageHeader.vue')['default']
|
PageHeader: typeof import('./src/components/PageHeader.vue')['default']
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
/* ===== 项目详情页:ui-ux-pro-max Bento Grid 风格 ===== */
|
/* ===== 项目详情页:ui-ux-pro-max Bento Grid 风格 ===== */
|
||||||
/* 浅色模式 + 科技蓝主题 + 卡片式布局 + 响应式设计 */
|
/* 浅色模式 + 科技蓝主题 + 卡片式布局 + 响应式设计 */
|
||||||
|
|
||||||
|
/* 下载图标旋转动画 */
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-page {
|
.detail-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
|
|||||||
@ -153,19 +153,35 @@
|
|||||||
<button
|
<button
|
||||||
v-if="androidPackage"
|
v-if="androidPackage"
|
||||||
class="action-btn primary"
|
class="action-btn primary"
|
||||||
|
:disabled="downloadState.android.downloading"
|
||||||
@click="onLaunchAndroid"
|
@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>
|
</button>
|
||||||
<!-- PC 端资源:下载PC端(当前为直接下载模式,唤醒逻辑保留可恢复) -->
|
<!-- PC 端资源:下载PC端(当前为直接下载模式,唤醒逻辑保留可恢复) -->
|
||||||
<button
|
<button
|
||||||
v-if="pcPackage"
|
v-if="pcPackage"
|
||||||
class="action-btn primary"
|
class="action-btn primary"
|
||||||
|
:disabled="downloadState.pc.downloading"
|
||||||
@click="onLaunchPC"
|
@click="onLaunchPC"
|
||||||
>
|
>
|
||||||
<SvgIcon name="windows" :size="18" :color="'#FFFFFF'" />
|
<SvgIcon
|
||||||
下载PC端
|
:name="downloadState.pc.downloading ? 'download' : 'windows'"
|
||||||
|
:size="18"
|
||||||
|
:color="'#FFFFFF'"
|
||||||
|
:class="{ 'spinning': downloadState.pc.downloading }"
|
||||||
|
/>
|
||||||
|
{{ downloadState.pc.downloading
|
||||||
|
? `下载中 ${downloadState.pc.progress}%`
|
||||||
|
: '下载PC端' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 第二行:收藏 + 分享 -->
|
<!-- 第二行:收藏 + 分享 -->
|
||||||
@ -274,9 +290,9 @@
|
|||||||
@close="viewerVisible = false"
|
@close="viewerVisible = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 下载弹窗:唤醒协议失败时显示,引导用户下载客户端 -->
|
<!-- 下载弹窗:当前禁用(直接下载模式),恢复唤醒模式时将 v-if 改为 downloadDialogVisible 即可 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="downloadDialogVisible"
|
v-if="false"
|
||||||
width="460px"
|
width="460px"
|
||||||
class="download-dialog"
|
class="download-dialog"
|
||||||
:show-close="true"
|
:show-close="true"
|
||||||
@ -314,7 +330,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { projectApi } from '@/api'
|
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 downloadDialogVisible = ref(false)
|
||||||
// 当前下载弹窗对应的资源包
|
|
||||||
const downloadDialogPkg = ref<Package | null>(null)
|
const downloadDialogPkg = ref<Package | null>(null)
|
||||||
|
const downloading = ref(false)
|
||||||
|
|
||||||
// 截图列表计算属性
|
// 截图列表计算属性
|
||||||
const screenshots = computed(() => project.value?.screenshots || [])
|
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)
|
packages.value.find((p) => p.type === PackageType.Windows || p.type === PackageType.Mac)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 下载弹窗图标:根据资源包类型显示对应平台图标
|
// 下载弹窗图标:根据资源包类型显示对应平台图标(弹窗模式,当前禁用)
|
||||||
const downloadDialogIcon = computed(() => {
|
const downloadDialogIcon = computed(() => {
|
||||||
const t = downloadDialogPkg.value?.type
|
const t = downloadDialogPkg.value?.type
|
||||||
if (t === PackageType.Android) return 'android'
|
if (t === PackageType.Android) return 'android'
|
||||||
@ -419,7 +441,7 @@ const downloadDialogIcon = computed(() => {
|
|||||||
return 'package'
|
return 'package'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 下载弹窗资源包名称
|
// 下载弹窗资源包名称(弹窗模式,当前禁用)
|
||||||
const downloadDialogPkgName = computed(() => {
|
const downloadDialogPkgName = computed(() => {
|
||||||
const t = downloadDialogPkg.value?.type
|
const t = downloadDialogPkg.value?.type
|
||||||
if (t === PackageType.Android) return '安卓端客户端'
|
if (t === PackageType.Android) return '安卓端客户端'
|
||||||
@ -547,7 +569,7 @@ function onLaunchAndroid() {
|
|||||||
// const protocolUrl = `${prefix}://${param}`
|
// const protocolUrl = `${prefix}://${param}`
|
||||||
// tryWakeProtocol(protocolUrl, () => showDownloadDialog(pkg))
|
// tryWakeProtocol(protocolUrl, () => showDownloadDialog(pkg))
|
||||||
// --- 下载模式(当前启用)---
|
// --- 下载模式(当前启用)---
|
||||||
showDownloadDialog(pkg)
|
streamDownload(pkg, 'android')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载 PC 端:当前为直接下载模式(唤醒协议逻辑保留注释,后续可无缝恢复)
|
// 下载 PC 端:当前为直接下载模式(唤醒协议逻辑保留注释,后续可无缝恢复)
|
||||||
@ -561,7 +583,7 @@ function onLaunchPC() {
|
|||||||
// const protocolUrl = `${prefix}://${param}`
|
// const protocolUrl = `${prefix}://${param}`
|
||||||
// tryWakeProtocol(protocolUrl, () => showDownloadDialog(pkg))
|
// tryWakeProtocol(protocolUrl, () => showDownloadDialog(pkg))
|
||||||
// --- 下载模式(当前启用)---
|
// --- 下载模式(当前启用)---
|
||||||
showDownloadDialog(pkg)
|
streamDownload(pkg, 'pc')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录校验:未登录时提示并跳转登录页
|
// 登录校验:未登录时提示并跳转登录页
|
||||||
@ -628,29 +650,82 @@ function tryWakeProtocol(protocolUrl: string, onFail: () => void) {
|
|||||||
}, 2500)
|
}, 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) {
|
function showDownloadDialog(pkg: Package) {
|
||||||
downloadDialogPkg.value = pkg
|
downloadDialogPkg.value = pkg
|
||||||
downloadDialogVisible.value = true
|
downloadDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确认下载:通过 fetch API 下载(带 Authorization 头),转为 blob 触发下载
|
// 弹窗模式下的确认下载(当前用 streamDownload 替代,保留供恢复使用)
|
||||||
// 使用原始文件名,解决安卓端下载失败问题(避免 URL 过长/弹窗拦截)
|
|
||||||
const downloading = ref(false)
|
|
||||||
async function onConfirmDownload() {
|
async function onConfirmDownload() {
|
||||||
const pkg = downloadDialogPkg.value
|
const pkg = downloadDialogPkg.value
|
||||||
if (!pkg) return
|
if (!pkg) return
|
||||||
if (!userStore.isLoggedIn) {
|
|
||||||
ElMessage.warning('请先登录后下载')
|
|
||||||
router.push({ path: '/login', query: { redirect: route.fullPath } })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (downloading.value) return
|
if (downloading.value) return
|
||||||
downloading.value = true
|
downloading.value = true
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token') || ''
|
const token = localStorage.getItem('token') || ''
|
||||||
const resp = await fetch(`/api/projects/packages/${pkg.id}/download`, {
|
const resp = await fetch(`/api/projects/packages/${pkg.id}/download`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const errText = await resp.text().catch(() => '')
|
const errText = await resp.text().catch(() => '')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user