feat(Materials): add new materials navigation and href functions
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m59s
Frontend CI / ota-android (push) Successful in 18m11s
Frontend CI / build-android-apk (push) Successful in 1h21m54s

- Introduced href functions for accessing materials, including hrefMaterials, hrefMaterialSubject, and hrefMaterialDetail.
- Updated AppsScreen to include a new entry for materials, enhancing the app's educational resources section.
- Exported material types in index.ts to support the new materials functionality.
This commit is contained in:
lafay
2026-03-25 21:17:17 +08:00
parent 619f08275c
commit 9529ea39c4
14 changed files with 2099 additions and 0 deletions

View File

@@ -37,6 +37,13 @@ const APP_ENTRIES: AppItem[] = [
icon: 'calendar-week',
href: hrefs.hrefSchedule(),
},
{
id: 'materials',
title: '学习资料',
subtitle: '按学科分类的学习资源网盘',
icon: 'folder-multiple',
href: hrefs.hrefMaterials(),
},
];
export const AppsScreen: React.FC = () => {

View File

@@ -0,0 +1,500 @@
/**
* 资料详情页:展示资料详细信息
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View,
StyleSheet,
ScrollView,
TouchableOpacity,
StatusBar,
Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import {
spacing,
fontSizes,
borderRadius,
shadows,
useAppColors,
useResolvedColorScheme,
type AppColors,
} from '../../theme';
import { Text, ResponsiveContainer, Loading } from '../../components/common';
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
import type { MaterialFile } from '../../types/material';
import { materialRepository } from '../../data/repositories/MaterialRepository';
import {
MATERIAL_FILE_TYPE_ICONS,
MATERIAL_FILE_TYPE_COLORS,
MATERIAL_FILE_TYPE_NAMES,
formatFileSize,
} from '../../types/material';
export const MaterialDetailScreen: React.FC = () => {
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
const styles = useMemo(() => createMaterialDetailStyles(colors), [colors]);
const router = useRouter();
const params = useLocalSearchParams<{ materialId: string }>();
const insets = useSafeAreaInsets();
const { isMobile } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const [material, setMaterial] = useState<MaterialFile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl'];
const loadMaterial = useCallback(async () => {
if (!params.materialId) {
setError('缺少资料ID');
setIsLoading(false);
return;
}
try {
setError(null);
const data = await materialRepository.getMaterialById(params.materialId);
setMaterial(data);
} catch (err) {
setError('加载资料详情失败');
console.error('Failed to load material:', err);
} finally {
setIsLoading(false);
}
}, [params.materialId]);
useEffect(() => {
loadMaterial();
}, [loadMaterial]);
const handleDownload = useCallback(() => {
if (!material) return;
Alert.alert(
'下载资料',
`确定要下载「${material.title}」吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '下载',
onPress: () => {
// Mock下载逻辑
Alert.alert('提示', '下载功能开发中,请稍后再试');
},
},
]
);
}, [material]);
const handlePreview = useCallback(() => {
if (!material?.file_url) return;
Alert.alert(
'在线预览',
'在线预览功能开发中,请稍后再试',
[{ text: '确定', style: 'default' }]
);
}, [material]);
const formatDate = useCallback((dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}, []);
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 renderContent = () => {
if (isLoading) {
return (
<View style={styles.centerContainer}>
<Loading size="large" />
</View>
);
}
if (error || !material) {
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={() => router.back()} style={styles.retryButton}>
<Text variant="body" color={colors.primary.main}>
</Text>
</TouchableOpacity>
</View>
);
}
const fileColor = MATERIAL_FILE_TYPE_COLORS[material.file_type];
const iconName = MATERIAL_FILE_TYPE_ICONS[material.file_type];
const fileTypeName = MATERIAL_FILE_TYPE_NAMES[material.file_type];
return (
<>
{/* 文件信息卡片 */}
<View style={[styles.fileCard, cardStyle]}>
<View style={[styles.fileTypeBanner, { backgroundColor: fileColor }]}>
<MaterialCommunityIcons name={iconName as any} size={48} color="#FFFFFF" />
<Text variant="body" style={styles.fileTypeName}>
{fileTypeName}
</Text>
</View>
<View style={styles.fileInfo}>
<Text variant="h3" style={styles.fileName}>
{material.title}
</Text>
{material.description && (
<Text variant="body" color={colors.text.secondary} style={styles.fileDescription}>
{material.description}
</Text>
)}
<View style={styles.fileMetaRow}>
<View style={styles.metaItem}>
<MaterialCommunityIcons name="file-outline" size={16} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint}>
{formatFileSize(material.file_size)}
</Text>
</View>
<View style={styles.metaItem}>
<MaterialCommunityIcons name="download" size={16} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint}>
{material.download_count}
</Text>
</View>
</View>
</View>
</View>
{/* 详细信息 */}
<View style={[styles.detailCard, cardStyle]}>
<Text variant="body" style={styles.sectionTitle}>
</Text>
<View style={styles.detailRow}>
<Text variant="caption" color={colors.text.hint} style={styles.detailLabel}>
</Text>
<Text variant="body" color={colors.text.primary} style={styles.detailValue}>
{material.file_name}
</Text>
</View>
<View style={styles.detailRow}>
<Text variant="caption" color={colors.text.hint} style={styles.detailLabel}>
</Text>
<Text variant="body" color={colors.text.primary} style={styles.detailValue}>
{formatDate(material.created_at)}
</Text>
</View>
{material.author_name && (
<View style={styles.detailRow}>
<Text variant="caption" color={colors.text.hint} style={styles.detailLabel}>
</Text>
<Text variant="body" color={colors.text.primary} style={styles.detailValue}>
{material.author_name}
</Text>
</View>
)}
{material.tags && material.tags.length > 0 && (
<View style={styles.tagContainer}>
<Text variant="caption" color={colors.text.hint} style={styles.detailLabel}>
</Text>
<View style={styles.tags}>
{material.tags.map((tag, index) => (
<View key={index} style={styles.tag}>
<Text variant="caption" color={colors.primary.main}>
{tag}
</Text>
</View>
))}
</View>
</View>
)}
</View>
{/* 操作按钮 */}
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.previewButton, { backgroundColor: colors.background.paper }]}
onPress={handlePreview}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="eye-outline" size={20} color={colors.text.primary} />
<Text variant="body" color={colors.text.primary} style={styles.actionButtonText}>
线
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.downloadButton, { backgroundColor: colors.primary.main }]}
onPress={handleDownload}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="download" size={20} color={colors.primary.contrast} />
<Text variant="body" color={colors.primary.contrast} style={styles.actionButtonText}>
</Text>
</TouchableOpacity>
</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={[styles.headerTitle, isWideScreen && styles.headerTitleWide]}>
</Text>
</View>
<View style={styles.headerRight}>
<TouchableOpacity style={styles.shareButton}>
<MaterialCommunityIcons name="share-variant" size={22} color={colors.text.primary} />
</TouchableOpacity>
</View>
</View>
{/* Content */}
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
showsVerticalScrollIndicator={false}
>
{renderContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
]}
showsVerticalScrollIndicator={false}
>
{renderContent()}
</ScrollView>
)}
</SafeAreaView>
);
};
export default MaterialDetailScreen;
function createMaterialDetailStyles(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',
},
shareButton: {
padding: spacing.xs,
},
scroll: {
flex: 1,
backgroundColor: headerBg,
},
scrollContent: {
paddingTop: spacing.md,
flexGrow: 1,
backgroundColor: headerBg,
},
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,
},
fileCard: {
marginBottom: spacing.md,
},
fileTypeBanner: {
height: 100,
alignItems: 'center',
justifyContent: 'center',
},
fileTypeName: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: fontSizes.lg,
marginTop: spacing.sm,
},
fileInfo: {
padding: spacing.lg,
},
fileName: {
color: colors.text.primary,
fontWeight: '700',
fontSize: fontSizes.lg,
},
fileDescription: {
marginTop: spacing.sm,
lineHeight: fontSizes.md * 1.5,
},
fileMetaRow: {
flexDirection: 'row',
marginTop: spacing.md,
gap: spacing.md,
},
metaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
detailCard: {
padding: spacing.lg,
marginBottom: spacing.md,
},
sectionTitle: {
fontWeight: '700',
fontSize: fontSizes.md,
color: colors.text.primary,
marginBottom: spacing.md,
},
detailRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
detailLabel: {
width: 80,
},
detailValue: {
flex: 1,
textAlign: 'right',
},
tagContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.sm,
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
flex: 1,
},
tag: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.primary.main,
},
actionButtons: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.lg,
},
previewButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
gap: spacing.xs,
},
downloadButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
gap: spacing.xs,
},
actionButtonText: {
fontWeight: '600',
},
});
}

