2026-03-17 22:18:05 +08:00
|
|
|
|
import React, { useEffect, useState, useRef } from "react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
View,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
|
Image,
|
|
|
|
|
|
ActivityIndicator,
|
2026-03-19 17:00:35 +08:00
|
|
|
|
Animated,
|
|
|
|
|
|
Alert,
|
2026-03-17 22:18:05 +08:00
|
|
|
|
} from "react-native";
|
2026-03-19 17:00:35 +08:00
|
|
|
|
import * as FileSystem from "expo-file-system/legacy";
|
|
|
|
|
|
import * as MediaLibrary from "expo-media-library";
|
|
|
|
|
|
import { Ionicons } from "@expo/vector-icons";
|
2026-03-17 22:18:05 +08:00
|
|
|
|
import { generateQRCode, pollQRCode, getUserInfo } from "../services/bilibili";
|
|
|
|
|
|
import { useAuthStore } from "../store/authStore";
|
2026-03-05 18:02:54 +08:00
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
visible: boolean;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function LoginModal({ visible, onClose }: Props) {
|
|
|
|
|
|
const [qrUrl, setQrUrl] = useState<string | null>(null);
|
|
|
|
|
|
const [qrKey, setQrKey] = useState<string | null>(null);
|
2026-03-19 17:00:35 +08:00
|
|
|
|
const [qrImageLoaded, setQrImageLoaded] = useState(false);
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const [status, setStatus] = useState<
|
|
|
|
|
|
"loading" | "waiting" | "scanned" | "done" | "error"
|
|
|
|
|
|
>("loading");
|
2026-03-05 18:02:54 +08:00
|
|
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
const login = useAuthStore((s) => s.login);
|
|
|
|
|
|
const setProfile = useAuthStore((s) => s.setProfile);
|
2026-03-05 18:02:54 +08:00
|
|
|
|
|
2026-03-19 17:00:35 +08:00
|
|
|
|
// sheet 滑入动画
|
|
|
|
|
|
const slideY = useRef(new Animated.Value(300)).current;
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (visible) {
|
|
|
|
|
|
Animated.spring(slideY, {
|
|
|
|
|
|
toValue: 0,
|
|
|
|
|
|
useNativeDriver: true,
|
|
|
|
|
|
bounciness: 4,
|
|
|
|
|
|
}).start();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
slideY.setValue(300);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [visible]);
|
|
|
|
|
|
|
2026-03-05 18:02:54 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!visible) return;
|
2026-03-17 22:18:05 +08:00
|
|
|
|
setStatus("loading");
|
2026-03-05 18:02:54 +08:00
|
|
|
|
setQrUrl(null);
|
|
|
|
|
|
setQrKey(null);
|
2026-03-19 17:00:35 +08:00
|
|
|
|
setQrImageLoaded(false);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
generateQRCode()
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
setQrUrl(
|
2026-03-19 17:00:35 +08:00
|
|
|
|
`https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(data.url)}&size=400x400`,
|
2026-03-17 22:18:05 +08:00
|
|
|
|
);
|
|
|
|
|
|
setQrKey(data.qrcode_key);
|
|
|
|
|
|
setStatus("waiting");
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => setStatus("error"));
|
2026-03-05 18:02:54 +08:00
|
|
|
|
|
2026-03-17 22:18:05 +08:00
|
|
|
|
return () => {
|
|
|
|
|
|
if (pollRef.current) clearInterval(pollRef.current);
|
|
|
|
|
|
};
|
2026-03-05 18:02:54 +08:00
|
|
|
|
}, [visible]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-17 22:18:05 +08:00
|
|
|
|
if (!qrKey || status !== "waiting") return;
|
2026-03-05 18:02:54 +08:00
|
|
|
|
pollRef.current = setInterval(async () => {
|
|
|
|
|
|
const result = await pollQRCode(qrKey);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
if (result.code === 86038) {
|
|
|
|
|
|
setStatus("error");
|
|
|
|
|
|
clearInterval(pollRef.current!);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (result.code === 86090) setStatus("scanned");
|
2026-03-05 18:02:54 +08:00
|
|
|
|
if (result.code === 0 && result.cookie) {
|
|
|
|
|
|
clearInterval(pollRef.current!);
|
2026-03-14 18:25:13 +08:00
|
|
|
|
try {
|
2026-03-17 22:18:05 +08:00
|
|
|
|
await login(result.cookie, "", "");
|
|
|
|
|
|
setStatus("done");
|
2026-03-14 18:25:13 +08:00
|
|
|
|
const info = await getUserInfo();
|
|
|
|
|
|
setProfile(info.face, info.uname, String(info.mid));
|
|
|
|
|
|
} catch {
|
2026-03-17 22:18:05 +08:00
|
|
|
|
setStatus("error");
|
2026-03-14 18:25:13 +08:00
|
|
|
|
}
|
2026-03-05 18:02:54 +08:00
|
|
|
|
onClose();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 2000);
|
2026-03-17 22:18:05 +08:00
|
|
|
|
return () => {
|
|
|
|
|
|
if (pollRef.current) clearInterval(pollRef.current);
|
|
|
|
|
|
};
|
2026-03-05 18:02:54 +08:00
|
|
|
|
}, [qrKey, status]);
|
|
|
|
|
|
|
2026-03-19 17:00:35 +08:00
|
|
|
|
async function handleSaveQR() {
|
|
|
|
|
|
if (!qrUrl) return;
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { status: perm } = await MediaLibrary.requestPermissionsAsync();
|
|
|
|
|
|
if (perm !== "granted") {
|
|
|
|
|
|
Alert.alert("提示", "需要相册权限才能保存图片");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const dest = `${FileSystem.cacheDirectory}bilibili_qr.png`;
|
|
|
|
|
|
const { uri } = await FileSystem.downloadAsync(qrUrl, dest);
|
|
|
|
|
|
await MediaLibrary.saveToLibraryAsync(uri);
|
|
|
|
|
|
Alert.alert("已保存", "二维码已存入相册");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
Alert.alert("失败", "保存失败,请重试");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 18:02:54 +08:00
|
|
|
|
return (
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<Modal
|
|
|
|
|
|
visible={visible}
|
|
|
|
|
|
transparent
|
|
|
|
|
|
animationType="none"
|
|
|
|
|
|
onRequestClose={onClose}
|
|
|
|
|
|
>
|
2026-03-19 17:00:35 +08:00
|
|
|
|
{/* 遮罩固定不动 */}
|
|
|
|
|
|
<View style={styles.overlay} pointerEvents="box-none" />
|
|
|
|
|
|
|
|
|
|
|
|
{/* sheet 独立滑入 */}
|
|
|
|
|
|
<Animated.View
|
|
|
|
|
|
style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}
|
|
|
|
|
|
>
|
2026-03-05 18:02:54 +08:00
|
|
|
|
<View style={styles.sheet}>
|
|
|
|
|
|
<Text style={styles.title}>扫码登录</Text>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
{status === "loading" && (
|
|
|
|
|
|
<ActivityIndicator
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
color="#00AEEC"
|
|
|
|
|
|
style={styles.loader}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{(status === "waiting" || status === "scanned") && qrUrl && (
|
2026-03-05 18:02:54 +08:00
|
|
|
|
<>
|
2026-03-19 17:00:35 +08:00
|
|
|
|
<View style={styles.qrWrapper}>
|
|
|
|
|
|
<Image
|
|
|
|
|
|
source={{ uri: qrUrl }}
|
|
|
|
|
|
style={styles.qr}
|
|
|
|
|
|
onLoad={() => setQrImageLoaded(true)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{!qrImageLoaded && (
|
|
|
|
|
|
<View style={styles.qrLoader}>
|
|
|
|
|
|
<ActivityIndicator size="large" color="#00AEEC" />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{qrImageLoaded && (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.saveBtn}
|
|
|
|
|
|
onPress={handleSaveQR}
|
|
|
|
|
|
disabled={saving}
|
|
|
|
|
|
>
|
|
|
|
|
|
{saving ? (
|
|
|
|
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Ionicons name="download-outline" size={16} color="#fff" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
2026-03-17 22:18:05 +08:00
|
|
|
|
<Text style={styles.hint}>
|
|
|
|
|
|
{status === "scanned"
|
|
|
|
|
|
? "扫描成功,请在手机确认"
|
|
|
|
|
|
: "使用 B站 APP 扫一扫"}
|
|
|
|
|
|
</Text>
|
2026-03-05 18:02:54 +08:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-03-17 22:18:05 +08:00
|
|
|
|
{status === "error" && (
|
|
|
|
|
|
<Text style={styles.hint}>二维码已过期,请关闭重试</Text>
|
|
|
|
|
|
)}
|
2026-03-05 18:02:54 +08:00
|
|
|
|
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
|
|
|
|
|
<Text style={styles.closeTxt}>关闭</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
2026-03-19 17:00:35 +08:00
|
|
|
|
</Animated.View>
|
2026-03-05 18:02:54 +08:00
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
2026-03-17 22:18:05 +08:00
|
|
|
|
overlay: {
|
2026-03-19 17:00:35 +08:00
|
|
|
|
...StyleSheet.absoluteFillObject,
|
2026-03-17 22:18:05 +08:00
|
|
|
|
backgroundColor: "rgba(0,0,0,0.5)",
|
2026-03-19 17:00:35 +08:00
|
|
|
|
},
|
|
|
|
|
|
sheetWrapper: {
|
|
|
|
|
|
position: "absolute",
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
2026-03-17 22:18:05 +08:00
|
|
|
|
},
|
|
|
|
|
|
sheet: {
|
|
|
|
|
|
backgroundColor: "#fff",
|
|
|
|
|
|
borderTopLeftRadius: 16,
|
|
|
|
|
|
borderTopRightRadius: 16,
|
|
|
|
|
|
padding: 24,
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
},
|
|
|
|
|
|
title: { fontSize: 18, fontWeight: "600", marginBottom: 20 },
|
2026-03-05 18:02:54 +08:00
|
|
|
|
loader: { marginVertical: 40 },
|
2026-03-19 17:00:35 +08:00
|
|
|
|
qrWrapper: { width: 200, height: 200, marginBottom: 12 },
|
|
|
|
|
|
qr: { width: 200, height: 200 },
|
|
|
|
|
|
qrLoader: {
|
|
|
|
|
|
...StyleSheet.absoluteFillObject,
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
|
backgroundColor: "#f4f4f4",
|
|
|
|
|
|
},
|
|
|
|
|
|
saveBtn: {
|
|
|
|
|
|
position: "absolute",
|
|
|
|
|
|
bottom: 6,
|
|
|
|
|
|
right: 6,
|
|
|
|
|
|
backgroundColor: "rgba(0,0,0,0.45)",
|
|
|
|
|
|
borderRadius: 14,
|
|
|
|
|
|
width: 28,
|
|
|
|
|
|
height: 28,
|
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
|
justifyContent: "center",
|
|
|
|
|
|
},
|
2026-03-17 22:18:05 +08:00
|
|
|
|
hint: { fontSize: 13, color: "#666", marginBottom: 20 },
|
2026-03-05 18:02:54 +08:00
|
|
|
|
closeBtn: { padding: 12 },
|
2026-03-17 22:18:05 +08:00
|
|
|
|
closeTxt: { fontSize: 14, color: "#00AEEC" },
|
2026-03-05 18:02:54 +08:00
|
|
|
|
});
|