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

770 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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