feat: 详情页按钮改为流式下载+进度显示,保留唤醒弹窗代码;恢复弹窗相关状态变量和函数

This commit is contained in:
zhanweizhang 2026-07-14 18:18:30 +08:00
parent cc9b68e4d4
commit efc9abefe5
3 changed files with 107 additions and 33 deletions

View File

@ -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']

View File

@ -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;

View File

@ -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(() => '')