Files
2026-03-25 12:29:03 -05:00

393 lines
12 KiB
TypeScript
Raw Permalink 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.
import { useCallback, useEffect, useState } from 'react'
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
Pressable,
Image,
Linking,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import { theme } from '@/constants/theme'
type WechatConfigView = {
configured: boolean
proxyUrl: string
apiKeyMasked: string
deviceType: string
proxy: string
webhookHost: string
webhookPort: number
webhookPath: string
accountId: string
session: { wcId?: string; nickName?: string }
}
export default function WeChatScreen() {
const { bind } = useBind()
const [cfg, setCfg] = useState<WechatConfigView | null>(null)
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
const [apiKeyInput, setApiKeyInput] = useState('')
const [proxyUrlInput, setProxyUrlInput] = useState('')
const [deviceType, setDeviceType] = useState('ipad')
const [proxyLine, setProxyLine] = useState('10')
const [wId, setWId] = useState('')
const [qrUrl, setQrUrl] = useState('')
const [poll, setPoll] = useState(false)
const [proxyStatus, setProxyStatus] = useState<Record<string, unknown> | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) {
setCfg(null)
return
}
setMsg('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/config')
if (!r.ok) throw new Error(String(r.status))
const j = (await r.json()) as WechatConfigView
setCfg(j)
setProxyUrlInput(j.proxyUrl || '')
setDeviceType(j.deviceType || 'ipad')
setProxyLine(j.proxy || '10')
setApiKeyInput('')
} catch (e) {
setCfg(null)
setMsg(e instanceof Error ? e.message : String(e))
}
}, [bind])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (!poll || !wId || !bind) return
const tick = async () => {
try {
const r = await apiFetch(bind, '/chatbot/wechat/login/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wId }),
})
const j = (await r.json()) as {
status?: string
wcId?: string
nickName?: string
verifyUrl?: string
error?: string
}
if (!r.ok) {
setMsg(j.error || String(r.status))
setPoll(false)
return
}
if (j.status === 'logged_in') {
setPoll(false)
setMsg(`已登录:${j.nickName || ''}${j.wcId || ''}`)
void load()
return
}
if (j.status === 'need_verify' && j.verifyUrl) {
setMsg(`需要辅助验证:${j.verifyUrl}`)
}
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
setPoll(false)
}
}
const id = setInterval(() => void tick(), 5000)
void tick()
return () => clearInterval(id)
}, [poll, wId, bind, load])
const saveConfig = async () => {
if (!bind) return
setBusy(true)
setMsg('')
try {
const body: Record<string, string | number> = {
proxyUrl: proxyUrlInput.trim(),
deviceType,
proxy: proxyLine.trim(),
}
if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim()
const r = await apiFetch(bind, '/chatbot/wechat/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!r.ok) {
const t = await r.text()
throw new Error(t || String(r.status))
}
await load()
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const loadProxyStatus = async () => {
if (!bind) return
setBusy(true)
setMsg('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/proxy/status')
const j = await r.json()
if (!r.ok) {
setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j))
setProxyStatus(null)
return
}
setProxyStatus(j as Record<string, unknown>)
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const startQr = async () => {
if (!bind) return
setBusy(true)
setMsg('')
setQrUrl('')
setWId('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/qr/start', { method: 'POST' })
const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string }
if (!r.ok) {
setMsg(j.error || String(r.status))
return
}
if (!j.wId || !j.qrCodeUrl) {
setMsg('未返回二维码')
return
}
setWId(j.wId)
setQrUrl(j.qrCodeUrl)
setPoll(true)
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const onRefresh = useCallback(async () => {
setRefreshing(true)
await load()
setRefreshing(false)
}, [load])
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
<Text style={styles.h1}></Text>
<Text style={styles.lead}>openclaw-wechat API</Text>
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC</Text>
</View>
)}
<View style={styles.card}>
<Text style={styles.cardTitle}> Chatbot</Text>
<Text style={styles.label}>proxyUrl</Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxyUrlInput}
editable={!!bind}
onChangeText={setProxyUrlInput}
placeholder="http://your-proxy:3000"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Text style={styles.label}>apiKey</Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={apiKeyInput}
editable={!!bind}
onChangeText={setApiKeyInput}
placeholder={cfg?.apiKeyMasked ? `已保存 ${cfg.apiKeyMasked}` : 'wc_live_…'}
placeholderTextColor={theme.muted}
secureTextEntry
autoCapitalize="none"
/>
<Text style={styles.label}>deviceType</Text>
<View style={styles.row}>
<Pressable
style={[styles.chip, deviceType === 'ipad' && styles.chipOn]}
onPress={() => setDeviceType('ipad')}
disabled={!bind || busy}
>
<Text style={styles.chipText}>ipad</Text>
</Pressable>
<Pressable
style={[styles.chip, deviceType === 'mac' && styles.chipOn]}
onPress={() => setDeviceType('mac')}
disabled={!bind || busy}
>
<Text style={styles.chipText}>mac</Text>
</Pressable>
</View>
<Text style={styles.label}>proxy </Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxyLine}
editable={!!bind}
onChangeText={setProxyLine}
placeholderTextColor={theme.muted}
/>
<View style={styles.btnRow}>
<Pressable style={[styles.btnSec, !bind && styles.disabled]} disabled={!bind || busy} onPress={() => void saveConfig()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable style={[styles.btnSec, !bind && styles.disabled]} disabled={!bind || busy} onPress={() => void load()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btnSec, !bind && styles.disabled]}
disabled={!bind || busy}
onPress={() => void loadProxyStatus()}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
</View>
{proxyStatus ? (
<Text style={styles.pre}>{JSON.stringify(proxyStatus, null, 2)}</Text>
) : null}
{cfg?.session?.wcId ? (
<Text style={styles.hint}>{cfg.session.nickName || '—'}{cfg.session.wcId}</Text>
) : null}
<Pressable
style={[styles.btnPri, (!bind || busy || !cfg?.configured) && styles.disabled]}
disabled={!bind || busy || !cfg?.configured}
onPress={() => void startQr()}
>
<Text style={styles.btnPriText}></Text>
</Pressable>
{!cfg?.configured && bind ? (
<Text style={styles.hint}> apiKey proxyUrl</Text>
) : null}
{qrUrl ? (
<View style={styles.qrBlock}>
<Text style={styles.hint}>使</Text>
<Image source={{ uri: qrUrl }} style={styles.qrImg} resizeMode="contain" />
<Pressable onPress={() => void Linking.openURL(qrUrl)}>
<Text style={styles.link}></Text>
</Pressable>
</View>
) : null}
{!!msg && (
<Text style={[styles.msg, msg.startsWith('已登录') ? styles.msgOk : styles.msgErr]}>{msg}</Text>
)}
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 16, paddingBottom: 40 },
h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 },
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
banner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(100,149,237,0.1)',
borderWidth: 1,
borderColor: 'rgba(100,149,237,0.25)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(100,149,237,0.28)',
},
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 12 },
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
input: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
marginBottom: 12,
backgroundColor: theme.bg,
},
inputDisabled: { opacity: 0.55 },
row: { flexDirection: 'row', gap: 10, marginBottom: 12 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
chipOn: { borderColor: theme.accent, backgroundColor: 'rgba(91,157,255,0.12)' },
chipText: { color: theme.text, fontWeight: '600' },
btnRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 12 },
btnSec: {
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 },
btnPri: {
marginTop: 8,
paddingVertical: 12,
borderRadius: 10,
backgroundColor: '#3d7eff',
alignItems: 'center',
},
btnPriText: { color: '#fff', fontWeight: '700' },
disabled: { opacity: 0.45 },
pre: {
fontFamily: 'monospace',
fontSize: 11,
color: '#c8cdd5',
marginBottom: 12,
lineHeight: 16,
},
hint: { fontSize: 13, color: theme.muted, marginTop: 8, lineHeight: 20 },
qrBlock: { marginTop: 16, alignItems: 'center' },
qrImg: { width: 224, height: 224, marginVertical: 12, backgroundColor: '#fff' },
link: { color: theme.accent, fontSize: 14, textDecorationLine: 'underline' },
msg: { marginTop: 12, fontSize: 14 },
msgOk: { color: theme.success },
msgErr: { color: theme.danger },
})