congming_huose-apk/pages/app-update/app-update.vue

770 lines
17 KiB
Vue
Raw Normal View History

<template>
<view class="app-update-page">
<app-top-push-notice />
<view class="tabback">
<view class="rtjt" @tap="goBack"></view>
<view class="name">{{ $i18n.t('softwareUpdate') }}</view>
<view class="tabback-placeholder" />
</view>
<view class="tabback-spacer" />
<scroll-view class="main-scroll" scroll-y :show-scrollbar="false">
<view class="main-pad">
<view class="panel">
<view class="ios-head">
<view class="ios-icon-wrap">
<image
class="ios-icon"
src="/static/newlogo.png"
mode="aspectFill"
/>
</view>
<view class="ios-head-main">
<text class="ios-title">{{ headlineTitle }}</text>
<text class="ios-subtitle">{{ headlineSubtitle }}</text>
</view>
</view>
<rich-text
v-if="updateContentHtml"
class="ios-body-rich"
:nodes="updateContentHtml"
/>
<text v-else class="ios-body preline">{{ mainBodyText }}</text>
</view>
<view class="scroll-tail" />
</view>
</scroll-view>
<view class="footer-bar">
<text class="footer-version">
{{ $i18n.t('appUpdateCurrentVersionLabel') }}
<text class="footer-version-num">{{ currentVersionDisplay }}</text>
</text>
<view
class="footer-btn"
:class="{ 'footer-btn--disabled': footerDisabled }"
@tap="onFooterClick"
>
<text class="footer-btn-text">{{ footerBtnText }}</text>
</view>
</view>
<!-- 下载进度真实进度完成后直接调起安装完成提示 -->
<view v-if="updateProgressVisible" class="progress-mask" @tap.stop="">
<view class="progress-card" @tap.stop="">
<view class="progress-card-glow" />
<view class="progress-top">
<view class="progress-pct-huge">
<text class="pct-value">{{ updateProgress }}</text>
<text class="pct-unit">%</text>
</view>
<text class="progress-headline">{{ $i18n.t('appUpdateDownloading') }}</text>
<text class="progress-stage">{{ $i18n.t('appUpdateStageDownload') }}</text>
</view>
<view class="progress-track-shell">
<view class="progress-track-bg">
<view class="progress-fill" :style="{ width: progressWidth }">
<view class="progress-shimmer" />
<view class="progress-fill-head" />
</view>
</view>
</view>
<text class="progress-hint">{{ $i18n.t('appUpdateInProgress') }}</text>
</view>
</view>
</view>
</template>
<script>
import {
getCurrentAppVersionInfo,
pickPackageSizeLabel,
extractLatestApkRecord,
normalizeVersionString,
pickDownloadUrl
} from '@/common/utils/appVersion.js'
import {
readGlobalVersionFromStorage,
syncGlobalVersionToLocalStorage
} from '@/common/config/appVersionConfig.js'
export default {
name: 'AppUpdatePage',
data() {
return {
hasUpdate: false,
currentVersion: '',
newVersionName: '',
newVersionIntro: '',
updateContentHtml: '',
downloadUrl: '',
packageSizeLabel: '',
updateProgressVisible: false,
updateProgress: 0,
updateRunning: false,
_downloadTask: null
}
},
computed: {
progressWidth() {
return `${Math.min(100, Math.max(0, this.updateProgress))}%`
},
headlineTitle() {
const brand = this.$i18n.t('appBrandName')
if (this.hasUpdate && this.newVersionName) {
return `${brand} ${this.newVersionName}`
}
return `${brand} ${this.currentVersion}`
},
headlineSubtitle() {
if (this.hasUpdate) {
return (
this.packageSizeLabel ||
this.$i18n.t('appUpdatePackageSizeFallback')
)
}
return this.$i18n.t('appUpdateAlreadyLatest')
},
mainBodyText() {
if (this.updateContentHtml) {
return ''
}
if (this.hasUpdate) {
return (
this.newVersionIntro ||
this.$i18n.t('appUpdateDefaultNewIntro')
)
}
return (
this.$i18n.t('appUpdateHeroLatestHint') +
'\n\n' +
this.$i18n.t('appUpdateCurrentOptimizations') +
'\n' +
this.$i18n.t('appUpdateCurrentBuildNotes')
)
},
footerDisabled() {
return !this.hasUpdate || this.updateRunning
},
footerBtnText() {
if (this.updateRunning) return this.$i18n.t('appUpdateInProgress')
return this.hasUpdate
? this.$i18n.t('appUpdateNow')
: this.$i18n.t('appUpdateBtnAlreadyUpdated')
},
/** 底部展示的当前版本(与接口对比用的本地版本一致) */
currentVersionDisplay() {
const v = (this.currentVersion || '').trim()
return v || '—'
}
},
onShow() {
this.refreshVersionState()
},
mounted() {
uni.$on('languageChanged', this.onLang)
},
beforeDestroy() {
uni.$off('languageChanged', this.onLang)
this.abortActiveDownload()
},
methods: {
onLang() {
this.$forceUpdate()
},
goBack() {
if (this.updateRunning) return
uni.navigateBack()
},
applyPendingSource(pending) {
if (!pending || typeof pending !== 'object') return
const size = pickPackageSizeLabel(pending)
this.packageSizeLabel = size || this.$i18n.t('appUpdatePackageSizeFallback')
this.newVersionName =
pending.versionName != null && pending.versionName !== ''
? String(pending.versionName)
: pending.versionCode != null && Number(pending.versionCode) > 0
? String(pending.versionCode)
: ''
const intro =
pending.changelog != null && String(pending.changelog).trim() !== ''
? String(pending.changelog).trim()
: this.$i18n.t('appUpdateDefaultNewIntro')
this.newVersionIntro = intro
this.updateContentHtml =
pending.updateContentHtml != null &&
String(pending.updateContentHtml).trim() !== ''
? String(pending.updateContentHtml).trim()
: ''
this.downloadUrl = pickDownloadUrl(pending)
},
clearNoUpdateUi() {
this.hasUpdate = false
this.newVersionName = ''
this.newVersionIntro = ''
this.updateContentHtml = ''
this.downloadUrl = ''
this.packageSizeLabel = ''
},
/** 请求最新包信息:接口 `version` 与当前安装包版本一致则已是最新,否则可更新 */
async refreshVersionState() {
// 对比必须以「当前运行包」为准plus.runtime / 小程序线上版),不能用本地缓存优先:
// 否则装新 APK 后若 APP_GLOBAL_VERSION_INFO 仍为旧值,会一直误判可更新。
syncGlobalVersionToLocalStorage()
const runtimeInfo = getCurrentAppVersionInfo()
const localVer = normalizeVersionString(runtimeInfo.versionName)
const storedFallback = normalizeVersionString(readGlobalVersionFromStorage() || '')
this.currentVersion = localVer || storedFallback
try {
const res = await this.$http.get('/app/apkVersion/latest')
if (!res || Number(res.code) !== 200) {
this.clearNoUpdateUi()
return
}
const record = extractLatestApkRecord(res)
if (!record || !record.versionName) {
this.clearNoUpdateUi()
return
}
const remoteVer = normalizeVersionString(record.versionName)
if (remoteVer === localVer) {
this.hasUpdate = false
this.newVersionName = ''
this.newVersionIntro = ''
this.downloadUrl = ''
this.packageSizeLabel = pickPackageSizeLabel(record) || ''
const html =
record.updateContentHtml != null &&
String(record.updateContentHtml).trim() !== ''
? String(record.updateContentHtml).trim()
: ''
this.updateContentHtml = html
return
}
this.hasUpdate = true
this.applyPendingSource(record)
} catch (e) {
console.warn('[app-update] apkVersion/latest', e)
this.clearNoUpdateUi()
}
},
abortActiveDownload() {
if (this._downloadTask && typeof this._downloadTask.abort === 'function') {
try {
this._downloadTask.abort()
} catch (e) {}
}
this._downloadTask = null
},
_finishDownloadFailed() {
this.updateProgressVisible = false
this.updateRunning = false
this.updateProgress = 0
this._downloadTask = null
uni.showToast({
title: this.$i18n.t('appUpdateDownloadFailed'),
icon: 'none',
duration: 2500
})
},
/** Android App真实下载进度完成后直接调起安装无完成 Toast */
startAndroidApkDownload(url) {
this.abortActiveDownload()
this.updateRunning = true
this.updateProgressVisible = true
this.updateProgress = 0
const task = uni.downloadFile({
url,
success: (res) => {
this._downloadTask = null
if (res.statusCode !== 200 || !res.tempFilePath) {
this._finishDownloadFailed()
return
}
this.updateProgress = 100
this.updateProgressVisible = false
this.updateRunning = false
// #ifdef APP-PLUS
try {
plus.runtime.install(
res.tempFilePath,
{},
() => {},
(e) => {
const msg =
e && e.message
? e.message
: this.$i18n.t('appUpdateInstallFailed')
uni.showToast({ title: msg, icon: 'none', duration: 3000 })
}
)
} catch (err) {
uni.showToast({
title: this.$i18n.t('appUpdateInstallFailed'),
icon: 'none',
duration: 2500
})
}
// #endif
},
fail: () => {
this._finishDownloadFailed()
}
})
this._downloadTask = task
if (task && typeof task.onProgressUpdate === 'function') {
task.onProgressUpdate((r) => {
const p =
typeof r.progress === 'number'
? r.progress
: r.totalBytesExpectedToWrite > 0
? Math.round(
(100 * r.totalBytesWritten) /
r.totalBytesExpectedToWrite
)
: 0
this.updateProgress = Math.min(100, Math.max(0, Math.round(p)))
})
}
},
startUpdateFlow() {
if (this.updateRunning) return
const url = (this.downloadUrl || '').trim()
if (!url) {
uni.showToast({
title: this.$i18n.t('appUpdateNoDownloadUrl'),
icon: 'none'
})
return
}
// #ifdef APP-PLUS
try {
if (uni.getSystemInfoSync().platform === 'android') {
this.startAndroidApkDownload(url)
return
}
} catch (e) {}
// #endif
this.openExternal(url)
},
openExternal(url) {
const u = String(url).trim()
if (!u) return
// #ifdef APP-PLUS
try {
plus.runtime.openURL(u)
} catch (e) {
uni.showToast({ title: this.$i18n.t('appUpdateNoDownloadUrl'), icon: 'none' })
}
// #endif
// #ifdef H5
try {
if (typeof window !== 'undefined') window.open(u, '_blank')
} catch (e) {
uni.showToast({ title: this.$i18n.t('appUpdateNoDownloadUrl'), icon: 'none' })
}
// #endif
// #ifdef MP-WEIXIN
uni.setClipboardData({
data: u,
success: () => {
uni.showToast({
title: this.$i18n.t('appUpdateLinkCopied'),
icon: 'none'
})
}
})
// #endif
},
onFooterClick() {
if (!this.hasUpdate || this.updateRunning) return
this.startUpdateFlow()
}
}
}
</script>
<style scoped lang="scss">
/* 黑白灰 + 主题色 #3996FD字阶与对比统一 */
$theme: #3996fd;
$ink: #0a0a0a;
$ink-strong: #141414;
$ink-body: #3a3a3a;
$ink-muted: #6b6b6b;
$ink-sub: #8c8c8c;
$ink-hint: #a3a3a3;
$page-bg: #f0f0f0;
$surface: #ffffff;
$line: #e8e8e8;
.app-update-page {
min-height: 100vh;
background-color: $page-bg;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.tabback {
width: 750rpx;
height: 204rpx;
background: $surface;
position: fixed;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28rpx;
padding-top: 52rpx;
box-sizing: border-box;
border-bottom: 1px solid $line;
z-index: 999;
.name {
font-size: 34rpx;
color: $ink-strong;
font-weight: 600;
letter-spacing: -0.5rpx;
}
.rtjt {
font-size: 38rpx;
color: $ink-strong;
font-weight: 400;
}
.tabback-placeholder {
width: 36rpx;
height: 36rpx;
}
}
.tabback-spacer {
width: 100%;
height: 204rpx;
flex-shrink: 0;
}
.main-scroll {
flex: 1;
height: 0;
min-height: 0;
margin-top: 30rpx;
}
.main-pad {
padding: 0 32rpx;
box-sizing: border-box;
}
.panel {
background: $surface;
border-radius: 16rpx;
padding: 32rpx 28rpx 36rpx;
box-sizing: border-box;
border: 1rpx solid $line;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.scroll-tail {
height: calc(200rpx + env(safe-area-inset-bottom));
}
.ios-head {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.ios-icon-wrap {
width: 100rpx;
height: 100rpx;
border-radius: 28rpx;
overflow: hidden;
flex-shrink: 0;
background: #ebebeb;
border: 1rpx solid $line;
}
.ios-icon {
width: 100%;
height: 100%;
display: block;
}
.ios-head-main {
flex: 1;
margin-left: 24rpx;
min-width: 0;
padding-top: 4rpx;
}
.ios-title {
display: block;
font-size: 38rpx;
font-weight: 700;
color: $ink;
line-height: 1.2;
letter-spacing: -0.8rpx;
}
.ios-subtitle {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
font-weight: 400;
color: $ink-muted;
line-height: 1.4;
}
.ios-body {
display: block;
margin-top: 36rpx;
font-size: 28rpx;
font-weight: 400;
color: $ink-body;
line-height: 1.7;
text-align: left;
}
.preline {
white-space: pre-line;
}
/* rich-text 与正文对齐;内部标签字号由 HTML 决定时可被 rich-text 默认样式覆盖 */
.ios-body-rich {
display: block;
margin-top: 36rpx;
font-size: 28rpx;
color: $ink-body;
line-height: 1.7;
text-align: left;
word-break: break-word;
overflow: hidden;
}
.footer-bar {
flex-shrink: 0;
padding: 16rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
background: $surface;
border-top: 1rpx solid $line;
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.04);
z-index: 998;
box-sizing: border-box;
}
.footer-version {
display: block;
text-align: center;
font-size: 24rpx;
font-weight: 400;
color: $ink-muted;
line-height: 1.5;
margin-bottom: 16rpx;
letter-spacing: 0.2rpx;
}
.footer-version-num {
font-weight: 600;
color: $ink-strong;
margin-left: 8rpx;
}
.footer-btn {
height: 96rpx;
border-radius: 48rpx;
background: $ink;
display: flex;
align-items: center;
justify-content: center;
}
.footer-btn--disabled {
background: #d0d0d0;
}
.footer-btn-text {
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
letter-spacing: 0.5rpx;
}
.footer-btn--disabled .footer-btn-text {
color: #ffffff;
opacity: 0.85;
}
.progress-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
box-sizing: border-box;
}
.progress-card {
position: relative;
width: 100%;
max-width: 620rpx;
overflow: hidden;
border-radius: 28rpx;
padding: 52rpx 40rpx 44rpx;
box-sizing: border-box;
background: $surface;
border: 1rpx solid $line;
box-shadow: 0 24rpx 64rpx rgba(0, 0, 0, 0.18);
}
.progress-card-glow {
position: absolute;
top: -100rpx;
left: 50%;
transform: translateX(-50%);
width: 400rpx;
height: 200rpx;
background: radial-gradient(
ellipse at center,
rgba(57, 150, 253, 0.12) 0%,
transparent 65%
);
pointer-events: none;
}
.progress-top {
position: relative;
z-index: 1;
margin-bottom: 40rpx;
}
.progress-pct-huge {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: center;
margin-bottom: 16rpx;
}
.pct-value {
font-size: 88rpx;
font-weight: 700;
color: $ink;
line-height: 0.9;
letter-spacing: -3rpx;
font-variant-numeric: tabular-nums;
}
.pct-unit {
font-size: 36rpx;
font-weight: 600;
color: $ink-sub;
line-height: 1.2;
margin-left: 6rpx;
margin-bottom: 10rpx;
}
.progress-headline {
display: block;
text-align: center;
font-size: 30rpx;
font-weight: 600;
color: $ink-strong;
letter-spacing: -0.3rpx;
}
.progress-stage {
display: block;
text-align: center;
margin-top: 14rpx;
font-size: 24rpx;
color: $ink-muted;
line-height: 1.5;
min-height: 40rpx;
}
.progress-track-shell {
position: relative;
z-index: 1;
margin-bottom: 20rpx;
}
.progress-track-bg {
position: relative;
height: 20rpx;
border-radius: 10rpx;
background: #e3e3e3;
box-shadow: inset 0 2rpx 5rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
box-sizing: border-box;
}
.progress-fill {
position: relative;
height: 100%;
border-radius: 10rpx;
background: linear-gradient(90deg, #5ba3fc 0%, $theme 45%, #2b7de0 100%);
box-shadow:
0 0 16rpx rgba(57, 150, 253, 0.35),
inset 0 1rpx 0 rgba(255, 255, 255, 0.25);
transition: width 0.1s linear, background 0.35s ease, box-shadow 0.35s ease;
overflow: hidden;
}
.progress-shimmer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 55%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.45) 45%,
rgba(255, 255, 255, 0) 90%
);
animation: progress-shine 1.35s ease-in-out infinite;
}
@keyframes progress-shine {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(220%);
}
}
.progress-fill-head {
position: absolute;
top: 50%;
right: 2rpx;
transform: translateY(-50%);
width: 8rpx;
height: 55%;
border-radius: 6rpx;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 0 14rpx rgba(255, 255, 255, 0.75);
}
.progress-hint {
position: relative;
z-index: 1;
display: block;
text-align: center;
font-size: 22rpx;
color: $ink-hint;
letter-spacing: 0.5rpx;
}
</style>