- Add BaseManager/CachedManager base classes to eliminate duplicate cache logic - Add postListSources.ts with IPostListPagedSource interface - Add groupListSources.ts with IGroupListPagedSource interface - Refactor postManager to use CachedManager and Sources pattern - Refactor groupManager to use CachedManager and Sources pattern - Clean up duplicate methods in groupService.ts - Fix TypeScript errors and remove mock data
332 lines
9.3 KiB
TypeScript
332 lines
9.3 KiB
TypeScript
/**
|
|
* 学习资料主页:学科分类网格
|
|
*/
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
View,
|
|
StyleSheet,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
StatusBar,
|
|
RefreshControl,
|
|
} from 'react-native';
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useRouter } from 'expo-router';
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
import {
|
|
spacing,
|
|
fontSizes,
|
|
borderRadius,
|
|
shadows,
|
|
useAppColors,
|
|
useResolvedColorScheme,
|
|
type AppColors,
|
|
} from '../../theme';
|
|
import * as hrefs from '../../navigation/hrefs';
|
|
import { Text, ResponsiveContainer, Loading } from '../../components/common';
|
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
|
import type { MaterialSubject } from '../../types/material';
|
|
import { materialRepository } from '../../data/repositories/MaterialRepository';
|
|
|
|
export const MaterialsScreen: React.FC = () => {
|
|
const colors = useAppColors();
|
|
const resolvedScheme = useResolvedColorScheme();
|
|
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
|
|
const styles = useMemo(() => createMaterialsStyles(colors), [colors]);
|
|
const router = useRouter();
|
|
const insets = useSafeAreaInsets();
|
|
const { isMobile } = useResponsive();
|
|
const isWideScreen = useBreakpointGTE('lg');
|
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
|
|
|
const [subjects, setSubjects] = useState<MaterialSubject[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl'];
|
|
|
|
const loadSubjects = useCallback(async () => {
|
|
try {
|
|
setError(null);
|
|
const data = await materialRepository.getSubjects();
|
|
setSubjects(data);
|
|
} catch (err) {
|
|
setError('加载学科列表失败');
|
|
console.error('Failed to load subjects:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadSubjects();
|
|
}, [loadSubjects]);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
setIsRefreshing(true);
|
|
loadSubjects();
|
|
}, [loadSubjects]);
|
|
|
|
const onOpenSubject = useCallback(
|
|
(subjectId: string) => {
|
|
router.push(hrefs.hrefMaterialSubject(subjectId));
|
|
},
|
|
[router]
|
|
);
|
|
|
|
const cardStyle = useMemo(
|
|
() => ({
|
|
backgroundColor: colors.background.paper,
|
|
borderRadius: 16,
|
|
overflow: 'hidden' as const,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.06,
|
|
shadowRadius: 8,
|
|
elevation: 2,
|
|
}),
|
|
[colors]
|
|
);
|
|
|
|
const renderSubjectCard = (subject: MaterialSubject) => (
|
|
<TouchableOpacity
|
|
key={subject.id}
|
|
style={[styles.subjectCard, cardStyle]}
|
|
onPress={() => onOpenSubject(subject.id)}
|
|
activeOpacity={0.85}
|
|
>
|
|
<View style={[styles.subjectIconCircle, { backgroundColor: subject.color }]}>
|
|
<MaterialCommunityIcons name={subject.icon as any} size={28} color="#FFFFFF" />
|
|
</View>
|
|
<View style={styles.subjectContent}>
|
|
<Text variant="body" style={styles.subjectName}>
|
|
{subject.name}
|
|
</Text>
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.subjectDesc}>
|
|
{subject.description || `${subject.material_count} 份资料`}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.subjectMeta}>
|
|
<Text variant="caption" color={colors.text.hint}>
|
|
{subject.material_count}
|
|
</Text>
|
|
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
|
|
const renderContent = () => {
|
|
if (isLoading) {
|
|
return (
|
|
<View style={styles.centerContainer}>
|
|
<Loading size="lg" />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<View style={styles.centerContainer}>
|
|
<MaterialCommunityIcons name="alert-circle-outline" size={48} color={colors.text.hint} />
|
|
<Text variant="body" color={colors.text.secondary} style={styles.errorText}>
|
|
{error}
|
|
</Text>
|
|
<TouchableOpacity onPress={loadSubjects} style={styles.retryButton}>
|
|
<Text variant="body" color={colors.primary.main}>
|
|
重试
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.pageSubtitle}>
|
|
按学科分类浏览学习资料
|
|
</Text>
|
|
<View style={styles.subjectsGrid}>{subjects.map(renderSubjectCard)}</View>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.default} />
|
|
|
|
{/* Header */}
|
|
<View style={[styles.header, isWideScreen && styles.headerWide]}>
|
|
<View style={styles.headerLeft}>
|
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
|
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
<View style={styles.headerCenter}>
|
|
<Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}>
|
|
学习资料
|
|
</Text>
|
|
</View>
|
|
<View style={styles.headerRight}>
|
|
<TouchableOpacity style={styles.searchButton}>
|
|
<MaterialCommunityIcons name="magnify" size={24} color={colors.text.primary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Content */}
|
|
{isWideScreen ? (
|
|
<ResponsiveContainer maxWidth={800}>
|
|
<ScrollView
|
|
style={styles.scroll}
|
|
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
|
showsVerticalScrollIndicator={false}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={isRefreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor={colors.primary.main}
|
|
/>
|
|
}
|
|
>
|
|
{renderContent()}
|
|
</ScrollView>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<ScrollView
|
|
style={styles.scroll}
|
|
contentContainerStyle={[
|
|
styles.scrollContent,
|
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
|
]}
|
|
showsVerticalScrollIndicator={false}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={isRefreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor={colors.primary.main}
|
|
/>
|
|
}
|
|
>
|
|
{renderContent()}
|
|
</ScrollView>
|
|
)}
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
export default MaterialsScreen;
|
|
|
|
function createMaterialsStyles(colors: AppColors) {
|
|
const headerBg = colors.background.default;
|
|
return StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.background.default,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.md,
|
|
backgroundColor: headerBg,
|
|
...shadows.sm,
|
|
},
|
|
headerWide: {
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.lg,
|
|
},
|
|
headerLeft: {
|
|
width: 44,
|
|
alignItems: 'flex-start',
|
|
},
|
|
backButton: {
|
|
padding: spacing.xs,
|
|
},
|
|
headerCenter: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
headerTitle: {
|
|
fontSize: 19,
|
|
fontWeight: '700',
|
|
color: colors.text.primary,
|
|
},
|
|
headerTitleWide: {
|
|
fontSize: 22,
|
|
},
|
|
headerRight: {
|
|
width: 44,
|
|
alignItems: 'flex-end',
|
|
},
|
|
searchButton: {
|
|
padding: spacing.xs,
|
|
},
|
|
scroll: {
|
|
flex: 1,
|
|
backgroundColor: headerBg,
|
|
},
|
|
scrollContent: {
|
|
paddingTop: spacing.md,
|
|
flexGrow: 1,
|
|
backgroundColor: headerBg,
|
|
},
|
|
pageSubtitle: {
|
|
marginBottom: spacing.lg,
|
|
lineHeight: fontSizes.sm * 1.45,
|
|
},
|
|
subjectsGrid: {
|
|
gap: spacing.md,
|
|
},
|
|
subjectCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: spacing.lg,
|
|
paddingHorizontal: spacing.lg,
|
|
},
|
|
subjectIconCircle: {
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: borderRadius.full,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
subjectContent: {
|
|
flex: 1,
|
|
marginLeft: spacing.md,
|
|
marginRight: spacing.sm,
|
|
},
|
|
subjectName: {
|
|
fontWeight: '700',
|
|
fontSize: fontSizes.md + 1,
|
|
color: colors.text.primary,
|
|
},
|
|
subjectDesc: {
|
|
marginTop: 4,
|
|
},
|
|
subjectMeta: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
},
|
|
centerContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
paddingVertical: spacing['3xl'],
|
|
},
|
|
errorText: {
|
|
marginTop: spacing.md,
|
|
textAlign: 'center',
|
|
},
|
|
retryButton: {
|
|
marginTop: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
paddingHorizontal: spacing.lg,
|
|
},
|
|
});
|
|
}
|