feat(Materials): add new materials navigation and href functions
- 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:
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function MaterialsStackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="subject" />
|
||||
<Stack.Screen name="detail" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialDetailRoute() {
|
||||
return <MaterialDetailScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialsRoute() {
|
||||
return <MaterialsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function SubjectMaterialsRoute() {
|
||||
return <SubjectMaterialsScreen />;
|
||||
}
|
||||
418
src/data/mock/materialMockData.ts
Normal file
418
src/data/mock/materialMockData.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 学习资料 Mock 数据
|
||||
*/
|
||||
import type { MaterialSubject, MaterialFile, MaterialFileType, SUBJECT_COLORS } from '../../types/material';
|
||||
|
||||
// 学科分类 Mock 数据
|
||||
export const MOCK_SUBJECTS: MaterialSubject[] = [
|
||||
{
|
||||
id: 'subject-math',
|
||||
name: '数学',
|
||||
icon: 'calculator-variant',
|
||||
color: '#FF6B6B',
|
||||
description: '高等数学、线性代数、概率论等',
|
||||
sort_order: 1,
|
||||
material_count: 15,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-03-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'subject-english',
|
||||
name: '英语',
|
||||
icon: 'translate',
|
||||
color: '#4ECDC4',
|
||||
description: '四六级、考研英语、托福雅思等',
|
||||
sort_order: 2,
|
||||
material_count: 12,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-03-18T14:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'subject-physics',
|
||||
name: '物理',
|
||||
icon: 'atom',
|
||||
color: '#45B7D1',
|
||||
description: '大学物理、电磁学、力学等',
|
||||
sort_order: 3,
|
||||
material_count: 8,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-03-15T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'subject-chemistry',
|
||||
name: '化学',
|
||||
icon: 'flask',
|
||||
color: '#96CEB4',
|
||||
description: '有机化学、无机化学、分析化学等',
|
||||
sort_order: 4,
|
||||
material_count: 6,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-03-10T16:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'subject-cs',
|
||||
name: '计算机',
|
||||
icon: 'laptop',
|
||||
color: '#FFEAA7',
|
||||
description: '数据结构、算法、操作系统等',
|
||||
sort_order: 5,
|
||||
material_count: 20,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-03-22T11:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'subject-economics',
|
||||
name: '经济管理',
|
||||
icon: 'chart-line',
|
||||
color: '#DDA0DD',
|
||||
description: '微观经济学、宏观经济学、管理学等',
|
||||
sort_order: 6,
|
||||
material_count: 10,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-03-19T08:45:00Z',
|
||||
},
|
||||
{
|
||||
id: 'subject-law',
|
||||
name: '法学',
|
||||
icon: 'scale-balance',
|
||||
color: '#F39C12',
|
||||
description: '民法、刑法、宪法等',
|
||||
sort_order: 7,
|
||||
material_count: 7,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-03-12T13:20:00Z',
|
||||
},
|
||||
{
|
||||
id: 'subject-chinese',
|
||||
name: '语文文学',
|
||||
icon: 'book-open-page-variant',
|
||||
color: '#3498DB',
|
||||
description: '古代文学、现代文学、写作等',
|
||||
sort_order: 8,
|
||||
material_count: 5,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-03-08T15:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// 文件资料 Mock 数据
|
||||
export const MOCK_MATERIALS: MaterialFile[] = [
|
||||
// 数学
|
||||
{
|
||||
id: 'math-001',
|
||||
subject_id: 'subject-math',
|
||||
title: '高等数学上册重点笔记',
|
||||
description: '包含极限、导数、积分等重点章节的详细笔记,配有典型例题解析',
|
||||
file_type: 'pdf',
|
||||
file_size: 5242880, // 5MB
|
||||
file_url: 'https://example.com/materials/math/advanced_math_1.pdf',
|
||||
file_name: '高等数学上册重点笔记.pdf',
|
||||
download_count: 1256,
|
||||
author_name: '张同学',
|
||||
tags: ['高等数学', '期末复习', '重点笔记'],
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
updated_at: '2024-02-20T14:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'math-002',
|
||||
subject_id: 'subject-math',
|
||||
title: '线性代数公式大全',
|
||||
description: '线性代数所有公式汇总,适合考前突击复习',
|
||||
file_type: 'pdf',
|
||||
file_size: 1572864, // 1.5MB
|
||||
file_url: 'https://example.com/materials/math/linear_algebra_formulas.pdf',
|
||||
file_name: '线性代数公式大全.pdf',
|
||||
download_count: 2341,
|
||||
author_name: '李同学',
|
||||
tags: ['线性代数', '公式', '复习'],
|
||||
created_at: '2024-01-20T09:00:00Z',
|
||||
updated_at: '2024-01-20T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'math-003',
|
||||
subject_id: 'subject-math',
|
||||
title: '概率论与数理统计课件',
|
||||
description: '概率论课程完整PPT课件,包含所有章节',
|
||||
file_type: 'ppt',
|
||||
file_size: 15728640, // 15MB
|
||||
file_url: 'https://example.com/materials/math/probability_slides.pptx',
|
||||
file_name: '概率论与数理统计课件.pptx',
|
||||
download_count: 876,
|
||||
author_name: '王老师',
|
||||
tags: ['概率论', '课件', 'PPT'],
|
||||
created_at: '2024-02-01T08:00:00Z',
|
||||
updated_at: '2024-02-15T10:00:00Z',
|
||||
},
|
||||
// 英语
|
||||
{
|
||||
id: 'english-001',
|
||||
subject_id: 'subject-english',
|
||||
title: '四级核心词汇手册',
|
||||
description: '精选四级核心词汇2000个,附带例句和记忆技巧',
|
||||
file_type: 'pdf',
|
||||
file_size: 3145728, // 3MB
|
||||
file_url: 'https://example.com/materials/english/cet4_vocabulary.pdf',
|
||||
file_name: '四级核心词汇手册.pdf',
|
||||
download_count: 3567,
|
||||
author_name: '刘同学',
|
||||
tags: ['四级', '词汇', '备考'],
|
||||
created_at: '2024-01-10T11:00:00Z',
|
||||
updated_at: '2024-02-28T16:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'english-002',
|
||||
subject_id: 'subject-english',
|
||||
title: '六级阅读理解技巧总结',
|
||||
description: '六级阅读理解题型分析与解题技巧,包含真题解析',
|
||||
file_type: 'word',
|
||||
file_size: 2097152, // 2MB
|
||||
file_url: 'https://example.com/materials/english/cet6_reading.docx',
|
||||
file_name: '六级阅读理解技巧总结.docx',
|
||||
download_count: 1892,
|
||||
author_name: '陈同学',
|
||||
tags: ['六级', '阅读理解', '技巧'],
|
||||
created_at: '2024-01-25T14:00:00Z',
|
||||
updated_at: '2024-01-25T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'english-003',
|
||||
subject_id: 'subject-english',
|
||||
title: '考研英语作文模板',
|
||||
description: '考研英语大作文、小作文模板合集,覆盖常考话题',
|
||||
file_type: 'pdf',
|
||||
file_size: 1048576, // 1MB
|
||||
file_url: 'https://example.com/materials/english/kaoyan_writing.pdf',
|
||||
file_name: '考研英语作文模板.pdf',
|
||||
download_count: 4521,
|
||||
author_name: '周同学',
|
||||
tags: ['考研', '英语', '作文'],
|
||||
created_at: '2024-02-05T10:30:00Z',
|
||||
updated_at: '2024-03-10T09:00:00Z',
|
||||
},
|
||||
// 物理
|
||||
{
|
||||
id: 'physics-001',
|
||||
subject_id: 'subject-physics',
|
||||
title: '大学物理期末复习提纲',
|
||||
description: '大学物理上下册复习要点,包含公式总结和典型例题',
|
||||
file_type: 'pdf',
|
||||
file_size: 4194304, // 4MB
|
||||
file_url: 'https://example.com/materials/physics/university_physics_review.pdf',
|
||||
file_name: '大学物理期末复习提纲.pdf',
|
||||
download_count: 987,
|
||||
author_name: '吴同学',
|
||||
tags: ['大学物理', '复习', '期末'],
|
||||
created_at: '2024-01-18T15:00:00Z',
|
||||
updated_at: '2024-02-22T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'physics-002',
|
||||
subject_id: 'subject-physics',
|
||||
title: '电磁学实验报告模板',
|
||||
description: '电磁学实验报告标准格式和填写示例',
|
||||
file_type: 'word',
|
||||
file_size: 524288, // 512KB
|
||||
file_url: 'https://example.com/materials/physics/em_lab_template.docx',
|
||||
file_name: '电磁学实验报告模板.docx',
|
||||
download_count: 654,
|
||||
author_name: '实验室',
|
||||
tags: ['电磁学', '实验', '报告'],
|
||||
created_at: '2024-02-10T09:00:00Z',
|
||||
updated_at: '2024-02-10T09:00:00Z',
|
||||
},
|
||||
// 化学
|
||||
{
|
||||
id: 'chemistry-001',
|
||||
subject_id: 'subject-chemistry',
|
||||
title: '有机化学反应机理汇总',
|
||||
description: '有机化学常见反应机理图文详解',
|
||||
file_type: 'pdf',
|
||||
file_size: 8388608, // 8MB
|
||||
file_url: 'https://example.com/materials/chemistry/organic_mechanisms.pdf',
|
||||
file_name: '有机化学反应机理汇总.pdf',
|
||||
download_count: 1123,
|
||||
author_name: '赵同学',
|
||||
tags: ['有机化学', '反应机理', '重点'],
|
||||
created_at: '2024-01-22T10:00:00Z',
|
||||
updated_at: '2024-02-18T14:00:00Z',
|
||||
},
|
||||
// 计算机
|
||||
{
|
||||
id: 'cs-001',
|
||||
subject_id: 'subject-cs',
|
||||
title: '数据结构与算法笔记',
|
||||
description: '数据结构核心内容整理,包含时间复杂度分析和代码实现',
|
||||
file_type: 'pdf',
|
||||
file_size: 7340032, // 7MB
|
||||
file_url: 'https://example.com/materials/cs/data_structures.pdf',
|
||||
file_name: '数据结构与算法笔记.pdf',
|
||||
download_count: 5678,
|
||||
author_name: '孙同学',
|
||||
tags: ['数据结构', '算法', '笔记'],
|
||||
created_at: '2024-01-05T08:00:00Z',
|
||||
updated_at: '2024-03-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'cs-002',
|
||||
subject_id: 'subject-cs',
|
||||
title: '操作系统概念详解',
|
||||
description: '操作系统核心概念讲解,进程、内存、文件系统等',
|
||||
file_type: 'pdf',
|
||||
file_size: 6291456, // 6MB
|
||||
file_url: 'https://example.com/materials/cs/operating_systems.pdf',
|
||||
file_name: '操作系统概念详解.pdf',
|
||||
download_count: 3421,
|
||||
author_name: '钱同学',
|
||||
tags: ['操作系统', '计算机', '笔记'],
|
||||
created_at: '2024-01-12T09:30:00Z',
|
||||
updated_at: '2024-02-25T16:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'cs-003',
|
||||
subject_id: 'subject-cs',
|
||||
title: '计算机网络课件',
|
||||
description: '计算机网络课程完整PPT,涵盖TCP/IP协议栈',
|
||||
file_type: 'ppt',
|
||||
file_size: 20971520, // 20MB
|
||||
file_url: 'https://example.com/materials/cs/network_slides.pptx',
|
||||
file_name: '计算机网络课件.pptx',
|
||||
download_count: 2134,
|
||||
author_name: '计算机学院',
|
||||
tags: ['计算机网络', '课件', 'PPT'],
|
||||
created_at: '2024-02-08T10:00:00Z',
|
||||
updated_at: '2024-02-28T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'cs-004',
|
||||
subject_id: 'subject-cs',
|
||||
title: 'LeetCode刷题笔记',
|
||||
description: 'LeetCode热门题目解题思路和代码实现',
|
||||
file_type: 'pdf',
|
||||
file_size: 10485760, // 10MB
|
||||
file_url: 'https://example.com/materials/cs/leetcode_notes.pdf',
|
||||
file_name: 'LeetCode刷题笔记.pdf',
|
||||
download_count: 8765,
|
||||
author_name: '郑同学',
|
||||
tags: ['算法', '面试', 'LeetCode'],
|
||||
created_at: '2024-01-28T14:00:00Z',
|
||||
updated_at: '2024-03-20T11:00:00Z',
|
||||
},
|
||||
// 经济管理
|
||||
{
|
||||
id: 'economics-001',
|
||||
subject_id: 'subject-economics',
|
||||
title: '微观经济学重点总结',
|
||||
description: '微观经济学核心理论和图形分析',
|
||||
file_type: 'pdf',
|
||||
file_size: 3145728, // 3MB
|
||||
file_url: 'https://example.com/materials/economics/microeconomics.pdf',
|
||||
file_name: '微观经济学重点总结.pdf',
|
||||
download_count: 1456,
|
||||
author_name: '冯同学',
|
||||
tags: ['微观经济学', '重点', '复习'],
|
||||
created_at: '2024-01-30T11:00:00Z',
|
||||
updated_at: '2024-02-20T15:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'economics-002',
|
||||
subject_id: 'subject-economics',
|
||||
title: '管理学案例分析模板',
|
||||
description: '管理学课程案例分析报告格式和范例',
|
||||
file_type: 'word',
|
||||
file_size: 1572864, // 1.5MB
|
||||
file_url: 'https://example.com/materials/economics/management_case.docx',
|
||||
file_name: '管理学案例分析模板.docx',
|
||||
download_count: 876,
|
||||
author_name: '商学院',
|
||||
tags: ['管理学', '案例分析', '模板'],
|
||||
created_at: '2024-02-12T10:00:00Z',
|
||||
updated_at: '2024-02-12T10:00:00Z',
|
||||
},
|
||||
// 法学
|
||||
{
|
||||
id: 'law-001',
|
||||
subject_id: 'subject-law',
|
||||
title: '民法总则笔记',
|
||||
description: '民法总则核心知识点梳理',
|
||||
file_type: 'pdf',
|
||||
file_size: 2621440, // 2.5MB
|
||||
file_url: 'https://example.com/materials/law/civil_law.pdf',
|
||||
file_name: '民法总则笔记.pdf',
|
||||
download_count: 765,
|
||||
author_name: '何同学',
|
||||
tags: ['民法', '笔记', '法学'],
|
||||
created_at: '2024-02-03T09:00:00Z',
|
||||
updated_at: '2024-02-15T14:00:00Z',
|
||||
},
|
||||
// 语文文学
|
||||
{
|
||||
id: 'chinese-001',
|
||||
subject_id: 'subject-chinese',
|
||||
title: '古代文学作品选读',
|
||||
description: '中国古代文学经典篇目赏析',
|
||||
file_type: 'pdf',
|
||||
file_size: 5242880, // 5MB
|
||||
file_url: 'https://example.com/materials/chinese/ancient_literature.pdf',
|
||||
file_name: '古代文学作品选读.pdf',
|
||||
download_count: 543,
|
||||
author_name: '中文系',
|
||||
tags: ['古代文学', '作品选', '赏析'],
|
||||
created_at: '2024-02-18T14:00:00Z',
|
||||
updated_at: '2024-02-18T14:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// 获取学科列表
|
||||
export function getMockSubjects(): Promise<MaterialSubject[]> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([...MOCK_SUBJECTS].sort((a, b) => a.sort_order - b.sort_order));
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// 根据学科ID获取资料列表
|
||||
export function getMockMaterialsBySubject(subjectId: string): Promise<MaterialFile[]> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const materials = MOCK_MATERIALS.filter((m) => m.subject_id === subjectId);
|
||||
resolve(materials);
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索资料
|
||||
export function searchMockMaterials(keyword: string): Promise<MaterialFile[]> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
const materials = MOCK_MATERIALS.filter(
|
||||
(m) =>
|
||||
m.title.toLowerCase().includes(lowerKeyword) ||
|
||||
m.description?.toLowerCase().includes(lowerKeyword) ||
|
||||
m.tags?.some((t) => t.toLowerCase().includes(lowerKeyword))
|
||||
);
|
||||
resolve(materials);
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// 根据ID获取资料详情
|
||||
export function getMockMaterialById(id: string): Promise<MaterialFile | null> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const material = MOCK_MATERIALS.find((m) => m.id === id);
|
||||
resolve(material || null);
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// 根据ID获取学科详情
|
||||
export function getMockSubjectById(id: string): Promise<MaterialSubject | null> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const subject = MOCK_SUBJECTS.find((s) => s.id === id);
|
||||
resolve(subject || null);
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
289
src/data/repositories/MaterialRepository.ts
Normal file
289
src/data/repositories/MaterialRepository.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 学习资料 Repository
|
||||
* 提供学习资料的数据访问接口
|
||||
*/
|
||||
import type {
|
||||
MaterialSubject,
|
||||
MaterialFile,
|
||||
MaterialFileType,
|
||||
} from '../../types/material';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
// ==================== 接口定义 ====================
|
||||
|
||||
export interface GetMaterialsParams {
|
||||
subjectId?: string;
|
||||
fileType?: MaterialFileType;
|
||||
keyword?: string;
|
||||
sortBy?: 'createdAt' | 'downloadCount' | 'title';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MaterialsResult {
|
||||
materials: MaterialFile[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// ==================== API 响应类型 ====================
|
||||
|
||||
interface SubjectResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
material_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface MaterialFileResponse {
|
||||
id: string;
|
||||
subject_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
file_url: string;
|
||||
file_name: string;
|
||||
download_count: number;
|
||||
author_id: string;
|
||||
tags: string[];
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
subject?: SubjectResponse;
|
||||
}
|
||||
|
||||
// ==================== 转换函数 ====================
|
||||
|
||||
function toMaterialSubject(res: SubjectResponse): MaterialSubject {
|
||||
return {
|
||||
id: res.id,
|
||||
name: res.name,
|
||||
icon: res.icon,
|
||||
color: res.color,
|
||||
description: res.description,
|
||||
sort_order: res.sort_order,
|
||||
is_active: res.is_active,
|
||||
material_count: res.material_count,
|
||||
created_at: res.created_at,
|
||||
updated_at: res.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toMaterialFile(res: MaterialFileResponse): MaterialFile {
|
||||
return {
|
||||
id: res.id,
|
||||
subject_id: res.subject_id,
|
||||
title: res.title,
|
||||
description: res.description,
|
||||
file_type: res.file_type as MaterialFileType,
|
||||
file_size: res.file_size,
|
||||
file_url: res.file_url,
|
||||
file_name: res.file_name,
|
||||
download_count: res.download_count,
|
||||
author_id: res.author_id,
|
||||
tags: res.tags || [],
|
||||
status: res.status as 'active' | 'inactive',
|
||||
created_at: res.created_at,
|
||||
updated_at: res.updated_at,
|
||||
subject: res.subject ? toMaterialSubject(res.subject) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Repository 实现 ====================
|
||||
|
||||
/**
|
||||
* 学习资料数据仓库
|
||||
* 使用真实 API
|
||||
*/
|
||||
export class MaterialRepository {
|
||||
private static instance: MaterialRepository;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): MaterialRepository {
|
||||
if (!MaterialRepository.instance) {
|
||||
MaterialRepository.instance = new MaterialRepository();
|
||||
}
|
||||
return MaterialRepository.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学科分类列表
|
||||
*/
|
||||
async getSubjects(): Promise<MaterialSubject[]> {
|
||||
try {
|
||||
const response = await api.get<{ list: SubjectResponse[] }>('/materials/subjects');
|
||||
return response.data.list.map(toMaterialSubject);
|
||||
} catch (error) {
|
||||
console.error('获取学科列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取学科详情
|
||||
*/
|
||||
async getSubjectById(id: string): Promise<MaterialSubject | null> {
|
||||
try {
|
||||
const response = await api.get<SubjectResponse>(`/materials/subjects/${id}`);
|
||||
return toMaterialSubject(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取学科详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资料列表
|
||||
*/
|
||||
async getMaterials(params: GetMaterialsParams): Promise<MaterialsResult> {
|
||||
const {
|
||||
subjectId,
|
||||
fileType,
|
||||
keyword,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, any> = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
|
||||
if (subjectId) {
|
||||
queryParams.subject_id = subjectId;
|
||||
}
|
||||
if (fileType) {
|
||||
queryParams.file_type = fileType;
|
||||
}
|
||||
if (keyword) {
|
||||
queryParams.keyword = keyword;
|
||||
}
|
||||
|
||||
const response = await api.get<{
|
||||
list: MaterialFileResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}>('/materials', queryParams);
|
||||
|
||||
return {
|
||||
materials: response.data.list.map(toMaterialFile),
|
||||
total: response.data.total,
|
||||
page: response.data.page,
|
||||
pageSize: response.data.page_size,
|
||||
hasMore: response.data.has_more,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取资料列表失败:', error);
|
||||
return {
|
||||
materials: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取资料详情
|
||||
*/
|
||||
async getMaterialById(id: string): Promise<MaterialFile | null> {
|
||||
try {
|
||||
const response = await api.get<MaterialFileResponse>(`/materials/${id}`);
|
||||
return toMaterialFile(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取资料详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索资料
|
||||
*/
|
||||
async searchMaterials(keyword: string, params?: { fileType?: MaterialFileType }): Promise<MaterialsResult> {
|
||||
return this.getMaterials({
|
||||
keyword,
|
||||
fileType: params?.fileType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门资料
|
||||
*/
|
||||
async getHotMaterials(limit: number = 10): Promise<MaterialFile[]> {
|
||||
try {
|
||||
const response = await api.get<{
|
||||
list: MaterialFileResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}>('/materials', {
|
||||
page: 1,
|
||||
page_size: limit,
|
||||
sort_by: 'download_count',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
return response.data.list.map(toMaterialFile);
|
||||
} catch (error) {
|
||||
console.error('获取热门资料失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新资料
|
||||
*/
|
||||
async getLatestMaterials(limit: number = 10): Promise<MaterialFile[]> {
|
||||
try {
|
||||
const response = await api.get<{
|
||||
list: MaterialFileResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}>('/materials', {
|
||||
page: 1,
|
||||
page_size: limit,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
return response.data.list.map(toMaterialFile);
|
||||
} catch (error) {
|
||||
console.error('获取最新资料失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资料的下载链接(增加下载次数)
|
||||
*/
|
||||
async downloadMaterial(id: string): Promise<{ file_url: string; file_name: string } | null> {
|
||||
try {
|
||||
const response = await api.get<{ file_url: string; file_name: string }>(`/materials/${id}/download`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取下载链接失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const materialRepository = MaterialRepository.getInstance();
|
||||
@@ -155,3 +155,17 @@ export function hrefAuthRegister(): string {
|
||||
export function hrefAuthForgot(): string {
|
||||
return '/forgot-password';
|
||||
}
|
||||
|
||||
// ==================== Materials (学习资料) ====================
|
||||
|
||||
export function hrefMaterials(): string {
|
||||
return '/apps/materials';
|
||||
}
|
||||
|
||||
export function hrefMaterialSubject(subjectId: string): string {
|
||||
return `/apps/materials/subject?subjectId=${encodeURIComponent(subjectId)}`;
|
||||
}
|
||||
|
||||
export function hrefMaterialDetail(materialId: string): string {
|
||||
return `/apps/materials/detail?materialId=${encodeURIComponent(materialId)}`;
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
500
src/screens/material/MaterialDetailScreen.tsx
Normal file
500
src/screens/material/MaterialDetailScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
331
src/screens/material/MaterialsScreen.tsx
Normal file
331
src/screens/material/MaterialsScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
404
src/screens/material/SubjectMaterialsScreen.tsx
Normal file
404
src/screens/material/SubjectMaterialsScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
6
src/screens/material/index.ts
Normal file
6
src/screens/material/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 学习资料屏幕导出
|
||||
*/
|
||||
export { MaterialsScreen } from './MaterialsScreen';
|
||||
export { SubjectMaterialsScreen } from './SubjectMaterialsScreen';
|
||||
export { MaterialDetailScreen } from './MaterialDetailScreen';
|
||||
@@ -6,6 +6,7 @@
|
||||
// 导出DTO类型作为主要类型
|
||||
export * from './dto';
|
||||
export * from './schedule';
|
||||
export * from './material';
|
||||
|
||||
// 兼容旧类型(用于内部组件)
|
||||
import type {
|
||||
|
||||
103
src/types/material.ts
Normal file
103
src/types/material.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 学习资料类型定义
|
||||
*/
|
||||
|
||||
// 文件类型枚举
|
||||
export type MaterialFileType = 'pdf' | 'word' | 'ppt';
|
||||
|
||||
// 学科分类
|
||||
export interface MaterialSubject {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string; // MaterialCommunityIcons name
|
||||
color: string; // 主题色
|
||||
description?: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
material_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 文件资料
|
||||
export interface MaterialFile {
|
||||
id: string;
|
||||
subject_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
file_type: MaterialFileType;
|
||||
file_size: number; // 字节数
|
||||
file_url: string;
|
||||
file_name: string;
|
||||
download_count: number;
|
||||
author_id?: string;
|
||||
author_name?: string;
|
||||
tags?: string[];
|
||||
status: 'active' | 'inactive';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
subject?: MaterialSubject;
|
||||
}
|
||||
|
||||
// 学科资料列表响应
|
||||
export interface SubjectMaterialsResponse {
|
||||
subject: MaterialSubject;
|
||||
materials: MaterialFile[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// 搜索结果响应
|
||||
export interface SearchMaterialsResponse {
|
||||
materials: MaterialFile[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// 文件类型图标映射
|
||||
export const MATERIAL_FILE_TYPE_ICONS: Record<MaterialFileType, string> = {
|
||||
pdf: 'file-pdf-box',
|
||||
word: 'file-word-box',
|
||||
ppt: 'file-powerpoint-box',
|
||||
};
|
||||
|
||||
// 文件类型颜色映射
|
||||
export const MATERIAL_FILE_TYPE_COLORS: Record<MaterialFileType, string> = {
|
||||
pdf: '#E53935',
|
||||
word: '#1E88E5',
|
||||
ppt: '#FF9800',
|
||||
};
|
||||
|
||||
// 文件类型名称映射
|
||||
export const MATERIAL_FILE_TYPE_NAMES: Record<MaterialFileType, string> = {
|
||||
pdf: 'PDF',
|
||||
word: 'Word',
|
||||
ppt: 'PPT',
|
||||
};
|
||||
|
||||
// 文件大小格式化
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// 学科颜色预设
|
||||
export const SUBJECT_COLORS = [
|
||||
'#FF6B6B', // 红色
|
||||
'#4ECDC4', // 青色
|
||||
'#45B7D1', // 蓝色
|
||||
'#96CEB4', // 绿色
|
||||
'#FFEAA7', // 黄色
|
||||
'#DDA0DD', // 紫色
|
||||
'#F39C12', // 橙色
|
||||
'#3498DB', // 深蓝
|
||||
'#27AE60', // 深绿
|
||||
'#9B59B6', // 深紫
|
||||
'#1ABC9C', // 青绿
|
||||
'#E74C3C', // 深红
|
||||
];
|
||||
Reference in New Issue
Block a user