393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
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 },
|
||
})
|