diff --git a/app/(tabs)/course.tsx b/app/(tabs)/course.tsx index 23af60a..2e9020f 100644 --- a/app/(tabs)/course.tsx +++ b/app/(tabs)/course.tsx @@ -62,6 +62,8 @@ export default function CourseScreen() { const [showTypePicker, setShowTypePicker] = useState(false); const [showLabImport, setShowLabImport] = useState(false); const [showDrawer, setShowDrawer] = useState(false); + const [pendingManualImport, setPendingManualImport] = + useState(null); const scheme = useColorScheme(); const isDark = scheme === "dark"; @@ -123,9 +125,18 @@ export default function CourseScreen() { const doImport = (type: ImportType) => { haptic(); - setShowTypePicker(false); setFabOpen(false); if (type === "lab") { + setShowTypePicker(false); + if (!isBound) { + Toast.show({ + type: "info", + text1: t("course.needBindTitle"), + text2: t("course.needBindSub"), + position: "bottom", + }); + return; + } // 实验系统只返回绝对日期,需要学期起始日才能换算周次 if (!termStart) { Toast.show({ @@ -138,9 +149,22 @@ export default function CourseScreen() { setShowLabImport(true); return; } + if (!isBound) { + setPendingManualImport(type); + setShowTypePicker(true); + return; + } + setShowTypePicker(false); importerRef.current?.startImport(type); }; + const continueManualImport = () => { + const type = pendingManualImport; + setPendingManualImport(null); + setShowTypePicker(false); + if (type) importerRef.current?.startImport(type); + }; + const changeWeek = (delta: number) => { const nextWeek = Math.max(1, Math.min(MAX_WEEK, week + delta)); if (nextWeek === week) return; @@ -272,88 +296,7 @@ export default function CourseScreen() { onWeekChange={goToWeek} /> - {!isBound && ( - - - - - - {t("course.needBindTitle")} - - - {t("course.needBindSub")} - - ({ - marginTop: 4, - flexDirection: "row", - alignItems: "center", - gap: 2, - opacity: pressed ? 0.5 : 1, - })} - onPress={() => { - haptic(); - router.navigate("/(tabs)/user"); - }} - > - - {t("course.goBind")} - - - - - )} - - {isBound && courses.length === 0 && ( + {courses.length === 0 && ( <> setShowTypePicker(false)} - title={t("course.selectImportType")} + onClose={() => { + setShowTypePicker(false); + setPendingManualImport(null); + }} + title={ + pendingManualImport + ? t("course.manualImportTitle") + : t("course.selectImportType") + } > - doImport("bachelor")} - > - - - {t("course.bachelor")} - - - doImport("master")} - > - - - {t("course.master")} - - - doImport("lab")} - > - - - {t("course.lab")} - - + {pendingManualImport ? ( + <> + + {t("course.manualImportDesc")} + + + { + haptic(); + setPendingManualImport(null); + setShowTypePicker(false); + }} + > + + {t("common.cancel")} + + + { + haptic(); + continueManualImport(); + }} + > + + {t("course.manualImportContinue")} + + + + + ) : ( + <> + doImport("bachelor")} + > + + + {t("course.bachelor")} + + + doImport("master")} + > + + + {t("course.master")} + + + doImport("lab")} + > + + + {t("course.lab")} + + + + )} setShowDrawer(false)} - isBound={isBound} onManage={() => router.push("/(pages)/settings/course/manage")} onReimport={handleReimport} onOpenSettings={() => router.push("/(pages)/settings/calendar")} diff --git a/app/(tabs)/function.tsx b/app/(tabs)/function.tsx index c0f45b6..f959ffb 100644 --- a/app/(tabs)/function.tsx +++ b/app/(tabs)/function.tsx @@ -22,6 +22,7 @@ import { useHaptics } from "@/hooks/use-haptics"; import { useMarkRouteInteractive } from "@/hooks/use-mark-route-interactive"; import { type TKey, useT } from "@/lib/i18n"; import { useScheduleStore } from "@/store/schedule"; +import { useUserBindStore } from "@/store/user-bind"; type WebApp = { icon: React.ComponentProps["name"]; @@ -130,6 +131,7 @@ export default function FunctionScreen() { const isDark = colorScheme === "dark"; const insets = useSafeAreaInsets(); const hasBgImage = useScheduleStore((s) => !!s.backgroundImageUri); + const isBound = useUserBindStore((s) => s.isBound); const { height } = useWindowDimensions(); const [showBrowser, setShowBrowser] = useState(false); const [uri, setUri] = useState(""); @@ -232,6 +234,76 @@ export default function FunctionScreen() { ))} + {!isBound && ( + + + + + + {t("course.needBindTitle")} + + ({ + marginTop: 4, + flexDirection: "row", + alignItems: "center", + gap: 2, + opacity: pressed ? 0.5 : 1, + })} + onPress={() => { + haptic(); + router.navigate("/(tabs)/user"); + }} + > + + {t("course.goBind")} + + + + + )} + {IS_DEV && ( (null); - const isBound = useRef(false); - const hasFailed = useRef(false); + const flowFinished = useRef(false); + const preserveSession = useRef(false); useEffect(() => { const currentWebView = webview.current; return () => { currentWebView?.clearCache(true); - if (!isBound.current) { + if (!preserveSession.current) { CookieManager.clearAll(true).catch(() => {}); } }; }, []); const handleFailure = () => { - if (isBound.current || hasFailed.current) return; - hasFailed.current = true; + if (flowFinished.current) return; + flowFinished.current = true; Toast.show({ type: "error", text1: t("user.bindFailed"), @@ -212,17 +213,24 @@ export default function BindScreen() { password: msg.password, }; } else if (msg.type === "profileUnavailable") { - if (isBound.current || hasFailed.current) return; - hasFailed.current = true; + if (flowFinished.current) return; + flowFinished.current = true; + preserveSession.current = true; + useOnboardingStore.getState().reset(); Toast.show({ - type: "error", + type: "info", text1: t("user.bindProfileUnavailable"), text2: t("user.bindProfileUnavailableSub"), position: "bottom", }); - if (router.canGoBack()) router.back(); + router.replace({ + pathname: "/onboarding", + params: { step: "setup" }, + }); } else if (msg.type === "info" && pendingCredentials.current) { - isBound.current = true; + if (flowFinished.current) return; + flowFinished.current = true; + preserveSession.current = true; const { username, password } = pendingCredentials.current; await useUserBindStore diff --git a/app/browser/course.tsx b/app/browser/course.tsx index a90cdca..0dc1a53 100644 --- a/app/browser/course.tsx +++ b/app/browser/course.tsx @@ -1,5 +1,6 @@ +import CookieManager from "@preeternal/react-native-cookie-manager"; import { router, Stack, useLocalSearchParams } from "expo-router"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Text, View } from "react-native"; import Animated, { Easing, @@ -34,6 +35,7 @@ import { } from "@/services/course-import/master-import"; import { syncWidgetData } from "@/services/widget-sync"; import { type ImportType, useCourseStore } from "@/store/course"; +import { useUserBindStore } from "@/store/user-bind"; function normalizeImportType(type: string | string[] | undefined): ImportType { return type === "master" ? "master" : "bachelor"; @@ -49,10 +51,12 @@ export default function CourseImportScreen() { const t = useT(); const params = useLocalSearchParams<{ type?: string }>(); const importType = normalizeImportType(params.type); + const isBound = useUserBindStore((s) => s.isBound); const webview = useRef(null); const injected = useRef(false); const finished = useRef(false); const injectTimer = useRef | null>(null); + const [showImportOverlay, setShowImportOverlay] = useState(isBound); const finish = useCallback( (success: boolean, message?: string) => { if (finished.current) return; @@ -76,13 +80,26 @@ export default function CourseImportScreen() { }); } - if (router.canGoBack()) { - router.back(); - } else { - router.replace("/(tabs)/course"); + const leaveImportScreen = () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/(tabs)/course"); + } + }; + + if (success && !isBound) { + CookieManager.clearAll(true) + .catch((error) => + reportError(error, { module: "course-session-cleanup" }), + ) + .finally(leaveImportScreen); + return; } + + leaveImportScreen(); }, - [t], + [isBound, t], ); const { onLoadEnd: autoLoginOnLoadEnd, @@ -133,14 +150,14 @@ export default function CourseImportScreen() { }, []); useEffect(() => { - if (sms.visible) return; + if (!isBound || sms.visible) return; const timeout = setTimeout(() => { if (!injected.current) { finish(false, t("course.importTimeout")); } }, 30000); return () => clearTimeout(timeout); - }, [sms.visible, finish, t]); + }, [isBound, sms.visible, finish, t]); const handleError = useCallback( (syntheticEvent: { @@ -165,6 +182,7 @@ export default function CourseImportScreen() { const url = e.nativeEvent.url; if (importType === "bachelor" && url.startsWith(BACHELOR_HOME_PREFIX)) { + setShowImportOverlay(true); injected.current = true; const script = buildBachelorFetchScript({ fetchUserFailed: t("course.fetchUserFailed"), @@ -176,6 +194,7 @@ export default function CourseImportScreen() { } if (importType === "master" && url.startsWith(MASTER_MAIN_PREFIX)) { + setShowImportOverlay(true); injected.current = true; const script = buildMasterFetchScript({ fetchUserFailed: t("course.fetchUserFailed"), @@ -280,115 +299,117 @@ export default function CourseImportScreen() { onMessage={handleMessage} ref={webview} /> - true} - onMoveShouldSetResponder={() => true} - > + {showImportOverlay && ( true} + onMoveShouldSetResponder={() => true} > - - - - - - {importingWait} - - - - + + + + + {importingWait} + + + - {PROGRESS_STRIPES.map((stripe) => ( - - ))} - + + {PROGRESS_STRIPES.map((stripe) => ( + + ))} + + - + )} {smsNode} ); diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 97e8133..a46e9f5 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -1,7 +1,8 @@ +import CookieManager from "@preeternal/react-native-cookie-manager"; import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; -import { router } from "expo-router"; -import { useState } from "react"; +import { router, useLocalSearchParams } from "expo-router"; +import { useRef, useState } from "react"; import { ActivityIndicator, Linking, @@ -21,6 +22,7 @@ import { useColorScheme } from "@/hooks/use-color-scheme"; import { useHaptics } from "@/hooks/use-haptics"; import { useMarkRouteInteractive } from "@/hooks/use-mark-route-interactive"; import { useT } from "@/lib/i18n"; +import { reportError } from "@/lib/report"; import { deleteAppCalendar, syncCoursesToCalendar, @@ -39,14 +41,19 @@ type StepId = "account" | "setup"; const appIcon = require("@/assets/images/icon.png"); -function getInitialStep(isBound: boolean, hasCourses: boolean): StepId { - if (isBound || hasCourses) return "setup"; +function getInitialStep( + isBound: boolean, + hasCourses: boolean, + requestedStep?: string, +): StepId { + if (requestedStep === "setup" || isBound || hasCourses) return "setup"; return "account"; } export default function OnboardingScreen() { useMarkRouteInteractive(); const t = useT(); + const params = useLocalSearchParams<{ step?: string }>(); const haptic = useHaptics(); const isDark = useColorScheme() === "dark"; const isBound = useUserBindStore((s) => s.isBound); @@ -59,12 +66,13 @@ export default function OnboardingScreen() { const hasCourses = courseCount > 0; const [selectedStep] = useState(() => - getInitialStep(isBound, hasCourses), + getInitialStep(isBound, hasCourses, params.step), ); const [acceptedLegal, setAcceptedLegal] = useState(false); const [notificationBusy, setNotificationBusy] = useState(false); const [permissionSheetVisible, setPermissionSheetVisible] = useState(false); const [calendarBusy, setCalendarBusy] = useState(false); + const finishStarted = useRef(false); const step = selectedStep === "account" && !isBound ? "account" : "setup"; @@ -165,9 +173,24 @@ export default function OnboardingScreen() { }; const finish = () => { + if (finishStarted.current) return; + finishStarted.current = true; haptic(); - completeOnboarding(); - router.replace("/"); + const complete = () => { + completeOnboarding(); + router.replace("/"); + }; + + if (isBound) { + complete(); + return; + } + + CookieManager.clearAll(true) + .catch((error) => + reportError(error, { module: "onboarding-session-cleanup" }), + ) + .finally(complete); }; const handleOpenNotificationSettings = () => { diff --git a/components/layout/course-drawer.tsx b/components/layout/course-drawer.tsx index bef0041..43d79b7 100644 --- a/components/layout/course-drawer.tsx +++ b/components/layout/course-drawer.tsx @@ -34,7 +34,6 @@ export const DRAWER_EXIT_MS = 200; interface Props { visible: boolean; onClose: () => void; - isBound: boolean; onManage: () => void; onReimport: () => void; onOpenSettings: () => void; @@ -43,7 +42,6 @@ interface Props { export function CourseDrawer({ visible, onClose, - isBound, onManage, onReimport, onOpenSettings, @@ -222,7 +220,6 @@ export function CourseDrawer({ textColor={primaryText} chevronColor={chevronColor} isDark={isDark} - disabled={!isBound} onPress={() => dismissThen(onReimport)} /> (INITIAL_SMS); const [code, setCode] = useState(""); - const onCancelRef = useRef(options?.onCancel); - onCancelRef.current = options?.onCancel; + const onCancel = options?.onCancel; useEffect(() => { - useUserBindStore - .getState() - .getCredentials() - .then((c) => { - creds.current = c; - }); + const userBind = useUserBindStore.getState(); + if (!userBind.isBound) return; + + userBind.getCredentials().then((c) => { + creds.current = c; + }); }, []); const onLoadEnd = useCallback( @@ -134,6 +133,10 @@ export function useZhlgdAutoLogin( return; } + if (!useUserBindStore.getState().isBound) { + creds.current = null; + return; + } if (!creds.current || lastFilledUrl.current === url) return; lastFilledUrl.current = url; @@ -174,8 +177,8 @@ export function useZhlgdAutoLogin( const cancelSms = useCallback(() => { setSms(INITIAL_SMS); setCode(""); - onCancelRef.current?.(); - }, []); + onCancel?.(); + }, [onCancel]); const smsNode = (