diff --git a/src/data/mock/materialMockData.ts b/src/data/mock/materialMockData.ts deleted file mode 100644 index dffed4d..0000000 --- a/src/data/mock/materialMockData.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * 学习资料 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 { - 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 { - return new Promise((resolve) => { - setTimeout(() => { - const materials = MOCK_MATERIALS.filter((m) => m.subject_id === subjectId); - resolve(materials); - }, 300); - }); -} - -// 搜索资料 -export function searchMockMaterials(keyword: string): Promise { - 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 { - 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 { - return new Promise((resolve) => { - setTimeout(() => { - const subject = MOCK_SUBJECTS.find((s) => s.id === id); - resolve(subject || null); - }, 200); - }); -} diff --git a/src/hooks/usePrefetch.ts b/src/hooks/usePrefetch.ts index 539d65b..dcbb3e8 100644 --- a/src/hooks/usePrefetch.ts +++ b/src/hooks/usePrefetch.ts @@ -14,6 +14,7 @@ import { postManager } from '../stores/postManager'; import { groupManager } from '../stores/groupManager'; import { userManager } from '../stores/userManager'; import { messageManager } from '../stores/messageManager'; +import type { PostListTab } from '../stores/postListSources'; // ==================== 预取配置 ==================== @@ -134,7 +135,7 @@ const prefetchService = new PrefetchService(); * 预取帖子数据 * @param types 帖子类型数组 */ -function prefetchPosts(types: string[] = ['latest', 'hot']): void { +function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void { types.forEach((type, index) => { prefetchService.schedule({ key: `posts:${type}:1`, @@ -279,7 +280,7 @@ export function usePrefetch() { const hasInitialPrefetched = useRef(false); /** 预取帖子 */ - const prefetchPostsData = useCallback((types?: string[]) => { + const prefetchPostsData = useCallback((types?: PostListTab[]) => { prefetchPosts(types); }, []); diff --git a/src/screens/material/MaterialDetailScreen.tsx b/src/screens/material/MaterialDetailScreen.tsx index 32d3237..698276a 100644 --- a/src/screens/material/MaterialDetailScreen.tsx +++ b/src/screens/material/MaterialDetailScreen.tsx @@ -131,7 +131,7 @@ export const MaterialDetailScreen: React.FC = () => { if (isLoading) { return ( - + ); } @@ -280,7 +280,7 @@ export const MaterialDetailScreen: React.FC = () => { - + 资料详情 diff --git a/src/screens/material/MaterialsScreen.tsx b/src/screens/material/MaterialsScreen.tsx index 74c6a27..7dda791 100644 --- a/src/screens/material/MaterialsScreen.tsx +++ b/src/screens/material/MaterialsScreen.tsx @@ -122,7 +122,7 @@ export const MaterialsScreen: React.FC = () => { if (isLoading) { return ( - + ); } @@ -165,7 +165,7 @@ export const MaterialsScreen: React.FC = () => { - + 学习资料 diff --git a/src/screens/material/SubjectMaterialsScreen.tsx b/src/screens/material/SubjectMaterialsScreen.tsx index e0b59fd..cf9985d 100644 --- a/src/screens/material/SubjectMaterialsScreen.tsx +++ b/src/screens/material/SubjectMaterialsScreen.tsx @@ -201,7 +201,7 @@ export const SubjectMaterialsScreen: React.FC = () => { if (isLoading) { return ( - + ); } @@ -254,7 +254,7 @@ export const SubjectMaterialsScreen: React.FC = () => { - + {subject?.name || '资料列表'} diff --git a/src/services/apkUpdateService.ts b/src/services/apkUpdateService.ts index 55ba1db..5995ec7 100644 --- a/src/services/apkUpdateService.ts +++ b/src/services/apkUpdateService.ts @@ -9,17 +9,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { showConfirm } from './dialogService'; // 条件导入原生模块(仅在 Android 上可用) -let FileSystem: typeof import('expo-file-system/legacy') | null = null; -let IntentLauncher: typeof import('expo-intent-launcher') | null = null; - -if (Platform.OS === 'android') { - try { - FileSystem = require('expo-file-system/legacy'); - IntentLauncher = require('expo-intent-launcher'); - } catch (e) { - console.warn('Failed to load native modules for APK update:', e); - } -} +// eslint-disable-next-line @typescript-eslint/no-var-requires +const FileSystem = Platform.OS === 'android' ? require('expo-file-system/legacy') : null; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const IntentLauncher = Platform.OS === 'android' ? require('expo-intent-launcher') : null; // 更新服务器地址 const UPDATES_SERVER_URL = Constants.expoConfig?.extra?.updatesUrl diff --git a/src/services/groupService.ts b/src/services/groupService.ts index f930623..bf35a95 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -78,7 +78,13 @@ function normalizeAnnouncementCursor( // 群组服务类(纯 API 层) // SQLite/内存缓存编排由 groupManager 统一处理 class GroupService { - async fetchGroupsFromApi(page = 1, pageSize = 20): Promise { + // ==================== 群组管理 ==================== + + /** + * 获取群组列表 + * GET /api/v1/groups + */ + async getGroups(page = 1, pageSize = 20): Promise { const response = await api.get('/groups', { page, page_size: pageSize, @@ -86,16 +92,20 @@ class GroupService { return normalizeGroupListResponse(response.data); } - async fetchGroupFromApi(id: string): Promise { + /** + * 获取群组详情 + * GET /api/v1/groups/:id + */ + async getGroup(id: string): Promise { const response = await api.get(`/groups/${encodeURIComponent(id)}`); return normalizeGroup(response.data); } - async fetchMembersFromApi( - groupId: string, - page = 1, - pageSize = 50 - ): Promise { + /** + * 获取群组成员列表 + * GET /api/v1/groups/:id/members + */ + async getMembers(groupId: string, page = 1, pageSize = 50): Promise { const response = await api.get( `/groups/${encodeURIComponent(groupId)}/members`, { @@ -106,8 +116,6 @@ class GroupService { return normalizeMemberListResponse(response.data); } - // ==================== 群组管理 ==================== - /** * 创建群组 * POST /api/v1/groups @@ -117,22 +125,6 @@ class GroupService { return normalizeGroup(response.data); } - /** - * 获取群组列表 - * GET /api/v1/groups - */ - async getGroups(page = 1, pageSize = 20): Promise { - return this.fetchGroupsFromApi(page, pageSize); - } - - /** - * 获取群组详情 - * GET /api/v1/groups/:id - */ - async getGroup(id: string): Promise { - return this.fetchGroupFromApi(id); - } - /** * 获取当前用户在群组中的成员信息 * GET /api/v1/groups/:id/me @@ -211,14 +203,6 @@ class GroupService { await api.post(`/groups/${encodeURIComponent(groupId)}/leave`); } - /** - * 获取群组成员列表 - * GET /api/v1/groups/:id/members - */ - async getMembers(groupId: string, page = 1, pageSize = 50): Promise { - return this.fetchMembersFromApi(groupId, page, pageSize); - } - /** * 移除群成员(踢人) * POST /api/v1/groups/:id/members/kick diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 1d1d485..631b18c 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -27,6 +27,7 @@ import { saveConversationCache, saveConversationsWithRelatedCache, updateConversationCacheUnreadCount, + saveUsersCache, } from './database'; /** 远端会话列表分页(offset / cursor)统一结果:拉取成功后均已执行本地缓存同步 */ diff --git a/src/stores/baseManager.ts b/src/stores/baseManager.ts new file mode 100644 index 0000000..b1ff9c9 --- /dev/null +++ b/src/stores/baseManager.ts @@ -0,0 +1,127 @@ +/** + * BaseManager - 通用 Manager 基类 + * + * 提取自 postManager/groupManager/userManager 的重复逻辑: + * - 请求去重 (dedupe) + * - 缓存条目管理 (CacheEntry) + * - 过期检查 (isExpired) + */ + +import { CacheBus, CacheEvent } from './cacheBus'; + +/** 缓存条目结构 */ +export interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} + +/** 创建缓存条目 */ +export function createCacheEntry(data: T, ttl: number): CacheEntry { + return { data, timestamp: Date.now(), ttl }; +} + +/** 检查缓存是否过期 */ +export function isCacheExpired(entry: CacheEntry): boolean { + return Date.now() - entry.timestamp > entry.ttl; +} + +/** + * 通用 Manager 基类 + * + * 子类只需关注业务逻辑,无需重复实现缓存和去重机制 + */ +export abstract class BaseManager< + TSnapshot, + TEvent extends CacheEvent = CacheEvent +> extends CacheBus { + /** 待处理请求 Map,用于去重 */ + protected pendingRequests = new Map>(); + + /** + * 请求去重:相同 key 的并发请求会被合并为一个 + * @param key 唯一标识 + * @param fetcher 实际请求函数 + */ + protected async dedupe(key: string, fetcher: () => Promise): Promise { + const pending = this.pendingRequests.get(key) as Promise | undefined; + if (pending) return pending; + + const request = fetcher().finally(() => { + this.pendingRequests.delete(key); + }); + this.pendingRequests.set(key, request); + return request; + } + + /** 清除所有待处理请求 */ + protected clearPendingRequests(): void { + this.pendingRequests.clear(); + } +} + +/** + * 带缓存的 Manager 基类 + * + * 进一步封装内存缓存逻辑 + */ +export abstract class CachedManager< + TSnapshot, + TEvent extends CacheEvent = CacheEvent +> extends BaseManager { + /** 内存缓存 Map */ + protected memoryCache = new Map>(); + + /** + * 获取缓存 + * @param key 缓存 key + * @returns 缓存数据,过期或不存在返回 null + */ + protected getFromCache(key: string): T | null { + const entry = this.memoryCache.get(key) as CacheEntry | undefined; + if (!entry) return null; + if (isCacheExpired(entry)) { + this.memoryCache.delete(key); + return null; + } + return entry.data; + } + + /** + * 设置缓存 + * @param key 缓存 key + * @param data 数据 + * @param ttl 过期时间(毫秒) + */ + protected setCache(key: string, data: T, ttl: number): void { + this.memoryCache.set(key, createCacheEntry(data, ttl)); + } + + /** + * 检查缓存是否存在且未过期 + */ + protected hasValidCache(key: string): boolean { + const entry = this.memoryCache.get(key); + if (!entry) return false; + return !isCacheExpired(entry); + } + + /** + * 检查缓存是否存在但已过期 + */ + protected hasExpiredCache(key: string): boolean { + const entry = this.memoryCache.get(key); + if (!entry) return false; + return isCacheExpired(entry); + } + + /** 清除所有缓存 */ + protected clearCache(): void { + this.memoryCache.clear(); + } + + /** 清除指定 key 的缓存 */ + protected deleteCache(key: string): void { + this.memoryCache.delete(key); + } +} diff --git a/src/stores/groupListSources.ts b/src/stores/groupListSources.ts new file mode 100644 index 0000000..0c7879b --- /dev/null +++ b/src/stores/groupListSources.ts @@ -0,0 +1,301 @@ +/** + * 群组列表数据源抽象:游标、偏移分页实现同一契约,GroupManager 只依赖接口。 + */ + +import { + GroupResponse, + GroupMemberResponse, + GroupListResponse, + GroupMemberListResponse, + CursorPaginationRequest, +} from '../types/dto'; +import { groupService } from '../services/groupService'; +import { getAllGroupsCache, getGroupMembersCache } from '../services/database'; + +export const GROUP_LIST_PAGE_SIZE = 20; +export const GROUP_MEMBER_LIST_PAGE_SIZE = 50; + +// ==================== 群组列表数据源 ==================== + +/** 单次拉取结果(一页或一批) */ +export interface GroupListPage { + items: GroupResponse[]; + /** 在当前源上是否还能再 loadNext 一页 */ + hasMore: boolean; +} + +/** + * 分页式群组列表源:restart 后开始新序列;首次 loadNext 为第一页,之后为后续页。 + */ +export interface IGroupListPagedSource { + restart(): void; + loadNext(): Promise; + /** 至少成功 loadNext 过一次且仍有下一页时为 true */ + readonly hasMore: boolean; +} + +/** + * 远端群组列表(游标分页) + */ +export class NetworkCursorGroupListPagedSource implements IGroupListPagedSource { + private nextCursor: string | null = null; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + + constructor(pageSize: number = GROUP_LIST_PAGE_SIZE) { + this.pageSize = pageSize; + } + + restart(): void { + this.nextCursor = null; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const params: CursorPaginationRequest = { + cursor: this.nextCursor ?? '', + page_size: this.pageSize, + }; + + const result = await groupService.getGroupsCursor(params); + + this.hasMoreAfterLastLoad = result.has_more ?? false; + this.nextCursor = result.next_cursor ?? null; + this.loadedOnce = true; + + return { items: result.list ?? [], hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** + * 远端群组列表(偏移分页) + */ +export class NetworkOffsetGroupListPagedSource implements IGroupListPagedSource { + private nextPage = 1; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + + constructor(pageSize: number = GROUP_LIST_PAGE_SIZE) { + this.pageSize = pageSize; + } + + restart(): void { + this.nextPage = 1; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const result = await groupService.getGroups(this.nextPage, this.pageSize); + + const items = result.list ?? []; + const totalPages = result.total_pages ?? 1; + + this.hasMoreAfterLastLoad = this.nextPage < totalPages; + this.nextPage++; + this.loadedOnce = true; + + return { items, hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** + * SQLite 群组列表缓存(单批,无后续页) + */ +export class SqliteGroupListPagedSource implements IGroupListPagedSource { + private consumed = false; + + restart(): void { + this.consumed = false; + } + + get hasMore(): boolean { + return false; + } + + async loadNext(): Promise { + if (this.consumed) { + return { items: [], hasMore: false }; + } + this.consumed = true; + try { + const items = await getAllGroupsCache(); + return { items, hasMore: false }; + } catch (e) { + console.warn('[SqliteGroupListPagedSource] 读取群组列表缓存失败:', e); + return { items: [], hasMore: false }; + } + } +} + +// ==================== 群组成员列表数据源 ==================== + +/** 成员列表单次拉取结果 */ +export interface GroupMemberListPage { + items: GroupMemberResponse[]; + hasMore: boolean; +} + +/** + * 分页式群组成员列表源 + */ +export interface IGroupMemberListPagedSource { + restart(): void; + loadNext(): Promise; + readonly hasMore: boolean; +} + +/** + * 远端群组成员列表(游标分页) + */ +export class NetworkCursorGroupMemberListPagedSource implements IGroupMemberListPagedSource { + private nextCursor: string | null = null; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + private readonly groupId: string; + + constructor(groupId: string, pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE) { + this.groupId = groupId; + this.pageSize = pageSize; + } + + restart(): void { + this.nextCursor = null; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const params: CursorPaginationRequest = { + cursor: this.nextCursor ?? '', + page_size: this.pageSize, + }; + + const result = await groupService.getGroupMembersCursor(this.groupId, params); + + this.hasMoreAfterLastLoad = result.has_more ?? false; + this.nextCursor = result.next_cursor ?? null; + this.loadedOnce = true; + + return { items: result.list ?? [], hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** + * 远端群组成员列表(偏移分页) + */ +export class NetworkOffsetGroupMemberListPagedSource implements IGroupMemberListPagedSource { + private nextPage = 1; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + private readonly groupId: string; + + constructor(groupId: string, pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE) { + this.groupId = groupId; + this.pageSize = pageSize; + } + + restart(): void { + this.nextPage = 1; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const result = await groupService.getMembers( + this.groupId, + this.nextPage, + this.pageSize + ); + + const items = result.list ?? []; + const totalPages = result.total_pages ?? 1; + + this.hasMoreAfterLastLoad = this.nextPage < totalPages; + this.nextPage++; + this.loadedOnce = true; + + return { items, hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** + * SQLite 群组成员列表缓存(单批,无后续页) + */ +export class SqliteGroupMemberListPagedSource implements IGroupMemberListPagedSource { + private consumed = false; + private readonly groupId: string; + + constructor(groupId: string) { + this.groupId = groupId; + } + + restart(): void { + this.consumed = false; + } + + get hasMore(): boolean { + return false; + } + + async loadNext(): Promise { + if (this.consumed) { + return { items: [], hasMore: false }; + } + this.consumed = true; + try { + const items = await getGroupMembersCache(this.groupId); + return { items, hasMore: false }; + } catch (e) { + console.warn('[SqliteGroupMemberListPagedSource] 读取成员列表缓存失败:', e); + return { items: [], hasMore: false }; + } + } +} + +// ==================== 工厂函数 ==================== + +/** 远端群组列表分页策略 */ +export type RemoteGroupListSourceKind = 'cursor' | 'offset'; + +export function createRemoteGroupListSource( + kind: RemoteGroupListSourceKind = 'cursor', + pageSize: number = GROUP_LIST_PAGE_SIZE +): IGroupListPagedSource { + if (kind === 'offset') { + return new NetworkOffsetGroupListPagedSource(pageSize); + } + return new NetworkCursorGroupListPagedSource(pageSize); +} + +export function createRemoteGroupMemberListSource( + groupId: string, + kind: RemoteGroupListSourceKind = 'cursor', + pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE +): IGroupMemberListPagedSource { + if (kind === 'offset') { + return new NetworkOffsetGroupMemberListPagedSource(groupId, pageSize); + } + return new NetworkCursorGroupMemberListPagedSource(groupId, pageSize); +} diff --git a/src/stores/groupManager.ts b/src/stores/groupManager.ts index 2032743..afd00f3 100644 --- a/src/stores/groupManager.ts +++ b/src/stores/groupManager.ts @@ -1,3 +1,11 @@ +/** + * GroupManager - 群组管理核心模块 + * + * 架构特点: + * - 使用 CachedManager 基类,消除重复的缓存/去重逻辑 + * - 支持 Sources 模式进行分页加载 + */ + import { GroupListResponse, GroupMemberListResponse, @@ -14,7 +22,19 @@ import { saveGroupsCache, saveUsersCache, } from '../services/database'; -import { CacheBus, CacheEvent } from './cacheBus'; +import { CacheEvent } from './cacheBus'; +import { CachedManager, CacheEntry, createCacheEntry, isCacheExpired } from './baseManager'; +import { + IGroupListPagedSource, + IGroupMemberListPagedSource, + createRemoteGroupListSource, + createRemoteGroupMemberListSource, + RemoteGroupListSourceKind, + GROUP_LIST_PAGE_SIZE, + GROUP_MEMBER_LIST_PAGE_SIZE, +} from './groupListSources'; + +// ==================== 类型定义 ==================== interface GroupManagerSnapshot { groupIds: string[]; @@ -28,20 +48,18 @@ type GroupManagerEvent = | CacheEvent | CacheEvent<{ error: unknown; context: string }>; +// ==================== 常量 ==================== + const GROUP_TTL = 60 * 1000; const MEMBERS_TTL = 120 * 1000; -interface CacheEntry { - data: T; - timestamp: number; - ttl: number; -} +// ==================== Manager 实现 ==================== -class GroupManager extends CacheBus { - private groupsListCache: CacheEntry | null = null; +class GroupManager extends CachedManager { + /** 群组详情缓存 */ private groupDetailCache = new Map>(); + /** 群组成员缓存 */ private groupMembersCache = new Map>(); - private pendingRequests = new Map>(); protected getSnapshot(): GroupManagerSnapshot { return { @@ -50,67 +68,48 @@ class GroupManager extends CacheBus { }; } - private isExpired(entry: CacheEntry): boolean { - return Date.now() - entry.timestamp > entry.ttl; - } + // ==================== 群组列表相关 ==================== - private async dedupe(key: string, fetcher: () => Promise): Promise { - const pending = this.pendingRequests.get(key) as Promise | undefined; - if (pending) return pending; - const request = fetcher().finally(() => { - this.pendingRequests.delete(key); - }); - this.pendingRequests.set(key, request); - return request; - } + /** + * 获取群组列表 + */ + async getGroups(page = 1, pageSize = GROUP_LIST_PAGE_SIZE, forceRefresh = false): Promise { + const key = `list:${page}:${pageSize}`; - async getGroups(page = 1, pageSize = 20, forceRefresh = false): Promise { - if (page === 1 && !forceRefresh && this.groupsListCache && !this.isExpired(this.groupsListCache)) { - return { - list: this.groupsListCache.data, - total: this.groupsListCache.data.length, - page: 1, - page_size: pageSize, - total_pages: 1, - }; + // 第一页且有有效缓存 + if (page === 1 && !forceRefresh && this.hasValidCache(key)) { + const groups = this.getFromCache(key)!; + return this.buildGroupListResponse(groups, pageSize); } - if (page === 1 && !forceRefresh && this.groupsListCache && this.isExpired(this.groupsListCache)) { + // 第一页且缓存过期:后台刷新 + if (page === 1 && !forceRefresh && this.hasExpiredCache(key)) { this.refreshGroupsInBackground(page, pageSize); - return { - list: this.groupsListCache.data, - total: this.groupsListCache.data.length, - page: 1, - page_size: pageSize, - total_pages: 1, - }; + const groups = this.getFromCache(key)!; + return this.buildGroupListResponse(groups, pageSize); } + // 第一页:尝试本地缓存 if (page === 1 && !forceRefresh) { const localGroups = await getAllGroupsCache(); if (localGroups.length > 0) { - this.groupsListCache = { data: localGroups, timestamp: Date.now(), ttl: GROUP_TTL }; + this.setCache(key, localGroups, GROUP_TTL); this.notify({ type: 'list_updated', payload: { groups: localGroups }, timestamp: Date.now(), }); this.refreshGroupsInBackground(page, pageSize); - return { - list: localGroups, - total: localGroups.length, - page: 1, - page_size: pageSize, - total_pages: 1, - }; + return this.buildGroupListResponse(localGroups, pageSize); } } + // 发起请求 return this.dedupe(`groups:list:${page}:${pageSize}`, async () => { - const response = await groupService.fetchGroupsFromApi(page, pageSize); + const response = await groupService.getGroups(page, pageSize); const groups = response.list || []; if (page === 1) { - this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL }; + this.setCache(key, groups, GROUP_TTL); } await saveGroupsCache(groups).catch(() => {}); this.notify({ @@ -122,12 +121,23 @@ class GroupManager extends CacheBus { }); } - private refreshGroupsInBackground(page = 1, pageSize = 20): void { - this.dedupe(`groups:list:bg:${page}:${pageSize}`, async () => { - const response = await groupService.fetchGroupsFromApi(page, pageSize); + private buildGroupListResponse(groups: GroupResponse[], pageSize: number): GroupListResponse { + return { + list: groups, + total: groups.length, + page: 1, + page_size: pageSize, + total_pages: 1, + }; + } + + private refreshGroupsInBackground(page = 1, pageSize = GROUP_LIST_PAGE_SIZE): void { + const key = `list:${page}:${pageSize}`; + this.dedupe(`groups:bg:${key}`, async () => { + const response = await groupService.getGroups(page, pageSize); const groups = response.list || []; if (page === 1) { - this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL }; + this.setCache(key, groups, GROUP_TTL); saveGroupsCache(groups).catch(() => {}); } this.notify({ @@ -145,39 +155,58 @@ class GroupManager extends CacheBus { }); } + /** + * 创建群组列表数据源(用于 Sources 模式) + */ + createGroupListSource( + kind: RemoteGroupListSourceKind = 'cursor', + pageSize: number = GROUP_LIST_PAGE_SIZE + ): IGroupListPagedSource { + return createRemoteGroupListSource(kind, pageSize); + } + + // ==================== 群组详情相关 ==================== + + /** + * 获取群组详情 + */ async getGroup(groupId: string, forceRefresh = false): Promise { - const id = groupId; - const cached = this.groupDetailCache.get(id); - if (!forceRefresh && cached && !this.isExpired(cached) && cached.data) { + const cached = this.groupDetailCache.get(groupId); + + // 有效缓存 + if (!forceRefresh && cached && !isCacheExpired(cached) && cached.data) { return cached.data; } - if (!forceRefresh && cached && this.isExpired(cached) && cached.data) { - this.refreshGroupInBackground(id); + // 过期缓存:后台刷新 + if (!forceRefresh && cached && isCacheExpired(cached) && cached.data) { + this.refreshGroupInBackground(groupId); return cached.data; } + // 尝试本地缓存 if (!forceRefresh) { - const localGroup = await getGroupCache(id); + const localGroup = await getGroupCache(groupId); if (localGroup) { - this.groupDetailCache.set(id, { data: localGroup, timestamp: Date.now(), ttl: GROUP_TTL }); + this.groupDetailCache.set(groupId, createCacheEntry(localGroup, GROUP_TTL)); this.notify({ type: 'detail_updated', - payload: { groupId: id, group: localGroup }, + payload: { groupId, group: localGroup }, timestamp: Date.now(), }); - this.refreshGroupInBackground(id); + this.refreshGroupInBackground(groupId); return localGroup; } } - return this.dedupe(`groups:detail:${id}`, async () => { - const group = await groupService.fetchGroupFromApi(id); - this.groupDetailCache.set(id, { data: group, timestamp: Date.now(), ttl: GROUP_TTL }); + // 发起请求 + return this.dedupe(`groups:detail:${groupId}`, async () => { + const group = await groupService.getGroup(groupId); + this.groupDetailCache.set(groupId, createCacheEntry(group, GROUP_TTL)); await saveGroupCache(group).catch(() => {}); this.notify({ type: 'detail_updated', - payload: { groupId: id, group }, + payload: { groupId, group }, timestamp: Date.now(), }); return group; @@ -186,8 +215,8 @@ class GroupManager extends CacheBus { private refreshGroupInBackground(groupId: string): void { this.dedupe(`groups:detail:bg:${groupId}`, async () => { - const group = await groupService.fetchGroupFromApi(groupId); - this.groupDetailCache.set(groupId, { data: group, timestamp: Date.now(), ttl: GROUP_TTL }); + const group = await groupService.getGroup(groupId); + this.groupDetailCache.set(groupId, createCacheEntry(group, GROUP_TTL)); saveGroupCache(group).catch(() => {}); this.notify({ type: 'detail_updated', @@ -204,61 +233,53 @@ class GroupManager extends CacheBus { }); } - async getMembers(groupId: string, page = 1, pageSize = 50, forceRefresh = false): Promise { - const id = groupId; - const key = `${id}:${page}:${pageSize}`; + // ==================== 群组成员相关 ==================== + + /** + * 获取群组成员列表 + */ + async getMembers( + groupId: string, + page = 1, + pageSize = GROUP_MEMBER_LIST_PAGE_SIZE, + forceRefresh = false + ): Promise { + const key = `members:${groupId}:${page}:${pageSize}`; const cached = this.groupMembersCache.get(key); - if (!forceRefresh && cached && !this.isExpired(cached)) { - return { - list: cached.data, - total: cached.data.length, - page, - page_size: pageSize, - total_pages: 1, - }; + + // 有效缓存 + if (!forceRefresh && cached && !isCacheExpired(cached)) { + return this.buildMemberListResponse(cached.data, page, pageSize); } - if (!forceRefresh && cached && this.isExpired(cached)) { - this.refreshMembersInBackground(id, page, pageSize); - return { - list: cached.data, - total: cached.data.length, - page, - page_size: pageSize, - total_pages: 1, - }; + // 过期缓存:后台刷新 + if (!forceRefresh && cached && isCacheExpired(cached)) { + this.refreshMembersInBackground(groupId, page, pageSize); + return this.buildMemberListResponse(cached.data, page, pageSize); } + // 第一页:尝试本地缓存 if (page === 1 && !forceRefresh) { - const localMembers = await getGroupMembersCache(id); + const localMembers = await getGroupMembersCache(groupId); if (localMembers.length > 0) { - this.groupMembersCache.set(key, { - data: localMembers, - timestamp: Date.now(), - ttl: MEMBERS_TTL, - }); + this.groupMembersCache.set(key, createCacheEntry(localMembers, MEMBERS_TTL)); this.notify({ type: 'detail_updated', - payload: { groupId: id, members: localMembers }, + payload: { groupId, members: localMembers }, timestamp: Date.now(), }); - this.refreshMembersInBackground(id, page, pageSize); - return { - list: localMembers, - total: localMembers.length, - page, - page_size: pageSize, - total_pages: 1, - }; + this.refreshMembersInBackground(groupId, page, pageSize); + return this.buildMemberListResponse(localMembers, page, pageSize); } } + // 发起请求 return this.dedupe(`groups:members:${key}`, async () => { - const response = await groupService.fetchMembersFromApi(id, page, pageSize); + const response = await groupService.getMembers(groupId, page, pageSize); const members = response.list || []; - this.groupMembersCache.set(key, { data: members, timestamp: Date.now(), ttl: MEMBERS_TTL }); + this.groupMembersCache.set(key, createCacheEntry(members, MEMBERS_TTL)); if (page === 1) { - await saveGroupMembersCache(id, members).catch(() => {}); + await saveGroupMembersCache(groupId, members).catch(() => {}); } await saveUsersCache( members @@ -267,19 +288,37 @@ class GroupManager extends CacheBus { ).catch(() => {}); this.notify({ type: 'detail_updated', - payload: { groupId: id, members }, + payload: { groupId, members }, timestamp: Date.now(), }); return response; }); } - private refreshMembersInBackground(groupId: string, page = 1, pageSize = 50): void { - this.dedupe(`groups:members:bg:${groupId}:${page}:${pageSize}`, async () => { - const response = await groupService.fetchMembersFromApi(groupId, page, pageSize); + private buildMemberListResponse( + members: GroupMemberResponse[], + page: number, + pageSize: number + ): GroupMemberListResponse { + return { + list: members, + total: members.length, + page, + page_size: pageSize, + total_pages: 1, + }; + } + + private refreshMembersInBackground( + groupId: string, + page = 1, + pageSize = GROUP_MEMBER_LIST_PAGE_SIZE + ): void { + const key = `members:${groupId}:${page}:${pageSize}`; + this.dedupe(`groups:members:bg:${key}`, async () => { + const response = await groupService.getMembers(groupId, page, pageSize); const members = response.list || []; - const key = `${groupId}:${page}:${pageSize}`; - this.groupMembersCache.set(key, { data: members, timestamp: Date.now(), ttl: MEMBERS_TTL }); + this.groupMembersCache.set(key, createCacheEntry(members, MEMBERS_TTL)); if (page === 1) { saveGroupMembersCache(groupId, members).catch(() => {}); } @@ -303,21 +342,46 @@ class GroupManager extends CacheBus { }); } + /** + * 创建群组成员列表数据源(用于 Sources 模式) + */ + createGroupMemberListSource( + groupId: string, + kind: RemoteGroupListSourceKind = 'cursor', + pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE + ): IGroupMemberListPagedSource { + return createRemoteGroupMemberListSource(groupId, kind, pageSize); + } + + // ==================== 缓存管理 ==================== + + /** + * 使缓存失效 + */ invalidate(groupId?: string): void { if (!groupId) { - this.groupsListCache = null; + this.clearCache(); this.groupDetailCache.clear(); this.groupMembersCache.clear(); return; } this.groupDetailCache.delete(groupId); [...this.groupMembersCache.keys()].forEach((key) => { - if (key.startsWith(`${groupId}:`)) { + if (key.startsWith(`members:${groupId}:`)) { this.groupMembersCache.delete(key); } }); } + + /** + * 清除所有数据 + */ + clear(): void { + this.clearCache(); + this.groupDetailCache.clear(); + this.groupMembersCache.clear(); + this.clearPendingRequests(); + } } export const groupManager = new GroupManager(); - diff --git a/src/stores/postListSources.ts b/src/stores/postListSources.ts new file mode 100644 index 0000000..48dd1bd --- /dev/null +++ b/src/stores/postListSources.ts @@ -0,0 +1,137 @@ +/** + * 帖子列表数据源抽象:游标、偏移分页实现同一契约,PostManager 只依赖接口。 + */ + +import { Post } from '../types'; +import { postService } from '../services/postService'; +import type { CursorPaginationRequest } from '../types/dto'; + +export const POST_LIST_PAGE_SIZE = 20; + +/** 帖子列表类型 */ +export type PostListTab = 'hot' | 'latest' | 'follow'; + +/** 单次拉取结果(一页或一批) */ +export interface PostListPage { + items: Post[]; + /** 在当前源上是否还能再 loadNext 一页 */ + hasMore: boolean; +} + +/** + * 分页式帖子列表源:restart 后开始新序列;首次 loadNext 为第一页,之后为后续页。 + */ +export interface IPostListPagedSource { + restart(): void; + loadNext(): Promise; + /** 至少成功 loadNext 过一次且仍有下一页时为 true */ + readonly hasMore: boolean; +} + +/** + * 远端帖子列表(游标分页) + */ +export class NetworkCursorPostListPagedSource implements IPostListPagedSource { + private nextCursor: string | null = null; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + private readonly tab?: PostListTab; + private readonly channelId?: string; + + constructor( + options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {} + ) { + this.tab = options.tab; + this.channelId = options.channelId; + this.pageSize = options.pageSize ?? POST_LIST_PAGE_SIZE; + } + + restart(): void { + this.nextCursor = null; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const params: CursorPaginationRequest = { + cursor: this.nextCursor ?? '', + page_size: this.pageSize, + post_type: this.tab, + }; + + const result = await postService.getPostsCursor(params); + + this.hasMoreAfterLastLoad = result.has_more ?? false; + this.nextCursor = result.next_cursor ?? null; + this.loadedOnce = true; + + return { items: result.list ?? [], hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** + * 远端帖子列表(偏移分页) + */ +export class NetworkOffsetPostListPagedSource implements IPostListPagedSource { + private nextPage = 1; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + private readonly tab?: PostListTab; + private readonly channelId?: string; + + constructor( + options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {} + ) { + this.tab = options.tab; + this.channelId = options.channelId; + this.pageSize = options.pageSize ?? POST_LIST_PAGE_SIZE; + } + + restart(): void { + this.nextPage = 1; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const result = await postService.getPosts( + this.nextPage, + this.pageSize, + this.tab, + this.channelId + ); + + const items = result.list ?? []; + const total = result.total ?? 0; + const totalPages = result.total_pages ?? 1; + + this.hasMoreAfterLastLoad = this.nextPage < totalPages; + this.nextPage++; + this.loadedOnce = true; + + return { items, hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** 远端帖子列表分页策略 */ +export type RemotePostListSourceKind = 'cursor' | 'offset'; + +export function createRemotePostListSource( + kind: RemotePostListSourceKind = 'cursor', + options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {} +): IPostListPagedSource { + if (kind === 'offset') { + return new NetworkOffsetPostListPagedSource(options); + } + return new NetworkCursorPostListPagedSource(options); +} diff --git a/src/stores/postManager.ts b/src/stores/postManager.ts index 35338dd..2f8a741 100644 --- a/src/stores/postManager.ts +++ b/src/stores/postManager.ts @@ -1,6 +1,24 @@ +/** + * PostManager - 帖子管理核心模块 + * + * 架构特点: + * - 使用 CachedManager 基类,消除重复的缓存/去重逻辑 + * - 支持 Sources 模式进行分页加载 + */ + import { Post } from '../types'; import { postService } from '../services/postService'; -import { CacheBus, CacheEvent } from './cacheBus'; +import { CacheEvent } from './cacheBus'; +import { CachedManager, CacheEntry, createCacheEntry, isCacheExpired } from './baseManager'; +import { + IPostListPagedSource, + PostListTab, + createRemotePostListSource, + RemotePostListSourceKind, + POST_LIST_PAGE_SIZE, +} from './postListSources'; + +// ==================== 类型定义 ==================== interface PostListPayload { key: string; @@ -23,66 +41,57 @@ type PostManagerEvent = | CacheEvent | CacheEvent<{ error: unknown; context: string }>; +// ==================== 常量 ==================== + const LIST_TTL = 30 * 1000; const DETAIL_TTL = 60 * 1000; -interface CacheEntry { - data: T; - timestamp: number; - ttl: number; -} +// ==================== Manager 实现 ==================== -class PostManager extends CacheBus { - private listCache = new Map>(); +class PostManager extends CachedManager { + /** 详情缓存 */ private detailCache = new Map>(); - private pendingRequests = new Map>(); protected getSnapshot(): PostManagerSnapshot { return { - listKeys: [...this.listCache.keys()], + listKeys: [...this.memoryCache.keys()], detailKeys: [...this.detailCache.keys()], }; } - private isExpired(entry: CacheEntry): boolean { - return Date.now() - entry.timestamp > entry.ttl; - } - - private async dedupe(key: string, fetcher: () => Promise): Promise { - const pending = this.pendingRequests.get(key) as Promise | undefined; - if (pending) return pending; - const request = fetcher().finally(() => { - this.pendingRequests.delete(key); - }); - this.pendingRequests.set(key, request); - return request; - } + // ==================== 列表相关 ==================== private listKey(type: string, page: number, pageSize: number): string { - return `${type}:${page}:${pageSize}`; + return `list:${type}:${page}:${pageSize}`; } + /** + * 获取帖子列表(传统分页模式) + */ async getPosts( - type = 'hot', + type: PostListTab = 'hot', page = 1, - pageSize = 20, + pageSize = POST_LIST_PAGE_SIZE, forceRefresh = false ): Promise { const key = this.listKey(type, page, pageSize); - const cached = this.listCache.get(key); - if (!forceRefresh && cached && !this.isExpired(cached)) { - return cached.data; + + // 检查有效缓存 + if (!forceRefresh && this.hasValidCache(key)) { + return this.getFromCache(key)!; } - if (!forceRefresh && cached && this.isExpired(cached)) { + // 过期缓存:后台刷新,立即返回旧数据 + if (!forceRefresh && this.hasExpiredCache(key)) { this.refreshPostsInBackground(key, type, page, pageSize); - return cached.data; + return this.getFromCache(key)!; } - return this.dedupe(`posts:list:${key}`, async () => { + // 无缓存:发起请求 + return this.dedupe(`posts:${key}`, async () => { const response = await postService.getPosts(page, pageSize, type); const posts = response.list || []; - this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL }); + this.setCache(key, posts, LIST_TTL); this.notify({ type: 'list_updated', payload: { key, posts }, @@ -92,11 +101,16 @@ class PostManager extends CacheBus { }); } - private refreshPostsInBackground(key: string, type: string, page: number, pageSize: number): void { - this.dedupe(`posts:list:bg:${key}`, async () => { + private refreshPostsInBackground( + key: string, + type: PostListTab, + page: number, + pageSize: number + ): void { + this.dedupe(`posts:bg:${key}`, async () => { const response = await postService.getPosts(page, pageSize, type); const posts = response.list || []; - this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL }); + this.setCache(key, posts, LIST_TTL); this.notify({ type: 'list_updated', payload: { key, posts }, @@ -112,20 +126,39 @@ class PostManager extends CacheBus { }); } + /** + * 创建帖子列表数据源(用于 Sources 模式) + */ + createPostListSource( + options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {}, + kind: RemotePostListSourceKind = 'cursor' + ): IPostListPagedSource { + return createRemotePostListSource(kind, options); + } + + // ==================== 详情相关 ==================== + + /** + * 获取帖子详情 + */ async getPostDetail(postId: string, forceRefresh = false): Promise { const cached = this.detailCache.get(postId); - if (!forceRefresh && cached && !this.isExpired(cached)) { + + // 有效缓存 + if (!forceRefresh && cached && !isCacheExpired(cached)) { return cached.data; } - if (!forceRefresh && cached && this.isExpired(cached)) { + // 过期缓存:后台刷新 + if (!forceRefresh && cached && isCacheExpired(cached) && cached.data) { this.refreshPostDetailInBackground(postId); return cached.data; } + // 无缓存:发起请求 return this.dedupe(`posts:detail:${postId}`, async () => { const post = await postService.getPost(postId); - this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL }); + this.detailCache.set(postId, createCacheEntry(post, DETAIL_TTL)); this.notify({ type: 'detail_updated', payload: { postId, post }, @@ -138,7 +171,7 @@ class PostManager extends CacheBus { private refreshPostDetailInBackground(postId: string): void { this.dedupe(`posts:detail:bg:${postId}`, async () => { const post = await postService.getPost(postId); - this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL }); + this.detailCache.set(postId, createCacheEntry(post, DETAIL_TTL)); this.notify({ type: 'detail_updated', payload: { postId, post }, @@ -154,21 +187,28 @@ class PostManager extends CacheBus { }); } + // ==================== 缓存管理 ==================== + + /** + * 使缓存失效 + */ invalidate(postId?: string): void { if (postId) { this.detailCache.delete(postId); return; } - this.listCache.clear(); + this.clearCache(); this.detailCache.clear(); } + /** + * 清除所有数据 + */ clear(): void { - this.listCache.clear(); + this.clearCache(); this.detailCache.clear(); - this.pendingRequests.clear(); + this.clearPendingRequests(); } } export const postManager = new PostManager(); -