XuqmGroup-RNChatDemo/src/components/UpdatePanel.tsx

321 行
10 KiB
TypeScript

import React, {useState} from 'react'
import {Linking, Platform, Pressable, StyleSheet, Text, View} from 'react-native'
import {UpdateSDK} from '@xuqm/rn-sdk'
interface Props {
appVersionCode: number
rnModuleId: string
rnCurrentVersion: string
onLog: (msg: string) => void
}
type StepStatus = 'idle' | 'loading' | 'ok' | 'error'
interface CheckResult {
needsUpdate: boolean
label: string
detail: string
}
export function UpdatePanel({appVersionCode, rnModuleId, rnCurrentVersion, onLog}: Props) {
const [appStep, setAppStep] = useState<StepStatus>('idle')
const [rnCheckStep, setRnCheckStep] = useState<StepStatus>('idle')
const [rnDownloadStep, setRnDownloadStep] = useState<StepStatus>('idle')
const [rnCacheStep, setRnCacheStep] = useState<StepStatus>('idle')
const [appResult, setAppResult] = useState<CheckResult | null>(null)
const [rnResult, setRnResult] = useState<CheckResult | null>(null)
const [rnDownloadInfo, setRnDownloadInfo] = useState('')
const [rnCacheInfo, setRnCacheInfo] = useState('')
const reset = () => {
setAppStep('idle')
setRnCheckStep('idle')
setRnDownloadStep('idle')
setRnCacheStep('idle')
setAppResult(null)
setRnResult(null)
setRnDownloadInfo('')
setRnCacheInfo('')
}
const runAppUpdate = async () => {
reset()
setAppStep('loading')
try {
const res = await UpdateSDK.checkAppUpdate(appVersionCode)
if (!res.needsUpdate) {
setAppResult({
needsUpdate: false,
label: '✓ App 已是最新版本',
detail: `当前版本码:${appVersionCode},服务端无更高版本`,
})
setAppStep('ok')
onLog('App 更新检查:当前最新')
return
}
setAppResult({
needsUpdate: true,
label: `↑ 发现新版本 ${res.versionName ?? ''}code=${res.versionCode ?? ''}`,
detail: [
res.changeLog ? `更新说明:${res.changeLog}` : '',
`强制更新:${res.forceUpdate ? '是' : '否'}`,
res.downloadUrl ? `下载:${res.downloadUrl}` : '',
res.appStoreUrl ? `App Store${res.appStoreUrl}` : '',
].filter(Boolean).join('\n'),
})
setAppStep('ok')
onLog(`发现 App 新版本:${res.versionName}`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'App 更新检查失败'
setAppResult({needsUpdate: false, label: '✗ 检查失败', detail: msg})
setAppStep('error')
onLog(`App 更新失败:${msg}`)
}
}
const runRnUpdate = async () => {
setRnCheckStep('loading')
setRnDownloadStep('idle')
setRnCacheStep('idle')
setRnResult(null)
setRnDownloadInfo('')
setRnCacheInfo('')
let checkInfo: Awaited<ReturnType<typeof UpdateSDK.checkRnUpdate>>
try {
checkInfo = await UpdateSDK.checkRnUpdate(rnModuleId, rnCurrentVersion)
if (!checkInfo.needsUpdate) {
setRnResult({
needsUpdate: false,
label: '✓ 插件已是最新版本',
detail: `当前版本:${rnCurrentVersion},服务端无更高版本`,
})
setRnCheckStep('ok')
onLog('插件更新检查:当前最新')
return
}
setRnResult({
needsUpdate: true,
label: `↑ 发现插件版本 ${checkInfo.latestVersion}(当前 ${rnCurrentVersion}`,
detail: [
`模块:${rnModuleId}`,
`平台:${Platform.OS}`,
`md5${checkInfo.md5}`,
checkInfo.note ? `说明:${checkInfo.note}` : '',
`minCommonVersion${checkInfo.minCommonVersion}`,
].filter(Boolean).join('\n'),
})
setRnCheckStep('ok')
onLog(`发现插件新版本:${checkInfo.latestVersion}`)
} catch (e) {
const msg = e instanceof Error ? e.message : '检查失败'
setRnResult({needsUpdate: false, label: '✗ 检查失败', detail: msg})
setRnCheckStep('error')
onLog(`插件更新检查失败:${msg}`)
return
}
// Step 2: Download
setRnDownloadStep('loading')
let source: string
try {
source = await UpdateSDK.downloadRnBundle(checkInfo.downloadUrl)
setRnDownloadInfo(`下载完成,源码长度:${source.length} 字符`)
setRnDownloadStep('ok')
onLog(`插件 bundle 下载完成:${source.length} chars`)
} catch (e) {
const msg = e instanceof Error ? e.message : '下载失败'
setRnDownloadInfo(`✗ 下载失败:${msg}`)
setRnDownloadStep('error')
onLog(`插件下载失败:${msg}`)
return
}
// Step 3: Cache
setRnCacheStep('loading')
try {
const cached = await UpdateSDK.cacheRnBundle(
rnModuleId,
checkInfo.latestVersion,
checkInfo.md5,
source,
)
setRnCacheInfo(
`已写入 AsyncStorage\n版本${cached.version}\nmd5${cached.md5}\n时间${cached.downloadedAt}`,
)
setRnCacheStep('ok')
onLog(`插件 bundle 已缓存:${cached.version}`)
} catch (e) {
const msg = e instanceof Error ? e.message : '缓存失败'
setRnCacheInfo(`✗ 缓存失败:${msg}`)
setRnCacheStep('error')
onLog(`插件缓存失败:${msg}`)
}
}
const handleOpenDownload = () => {
if (appResult?.detail) {
const match = appResult.detail.match(/下载:(.+)/)
const url = match?.[1]?.trim()
if (url) Linking.openURL(url)
}
}
return (
<View style={styles.container}>
{/* App 整包更新 */}
<View style={styles.block}>
<Text style={styles.blockTitle}>App APK / IPA</Text>
<Text style={styles.blockHint}>
Android APK iOS App Store
{appVersionCode}
</Text>
<StepRow
step={1}
label="向服务端查询最新版本号"
status={appStep}
onPress={runAppUpdate}
buttonText="检查 App 更新"
/>
{appResult && (
<ResultBox label={appResult.label} detail={appResult.detail} ok={appStep === 'ok' && !appResult.needsUpdate} />
)}
{appResult?.needsUpdate && appStep === 'ok' && (
<Pressable onPress={handleOpenDownload} style={styles.openBtn}>
<Text style={styles.openBtnText}> </Text>
</Pressable>
)}
</View>
{/* RN 插件热更新 */}
<View style={styles.block}>
<Text style={styles.blockTitle}>RN JS Bundle</Text>
<Text style={styles.blockHint}>
<Text style={styles.mono}>{rnModuleId}</Text> <Text style={styles.mono}>{rnCurrentVersion}</Text>
AsyncStorage
</Text>
<StepRow
step={1}
label="检查服务端是否有更高版本"
status={rnCheckStep}
onPress={runRnUpdate}
buttonText="开始热更新流程"
/>
{rnResult && (
<ResultBox label={rnResult.label} detail={rnResult.detail} ok={rnCheckStep === 'ok' && !rnResult.needsUpdate} />
)}
{rnCheckStep === 'ok' && rnResult?.needsUpdate && (
<>
<StepRow
step={2}
label="下载 bundle 文件"
status={rnDownloadStep}
/>
{rnDownloadInfo ? (
<ResultBox label={rnDownloadInfo} detail="" ok={rnDownloadStep === 'ok'} />
) : null}
<StepRow
step={3}
label="写入本地缓存AsyncStorage"
status={rnCacheStep}
/>
{rnCacheInfo ? (
<ResultBox label={rnCacheInfo} detail="" ok={rnCacheStep === 'ok'} />
) : null}
</>
)}
</View>
</View>
)
}
function StepRow({
step,
label,
status,
onPress,
buttonText,
}: {
step: number
label: string
status: StepStatus
onPress?: () => void
buttonText?: string
}) {
const icon = status === 'idle' ? '○' : status === 'loading' ? '⟳' : status === 'ok' ? '✓' : '✗'
const iconColor = status === 'ok' ? '#22c55e' : status === 'error' ? '#ef4444' : status === 'loading' ? '#f59e0b' : '#9ca3af'
return (
<View style={styles.stepRow}>
<Text style={[styles.stepIcon, {color: iconColor}]}>{icon}</Text>
<View style={styles.stepBody}>
<Text style={styles.stepLabel}>
<Text style={styles.stepNum}> {step}</Text>
{label}
</Text>
</View>
{onPress && buttonText && (
<Pressable
onPress={onPress}
disabled={status === 'loading'}
style={[styles.stepBtn, status === 'loading' && styles.btnDisabled]}>
<Text style={styles.stepBtnText}>
{status === 'loading' ? '检查中…' : buttonText}
</Text>
</Pressable>
)}
</View>
)
}
function ResultBox({label, detail, ok}: {label: string; detail: string; ok: boolean}) {
return (
<View style={[styles.resultBox, ok ? styles.resultBoxOk : styles.resultBoxInfo]}>
<Text style={styles.resultLabel}>{label}</Text>
{detail ? <Text style={styles.resultDetail}>{detail}</Text> : null}
</View>
)
}
const styles = StyleSheet.create({
container: {gap: 16},
block: {gap: 10},
blockTitle: {fontSize: 15, fontWeight: '800', color: '#1f2933'},
blockHint: {fontSize: 13, lineHeight: 19, color: '#6b7280'},
mono: {fontFamily: 'monospace', fontSize: 12},
stepRow: {flexDirection: 'row', alignItems: 'center', gap: 8},
stepIcon: {fontSize: 18, width: 24, textAlign: 'center'},
stepBody: {flex: 1},
stepLabel: {fontSize: 13, color: '#374151'},
stepNum: {fontWeight: '700', color: '#1f2933'},
stepBtn: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 12,
backgroundColor: '#1f2933',
},
stepBtnText: {color: '#fff', fontSize: 12, fontWeight: '700'},
btnDisabled: {opacity: 0.5},
resultBox: {
borderRadius: 12,
padding: 10,
gap: 4,
},
resultBoxOk: {backgroundColor: '#f0fdf4', borderWidth: 1, borderColor: '#86efac'},
resultBoxInfo: {backgroundColor: '#fff7ed', borderWidth: 1, borderColor: '#fdba74'},
resultLabel: {fontSize: 13, fontWeight: '600', color: '#1f2933'},
resultDetail: {fontSize: 12, color: '#4b5563', lineHeight: 18, fontFamily: 'monospace'},
openBtn: {
minHeight: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#2563eb',
},
openBtnText: {color: '#fff', fontSize: 13, fontWeight: '700'},
})