From 3ac2b708246f15fdab6036901d4d899ad30efa70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=B9=E7=B3=96?= Date: Wed, 5 Mar 2025 22:43:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20useApiRequest=20hook=20(#1?= =?UTF-8?q?22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用自定义的 useApiRequest hook 简化 app 中的请求处理逻辑 --- app/(guest)/contributors.tsx | 5 +- app/common/academic-calendar.tsx | 192 ++++++++------------- app/toolbox/academic/credits.tsx | 54 ++---- app/toolbox/academic/gpa.tsx | 114 +++++-------- app/toolbox/academic/grades.tsx | 196 ++++++++++------------ app/toolbox/academic/index.tsx | 84 +++++----- app/toolbox/academic/unified-exam.tsx | 56 +++---- app/toolbox/empty-room.tsx | 45 ++--- app/toolbox/exam-room.tsx | 232 ++++++++++---------------- app/toolbox/paper/index.tsx | 41 ++--- components/dom/contributors.tsx | 27 +-- hooks/useApiRequest.ts | 59 +++++++ types/loading-state.ts | 6 - 13 files changed, 462 insertions(+), 649 deletions(-) create mode 100644 hooks/useApiRequest.ts delete mode 100644 types/loading-state.ts diff --git a/app/(guest)/contributors.tsx b/app/(guest)/contributors.tsx index ae5ffb74..8e70bcd9 100644 --- a/app/(guest)/contributors.tsx +++ b/app/(guest)/contributors.tsx @@ -2,13 +2,16 @@ import { Stack } from 'expo-router'; import { useColorScheme } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { getApiV1CommonContributor } from '@/api/generate'; import ContributorsDOMComponent from '@/components/dom/contributors'; +import useApiRequest from '@/hooks/useApiRequest'; // 这个页面更多地作为一种测试用途,用于展示 DOM 组件的使用 // 复杂的页面可以使用 DOM 组件来简化开发流程,毕竟相应的 Native 组件不太好做 // -- Baoshuo , 2025-02-06 export default function Contributors() { + const { data } = useApiRequest(getApiV1CommonContributor, {}); const colorScheme = useColorScheme(); return ( @@ -17,7 +20,7 @@ export default function Contributors() { {/* 在原生端传入 colorScheme,防止出现闪动 */} - + {data && } ); diff --git a/app/common/academic-calendar.tsx b/app/common/academic-calendar.tsx index 8936fe1e..f3e5656b 100644 --- a/app/common/academic-calendar.tsx +++ b/app/common/academic-calendar.tsx @@ -1,5 +1,5 @@ import { Stack } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { Dimensions, RefreshControl, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -11,140 +11,96 @@ import { TabFlatList } from '@/components/tab-flatlist'; import { Text } from '@/components/ui/text'; import { ResultEnum } from '@/api/enum'; -import { getApiV1TermsInfo, getApiV1TermsList } from '@/api/generate'; -import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; -import { type AcademicCalendar } from '@/types/calendar'; +import { getApiV1JwchTermList, getApiV1TermsInfo } from '@/api/generate'; +import useApiRequest from '@/hooks/useApiRequest'; + +// 处理API错误 +const handleApiError = (errorData: any) => { + if (errorData) { + if (errorData.code === ResultEnum.BizErrorCode) { + return; + } + toast.error(errorData.message || '发生未知错误,请稍后再试'); + } +}; -export default function AcademicCalendarPage() { - const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新 - const [termList, setTermList] = useState([]); // 学期列表 - const [currentTerm, setCurrentTerm] = useState(''); // 当前学期 - const [academicCalendarDataMap, setAcademicCalendarMap] = useState< - Record - >({}); - const screenWidth = Dimensions.get('window').width; // 获取屏幕宽度 +interface CourseContentProps { + term: string; +} - const handleErrorRef = useRef(useSafeResponseSolve().handleError); +// 每个学期的内容 +const CourseContent: React.FC = ({ term }) => { + const screenWidth = Dimensions.get('window').width; // 获取屏幕宽度 + // 获取学期数据 + const { data, dataUpdatedAt, isLoading, refetch } = useApiRequest( + getApiV1TermsInfo, + { term }, + { errorHandler: handleApiError }, + ); - // 处理API错误 - const handleApiError = useCallback( - (error: any) => { - const data = handleErrorRef.current(error); + const termData = data?.events || []; + const lastUpdated = new Date(dataUpdatedAt); - if (data) { - if (data.code === ResultEnum.BizErrorCode) { - return; - } - toast.error(data.message || '发生未知错误,请稍后再试'); + return ( + } - }, - [handleErrorRef], + className="grow" + style={{ width: screenWidth }} + > + {/* 渲染考试数据 */} + + {termData.length > 0 ? ( + termData.map((item, idx) => ( + + + + )) + ) : ( + {isLoading ? '正在刷新中' : '暂无学期数据'} + )} + + + {/* 显示刷新时间 */} + {lastUpdated && ( + + + 数据同步时间:{lastUpdated.toLocaleString()} + + )} + ); +}; +export default function AcademicCalendarPage() { + const [currentTerm, setCurrentTerm] = useState(''); // 当前学期 // 获取学期列表(当前用户) - const fetchTermList = useCallback(async () => { - try { - const result = await getApiV1TermsList(); - const terms = result.data.data.terms.map(item => item.term) as string[]; - setTermList(terms); + const onSuccess = useCallback( + (terms: string[]) => { if (!currentTerm && terms.length) { setCurrentTerm(terms[0]); } - } catch (error: any) { - handleApiError(error); - } - }, [currentTerm, handleApiError]); - - // 刷新当前学期数据 - const refreshData = useCallback(async () => { - // 清空当前学期的数据,保留对象结构 - setAcademicCalendarMap(prev => ({ - ...prev, - [currentTerm]: { data: [], lastUpdated: undefined }, - })); - - try { - const result = await getApiV1TermsInfo({ term: currentTerm }); - - setAcademicCalendarMap(prev => ({ - ...prev, - [currentTerm]: { - data: result.data.data.events as AcademicCalendar[], - lastUpdated: new Date(), // 记录刷新时间 - }, - })); - } catch (error) { - handleApiError(error); - } finally { - setIsRefreshing(false); - } - }, [currentTerm, handleApiError]); - - // 加载学期列表 - useEffect(() => { - fetchTermList(); - }, [fetchTermList]); - - // 当 currentTerm 变化或 examDataMap 缺少数据时刷新 - useEffect(() => { - if (!isRefreshing && currentTerm && !academicCalendarDataMap[currentTerm]) { - setIsRefreshing(true); - refreshData(); - } - }, [isRefreshing, currentTerm, academicCalendarDataMap, refreshData]); - - // 处理下拉刷新逻辑 - const handleRefresh = useCallback(() => { - setIsRefreshing(true); - refreshData(); - }, [refreshData]); - - // 渲染每个学期的内容 - const renderContent = (term: string) => { - const termData = academicCalendarDataMap[term]?.data || []; - const lastUpdated = academicCalendarDataMap[term]?.lastUpdated; - - return ( - } - className="grow" - style={{ width: screenWidth }} - > - {/* 渲染考试数据 */} - - {termData.length > 0 ? ( - termData.map((item, idx) => ( - - - - )) - ) : ( - {isRefreshing ? '正在刷新中' : '暂无学期数据'} - )} - - - {/* 显示刷新时间 */} - {lastUpdated && ( - - - 数据同步时间:{lastUpdated.toLocaleString()} - - )} - - ); - }; + }, + [currentTerm], + ); + const { data: termList } = useApiRequest(getApiV1JwchTermList, {}, { onSuccess, errorHandler: handleApiError }); return ( <> - + } + /> ); diff --git a/app/toolbox/academic/credits.tsx b/app/toolbox/academic/credits.tsx index 0a19390d..9be27a31 100644 --- a/app/toolbox/academic/credits.tsx +++ b/app/toolbox/academic/credits.tsx @@ -1,5 +1,4 @@ import { Stack } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; import { RefreshControl, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -11,55 +10,32 @@ import Loading from '@/components/loading'; import PageContainer from '@/components/page-container'; import { Text } from '@/components/ui/text'; -import type { JwchAcademicCreditResponse_AcademicCreditData as CreditData } from '@/api/backend'; import { getApiV1JwchAcademicCredit } from '@/api/generate'; -import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; +import useApiRequest from '@/hooks/useApiRequest'; -export default function CreditsPage() { - const [isRefreshing, setIsRefreshing] = useState(true); // 是否正在刷新 - const [creditData, setCreditData] = useState(null); // 学分数据 - const [lastUpdated, setLastUpdated] = useState(null); // 数据最后更新时间 - const handleErrorRef = useRef(useSafeResponseSolve().handleError); // 错误处理函数 - - // 获取学分数据 - const fetchCreditData = useCallback(async () => { - try { - const response = await getApiV1JwchAcademicCredit(); - setCreditData(response.data.data); - setLastUpdated(new Date()); // 更新最后更新时间 - } catch (error: any) { - const data = handleErrorRef.current(error); - if (data) toast.error(data.message || '发生未知错误,请稍后再试'); - } finally { - setIsRefreshing(false); - } - }, []); - - // 初始化时获取学分数据 - useEffect(() => { - fetchCreditData(); - }, [fetchCreditData]); +const errorHandler = (data: any) => { + if (data) toast.error(data.msg || '发生未知错误,请稍后再试'); +}; - // 处理下拉刷新逻辑 - const handleRefresh = useCallback(() => { - if (!isRefreshing) { - setIsRefreshing(true); // 确保不会重复触发刷新 - setCreditData([]); // 清空数据 - fetchCreditData(); - } - }, [fetchCreditData, isRefreshing]); +export default function CreditsPage() { + const { + data: creditData, + dataUpdatedAt, + isLoading, + refetch, + } = useApiRequest(getApiV1JwchAcademicCredit, {}, { errorHandler }); + const lastUpdated = new Date(dataUpdatedAt); // 数据最后更新时间 return ( <> - - - {isRefreshing ? ( + + {isLoading ? ( ) : ( } + refreshControl={} > {creditData && creditData.length > 0 && ( <> diff --git a/app/toolbox/academic/gpa.tsx b/app/toolbox/academic/gpa.tsx index 9b2d74d4..dd71c664 100644 --- a/app/toolbox/academic/gpa.tsx +++ b/app/toolbox/academic/gpa.tsx @@ -1,92 +1,60 @@ -import { useNavigation } from 'expo-router'; -import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { Stack } from 'expo-router'; import { RefreshControl, ScrollView, View } from 'react-native'; import { toast } from 'sonner-native'; import PageContainer from '@/components/page-container'; import { Text } from '@/components/ui/text'; -import type { JwchAcademicGpaResponse } from '@/api/backend'; import { getApiV1JwchAcademicGpa } from '@/api/generate'; import { Icon } from '@/components/Icon'; import Loading from '@/components/loading'; -import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; +import useApiRequest from '@/hooks/useApiRequest'; import { SafeAreaView } from 'react-native-safe-area-context'; const NAVIGATION_TITLE = '绩点排名'; +const errorHandler = (data: any) => { + if (data) toast.error(data.msg || '发生未知错误,请稍后再试'); +}; export default function GPAPage() { - const [isRefreshing, setIsRefreshing] = useState(true); // 按钮是否禁用 - const [academicData, setAcademicData] = useState(null); // 学术成绩数据 - - const { handleError } = useSafeResponseSolve(); // HTTP 请求错误处理 - - // 设置导航栏标题 - const navigation = useNavigation(); - useLayoutEffect(() => { - navigation.setOptions({ title: NAVIGATION_TITLE }); - }, [navigation]); - - // 访问 west2-online 服务器 - const getAcademicData = useCallback(async () => { - try { - const result = await getApiV1JwchAcademicGpa(); - setAcademicData(result.data.data); // 第一个 data 指的是响应 HTTP 的 data 字段,第二个 data 指的是响应数据的 data 字段 - } catch (error: any) { - const data = handleError(error); - if (data) { - toast.error(data.msg ? data.msg : '未知错误'); - } - } finally { - setIsRefreshing(false); - } - }, [handleError]); - - useEffect(() => { - getAcademicData(); - }, [getAcademicData]); - - const handleRefresh = useCallback(() => { - if (!isRefreshing) { - setIsRefreshing(true); - setAcademicData(null); - getAcademicData(); - } - }, [getAcademicData, isRefreshing]); + const { data: academicData, isLoading, refetch } = useApiRequest(getApiV1JwchAcademicGpa, {}, { errorHandler }); return ( - - {isRefreshing ? ( - - ) : ( - } - > - {/* 学术成绩数据列表 */} - {academicData && ( - - {/* 数据列表 */} - - {academicData.data.map((item, index) => ( - - {item.type} - {item.value} + <> + + + {isLoading ? ( + + ) : ( + } + > + {/* 学术成绩数据列表 */} + {academicData && ( + + {/* 数据列表 */} + + {academicData.data.map((item, index) => ( + + {item.type} + {item.value} + + ))} + {/* 显示最后更新时间 */} + + + {academicData.time} - ))} - {/* 显示最后更新时间 */} - - - {academicData.time} - - - 注:绩点排名中的总学分只计算参与绩点计算的学分总和,并不代表所修学分总和。 - - - - )} - - )} - + + 注:绩点排名中的总学分只计算参与绩点计算的学分总和,并不代表所修学分总和。 + + + + )} + + )} + + ); } diff --git a/app/toolbox/academic/grades.tsx b/app/toolbox/academic/grades.tsx index 7bb6b657..f60d61cc 100644 --- a/app/toolbox/academic/grades.tsx +++ b/app/toolbox/academic/grades.tsx @@ -1,5 +1,5 @@ import { Tabs } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { Dimensions, Pressable, RefreshControl, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -14,133 +14,105 @@ import { TabFlatList } from '@/components/tab-flatlist'; import { Text } from '@/components/ui/text'; import { getApiV1JwchAcademicScores, getApiV1JwchTermList } from '@/api/generate'; -import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; +import useApiRequest from '@/hooks/useApiRequest'; import { FAQ_COURSE_GRADE } from '@/lib/FAQ'; import { calSingleTermSummary, parseScore } from '@/lib/grades'; -import type { CourseGradesData } from '@/types/academic'; -export default function GradesPage() { - const [isRefreshing, setIsRefreshing] = useState(true); // 按钮是否禁用 - const [termList, setTermList] = useState([]); // 学期列表 - const [currentTerm, setCurrentTerm] = useState(''); // 当前学期 - const [academicData, setAcademicData] = useState([]); // 学术成绩数据 - const [lastUpdated, setLastUpdated] = useState(null); // 最后更新时间 - const [showFAQ, setShowFAQ] = useState(false); // 是否显示 FAQ 模态框 +const errorHandler = (data: any) => { + if (data) { + toast.error(data.message || '发生未知错误,请稍后再试'); + } +}; - const handleErrorRef = useRef(useSafeResponseSolve().handleError); - const screenWidth = Dimensions.get('window').width; // 获取屏幕宽度 +interface TermContentProps { + term: string; + onRefresh?: () => void; +} +// 单个学期的内容 +const TermContent: React.FC = ({ term, onRefresh }) => { + const screenWidth = Dimensions.get('window').width; // 获取屏幕宽度 // 访问 west2-online 服务器获取成绩数据(由于教务处限制,只能获取全部数据) // 由于教务处限制,成绩数据会直接返回所有课程的成绩,我们需要在本地进行区分,因此引入了下一个获取学期列表的函数 - const getAcademicData = useCallback(async () => { - try { - const result = await getApiV1JwchAcademicScores(); - setAcademicData(result.data.data); - setLastUpdated(new Date()); // 更新最后更新时间 - } catch (error: any) { - console.log(error); - const data = handleErrorRef.current(error); - if (data) { - toast.error(data.message || '发生未知错误,请稍后再试'); + const { + data: academicData, + dataUpdatedAt, + isLoading, + refetch, + } = useApiRequest(getApiV1JwchAcademicScores, {}, { errorHandler }); + const lastUpdated = new Date(dataUpdatedAt); + const filteredData = (academicData ?? []).filter(it => it.term === term); + const summary = calSingleTermSummary(filteredData, term); + + return ( + { + onRefresh?.(); + refetch(); + }} + /> } - } finally { - setIsRefreshing(false); // 确保结束刷新 - } - }, []); + > + {academicData && academicData.length > 0 && summary && ( + + + + )} - // 获取学期列表(当前用户),此处不使用 usePersistedQuery - // 这和课表的 getApiV1TermsList 不一致,前者(即 getApiV1JwchTermList)只返回用户就读的学期列表 - const fetchTermList = useCallback(async () => { - try { - const result = await getApiV1JwchTermList(); - const terms = result.data.data as string[]; - setTermList(terms); - // 只在首次加载(terms 存在且 currentTerm 为空)时设置 currentTerm + + {filteredData.length > 0 ? ( + filteredData + .sort((a, b) => parseScore(b.score) - parseScore(a.score)) + .map((item, index) => ( + + + + )) + ) : ( + 暂无成绩数据 + )} + {filteredData.length > 0 && ( + + + + 数据同步时间:{(lastUpdated && lastUpdated.toLocaleString()) || '请进行一次同步'} + + + )} + + + ); +}; + +export default function GradesPage() { + const [currentTerm, setCurrentTerm] = useState(''); // 当前学期 + + // 只在首次加载(terms 存在且 currentTerm 为空)时设置 currentTerm + const onSuccess = useCallback( + async (terms: string[]) => { if (terms.length > 0 && !currentTerm) { setCurrentTerm(terms[0]); } - } catch (error: any) { - const data = handleErrorRef.current(error); - if (data) { - toast.error(data.message || '发生未知错误,请稍后再试'); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // 移除 currentTerm 依赖,避免重复获取 - - // 分离初始化加载逻辑 - useEffect(() => { - fetchTermList(); - getAcademicData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // 只在组件挂载时执行一次 + }, + [currentTerm], + ); - // 处理下拉刷新逻辑 - const handleRefresh = useCallback(() => { - if (!isRefreshing) { - setIsRefreshing(true); // 确保不会重复触发刷新 - setAcademicData([]); // 清空数据 - getAcademicData(); - } - }, [setAcademicData, getAcademicData, isRefreshing]); + // 获取学期列表(当前用户),此处不使用 usePersistedQuery + // 这和课表的 getApiV1TermsList 不一致,前者(即 getApiV1JwchTermList)只返回用户就读的学期列表 + const { data: termList, isLoading, refetch } = useApiRequest(getApiV1JwchTermList, {}, { onSuccess, errorHandler }); + const [showFAQ, setShowFAQ] = useState(false); // 是否显示 FAQ 模态框 // 处理 Modal 显示事件 const handleModalVisible = useCallback(() => { setShowFAQ(prev => !prev); }, []); - // 渲染单个学期的内容 - const renderTermContent = (term: string) => { - const filteredData = academicData.filter(it => it.term === term); - const summary = calSingleTermSummary(filteredData, term); - - return ( - { - if (!isRefreshing) { - handleRefresh(); - } - }} - /> - } - > - {filteredData.length > 0 && summary && ( - - - - )} - - - {filteredData.length > 0 ? ( - filteredData - .sort((a, b) => parseScore(b.score) - parseScore(a.score)) - .map((item, index) => ( - - - - )) - ) : ( - 暂无成绩数据 - )} - {academicData.length > 0 && ( - - - - 数据同步时间:{(lastUpdated && lastUpdated.toLocaleString()) || '请进行一次同步'} - - - )} - - - ); - }; - return ( <> - {isRefreshing ? ( + {isLoading ? ( ) : ( } /> )} diff --git a/app/toolbox/academic/index.tsx b/app/toolbox/academic/index.tsx index 55b9370b..13b0fc95 100644 --- a/app/toolbox/academic/index.tsx +++ b/app/toolbox/academic/index.tsx @@ -1,5 +1,5 @@ -import { useNavigation, useRouter } from 'expo-router'; -import { useCallback, useLayoutEffect, useState } from 'react'; +import { Stack, useRouter } from 'expo-router'; +import { useState } from 'react'; import { View } from 'react-native'; import CreditIcon from '@/assets/images/toolbox/academic/ic_credit.png'; @@ -13,7 +13,7 @@ import { Text } from '@/components/ui/text'; import { getApiV1JwchAcademicPlan } from '@/api/generate'; import { LoadingDialog } from '@/components/loading'; -import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; +import useApiRequest from '@/hooks/useApiRequest'; import { LocalUser, USER_TYPE_UNDERGRADUATE } from '@/lib/user'; import { pushToWebViewJWCH } from '@/lib/webview'; import { ToolType, UserType, toolOnPress, type Tool } from '@/utils/tools'; @@ -21,16 +21,16 @@ import { toast } from 'sonner-native'; const NAVIGATION_TITLE = '学业状况'; -export default function AcademicPage() { - // 设置导航栏标题 - const navigation = useNavigation(); - useLayoutEffect(() => { - navigation.setOptions({ title: NAVIGATION_TITLE }); - }, [navigation]); +const errorHandler = (error: any) => { + if (error) { + toast.error(error.msg ? error.msg : '培养计划没有找到'); + } +}; +export default function AcademicPage() { const router = useRouter(); const [isRefreshing, setIsRefreshing] = useState(false); // 按钮是否禁用 - const { handleError } = useSafeResponseSolve(); // HTTP 请求错误处理 + const { refetch } = useApiRequest(getApiV1JwchAcademicPlan, {}, { enabled: false, errorHandler }); // 菜单项数据 const MENU_ITEMS: Tool[] = [ @@ -68,47 +68,39 @@ export default function AcademicPage() { action: async () => { if (isRefreshing) return; setIsRefreshing(true); - handlePlanData(); + const planData = await refetch(); + setIsRefreshing(false); + if (planData.data) { + pushToWebViewJWCH(planData.data || '', '培养计划'); + } }, userTypes: [USER_TYPE_UNDERGRADUATE], }, ]; - const handlePlanData = useCallback(async () => { - try { - const result = await getApiV1JwchAcademicPlan(); - pushToWebViewJWCH(result.data.data || '', '培养计划'); - } catch (error: any) { - const data = handleError(error); - console.log(data); - if (data) { - toast.error(data.msg ? data.msg : '培养计划没有找到'); - } - } finally { - setIsRefreshing(false); - } - }, [handleError]); - return ( - - {/* 菜单列表 */} - - {MENU_ITEMS.filter( - item => !item.userTypes || item.userTypes.includes(LocalUser.getUser().type as UserType), - ).map((item, index) => ( - toolOnPress(item, router)} /> - ))} - - - 友情提示 - - 在教务系统中可能没有全部专业的培养计划,或没有当前专业当前年级的培养计划 - - - 统考成绩采集自教务系统数据,数据更新时间会晚于官方统考成绩公布渠道 - - - - + <> + + + {/* 菜单列表 */} + + {MENU_ITEMS.filter( + item => !item.userTypes || item.userTypes.includes(LocalUser.getUser().type as UserType), + ).map((item, index) => ( + toolOnPress(item, router)} /> + ))} + + + 友情提示 + + 在教务系统中可能没有全部专业的培养计划,或没有当前专业当前年级的培养计划 + + + 统考成绩采集自教务系统数据,数据更新时间会晚于官方统考成绩公布渠道 + + + + + ); } diff --git a/app/toolbox/academic/unified-exam.tsx b/app/toolbox/academic/unified-exam.tsx index 689b4ec1..36902064 100644 --- a/app/toolbox/academic/unified-exam.tsx +++ b/app/toolbox/academic/unified-exam.tsx @@ -1,6 +1,5 @@ import { Icon } from '@/components/Icon'; import { Stack } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; import { RefreshControl, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; @@ -10,55 +9,40 @@ import Loading from '@/components/loading'; import PageContainer from '@/components/page-container'; import { Text } from '@/components/ui/text'; -import type { JwchAcademicUnifiedExamResponse_UnifiedExamData as UnifiedExamData } from '@/api/backend'; import { getApiV1JwchAcademicUnifiedExam } from '@/api/generate'; -import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; +import useApiRequest from '@/hooks/useApiRequest'; -export default function UnifiedExamScorePage() { - const [isRefreshing, setIsRefreshing] = useState(true); // 是否正在刷新 - const [unifiedExamData, setUnifiedExamData] = useState(null); // 学术成绩数据 - const [lastUpdated, setLastUpdated] = useState(null); // 数据最后更新时间 - const handleErrorRef = useRef(useSafeResponseSolve().handleError); // 错误处理函数 +const errorHandler = (data: any) => { + if (data) toast.error(data.msg || '发生未知错误,请稍后再试'); +}; +export default function UnifiedExamScorePage() { // 获取统考成绩数据 - const fetchUnifiedExamData = useCallback(async () => { - try { - const result = await getApiV1JwchAcademicUnifiedExam(); - setUnifiedExamData(result.data.data); - setLastUpdated(new Date()); // 更新最后更新时间 - } catch (error: any) { - const data = handleErrorRef.current(error); - if (data) toast.error(data.msg || '发生未知错误,请稍后再试'); - } finally { - setIsRefreshing(false); - } - }, []); - - // 初始化时获取数据 - useEffect(() => { - fetchUnifiedExamData(); - }, [fetchUnifiedExamData]); - - // 下拉刷新逻辑 - const handleRefresh = useCallback(() => { - if (!isRefreshing) { - setIsRefreshing(true); // 确保不会重复触发刷新 - setUnifiedExamData([]); // 清空数据 - fetchUnifiedExamData(); - } - }, [fetchUnifiedExamData, isRefreshing]); + const { + data: unifiedExamData, + dataUpdatedAt, + isLoading, + refetch, + } = useApiRequest(getApiV1JwchAcademicUnifiedExam, {}, { errorHandler }); + const lastUpdated = new Date(dataUpdatedAt); return ( <> - {isRefreshing ? ( + {isLoading ? ( ) : ( } + refreshControl={ + + } > {unifiedExamData && unifiedExamData.length > 0 && ( <> diff --git a/app/toolbox/empty-room.tsx b/app/toolbox/empty-room.tsx index 04adf755..5d5e06c7 100644 --- a/app/toolbox/empty-room.tsx +++ b/app/toolbox/empty-room.tsx @@ -1,4 +1,3 @@ -import { CommonClassroomEmptyResponse } from '@/api/backend'; import { getApiV1CommonClassroomEmpty } from '@/api/generate'; import FAQModal from '@/components/FAQModal'; import { Icon } from '@/components/Icon'; @@ -8,14 +7,13 @@ import PageContainer from '@/components/page-container'; import PickerModal from '@/components/picker-modal'; import FloatModal from '@/components/ui/float-modal'; import { Text } from '@/components/ui/text'; -import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; +import useApiRequest from '@/hooks/useApiRequest'; import { FAQ_EMPTY_ROOM } from '@/lib/FAQ'; import { type IntRange } from '@/types/int-range'; -import { LoadingState } from '@/types/loading-state'; import { Stack } from 'expo-router'; import { CalendarDaysIcon } from 'lucide-react-native'; import { DateTime } from 'luxon'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Pressable, TouchableOpacity, View, useColorScheme } from 'react-native'; import DateTimePicker, { getDefaultClassNames } from 'react-native-ui-datepicker'; @@ -62,7 +60,6 @@ function generateEndPickerData(start: number): { value: IntRange<1, 12>; label: export default function EmptyRoomPage() { const today = DateTime.local({ zone: TIMEZONE }); - const currentColorScheme = useColorScheme(); const [selectedRange, setSelectedRange] = useState({ start: 1, end: 11 }); const [selectedDate, setSelectedDate] = useState(today); @@ -75,32 +72,14 @@ export default function EmptyRoomPage() { const [isRangeStartPickerVisible, setIsRangeStartPickerVisible] = useState(false); const [isRangeEndPickerVisible, setIsRangeEndPickerVisible] = useState(false); const [isCampusPickerVisible, setCampusPickerVisible] = useState(false); - - const { handleError } = useSafeResponseSolve(); // HTTP 请求错误处理 - const [roomData, setRoomData] = useState([]); - const [loadingState, setLoadingState] = useState(LoadingState.UNINIT); const campusData = CAMPUS_LIST.map(campus => ({ value: campus, label: campus })); - const getRoomData = useCallback(async () => { - setLoadingState(LoadingState.PENDING); - try { - const result = await getApiV1CommonClassroomEmpty({ - date: selectedDate.toFormat(DATE_FMT), - campus: selectedCampus, - startTime: selectedRange.start.toString(), - endTime: selectedRange.end.toString(), - }); - setRoomData(result.data.data); - setLoadingState(LoadingState.SUCCESS); - } catch (error: any) { - handleError(error); - setLoadingState(LoadingState.FAILED); - } - }, [handleError, selectedCampus, selectedDate, selectedRange]); - - useEffect(() => { - getRoomData(); - }, [getRoomData]); + const { data: roomData, status: loadingState } = useApiRequest(getApiV1CommonClassroomEmpty, { + date: selectedDate.toFormat(DATE_FMT), + campus: selectedCampus, + startTime: selectedRange.start.toString(), + endTime: selectedRange.end.toString(), + }); // 处理 Modal 显示事件 const handleModalVisible = useCallback(() => { @@ -153,12 +132,12 @@ export default function EmptyRoomPage() { - {loadingState === LoadingState.PENDING ? ( + {loadingState === 'success' ? ( + + ) : loadingState === 'pending' ? ( - ) : loadingState === LoadingState.FAILED ? ( - 获取空教室数据失败 // FIXME: 替换为加载失败图片 ) : ( - + 获取空教室数据失败 // FIXME: 替换为加载失败图片 )} {/* 日期选择器 */} ([]); // 学期列表 - const [currentTerm, setCurrentTerm] = useState(''); // 当前学期 - const [examDataMap, setExamDataMap] = useState>({}); - const [showFAQ, setShowFAQ] = useState(false); // 是否显示 FAQ +// 处理API错误 +const errorHandler = (data: any) => { + if (data) { + if (data.code === ResultEnum.BizErrorCode) { + return; + } + toast.error(data.message || '发生未知错误,请稍后再试'); + } +}; + +interface TermContentProps { + term: string; +} + +const TermContent: React.FC = ({ term }) => { const screenWidth = Dimensions.get('window').width; // 获取屏幕宽度 + const { data, dataUpdatedAt, isLoading, refetch } = useApiRequest(getApiV1JwchClassroomExam, { term }); + const termData = formatExamData(data || []).sort((a, b) => { + const now = new Date(); // 当前日期 - const handleErrorRef = useRef(useSafeResponseSolve().handleError); + // 如果只有一个有 date,优先排序有 date 的 + if (!a.date && b.date) return 1; // a 没有 date,b 有 date,b 优先 + if (a.date && !b.date) return -1; // a 有 date,b 没有 date,a 优先 - // 处理API错误 - const handleApiError = useCallback( - (error: any) => { - const data = handleErrorRef.current(error); + // 如果两个都没有 date,保持原顺序 + if (!a.date && !b.date) return 0; - if (data) { - if (data.code === ResultEnum.BizErrorCode) { - return; - } - toast.error(data.message || '发生未知错误,请稍后再试'); - } - }, - [handleErrorRef], + // 两者都有 date,确保 date 是有效的 + const dateA = new Date(a.date!); // 使用非空断言(!)告诉 TypeScript 这里一定有值 + const dateB = new Date(b.date!); + + // 如果一个未完成一个已完成,未完成优先 + if (a.isFinished && !b.isFinished) return 1; // a 已完成,b 未完成,b 优先 + + // 计算与当前日期的时间差 + const diffA = Math.abs(dateA.getTime() - now.getTime()); + const diffB = Math.abs(dateB.getTime() - now.getTime()); + + // 时间差小的优先 + return diffA - diffB; + }); + const lastUpdated = new Date(dataUpdatedAt); + + return ( + } + className="grow" + style={{ width: screenWidth }} + > + {/* 渲染考试数据 */} + + {termData.length > 0 ? ( + termData.map((item, idx) => ( + + + + )) + ) : ( + {isLoading ? '正在刷新中' : '暂无考试数据'} + )} + + + {/* 显示刷新时间 */} + {lastUpdated && ( + + + 数据同步时间:{lastUpdated.toLocaleString()} + + )} + ); +}; - // 获取学期列表(当前用户) - const fetchTermList = useCallback(async () => { - try { - const result = await getApiV1JwchTermList(); - const terms = result.data.data as string[]; - setTermList(terms); +export default function ExamRoomPage() { + const [currentTerm, setCurrentTerm] = useState(''); // 当前学期 + const onSuccess = useCallback( + (terms: string[]) => { if (!currentTerm && terms.length) { setCurrentTerm(terms[0]); } - } catch (error: any) { - handleApiError(error); - } - }, [currentTerm, handleApiError]); - - // 刷新当前学期数据 - const refreshData = useCallback(async () => { - console.log('Refreshing exam data...'); - // 清空当前学期的数据,保留对象结构 - setExamDataMap(prev => ({ - ...prev, - [currentTerm]: { data: [], lastUpdated: undefined }, - })); - - try { - const newExamData = await getApiV1JwchClassroomExam({ term: currentTerm }); - const formattedData = formatExamData(newExamData.data.data as ExamData).sort((a, b) => { - const now = new Date(); // 当前日期 - - // 如果只有一个有 date,优先排序有 date 的 - if (!a.date && b.date) return 1; // a 没有 date,b 有 date,b 优先 - if (a.date && !b.date) return -1; // a 有 date,b 没有 date,a 优先 - - // 如果两个都没有 date,保持原顺序 - if (!a.date && !b.date) return 0; - - // 两者都有 date,确保 date 是有效的 - const dateA = new Date(a.date!); // 使用非空断言(!)告诉 TypeScript 这里一定有值 - const dateB = new Date(b.date!); - - // 如果一个未完成一个已完成,未完成优先 - if (a.isFinished && !b.isFinished) return 1; // a 已完成,b 未完成,b 优先 - - // 计算与当前日期的时间差 - const diffA = Math.abs(dateA.getTime() - now.getTime()); - const diffB = Math.abs(dateB.getTime() - now.getTime()); - - // 时间差小的优先 - return diffA - diffB; - }); - - console.log('Exam data refreshed:', formattedData); - - setExamDataMap(prev => ({ - ...prev, - [currentTerm]: { - data: formattedData, - lastUpdated: new Date(), // 记录刷新时间 - }, - })); - } catch (error) { - handleApiError(error); - } finally { - setIsRefreshing(false); - } - }, [currentTerm, handleApiError]); - - // 加载学期列表 - useEffect(() => { - fetchTermList(); - }, [fetchTermList]); - - // 当 currentTerm 变化或 examDataMap 缺少数据时刷新 - useEffect(() => { - if (!isRefreshing && currentTerm && !examDataMap[currentTerm]) { - setIsRefreshing(true); - refreshData(); - } - }, [isRefreshing, currentTerm, examDataMap, refreshData]); + }, + [currentTerm], + ); + // 获取学期列表(当前用户) + const { data: termList } = useApiRequest( + getApiV1JwchTermList, + {}, + { + onSuccess, + errorHandler, + }, + ); + const [showFAQ, setShowFAQ] = useState(false); // 是否显示 FAQ // 处理 Modal 显示事件 const handleModalVisible = useCallback(() => { setShowFAQ(prev => !prev); }, []); - // 处理下拉刷新逻辑 - const handleRefresh = useCallback(() => { - setIsRefreshing(true); - refreshData(); - }, [refreshData]); - - // 渲染每个学期的内容 - const renderContent = (term: string) => { - const termData = examDataMap[term]?.data || []; - const lastUpdated = examDataMap[term]?.lastUpdated; - - return ( - } - className="grow" - style={{ width: screenWidth }} - > - {/* 渲染考试数据 */} - - {termData.length > 0 ? ( - termData.map((item, idx) => ( - - - - )) - ) : ( - {isRefreshing ? '正在刷新中' : '暂无考试数据'} - )} - - - {/* 显示刷新时间 */} - {lastUpdated && ( - - - 数据同步时间:{lastUpdated.toLocaleString()} - - )} - - ); - }; - return ( <> - + } + /> {/* FAQ Modal */} diff --git a/app/toolbox/paper/index.tsx b/app/toolbox/paper/index.tsx index f505c888..0a098b20 100644 --- a/app/toolbox/paper/index.tsx +++ b/app/toolbox/paper/index.tsx @@ -3,11 +3,10 @@ import Breadcrumb from '@/components/Breadcrumb'; import { Icon } from '@/components/Icon'; import PageContainer from '@/components/page-container'; import PaperList, { PaperType, type Paper } from '@/components/PaperList'; -import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; -import { LoadingState } from '@/types/loading-state'; +import useApiRequest from '@/hooks/useApiRequest'; import { useFocusEffect } from '@react-navigation/native'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { BackHandler, Platform } from 'react-native'; interface PaperPageParam { @@ -34,11 +33,18 @@ function SearchButton({ currentPath, papers }: SearchButtonProps) { } export default function PaperPage() { - const [loadingState, setLoadingState] = useState(LoadingState.UNINIT); const { path } = useLocalSearchParams(); const [currentPath, setCurrentPath] = useState(path !== undefined ? path : '/'); - const [currentPapers, setCurrentPapers] = useState([]); - const { handleError } = useSafeResponseSolve(); // HTTP 请求错误处理 + const { data: paperData, status: loadingState, refetch } = useApiRequest(getApiV1PaperList, { path: currentPath }); + const currentPapers = useMemo(() => { + if (paperData) { + const folders: Paper[] = paperData.folders.map(name => ({ name, type: PaperType.FOLDER })); + const files: Paper[] = paperData.files.map(name => ({ name, type: PaperType.FILE })); + return [...folders, ...files]; + } else { + return []; + } + }, [paperData]); // 使用 useFocusEffect 替代 useEffect useFocusEffect( @@ -65,25 +71,6 @@ export default function PaperPage() { }, [currentPath]), ); - // 访问 west2-online 服务器 - const getPaperData = useCallback(async () => { - setLoadingState(LoadingState.PENDING); - try { - const result = (await getApiV1PaperList({ path: currentPath })).data; - const folders: Paper[] = result.data.folders.map(name => ({ name, type: PaperType.FOLDER })); - const files: Paper[] = result.data.files.map(name => ({ name, type: PaperType.FILE })); - setCurrentPapers([...folders, ...files]); - setLoadingState(LoadingState.SUCCESS); - } catch (error: any) { - handleError(error); - setLoadingState(LoadingState.FAILED); - } - }, [currentPath, handleError]); - - useEffect(() => { - getPaperData(); - }, [getPaperData]); - return ( <> diff --git a/components/dom/contributors.tsx b/components/dom/contributors.tsx index 8e6261ca..84cca61e 100644 --- a/components/dom/contributors.tsx +++ b/components/dom/contributors.tsx @@ -1,13 +1,9 @@ 'use dom'; -import { useEffect, useState } from 'react'; -import type { ColorSchemeName } from 'react-native'; - import type { CommonContributorResponse, CommonContributorResponse_Contributor as Contributor } from '@/api/backend'; -import { getApiV1CommonContributor } from '@/api/generate'; -import { cn } from '@/lib/utils'; - import Loading from '@/components/dom/loading'; +import { cn } from '@/lib/utils'; +import type { ColorSchemeName } from 'react-native'; // 在 DOM Component 中,需要手动再引入一次全局样式才能使用 Tailwind CSS import '@/global.css'; @@ -43,25 +39,20 @@ const ContributorItem: React.FC = ({ contributor }) => ( ); interface ContributorsDOMComponentProps { + data: CommonContributorResponse; colorScheme: ColorSchemeName; } -export default function Contributors({ colorScheme }: ContributorsDOMComponentProps) { - const [response, setResponse] = useState(null); - - useEffect(() => { - getApiV1CommonContributor().then(res => setResponse(res.data.data)); - }, []); - +export default function Contributors({ data, colorScheme }: ContributorsDOMComponentProps) { return (
- {response ? ( + {data ? (
客户端 - {response.fzuhelper_app.map(contributor => ( + {data.fzuhelper_app.map(contributor => ( ))} @@ -71,7 +62,7 @@ export default function Contributors({ colorScheme }: ContributorsDOMComponentPr 服务端 - {response.fzuhelper_server.map(contributor => ( + {data.fzuhelper_server.map(contributor => ( ))} @@ -81,7 +72,7 @@ export default function Contributors({ colorScheme }: ContributorsDOMComponentPr 本科教学管理系统(对接) - {response.jwch.map(contributor => ( + {data.jwch.map(contributor => ( ))} @@ -91,7 +82,7 @@ export default function Contributors({ colorScheme }: ContributorsDOMComponentPr 研究生教育管理信息系统(对接) - {response.yjsy.map(contributor => ( + {data.yjsy.map(contributor => ( ))} diff --git a/hooks/useApiRequest.ts b/hooks/useApiRequest.ts new file mode 100644 index 00000000..5c797a46 --- /dev/null +++ b/hooks/useApiRequest.ts @@ -0,0 +1,59 @@ +import { AxiosResponse } from 'axios'; + +import { useSafeResponseSolve } from '@/hooks/useSafeResponseSolve'; +import { useQuery } from '@tanstack/react-query'; +import { type UseQueryResult } from '@tanstack/react-query/src/types'; + +type ApiReturn = AxiosResponse<{ code: string; message: string; data: T }>; +type ApiFunction = (params: T) => Promise>; + +interface useApiRequestOption { + staleTime?: number; + enabled?: boolean; + onSuccess?: (data: TData) => void; + errorHandler?: (errorData: any) => any; +} + +/** + * 用于简化 API 请求中的状态处理 + * @param apiRequest - API 请求函数,应当是 api/generate 中生成的请求函数,需返回 AxiosResponse<{ code, message, data }> 结构 + * @param params - API 请求参数对象 + * @param option useApiRequestOption - 客户端请求选项 (可选) + * - staleTime - 缓存过期时间,默认为 5 分钟,设置为 0 表示不缓存 + * - enabled - 是否自动发起请求,默认为 true + * - onSuccess - 查询成功时的回调函数 + * - errorHandler - 自定义错误处理,接受 hooks/useSafeResponseSolve 中的错误处理结果后进一步处理,返回结果会覆盖原本返回的 error + * @example 基础示例 + * // 使用 useMemo 包裹参数对象 + * const params = { ... }; + * const { data } = useApiRequest(fetchUserInfo, params); + */ +export default function useApiRequest( + apiRequest: ApiFunction, + params: TParam, + option: useApiRequestOption = {}, +): UseQueryResult { + const { handleError } = useSafeResponseSolve(); // HTTP 请求错误处理 + return useQuery({ + // 此处把函数转换为 string 作为查询字符串的一部分,避免不同 api 的返回混在一起 + queryKey: [params, apiRequest.toString()], + queryFn: async ({ queryKey }) => { + try { + return (await apiRequest(queryKey[0] as TParam)).data.data; + } catch (err: any) { + const errorData = handleError(err); + if (option.errorHandler) { + throw option.errorHandler(errorData); + } else { + throw errorData; + } + } + }, + staleTime: option.staleTime ?? 5 * 60, + enabled: option.enabled ?? true, + select: data => { + option.onSuccess?.(data); + return data; + }, + }); +} diff --git a/types/loading-state.ts b/types/loading-state.ts deleted file mode 100644 index f8cdb896..00000000 --- a/types/loading-state.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum LoadingState { - UNINIT = 'uninit', - PENDING = 'pending', - SUCCESS = 'success', - FAILED = 'failed', -}