321 行
10 KiB
TypeScript
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'},
|
|||
|
|
})
|