View File

@@ -0,0 +1,331 @@
/**
* 学习资料主页:学科分类网格
*/
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="large" />
</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={[styles.headerTitle, isWideScreen && styles.headerTitleWide]}>
</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,
},
});
}

View File

@@ -0,0 +1,404 @@
/**
* 学科资料列表页:展示某个学科下的所有资料
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View,
StyleSheet,
ScrollView,
TouchableOpacity,
StatusBar,
RefreshControl,
FlatList,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } 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, MaterialFile, MaterialFileType } from '../../types/material';
import {
materialRepository,
} from '../../data/repositories/MaterialRepository';
import {
MATERIAL_FILE_TYPE_ICONS,
MATERIAL_FILE_TYPE_COLORS,
formatFileSize,
} from '../../types/material';
export const SubjectMaterialsScreen: React.FC = () => {
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
const styles = useMemo(() => createSubjectMaterialsStyles(colors), [colors]);
const router = useRouter();
const params = useLocalSearchParams<{ subjectId: string }>();
const insets = useSafeAreaInsets();
const { isMobile } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const [subject, setSubject] = useState<MaterialSubject | null>(null);
const [materials, setMaterials] = useState<MaterialFile[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFileType, setSelectedFileType] = useState<MaterialFileType | null>(null);
const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl'];
const loadData = useCallback(async () => {
if (!params.subjectId) {
setError('缺少学科ID');
setIsLoading(false);
return;
}
try {
setError(null);
const [subjectData, materialsData] = await Promise.all([
materialRepository.getSubjectById(params.subjectId),
materialRepository.getMaterials({ subjectId: params.subjectId }),
]);
setSubject(subjectData);
setMaterials(materialsData.materials);
} catch (err) {
setError('加载资料列表失败');
console.error('Failed to load materials:', err);
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [params.subjectId]);
useEffect(() => {
loadData();
}, [loadData]);
const handleRefresh = useCallback(() => {
setIsRefreshing(true);
loadData();
}, [loadData]);
const onOpenMaterial = useCallback(
(materialId: string) => {
router.push(hrefs.hrefMaterialDetail(materialId));
},
[router]
);
const filteredMaterials = useMemo(() => {
if (!selectedFileType) return materials;
return materials.filter((m) => m.file_type === selectedFileType);
}, [materials, selectedFileType]);
const fileTypes: { type: MaterialFileType | null; label: string }[] = [
{ type: null, label: '全部' },
{ type: 'pdf', label: 'PDF' },
{ type: 'word', label: 'Word' },
{ type: 'ppt', label: 'PPT' },
];
const cardStyle = useMemo(
() => ({
backgroundColor: colors.background.paper,
borderRadius: 12,
overflow: 'hidden' as const,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 1,
}),
[colors]
);
const renderMaterialItem = ({ item }: { item: MaterialFile }) => {
const fileColor = MATERIAL_FILE_TYPE_COLORS[item.file_type];
const iconName = MATERIAL_FILE_TYPE_ICONS[item.file_type];
return (
<TouchableOpacity
style={[styles.materialCard, cardStyle]}
onPress={() => onOpenMaterial(item.id)}
activeOpacity={0.85}
>
<View style={[styles.fileTypeIcon, { backgroundColor: fileColor }]}>
<MaterialCommunityIcons name={iconName as any} size={24} color="#FFFFFF" />
</View>
<View style={styles.materialContent}>
<Text variant="body" style={styles.materialTitle} numberOfLines={2}>
{item.title}
</Text>
<View style={styles.materialMeta}>
<Text variant="caption" color={colors.text.hint}>
{formatFileSize(item.file_size)}
</Text>
<Text variant="caption" color={colors.text.hint}>
{' · '}
</Text>
<Text variant="caption" color={colors.text.hint}>
{item.download_count}
</Text>
</View>
{item.description && (
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
{item.description}
</Text>
)}
</View>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</TouchableOpacity>
);
};
const renderFilterTabs = () => (
<View style={styles.filterContainer}>
{fileTypes.map(({ type, label }) => (
<TouchableOpacity
key={label}
style={[
styles.filterTab,
selectedFileType === type && styles.filterTabActive,
selectedFileType === type && { backgroundColor: colors.primary.main },
]}
onPress={() => setSelectedFileType(type)}
activeOpacity={0.7}
>
<Text
variant="caption"
color={selectedFileType === type ? colors.primary.contrast : colors.text.secondary}
style={styles.filterTabText}
>
{label}
</Text>
</TouchableOpacity>
))}
</View>
);
const renderEmptyComponent = () => (
<View style={styles.centerContainer}>
<MaterialCommunityIcons name="file-document-outline" size={64} color={colors.text.hint} />
<Text variant="body" color={colors.text.secondary} style={styles.emptyText}>
{selectedFileType ? '该类型暂无资料' : '暂无资料'}
</Text>
</View>
);
const renderContent = () => {
if (isLoading) {
return (
<View style={styles.centerContainer}>
<Loading size="large" />
</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={loadData} style={styles.retryButton}>
<Text variant="body" color={colors.primary.main}>
</Text>
</TouchableOpacity>
</View>
);
}
return (
<FlatList
data={filteredMaterials}
keyExtractor={(item) => item.id}
renderItem={renderMaterialItem}
ListHeaderComponent={renderFilterTabs}
ListEmptyComponent={renderEmptyComponent}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colors.primary.main}
/>
}
/>
);
};
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={[styles.headerTitle, isWideScreen && styles.headerTitleWide]}>
{subject?.name || '资料列表'}
</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}>
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</View>
</ResponsiveContainer>
) : (
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
{renderContent()}
</View>
)}
</SafeAreaView>
);
};
export default SubjectMaterialsScreen;
function createSubjectMaterialsStyles(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,
},
contentWrapper: {
flex: 1,
},
listContent: {
paddingTop: spacing.md,
},
filterContainer: {
flexDirection: 'row',
gap: spacing.sm,
marginBottom: spacing.md,
},
filterTab: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.full,
backgroundColor: colors.background.paper,
},
filterTabActive: {
// backgroundColor set inline
},
filterTabText: {
fontWeight: '600',
},
materialCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
marginBottom: spacing.sm,
},
fileTypeIcon: {
width: 44,
height: 44,
borderRadius: borderRadius.md,
alignItems: 'center',
justifyContent: 'center',
},
materialContent: {
flex: 1,
marginLeft: spacing.md,
marginRight: spacing.sm,
},
materialTitle: {
fontWeight: '600',
fontSize: fontSizes.md,
color: colors.text.primary,
},
materialMeta: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing['3xl'],
},
errorText: {
marginTop: spacing.md,
textAlign: 'center',
},
emptyText: {
marginTop: spacing.md,
textAlign: 'center',
},
retryButton: {
marginTop: spacing.md,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.lg,
},
});
}

View File

@@ -0,0 +1,6 @@
/**
* 学习资料屏幕导出
*/
export { MaterialsScreen } from './MaterialsScreen';
export { SubjectMaterialsScreen } from './SubjectMaterialsScreen';
export { MaterialDetailScreen } from './MaterialDetailScreen';