feat(demo): 添加群聊演示和完整热更新流程演示
群聊(Section 3): - 新增 GroupChatPanel 组件,支持创建群组、加载群列表、切换群组 - 群聊支持全 12 种消息类型发送和撤回 - 订阅群 topic 接收实时群消息 升级演示(Section 4): - 新增 UpdatePanel 组件,按步骤展示: - App 整包更新:检查版本 → 显示下载链接/App Store - RN 插件热更新:检查 → 下载 bundle → 写入 AsyncStorage - 每步骤显示状态图标(○/⟳/✓/✗)和详细信息 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
85c23b34a4
当前提交
681902ab34
274
App.tsx
274
App.tsx
@ -10,9 +10,11 @@ import {
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'
|
||||
import {ImSDK, type ImEventListener, type ImMessage, type MsgType, UpdateSDK, XuqmSDK} from '@xuqm/rn-sdk'
|
||||
import {ImSDK, type ImEventListener, type ImMessage, type MsgType, XuqmSDK} from '@xuqm/rn-sdk'
|
||||
import {MessageBubble} from './src/components/MessageBubble'
|
||||
import {DEMO_CONTENT, MessageComposer} from './src/components/MessageComposer'
|
||||
import {GroupChatPanel} from './src/components/GroupChatPanel'
|
||||
import {UpdatePanel} from './src/components/UpdatePanel'
|
||||
|
||||
const APP_ID = 'ak_demo_chat'
|
||||
const MODULE_ID = 'chat-home'
|
||||
@ -48,46 +50,39 @@ function App() {
|
||||
function DemoConsole() {
|
||||
const [currentUser, setCurrentUser] = useState<DemoUser>(DEMO_USERS[0])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||
const [singleMessages, setSingleMessages] = useState<ImMessage[]>([])
|
||||
const [groupMessages, setGroupMessages] = useState<ImMessage[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [activityLog, setActivityLog] = useState<string[]>([])
|
||||
const [appUpdateResult, setAppUpdateResult] = useState('点击"检查 App 更新"查看线上版本结果')
|
||||
const [rnUpdateResult, setRnUpdateResult] = useState('点击"检查插件更新"查看热更新结果')
|
||||
const [cachedBundleSummary, setCachedBundleSummary] = useState('暂无本地缓存')
|
||||
const activeUserRef = useRef(currentUser)
|
||||
const peerUser = DEMO_USERS.find(u => u.id !== currentUser.id) ?? DEMO_USERS[1]
|
||||
|
||||
const appendLog = (msg: string) => {
|
||||
startTransition(() => {
|
||||
setActivityLog(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 20))
|
||||
setActivityLog(prev =>
|
||||
[`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 30),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const mergeMessage = (message: ImMessage) => {
|
||||
const mergeSingle = (message: ImMessage) => {
|
||||
startTransition(() => {
|
||||
setMessages(prev => {
|
||||
const exists = prev.some(item => item.id === message.id)
|
||||
if (exists) return prev.map(item => item.id === message.id ? message : item)
|
||||
setSingleMessages(prev => {
|
||||
const exists = prev.some(m => m.id === message.id)
|
||||
if (exists) return prev.map(m => m.id === message.id ? message : m)
|
||||
return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const connectUser = async (user: DemoUser) => {
|
||||
try {
|
||||
ImSDK.disconnect()
|
||||
setConnected(false)
|
||||
setMessages([])
|
||||
await ImSDK.login(user.id, user.nickname)
|
||||
appendLog(`已登录 IM:${user.nickname}`)
|
||||
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
||||
history.slice().reverse().forEach(item => mergeMessage(item))
|
||||
setConnected(ImSDK.isConnected())
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'IM 登录失败'
|
||||
appendLog(`连接失败:${msg}`)
|
||||
Alert.alert('IM 登录失败', msg)
|
||||
}
|
||||
const mergeGroup = (message: ImMessage) => {
|
||||
startTransition(() => {
|
||||
setGroupMessages(prev => {
|
||||
const exists = prev.some(m => m.id === message.id)
|
||||
if (exists) return prev.map(m => m.id === message.id ? message : m)
|
||||
return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -115,13 +110,14 @@ function DemoConsole() {
|
||||
setConnected(false)
|
||||
appendLog(`WebSocket 断开:${reason || 'unknown'}`)
|
||||
},
|
||||
onMessage(message) {
|
||||
mergeMessage(message)
|
||||
appendLog(`收到消息 [${message.msgType}] from ${message.fromUserId}`)
|
||||
onMessage(msg) {
|
||||
if (msg.chatType === 'SINGLE') mergeSingle(msg)
|
||||
else mergeGroup(msg)
|
||||
appendLog(`收到 [${msg.chatType}/${msg.msgType}] from ${msg.fromUserId}`)
|
||||
},
|
||||
onGroupMessage(message) {
|
||||
mergeMessage(message)
|
||||
appendLog(`收到群消息 [${message.msgType}]`)
|
||||
onGroupMessage(msg) {
|
||||
mergeGroup(msg)
|
||||
appendLog(`收到群消息 [${msg.msgType}]`)
|
||||
},
|
||||
onError(error) {
|
||||
appendLog(`IM 错误:${error}`)
|
||||
@ -139,14 +135,14 @@ function DemoConsole() {
|
||||
try {
|
||||
ImSDK.disconnect()
|
||||
setConnected(false)
|
||||
setMessages([])
|
||||
setSingleMessages([])
|
||||
await ImSDK.login(currentUser.id, currentUser.nickname)
|
||||
appendLog(`已登录 IM:${currentUser.nickname}`)
|
||||
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
||||
history.slice().reverse().forEach(item => mergeMessage(item))
|
||||
history.slice().reverse().forEach(m => mergeSingle(m))
|
||||
setConnected(ImSDK.isConnected())
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'IM 登录失败'
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'IM 登录失败'
|
||||
appendLog(`连接失败:${msg}`)
|
||||
Alert.alert('IM 登录失败', msg)
|
||||
}
|
||||
@ -154,21 +150,14 @@ function DemoConsole() {
|
||||
run()
|
||||
}, [currentUser, peerUser.id])
|
||||
|
||||
useEffect(() => {
|
||||
UpdateSDK.getCachedRnBundle(MODULE_ID).then(bundle => {
|
||||
if (!bundle) return
|
||||
setCachedBundleSummary(`已缓存 ${bundle.version},md5=${bundle.md5}`)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSend = async (msgType: MsgType, content: string) => {
|
||||
const handleSendSingle = async (msgType: MsgType, content: string) => {
|
||||
setSending(true)
|
||||
try {
|
||||
const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', msgType, content)
|
||||
mergeMessage(sent)
|
||||
mergeSingle(sent)
|
||||
appendLog(`已发送 [${msgType}] 给 ${peerUser.nickname}`)
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '发送失败'
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '发送失败'
|
||||
appendLog(`发送失败 [${msgType}]:${msg}`)
|
||||
Alert.alert('发送失败', msg)
|
||||
} finally {
|
||||
@ -176,96 +165,52 @@ function DemoConsole() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendAllTypes = async () => {
|
||||
const handleSendAllSingle = async () => {
|
||||
setSending(true)
|
||||
appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型…`)
|
||||
appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型(单聊)…`)
|
||||
for (const type of ALL_DEMO_TYPES) {
|
||||
const content = DEMO_CONTENT[type]
|
||||
if (!content) continue
|
||||
try {
|
||||
const content = DEMO_CONTENT[type]
|
||||
if (!content) continue
|
||||
const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', type, content)
|
||||
mergeMessage(sent)
|
||||
mergeSingle(sent)
|
||||
appendLog(`✓ [${type}]`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'error'
|
||||
appendLog(`✗ [${type}]:${msg}`)
|
||||
} catch (e) {
|
||||
appendLog(`✗ [${type}]:${e instanceof Error ? e.message : 'error'}`)
|
||||
}
|
||||
}
|
||||
appendLog('全类型演示发送完成')
|
||||
appendLog('单聊全类型演示完成')
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
const handleRevoke = async (messageId: string) => {
|
||||
const handleRevokeSingle = async (messageId: string) => {
|
||||
try {
|
||||
const revoked = await ImSDK.revokeMessage(messageId)
|
||||
mergeMessage(revoked)
|
||||
mergeSingle(revoked)
|
||||
appendLog(`消息已撤回:${messageId}`)
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '撤回失败'
|
||||
appendLog(`撤回失败:${msg}`)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '撤回失败'
|
||||
Alert.alert('撤回失败', msg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReloadHistory = async () => {
|
||||
const handleReloadSingle = async () => {
|
||||
try {
|
||||
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
||||
setMessages(history.slice().reverse())
|
||||
setSingleMessages(history.slice().reverse())
|
||||
appendLog(`历史消息已刷新,共 ${history.length} 条`)
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '拉取失败'
|
||||
appendLog(`历史拉取失败:${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckAppUpdate = async () => {
|
||||
try {
|
||||
const result = await UpdateSDK.checkAppUpdate(CURRENT_APP_VERSION_CODE)
|
||||
if (!result.needsUpdate) {
|
||||
setAppUpdateResult('当前已是最新 App 版本')
|
||||
appendLog('App 更新检查:当前最新')
|
||||
return
|
||||
}
|
||||
setAppUpdateResult(
|
||||
`发现版本 ${result.versionName}(code=${result.versionCode})\nforce=${String(result.forceUpdate)}\n${result.changeLog ?? ''}\n下载:${result.downloadUrl ?? '未提供'}`,
|
||||
)
|
||||
appendLog(`发现 App 新版本:${result.versionName}`)
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'App 更新检查失败'
|
||||
setAppUpdateResult(msg)
|
||||
appendLog(`App 更新失败:${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckRnUpdate = async () => {
|
||||
try {
|
||||
const result = await UpdateSDK.checkRnUpdate(MODULE_ID, CURRENT_RN_VERSION)
|
||||
if (!result.needsUpdate) {
|
||||
setRnUpdateResult('当前已是最新插件版本')
|
||||
appendLog('插件更新检查:当前最新')
|
||||
return
|
||||
}
|
||||
const source = await UpdateSDK.downloadRnBundle(result.downloadUrl)
|
||||
const cached = await UpdateSDK.cacheRnBundle(MODULE_ID, result.latestVersion, result.md5, source)
|
||||
setRnUpdateResult(
|
||||
`发现插件版本 ${result.latestVersion}\nmd5=${result.md5}\n说明:${result.note || '无'}\n已下载源码长度:${source.length}`,
|
||||
)
|
||||
setCachedBundleSummary(`已缓存 ${cached.version},下载于 ${cached.downloadedAt}`)
|
||||
appendLog(`插件更新已缓存:${cached.version}`)
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '插件更新检查失败'
|
||||
setRnUpdateResult(msg)
|
||||
appendLog(`插件更新失败:${msg}`)
|
||||
} catch (e) {
|
||||
appendLog(`历史拉取失败:${e instanceof Error ? e.message : 'error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.eyebrow}>XuqmGroup RN SDK Demo</Text>
|
||||
<Text style={styles.title}>IM · 全消息类型 · App更新 · 热更新</Text>
|
||||
<Text style={styles.title}>IM · 全消息类型 · 群聊 · App 更新 · 热更新</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
连接 {API_BASE_URL},使用两个演示用户演示全部 IM 消息类型、App 版本检查和插件热更新。
|
||||
连接 {API_BASE_URL},演示单聊/群聊全消息类型、整包更新和 RN 插件热更新完整流程。
|
||||
</Text>
|
||||
|
||||
{/* Section 1: 当前会话 */}
|
||||
@ -285,72 +230,84 @@ function DemoConsole() {
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<Text style={styles.metaText}>当前用户:{currentUser.nickname}({currentUser.id})</Text>
|
||||
<Text style={styles.metaText}>聊天对象:{peerUser.nickname}({peerUser.id})</Text>
|
||||
<Text style={styles.meta}>当前用户:{currentUser.nickname}({currentUser.id})</Text>
|
||||
<Text style={styles.meta}>单聊对象:{peerUser.nickname}({peerUser.id})</Text>
|
||||
<View style={styles.statusRow}>
|
||||
<View style={[styles.dot, connected ? styles.dotGreen : styles.dotGray]} />
|
||||
<Text style={styles.metaText}>{connected ? '已连接' : '连接中 / 未连接'}</Text>
|
||||
<Text style={styles.meta}>{connected ? 'IM 已连接' : '连接中 / 未连接'}</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<PrimaryButton title="刷新历史" onPress={handleReloadHistory} />
|
||||
<GhostButton title="重新登录" onPress={() => connectUser(currentUser)} />
|
||||
<PrimaryBtn title="刷新历史" onPress={handleReloadSingle} />
|
||||
<GhostBtn title="重新登录" onPress={() => {
|
||||
ImSDK.disconnect()
|
||||
setConnected(false)
|
||||
setSingleMessages([])
|
||||
ImSDK.login(currentUser.id, currentUser.nickname)
|
||||
.then(() => ImSDK.fetchHistory(peerUser.id, 0, 50))
|
||||
.then(h => { h.slice().reverse().forEach(m => mergeSingle(m)); setConnected(ImSDK.isConnected()) })
|
||||
.catch(e => appendLog(`重登录失败:${e instanceof Error ? e.message : 'error'}`))
|
||||
}} />
|
||||
</View>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 2: 聊天演示 */}
|
||||
<SectionCard title="2. 聊天演示(全消息类型)">
|
||||
<Text style={styles.hint}>
|
||||
长按自己的消息可撤回。点击消息类型选择器切换,"全部类型演示"按钮依次发送所有 12 种类型。
|
||||
</Text>
|
||||
{/* Section 2: 单聊 */}
|
||||
<SectionCard title="2. 单聊演示(全 12 种消息类型)">
|
||||
<Text style={styles.hint}>长按自己的消息可撤回。"全部类型演示"依次发送所有 12 种消息类型。</Text>
|
||||
<ScrollView
|
||||
style={styles.chatPanel}
|
||||
contentContainerStyle={styles.chatContent}
|
||||
showsVerticalScrollIndicator={false}>
|
||||
{messages.length === 0 ? (
|
||||
<Text style={styles.emptyText}>还没有消息,发一条试试看。</Text>
|
||||
{singleMessages.length === 0 ? (
|
||||
<Text style={styles.empty}>还没有消息,发一条试试看。</Text>
|
||||
) : (
|
||||
messages.map(message => (
|
||||
singleMessages.map(m => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isOwn={message.fromUserId === currentUser.id}
|
||||
key={m.id}
|
||||
message={m}
|
||||
isOwn={m.fromUserId === currentUser.id}
|
||||
peerNickname={peerUser.nickname}
|
||||
onRevoke={handleRevoke}
|
||||
onRevoke={handleRevokeSingle}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
<MessageComposer
|
||||
onSend={handleSend}
|
||||
onSendAll={handleSendAllTypes}
|
||||
onSend={handleSendSingle}
|
||||
onSendAll={handleSendAllSingle}
|
||||
disabled={sending || !connected}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 3: App 更新 */}
|
||||
<SectionCard title="3. App 更新演示">
|
||||
<Text style={styles.paragraph}>
|
||||
本地版本号:{CURRENT_APP_VERSION_CODE}。在服务端发布更高版本后点击检查即可看到结果。
|
||||
{/* Section 3: 群聊 */}
|
||||
<SectionCard title="3. 群聊演示">
|
||||
<Text style={styles.hint}>
|
||||
点击"创建演示群"创建包含 Alice+Bob 的群组,"加载群列表"列出已有群,然后即可发送群消息。
|
||||
</Text>
|
||||
<PrimaryButton title="检查 App 更新" onPress={handleCheckAppUpdate} />
|
||||
<Text style={styles.resultText}>{appUpdateResult}</Text>
|
||||
<GroupChatPanel
|
||||
currentUserId={currentUser.id}
|
||||
peerUserId={peerUser.id}
|
||||
peerNickname={peerUser.nickname}
|
||||
appId={APP_ID}
|
||||
onLog={appendLog}
|
||||
groupMessages={groupMessages}
|
||||
onMergeMessage={mergeGroup}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 4: 插件热更新 */}
|
||||
<SectionCard title="4. 插件热更新演示">
|
||||
<Text style={styles.paragraph}>
|
||||
模块:{MODULE_ID},本地版本:{CURRENT_RN_VERSION}。
|
||||
热更新流程:检查 → 下载 → 缓存至 AsyncStorage。
|
||||
</Text>
|
||||
<PrimaryButton title="检查插件更新并缓存" onPress={handleCheckRnUpdate} />
|
||||
<Text style={styles.resultText}>{rnUpdateResult}</Text>
|
||||
<Text style={styles.cacheText}>本地缓存:{cachedBundleSummary}</Text>
|
||||
{/* Section 4: 更新演示 */}
|
||||
<SectionCard title="4. 更新演示">
|
||||
<UpdatePanel
|
||||
appVersionCode={CURRENT_APP_VERSION_CODE}
|
||||
rnModuleId={MODULE_ID}
|
||||
rnCurrentVersion={CURRENT_RN_VERSION}
|
||||
onLog={appendLog}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* Section 5: 活动日志 */}
|
||||
<SectionCard title="5. 活动日志">
|
||||
{activityLog.length === 0 ? (
|
||||
<Text style={styles.emptyText}>暂无日志</Text>
|
||||
<Text style={styles.empty}>暂无日志</Text>
|
||||
) : (
|
||||
activityLog.map((item, i) => (
|
||||
<Text key={i} style={styles.logLine}>{item}</Text>
|
||||
@ -370,7 +327,7 @@ function SectionCard({title, children}: {title: string; children: React.ReactNod
|
||||
)
|
||||
}
|
||||
|
||||
function PrimaryButton({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) {
|
||||
function PrimaryBtn({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) {
|
||||
return (
|
||||
<Pressable onPress={onPress} disabled={disabled} style={[styles.primaryBtn, disabled && styles.btnDisabled]}>
|
||||
<Text style={styles.primaryBtnText}>{title}</Text>
|
||||
@ -378,7 +335,7 @@ function PrimaryButton({title, onPress, disabled}: {title: string; onPress: () =
|
||||
)
|
||||
}
|
||||
|
||||
function GhostButton({title, onPress}: {title: string; onPress: () => void}) {
|
||||
function GhostBtn({title, onPress}: {title: string; onPress: () => void}) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.ghostBtn}>
|
||||
<Text style={styles.ghostBtnText}>{title}</Text>
|
||||
@ -389,12 +346,12 @@ function GhostButton({title, onPress}: {title: string; onPress: () => void}) {
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {flex: 1, backgroundColor: '#f5efe3'},
|
||||
scroll: {flex: 1, backgroundColor: '#f5efe3'},
|
||||
content: {padding: 18, gap: 14, paddingBottom: 32},
|
||||
content: {padding: 18, gap: 14, paddingBottom: 40},
|
||||
eyebrow: {fontSize: 13, fontWeight: '700', color: '#915f34', textTransform: 'uppercase', letterSpacing: 1.1},
|
||||
title: {fontSize: 28, lineHeight: 34, fontWeight: '800', color: '#1f2933'},
|
||||
subtitle: {fontSize: 14, lineHeight: 21, color: '#4b5563'},
|
||||
title: {fontSize: 26, lineHeight: 32, fontWeight: '800', color: '#1f2933'},
|
||||
subtitle: {fontSize: 13, lineHeight: 20, color: '#4b5563'},
|
||||
card: {borderRadius: 24, padding: 16, backgroundColor: '#fffaf2', borderWidth: 1, borderColor: '#ead7b7', gap: 12},
|
||||
cardTitle: {fontSize: 18, fontWeight: '800', color: '#1f2933'},
|
||||
cardTitle: {fontSize: 17, fontWeight: '800', color: '#1f2933'},
|
||||
hint: {fontSize: 13, lineHeight: 19, color: '#6b7280'},
|
||||
userRow: {flexDirection: 'row', gap: 10},
|
||||
userChip: {paddingHorizontal: 16, paddingVertical: 10, borderRadius: 999, backgroundColor: '#efe2c5'},
|
||||
@ -405,20 +362,17 @@ const styles = StyleSheet.create({
|
||||
dot: {width: 8, height: 8, borderRadius: 4},
|
||||
dotGreen: {backgroundColor: '#22c55e'},
|
||||
dotGray: {backgroundColor: '#9ca3af'},
|
||||
metaText: {fontSize: 14, color: '#4b5563'},
|
||||
meta: {fontSize: 13, color: '#4b5563'},
|
||||
row: {flexDirection: 'row', gap: 10},
|
||||
chatPanel: {maxHeight: 360, borderRadius: 18, backgroundColor: '#f4ecdf'},
|
||||
chatPanel: {maxHeight: 340, borderRadius: 18, backgroundColor: '#f4ecdf'},
|
||||
chatContent: {padding: 12, gap: 6},
|
||||
emptyText: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20},
|
||||
paragraph: {fontSize: 14, lineHeight: 21, color: '#4b5563'},
|
||||
primaryBtn: {minHeight: 46, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1f2933', paddingHorizontal: 16},
|
||||
primaryBtnText: {color: '#fffdf8', fontSize: 15, fontWeight: '800'},
|
||||
ghostBtn: {minHeight: 46, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#efe2c5', paddingHorizontal: 16},
|
||||
ghostBtnText: {color: '#4b5563', fontSize: 15, fontWeight: '700'},
|
||||
empty: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20},
|
||||
primaryBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1f2933', paddingHorizontal: 16},
|
||||
primaryBtnText: {color: '#fffdf8', fontSize: 14, fontWeight: '800'},
|
||||
ghostBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#efe2c5', paddingHorizontal: 16},
|
||||
ghostBtnText: {color: '#4b5563', fontSize: 14, fontWeight: '700'},
|
||||
btnDisabled: {opacity: 0.45},
|
||||
resultText: {fontSize: 14, lineHeight: 21, color: '#1f2933'},
|
||||
cacheText: {fontSize: 13, color: '#6b7280'},
|
||||
logLine: {fontSize: 12, lineHeight: 18, color: '#374151', fontFamily: 'monospace'},
|
||||
logLine: {fontSize: 11, lineHeight: 17, color: '#374151', fontFamily: 'monospace'},
|
||||
})
|
||||
|
||||
export default App
|
||||
|
||||
269
src/components/GroupChatPanel.tsx
普通文件
269
src/components/GroupChatPanel.tsx
普通文件
@ -0,0 +1,269 @@
|
||||
import React, {useState} from 'react'
|
||||
import {Alert, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native'
|
||||
import {ImSDK, type ImGroup, type ImMessage, type MsgType} from '@xuqm/rn-sdk'
|
||||
import {MessageBubble} from './MessageBubble'
|
||||
import {DEMO_CONTENT, MessageComposer} from './MessageComposer'
|
||||
|
||||
const ALL_DEMO_TYPES: MsgType[] = [
|
||||
'TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'FILE', 'CUSTOM',
|
||||
'LOCATION', 'NOTIFY', 'RICH_TEXT', 'CALL_AUDIO', 'CALL_VIDEO', 'FORWARD',
|
||||
]
|
||||
|
||||
interface Props {
|
||||
currentUserId: string
|
||||
peerUserId: string
|
||||
peerNickname: string
|
||||
appId: string
|
||||
onLog: (msg: string) => void
|
||||
groupMessages: ImMessage[]
|
||||
onMergeMessage: (msg: ImMessage) => void
|
||||
}
|
||||
|
||||
export function GroupChatPanel({
|
||||
currentUserId,
|
||||
peerUserId,
|
||||
peerNickname,
|
||||
onLog,
|
||||
groupMessages,
|
||||
onMergeMessage,
|
||||
}: Props) {
|
||||
const [activeGroup, setActiveGroup] = useState<ImGroup | null>(null)
|
||||
const [groups, setGroups] = useState<ImGroup[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loadingGroups, setLoadingGroups] = useState(false)
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
try {
|
||||
const group = await ImSDK.createGroup('RN Demo 演示群', [currentUserId, peerUserId])
|
||||
onLog(`创建群组:${group.name}(id=${group.id})`)
|
||||
setActiveGroup(group)
|
||||
setGroups(prev => [group, ...prev.filter(g => g.id !== group.id)])
|
||||
ImSDK.subscribeGroup(group.id)
|
||||
const history = await ImSDK.fetchGroupHistory(group.id, 0, 50)
|
||||
history.slice().reverse().forEach(m => onMergeMessage(m))
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建群组失败'
|
||||
onLog(`群组创建失败:${msg}`)
|
||||
Alert.alert('创建群组失败', msg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadGroups = async () => {
|
||||
setLoadingGroups(true)
|
||||
try {
|
||||
const list = await ImSDK.listGroups()
|
||||
setGroups(list)
|
||||
onLog(`已加载 ${list.length} 个群组`)
|
||||
if (list.length > 0 && !activeGroup) {
|
||||
const first = list[0]
|
||||
setActiveGroup(first)
|
||||
ImSDK.subscribeGroup(first.id)
|
||||
const history = await ImSDK.fetchGroupHistory(first.id, 0, 50)
|
||||
history.slice().reverse().forEach(m => onMergeMessage(m))
|
||||
onLog(`加入群组:${first.name}`)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '加载群组失败'
|
||||
onLog(`加载群组失败:${msg}`)
|
||||
} finally {
|
||||
setLoadingGroups(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchGroup = async (group: ImGroup) => {
|
||||
setActiveGroup(group)
|
||||
ImSDK.subscribeGroup(group.id)
|
||||
try {
|
||||
const history = await ImSDK.fetchGroupHistory(group.id, 0, 50)
|
||||
history.slice().reverse().forEach(m => onMergeMessage(m))
|
||||
onLog(`切换群组:${group.name}`)
|
||||
} catch (e) {
|
||||
onLog(`历史拉取失败:${e instanceof Error ? e.message : 'error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async (msgType: MsgType, content: string) => {
|
||||
if (!activeGroup) {
|
||||
Alert.alert('提示', '请先创建或选择一个群组')
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
try {
|
||||
const sent = await ImSDK.sendMessage(activeGroup.id, 'GROUP', msgType, content)
|
||||
onMergeMessage(sent)
|
||||
onLog(`群发 [${msgType}] → ${activeGroup.name}`)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '发送失败'
|
||||
onLog(`群发失败 [${msgType}]:${msg}`)
|
||||
Alert.alert('发送失败', msg)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendAllTypes = async () => {
|
||||
if (!activeGroup) {
|
||||
Alert.alert('提示', '请先创建或选择一个群组')
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
onLog(`开始群发全部 ${ALL_DEMO_TYPES.length} 种消息类型…`)
|
||||
for (const type of ALL_DEMO_TYPES) {
|
||||
const content = DEMO_CONTENT[type]
|
||||
if (!content) continue
|
||||
try {
|
||||
const sent = await ImSDK.sendMessage(activeGroup.id, 'GROUP', type, content)
|
||||
onMergeMessage(sent)
|
||||
onLog(`✓ 群发 [${type}]`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
} catch (e) {
|
||||
onLog(`✗ 群发 [${type}]:${e instanceof Error ? e.message : 'error'}`)
|
||||
}
|
||||
}
|
||||
onLog('群发全类型演示完成')
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
const handleRevoke = async (messageId: string) => {
|
||||
try {
|
||||
const revoked = await ImSDK.revokeMessage(messageId)
|
||||
onMergeMessage(revoked)
|
||||
onLog(`群消息已撤回:${messageId}`)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '撤回失败'
|
||||
Alert.alert('撤回失败', msg)
|
||||
}
|
||||
}
|
||||
|
||||
const groupMessages_ = activeGroup
|
||||
? groupMessages.filter(m => m.toId === activeGroup.id)
|
||||
: []
|
||||
|
||||
const parsedMembers = activeGroup ? (() => {
|
||||
try { return JSON.parse(activeGroup.memberIds) as string[] } catch { return [] }
|
||||
})() : []
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 群组管理 */}
|
||||
<View style={styles.groupBar}>
|
||||
<Pressable onPress={handleCreateGroup} style={styles.btnCreate}>
|
||||
<Text style={styles.btnCreateText}>+ 创建演示群</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleLoadGroups}
|
||||
disabled={loadingGroups}
|
||||
style={[styles.btnLoad, loadingGroups && styles.btnDisabled]}>
|
||||
<Text style={styles.btnLoadText}>{loadingGroups ? '加载中…' : '加载群列表'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 群列表 */}
|
||||
{groups.length > 0 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.groupList}>
|
||||
{groups.map(g => (
|
||||
<Pressable
|
||||
key={g.id}
|
||||
onPress={() => handleSwitchGroup(g)}
|
||||
style={[styles.groupChip, activeGroup?.id === g.id && styles.groupChipActive]}>
|
||||
<Text style={[styles.groupChipText, activeGroup?.id === g.id && styles.groupChipTextActive]}>
|
||||
{g.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* 当前群信息 */}
|
||||
{activeGroup ? (
|
||||
<View style={styles.groupInfo}>
|
||||
<Text style={styles.groupInfoName}>📋 {activeGroup.name}</Text>
|
||||
<Text style={styles.groupInfoMeta}>
|
||||
群 ID:{activeGroup.id.slice(0, 8)}…
|
||||
{' · '}成员 {parsedMembers.length} 人
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.noGroupHint}>创建或加载群组后,即可在此发送群消息。</Text>
|
||||
)}
|
||||
|
||||
{/* 群消息列表 */}
|
||||
<ScrollView
|
||||
style={styles.chatPanel}
|
||||
contentContainerStyle={styles.chatContent}
|
||||
showsVerticalScrollIndicator={false}>
|
||||
{groupMessages_.length === 0 ? (
|
||||
<Text style={styles.emptyText}>
|
||||
{activeGroup ? '还没有群消息,发一条试试看。' : '暂无群组'}
|
||||
</Text>
|
||||
) : (
|
||||
groupMessages_.map(m => (
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
isOwn={m.fromUserId === currentUserId}
|
||||
peerNickname={peerNickname}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 发送区 */}
|
||||
<MessageComposer
|
||||
onSend={handleSend}
|
||||
onSendAll={handleSendAllTypes}
|
||||
disabled={sending || !activeGroup}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {gap: 10},
|
||||
groupBar: {flexDirection: 'row', gap: 10},
|
||||
btnCreate: {
|
||||
flex: 1,
|
||||
minHeight: 42,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#1f2933',
|
||||
},
|
||||
btnCreateText: {color: '#fffdf8', fontSize: 14, fontWeight: '700'},
|
||||
btnLoad: {
|
||||
flex: 1,
|
||||
minHeight: 42,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#efe2c5',
|
||||
},
|
||||
btnLoadText: {color: '#4b5563', fontSize: 14, fontWeight: '700'},
|
||||
btnDisabled: {opacity: 0.5},
|
||||
groupList: {gap: 8, paddingVertical: 2},
|
||||
groupChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#efe2c5',
|
||||
},
|
||||
groupChipActive: {backgroundColor: '#915f34'},
|
||||
groupChipText: {fontSize: 13, fontWeight: '700', color: '#4b5563'},
|
||||
groupChipTextActive: {color: '#fff'},
|
||||
groupInfo: {
|
||||
backgroundColor: '#f0e8d8',
|
||||
borderRadius: 14,
|
||||
padding: 10,
|
||||
gap: 3,
|
||||
},
|
||||
groupInfoName: {fontSize: 14, fontWeight: '700', color: '#1f2933'},
|
||||
groupInfoMeta: {fontSize: 12, color: '#6b7280'},
|
||||
noGroupHint: {fontSize: 13, color: '#9ca3af', textAlign: 'center', paddingVertical: 8},
|
||||
chatPanel: {maxHeight: 320, borderRadius: 18, backgroundColor: '#f4ecdf'},
|
||||
chatContent: {padding: 12, gap: 6},
|
||||
emptyText: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20},
|
||||
})
|
||||
320
src/components/UpdatePanel.tsx
普通文件
320
src/components/UpdatePanel.tsx
普通文件
@ -0,0 +1,320 @@
|
||||
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'},
|
||||
})
|
||||
正在加载...
在新工单中引用
屏蔽一个用户