7 Commits

Author SHA1 Message Date
lan
4b89b50006 refactor(stores): unify data sources pattern and extract BaseManager base class
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m4s
Frontend CI / ota-android (push) Successful in 11m9s
Frontend CI / build-android-apk (push) Successful in 1h23m57s
- Add BaseManager/CachedManager base classes to eliminate duplicate cache logic
- Add postListSources.ts with IPostListPagedSource interface
- Add groupListSources.ts with IGroupListPagedSource interface
- Refactor postManager to use CachedManager and Sources pattern
- Refactor groupManager to use CachedManager and Sources pattern
- Clean up duplicate methods in groupService.ts
- Fix TypeScript errors and remove mock data
2026-03-26 16:46:22 +08:00
lafay
b6583e07c8 feat(TextComponent): enhance text styling with dynamic font weights and improved layout
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m7s
Frontend CI / ota-android (push) Successful in 13m37s
Frontend CI / build-android-apk (push) Failing after 54m7s
- Updated Text component to support dynamic font weights, allowing for more flexible text styling.
- Refactored variant styles to utilize new font weight constants for better consistency across text variants.
- Introduced letter spacing adjustments for improved readability and visual appeal.
- Added font family configuration for iOS and Android to optimize typography across platforms.
2026-03-26 03:58:09 +08:00
lafay
6d1514b2d1 refactor(ChatScreen): update text colors for dark mode compatibility
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m15s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
- Modified text color styles in SegmentRenderer and styles.ts to utilize theme colors for better adaptability in dark mode.
- Updated bubble colors in palettes.ts to enhance visual consistency across chat components.
2026-03-26 03:51:26 +08:00
lafay
d280ad1656 feat(APKUpdate): conditionally import native modules for APK updates on Android
Some checks failed
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
- Implemented conditional imports for `expo-file-system` and `expo-intent-launcher` to ensure compatibility with non-Android environments.
- Added error handling to alert users when native modules are unavailable, directing them to download APKs via the browser.
- Enhanced the `installAPK` function to check for the availability of native modules before proceeding with installation.
2026-03-26 03:45:39 +08:00
lafay
c903990aaf feat(APKUpdate): implement APK update check and enhance build workflow
Some checks failed
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
- Added `expo-intent-launcher` dependency to support APK launching functionality.
- Introduced `APKUpdateBootstrap` component to check for APK updates during app initialization.
- Enhanced build workflow in `build.yml` to resolve runtime version and upload APK to the updates server.
- Updated `HomeScreen` and `TabsLayout` to manage tab visibility and scroll behavior based on user interactions.
- Refactored message bubble styles for improved UI consistency and user experience.
2026-03-26 03:41:43 +08:00
lafay
405cd271db feat(Comment): enhance like functionality for comments and replies
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m54s
Frontend CI / ota-android (push) Failing after 5m36s
Frontend CI / build-android-apk (push) Failing after 9m3s
- Updated CommentItem component to accept the comment object in the onLike callback, allowing for more detailed like handling.
- Added a like button for replies, improving user interaction with nested comments.
- Refactored handleLikeComment function in PostDetailScreen for optimized state management and error handling during like operations.
- Enhanced SettingsScreen to provide debug information for theme preferences, improving user experience in theme selection.
- Updated themeStore to dynamically manage system theme changes and improve overall theme handling.
2026-03-26 01:25:42 +08:00
lafay
9529ea39c4 feat(Materials): add new materials navigation and href functions
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m59s
Frontend CI / ota-android (push) Successful in 18m11s
Frontend CI / build-android-apk (push) Successful in 1h21m54s
- Introduced href functions for accessing materials, including hrefMaterials, hrefMaterialSubject, and hrefMaterialDetail.
- Updated AppsScreen to include a new entry for materials, enhancing the app's educational resources section.
- Exported material types in index.ts to support the new materials functionality.
2026-03-25 21:17:17 +08:00
43 changed files with 3240 additions and 330 deletions

View File

@@ -272,6 +272,42 @@ jobs:
name: carrot-bbs-android-release-apk name: carrot-bbs-android-release-apk
path: android/app/build/outputs/apk/release/app-release.apk path: android/app/build/outputs/apk/release/app-release.apk
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
- name: Upload APK to Updates Server
env:
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
UPDATES_SERVER_URL: https://updates.littlelan.cn
run: |
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
-X POST \
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @"${APK_PATH}")
echo "HTTP Response Code: ${HTTP_CODE}"
cat response.json
if [ "${HTTP_CODE}" -ne 200 ]; then
echo "Failed to upload APK"
exit 1
fi
echo "APK uploaded successfully!"
build-and-push-web: build-and-push-web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:

View File

@@ -59,6 +59,7 @@
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"./plugins/withMainActivityConfigChange",
[ [
"expo-router", "expo-router",
{ {

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useCallback } from 'react';
import { Platform, useWindowDimensions } from 'react-native'; import { Platform, useWindowDimensions } from 'react-native';
import { Tabs, usePathname } from 'expo-router'; import { Tabs, usePathname } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAppColors, shadows } from '../../../src/theme'; import { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores'; import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
const TAB_BAR_HEIGHT = 56; const TAB_BAR_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12; const TAB_BAR_FLOATING_MARGIN = 12;
@@ -19,10 +19,17 @@ export default function TabsLayout() {
const pathname = usePathname(); const pathname = usePathname();
const unreadCount = useTotalUnreadCount(); const unreadCount = useTotalUnreadCount();
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop; const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/'); const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
const handleHomeTabPress = useCallback(() => {
if (pathname === '/home') {
triggerHomeTabPress();
}
}, [pathname, triggerHomeTabPress]);
const tabBarStyle = useMemo(() => { const tabBarStyle = useMemo(() => {
if (hideTabBar) { if (hideTabBar) {
return { display: 'none' as const, height: 0, overflow: 'hidden' as const }; return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
@@ -81,6 +88,9 @@ export default function TabsLayout() {
/> />
), ),
}} }}
listeners={{
tabPress: handleHomeTabPress,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="apps" name="apps"

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { MaterialDetailScreen } from '../../../../../src/screens/material';
export default function MaterialDetailRoute() {
return <MaterialDetailScreen />;
}

View File

@@ -0,0 +1,5 @@
import { MaterialsScreen } from '../../../../../src/screens/material';
export default function MaterialsRoute() {
return <MaterialsScreen />;
}

View File

@@ -0,0 +1,5 @@
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
export default function SubjectMaterialsRoute() {
return <SubjectMaterialsScreen />;
}

View File

@@ -21,6 +21,7 @@ import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost'; import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride'; import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores'; import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
registerNotificationPresentationHandler(); registerNotificationPresentationHandler();
@@ -137,6 +138,26 @@ function NotificationBootstrap() {
return null; return null;
} }
function APKUpdateBootstrap() {
const hasChecked = useRef(false);
useEffect(() => {
if (hasChecked.current) return;
hasChecked.current = true;
// 延迟执行,避免与启动流程冲突
const timer = setTimeout(() => {
checkForAPKUpdate().catch((error) => {
console.error('APK update check failed:', error);
});
}, 3000);
return () => clearTimeout(timer);
}, []);
return null;
}
function ThemedStack() { function ThemedStack() {
const router = useRouter(); const router = useRouter();
const colors = useAppColors(); const colors = useAppColors();
@@ -148,6 +169,7 @@ function ThemedStack() {
<SystemChrome /> <SystemChrome />
<SessionGate> <SessionGate>
<NotificationBootstrap /> <NotificationBootstrap />
<APKUpdateBootstrap />
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} /> <Stack.Screen name="(auth)" options={{ headerShown: false }} />

10
package-lock.json generated
View File

@@ -29,6 +29,7 @@
"expo-haptics": "~55.0.8", "expo-haptics": "~55.0.8",
"expo-image": "^55.0.5", "expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10", "expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8", "expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9", "expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10", "expo-notifications": "^55.0.10",
@@ -5747,6 +5748,15 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-intent-launcher": {
"version": "55.0.9",
"resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz",
"integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-json-utils": { "node_modules/expo-json-utils": {
"version": "55.0.0", "version": "55.0.0",
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz", "resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",

View File

@@ -35,6 +35,7 @@
"expo-haptics": "~55.0.8", "expo-haptics": "~55.0.8",
"expo-image": "^55.0.5", "expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10", "expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8", "expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9", "expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10", "expo-notifications": "^55.0.10",

View File

@@ -0,0 +1,73 @@
const { withMainActivity } = require('@expo/config-plugins');
/**
* 在 MainActivity 中添加 onConfigurationChanged 方法
* 用于监听系统深色模式变化并通知 React Native
*/
const withMainActivityConfigChange = (config) => {
return withMainActivity(config, async (config) => {
const { contents } = config.modResults;
// 检查是否已经添加了 onConfigurationChanged
if (contents.includes('onConfigurationChanged')) {
return config;
}
// 需要添加的 imports
const importsToAdd = `import android.content.Intent
import android.content.res.Configuration`;
// 需要添加的方法
const methodToAdd = `
/**
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
* 这对于 Appearance API 正确检测系统主题至关重要
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val intent = Intent("onConfigurationChanged")
intent.putExtra("newConfig", newConfig)
sendBroadcast(intent)
}
`;
let newContents = contents;
// 添加 imports在已有 imports 后面)
if (!newContents.includes('import android.content.Intent')) {
// 找到最后一个 import 语句
const importRegex = /import [^\n]+\n/g;
const imports = newContents.match(importRegex);
if (imports && imports.length > 0) {
const lastImport = imports[imports.length - 1];
const lastImportIndex = newContents.lastIndexOf(lastImport);
const insertPosition = lastImportIndex + lastImport.length;
newContents =
newContents.slice(0, insertPosition) +
importsToAdd +
'\n' +
newContents.slice(insertPosition);
}
}
// 在类体的开头添加 onConfigurationChanged 方法
// 找到类定义后的第一个方法或属性
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
const match = newContents.match(classBodyRegex);
if (match) {
// 找到 onCreate 方法之前插入
const onCreateIndex = newContents.indexOf('override fun onCreate');
if (onCreateIndex !== -1) {
newContents =
newContents.slice(0, onCreateIndex) +
methodToAdd +
newContents.slice(onCreateIndex);
}
}
config.modResults.contents = newContents;
return config;
});
};
module.exports = withMainActivityConfigChange;

View File

@@ -18,7 +18,7 @@ interface CommentItemProps {
comment: Comment; comment: Comment;
onUserPress: () => void; onUserPress: () => void;
onReply: () => void; onReply: () => void;
onLike: () => void; onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
floorNumber?: number; // 楼层号 floorNumber?: number; // 楼层号
isAuthor?: boolean; // 是否是楼主 isAuthor?: boolean; // 是否是楼主
replyToUser?: string; // 回复给哪位用户 replyToUser?: string; // 回复给哪位用户
@@ -504,6 +504,25 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{/* 点赞按钮 */}
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onLike?.(reply)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={reply.is_liked ? 'heart' : 'heart-outline'}
size={12}
color={reply.is_liked ? colors.error.main : colors.text.hint}
/>
<Text
variant="caption"
color={reply.is_liked ? colors.error.main : colors.text.hint}
style={styles.subActionText}
>
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
</Text>
</TouchableOpacity>
{/* 删除按钮 - 子评论作者可见 */} {/* 删除按钮 - 子评论作者可见 */}
{isSubReplyAuthor && ( {isSubReplyAuthor && (
<TouchableOpacity <TouchableOpacity
@@ -592,7 +611,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 操作按钮 - 更紧凑 */} {/* 操作按钮 - 更紧凑 */}
<View style={styles.actions}> <View style={styles.actions}>
{/* 点赞 */} {/* 点赞 */}
<TouchableOpacity style={styles.actionButton} onPress={onLike}> <TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
<MaterialCommunityIcons <MaterialCommunityIcons
name={comment.is_liked ? 'heart' : 'heart-outline'} name={comment.is_liked ? 'heart' : 'heart-outline'}
size={14} size={14}

View File

@@ -4,8 +4,8 @@
*/ */
import React from 'react'; import React from 'react';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native'; import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
import { useAppColors, fontSizes } from '../../theme'; import { useAppColors, fontSizes, fontWeights } from '../../theme';
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label'; type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
@@ -16,38 +16,45 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
numberOfLines?: number; numberOfLines?: number;
onPress?: () => void; onPress?: () => void;
style?: TextStyle | TextStyle[]; style?: TextStyle | TextStyle[];
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
} }
const variantStyles: Record<TextVariant, object> = { const variantStyles: Record<TextVariant, object> = {
h1: { h1: {
fontSize: fontSizes['4xl'], fontSize: fontSizes['4xl'],
fontWeight: '700', fontWeight: fontWeights.bold,
lineHeight: fontSizes['4xl'] * 1.4, lineHeight: fontSizes['4xl'] * 1.3,
letterSpacing: -0.5,
}, },
h2: { h2: {
fontSize: fontSizes['3xl'], fontSize: fontSizes['3xl'],
fontWeight: '600', fontWeight: fontWeights.semibold,
lineHeight: fontSizes['3xl'] * 1.4, lineHeight: fontSizes['3xl'] * 1.3,
letterSpacing: -0.3,
}, },
h3: { h3: {
fontSize: fontSizes['2xl'], fontSize: fontSizes['2xl'],
fontWeight: '600', fontWeight: fontWeights.semibold,
lineHeight: fontSizes['2xl'] * 1.3, lineHeight: fontSizes['2xl'] * 1.3,
letterSpacing: -0.2,
}, },
body: { body: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
fontWeight: '400', fontWeight: fontWeights.regular,
lineHeight: fontSizes.md * 1.5, lineHeight: fontSizes.md * 1.6,
letterSpacing: 0.1,
}, },
caption: { caption: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
fontWeight: '400', fontWeight: fontWeights.regular,
lineHeight: fontSizes.sm * 1.4, lineHeight: fontSizes.sm * 1.5,
letterSpacing: 0.2,
}, },
label: { label: {
fontSize: fontSizes.xs, fontSize: fontSizes.xs,
fontWeight: '500', fontWeight: fontWeights.medium,
lineHeight: fontSizes.xs * 1.4, lineHeight: fontSizes.xs * 1.4,
letterSpacing: 0.3,
}, },
}; };
@@ -55,6 +62,7 @@ const Text: React.FC<CustomTextProps> = ({
children, children,
variant = 'body', variant = 'body',
color, color,
weight,
numberOfLines, numberOfLines,
onPress, onPress,
style, style,
@@ -64,6 +72,7 @@ const Text: React.FC<CustomTextProps> = ({
const textStyle = [ const textStyle = [
styles.base, styles.base,
variantStyles[variant], variantStyles[variant],
weight ? { fontWeight: fontWeights[weight] } : null,
color ? { color } : { color: colors.text.primary }, color ? { color } : { color: colors.text.primary },
style, style,
]; ];

View 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();

View File

@@ -14,6 +14,7 @@ import { postManager } from '../stores/postManager';
import { groupManager } from '../stores/groupManager'; import { groupManager } from '../stores/groupManager';
import { userManager } from '../stores/userManager'; import { userManager } from '../stores/userManager';
import { messageManager } from '../stores/messageManager'; import { messageManager } from '../stores/messageManager';
import type { PostListTab } from '../stores/postListSources';
// ==================== 预取配置 ==================== // ==================== 预取配置 ====================
@@ -134,7 +135,7 @@ const prefetchService = new PrefetchService();
* 预取帖子数据 * 预取帖子数据
* @param types 帖子类型数组 * @param types 帖子类型数组
*/ */
function prefetchPosts(types: string[] = ['latest', 'hot']): void { function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void {
types.forEach((type, index) => { types.forEach((type, index) => {
prefetchService.schedule({ prefetchService.schedule({
key: `posts:${type}:1`, key: `posts:${type}:1`,
@@ -279,7 +280,7 @@ export function usePrefetch() {
const hasInitialPrefetched = useRef(false); const hasInitialPrefetched = useRef(false);
/** 预取帖子 */ /** 预取帖子 */
const prefetchPostsData = useCallback((types?: string[]) => { const prefetchPostsData = useCallback((types?: PostListTab[]) => {
prefetchPosts(types); prefetchPosts(types);
}, []); }, []);

View File

@@ -155,3 +155,17 @@ export function hrefAuthRegister(): string {
export function hrefAuthForgot(): string { export function hrefAuthForgot(): string {
return '/forgot-password'; 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)}`;
}

View File

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

View File

@@ -26,7 +26,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme'; import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores'; import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore'; import { useCurrentUser } from '../../stores/authStore';
import { channelService, postService } from '../../services'; import { channelService, postService } from '../../services';
import { PostCard, TabBar, SearchBar } from '../../components/business'; import { PostCard, TabBar, SearchBar } from '../../components/business';
@@ -212,9 +212,14 @@ export const HomeScreen: React.FC = () => {
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll); const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
const homeListScrollYRef = useRef(0); const homeListScrollYRef = useRef(0);
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// FlatList 和 ScrollView 的 ref用于滚动到顶部
const flatListRef = useRef<FlatList<Post> | null>(null);
const scrollViewRef = useRef<ScrollView | null>(null);
const clearTabBarIdleRestoreTimer = useCallback(() => { const clearTabBarIdleRestoreTimer = useCallback(() => {
if (tabBarIdleRestoreTimerRef.current != null) { if (tabBarIdleRestoreTimerRef.current != null) {
clearTimeout(tabBarIdleRestoreTimerRef.current); clearTimeout(tabBarIdleRestoreTimerRef.current);
@@ -269,6 +274,19 @@ export const HomeScreen: React.FC = () => {
} }
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]); }, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
// 监听首页 Tab 点击事件,滚动到顶部
useEffect(() => {
if (homeTabPressCount > 0) {
// 滚动 FlatList 到顶部
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
// 滚动 ScrollView 到顶部(网格模式)
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 重置底部 Tab 栏状态
setBottomTabBarHiddenByScroll(false);
homeListScrollYRef.current = 0;
}
}, [homeTabPressCount, setBottomTabBarHiddenByScroll]);
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */ /** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
const capsuleHScrollRef = useRef<ScrollView | null>(null); const capsuleHScrollRef = useRef<ScrollView | null>(null);
const capsuleScrollXRef = useRef(0); const capsuleScrollXRef = useRef(0);
@@ -819,6 +837,7 @@ export const HomeScreen: React.FC = () => {
// 移动端使用瀑布流布局2列 // 移动端使用瀑布流布局2列
return ( return (
<ScrollView <ScrollView
ref={scrollViewRef}
style={styles.waterfallScroll} style={styles.waterfallScroll}
contentContainerStyle={[ contentContainerStyle={[
styles.waterfallContainer, styles.waterfallContainer,
@@ -899,6 +918,7 @@ export const HomeScreen: React.FC = () => {
// 移动端和宽屏都使用单列 FlatList宽屏下居中显示 // 移动端和宽屏都使用单列 FlatList宽屏下居中显示
return ( return (
<FlatList <FlatList
ref={flatListRef}
data={displayPosts} data={displayPosts}
renderItem={renderPostList} renderItem={renderPostList}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}

View File

@@ -895,61 +895,48 @@ export const PostDetailScreen: React.FC = () => {
} }
}; };
// 点赞评论(包括回复) // 点赞评论(包括回复)- 优化版
const handleLikeComment = async (comment: Comment) => { const handleLikeComment = async (comment: Comment) => {
// 先保存旧状态用于回滚 const { id, is_liked, likes_count } = comment;
const oldIsLiked = comment.is_liked;
const oldLikesCount = comment.likes_count;
// 乐观更新本地状态 - 辅助函数用于更新评论或回复 // 乐观更新:切换点赞状态
const updateCommentLike = (c: Comment): Comment => { const newIsLiked = !is_liked;
// 如果是目标评论/回复 const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
if (c.id === comment.id) {
return { // 递归更新评论树中的点赞状态
...c, const updateLikeInTree = (c: Comment): Comment => {
is_liked: !c.is_liked, if (c.id === id) {
likes_count: c.is_liked ? c.likes_count - 1 : c.likes_count + 1 return { ...c, is_liked: newIsLiked, likes_count: newLikesCount };
};
} }
// 如果有回复,递归查找 if (c.replies?.length) {
if (c.replies && c.replies.length > 0) { return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) };
return {
...c,
replies: c.replies.map(r => updateCommentLike(r))
};
} }
return c; return c;
}; };
// 更新本地状态 setComments(prev => prev.map(c => updateLikeInTree(c)));
setComments(prev => prev.map(c => updateCommentLike(c)));
try { try {
if (oldIsLiked) { const success = newIsLiked
await commentService.unlikeComment(comment.id); ? await commentService.likeComment(id)
} else { : await commentService.unlikeComment(id);
await commentService.likeComment(comment.id);
if (!success) {
throw new Error('点赞操作失败');
} }
} catch (error) { } catch (error) {
console.error('评论点赞操作失败:', error); console.error('评论点赞操作失败:', error);
// 失败时回滚状态 // 回滚状态
const rollbackCommentLike = (c: Comment): Comment => { const rollbackLikeInTree = (c: Comment): Comment => {
if (c.id === comment.id) { if (c.id === id) {
return { return { ...c, is_liked, likes_count };
...c,
is_liked: oldIsLiked,
likes_count: oldLikesCount
};
} }
if (c.replies && c.replies.length > 0) { if (c.replies?.length) {
return { return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) };
...c,
replies: c.replies.map(r => rollbackCommentLike(r))
};
} }
return c; return c;
}; };
setComments(prev => prev.map(c => rollbackCommentLike(c))); setComments(prev => prev.map(c => rollbackLikeInTree(c)));
} }
}; };
@@ -1373,7 +1360,7 @@ export const PostDetailScreen: React.FC = () => {
return ( return (
<CommentItem <CommentItem
comment={item} comment={item}
onLike={() => handleLikeComment(item)} onLike={handleLikeComment}
onReply={() => handleReply(item)} onReply={() => handleReply(item)}
onUserPress={() => authorId && handleUserPress(authorId)} onUserPress={() => authorId && handleUserPress(authorId)}
floorNumber={index + 1} floorNumber={index + 1}

View File

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

View File

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

View File

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

View File

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

View File

@@ -404,7 +404,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
> >
<Avatar <Avatar
source={senderInfo.avatar} source={senderInfo.avatar}
size={36} size={40}
name={senderInfo.nickname} name={senderInfo.nickname}
/> />
</TouchableOpacity> </TouchableOpacity>
@@ -418,7 +418,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
> >
<Avatar <Avatar
source={otherUser?.avatar || null} source={otherUser?.avatar || null}
size={36} size={40}
name={otherUser?.nickname || ''} name={otherUser?.nickname || ''}
/> />
</TouchableOpacity> </TouchableOpacity>
@@ -452,7 +452,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
<View style={styles.avatarWrapper}> <View style={styles.avatarWrapper}>
<Avatar <Avatar
source={currentUser?.avatar || null} source={currentUser?.avatar || null}
size={36} size={40}
name={currentUser?.nickname || ''} name={currentUser?.nickname || ''}
/> />
</View> </View>

View File

@@ -847,11 +847,11 @@ function createSegmentStyles(colors: AppColors) {
marginTop: 2, marginTop: 2,
}, },
// 文本 - Telegram风格统一深色字体 // 文本 - 适配暗色模式
textContent: { textContent: {
fontSize: 16, fontSize: 17.5,
lineHeight: 23, lineHeight: 25,
letterSpacing: 0.2, letterSpacing: 0.1,
fontWeight: '400', fontWeight: '400',
}, },
textMe: { textMe: {
@@ -861,27 +861,22 @@ function createSegmentStyles(colors: AppColors) {
color: colors.chat.textPrimary, color: colors.chat.textPrimary,
}, },
// @提及 - Telegram风格蓝色高亮 // @提及 - 微信/QQ 风格:更精致的高亮
atText: { atText: {
fontSize: 16, fontSize: 16,
lineHeight: 23, lineHeight: 22,
fontWeight: '600', fontWeight: '500',
paddingHorizontal: 2,
}, },
atTextMe: { atTextMe: {
color: '#4A88C7', color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰
backgroundColor: 'rgba(74, 136, 199, 0.12)',
borderRadius: 4,
}, },
atTextOther: { atTextOther: {
color: colors.chat.link, color: colors.chat.link,
backgroundColor: 'rgba(74, 136, 199, 0.12)',
borderRadius: 4,
}, },
atHighlight: { atHighlight: {
backgroundColor: 'rgba(74, 136, 199, 0.2)', backgroundColor: 'rgba(74, 136, 199, 0.15)',
borderRadius: 4, borderRadius: 4,
paddingHorizontal: 4, paddingHorizontal: 3,
}, },
// 图片 - QQ风格保持原始宽高比 // 图片 - QQ风格保持原始宽高比

View File

@@ -201,7 +201,7 @@ export function createChatScreenStyles(colors: AppColors) {
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
// 头像 // 头像 - 更大一点
avatarWrapper: { avatarWrapper: {
marginHorizontal: 2, marginHorizontal: 2,
shadowColor: colors.chat.shadow, shadowColor: colors.chat.shadow,
@@ -226,43 +226,43 @@ export function createChatScreenStyles(colors: AppColors) {
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
senderName: { senderName: {
fontSize: 13, fontSize: 14,
color: colors.chat.link, color: colors.chat.textSecondary, // 使用主题次要文字颜色
marginBottom: 4, marginBottom: 4,
marginLeft: 4, marginLeft: 4,
fontWeight: '600', fontWeight: '600',
}, },
// 消息气泡 - 外层极轻阴影(纯文字气泡更接近扁平 IM // 消息气泡 - 微信风格:更小的圆角 + 更紧凑的内边距
messageBubbleOuter: { messageBubbleOuter: {
borderRadius: 16, borderRadius: 12,
minWidth: 60, minWidth: 44,
shadowColor: colors.chat.shadow, shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 0.5 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04, shadowOpacity: 0.06,
shadowRadius: 1, shadowRadius: 2,
elevation: 1, elevation: 1,
}, },
myBubbleOuter: { myBubbleOuter: {
borderBottomRightRadius: 4, borderBottomRightRadius: 4, // 微信的小尾巴更尖
}, },
theirBubbleOuter: { theirBubbleOuter: {
borderTopLeftRadius: 4, borderTopLeftRadius: 4,
}, },
// 内层:背景 + 裁剪,防止回复条/@ 高亮在圆角外露出颜色 // 内层:更紧凑的微信风格内边距
messageBubbleInner: { messageBubbleInner: {
borderRadius: 16, borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
paddingHorizontal: spacing.md, paddingHorizontal: 10, // 微信:更窄的左右边距
paddingVertical: spacing.sm + 4, paddingVertical: 7, // 微信:更紧的上下边距
minWidth: 60, minWidth: 44,
}, },
myBubbleInner: { myBubbleInner: {
backgroundColor: colors.chat.replyTint, backgroundColor: colors.chat.bubbleOutgoing,
borderBottomRightRadius: 4, borderBottomRightRadius: 4,
}, },
theirBubbleInner: { theirBubbleInner: {
backgroundColor: colors.chat.card, backgroundColor: colors.chat.bubbleIncoming,
borderTopLeftRadius: 4, borderTopLeftRadius: 4,
}, },
recalledBubble: { recalledBubble: {
@@ -272,17 +272,17 @@ export function createChatScreenStyles(colors: AppColors) {
borderStyle: 'dashed', borderStyle: 'dashed',
}, },
myBubbleText: { myBubbleText: {
color: colors.chat.textPrimary, // 主文案 color: colors.chat.textPrimary, // 使用主题文字颜色
fontSize: 16, fontSize: 18,
lineHeight: 23, lineHeight: 25,
letterSpacing: 0.2, letterSpacing: 0.1,
fontWeight: '400', fontWeight: '400',
}, },
theirBubbleText: { theirBubbleText: {
color: colors.chat.textPrimary, // 主文案 color: colors.chat.textPrimary, // 使用主题文字颜色
fontSize: 16, fontSize: 18,
lineHeight: 23, lineHeight: 25,
letterSpacing: 0.2, letterSpacing: 0.1,
fontWeight: '400', fontWeight: '400',
}, },
// 选中状态 // 选中状态
@@ -312,21 +312,21 @@ export function createChatScreenStyles(colors: AppColors) {
fontStyle: 'italic', fontStyle: 'italic',
}, },
// 图片消息 - QQ风格更大的图片,更明显的圆角 // 图片消息 - QQ/微信风格:更大的圆角,更柔和的阴影
imageBubble: { imageBubble: {
borderRadius: 18, borderRadius: 18,
overflow: 'hidden', overflow: 'hidden',
shadowColor: colors.chat.shadow, shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 3 }, shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.15, shadowOpacity: 0.12,
shadowRadius: 6, shadowRadius: 6,
elevation: 5, elevation: 4,
}, },
myImageBubble: { myImageBubble: {
borderBottomRightRadius: 6, // 右下角尖,箭头在右下 borderBottomRightRadius: 8,
}, },
theirImageBubble: { theirImageBubble: {
borderTopLeftRadius: 6, // 左上角尖,箭头在左上 borderTopLeftRadius: 8,
}, },
messageImage: { messageImage: {
width: 220, width: 220,

View File

@@ -23,6 +23,7 @@ import {
borderRadius, borderRadius,
useThemePreference, useThemePreference,
useSetThemePreference, useSetThemePreference,
useThemeStore,
type AppColors, type AppColors,
} from '../../theme'; } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
@@ -204,8 +205,18 @@ export const SettingsScreen: React.FC = () => {
const handleItemPress = (key: string) => { const handleItemPress = (key: string) => {
switch (key) { switch (key) {
case 'theme': case 'theme': {
Alert.alert('主题模式', '选择应用界面明暗', [ // 获取调试信息
const { Appearance } = require('react-native');
const systemScheme = Appearance?.getColorScheme?.() || 'undefined';
const resolvedScheme = useThemeStore.getState().resolvedScheme;
const storedPref = useThemeStore.getState().preference;
const storedSystem = useThemeStore.getState().systemScheme;
Alert.alert(
'主题模式',
`选择应用界面明暗\n\n[调试信息]\n系统主题: ${systemScheme}\n存储偏好: ${storedPref}\n存储系统: ${storedSystem}\n实际应用: ${resolvedScheme}`,
[
{ text: '取消', style: 'cancel' }, { text: '取消', style: 'cancel' },
{ {
text: '跟随系统', text: '跟随系统',
@@ -225,8 +236,10 @@ export const SettingsScreen: React.FC = () => {
void setThemePreference('dark'); void setThemePreference('dark');
}, },
}, },
]); ]
);
break; break;
}
case 'edit_profile': case 'edit_profile':
router.push(hrefs.hrefProfileEdit()); router.push(hrefs.hrefProfileEdit());
break; break;

View File

@@ -0,0 +1,290 @@
/**
* APK 更新检查服务
* 用于检查最新版本并提示用户下载更新
*/
import { Platform, Linking, Alert } from 'react-native';
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { showConfirm } from './dialogService';
// 条件导入原生模块(仅在 Android 上可用)
// 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
? Constants.expoConfig.extra.updatesUrl.replace('/api/manifest', '')
: 'https://updates.littlelan.cn';
// 检查间隔毫秒24小时
const CHECK_INTERVAL = 24 * 60 * 60 * 1000;
// 最后检查时间存储键
const LAST_CHECK_KEY = 'apk_update_last_check';
// APK 版本信息
export interface APKVersionInfo {
runtimeVersion: string;
versionName: string;
size: number;
downloadUrl: string;
createdAt: string;
}
// 版本比较结果
export interface VersionCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
downloadUrl: string;
size: number;
}
/**
* 比较版本号
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 > p2) return 1;
if (p1 < p2) return -1;
}
return 0;
}
/**
* 获取最新 APK 版本信息
*/
async function fetchLatestAPKVersion(): Promise<APKVersionInfo | null> {
try {
const response = await fetch(`${UPDATES_SERVER_URL}/api/apk/latest`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
console.warn('Failed to fetch latest APK version:', response.status);
return null;
}
const data: APKVersionInfo = await response.json();
return data;
} catch (error) {
console.error('Error fetching latest APK version:', error);
return null;
}
}
/**
* 获取当前应用版本
*/
function getCurrentVersion(): string {
return Constants.expoConfig?.version || '1.0.0';
}
/**
* 格式化文件大小
*/
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
/**
* 检查是否应该执行检查(避免频繁检查)
*/
async function shouldCheck(): Promise<boolean> {
try {
const lastCheck = await AsyncStorage.getItem(LAST_CHECK_KEY);
if (!lastCheck) return true;
const lastCheckTime = parseInt(lastCheck, 10);
return Date.now() - lastCheckTime > CHECK_INTERVAL;
} catch {
return true;
}
}
/**
* 记录检查时间
*/
async function recordCheckTime(): Promise<void> {
try {
await AsyncStorage.setItem(LAST_CHECK_KEY, Date.now().toString());
} catch (error) {
console.error('Failed to record check time:', error);
}
}
/**
* 下载并安装 APK
*/
async function downloadAndInstallAPK(downloadUrl: string, version: string): Promise<void> {
if (Platform.OS !== 'android') {
Alert.alert('提示', '此功能仅支持 Android 设备');
return;
}
if (!FileSystem) {
Alert.alert('提示', '当前环境不支持 APK 更新,请在浏览器中下载');
Linking.openURL(downloadUrl);
return;
}
try {
const downloadPath = `${FileSystem.documentDirectory}carrot-bbs-${version}.apk`;
// 显示下载进度
showConfirm({
title: '正在下载',
message: `正在下载新版本 ${version},请稍候...`,
confirmText: '后台下载',
cancelText: '取消',
onConfirm: () => {
// 用户选择后台下载,继续
},
onCancel: () => {
// 用户取消
},
});
// 下载 APK
const downloadResult = await FileSystem.downloadAsync(downloadUrl, downloadPath);
if (!downloadResult.uri) {
Alert.alert('下载失败', '无法下载安装包,请稍后重试');
return;
}
// 安装 APK
await installAPK(downloadResult.uri);
} catch (error) {
console.error('Download/Install error:', error);
Alert.alert('更新失败', '下载或安装过程中出错,请稍后重试');
}
}
/**
* 安装 APK
*/
async function installAPK(apkUri: string): Promise<void> {
try {
if (!FileSystem || !IntentLauncher) {
throw new Error('Native modules not available');
}
// 获取文件的 content URI
const contentUri = await FileSystem.getContentUriAsync(apkUri);
// 使用 Intent 安装 APK
await IntentLauncher.startActivityAsync('android.intent.action.INSTALL_PACKAGE', {
data: contentUri,
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
});
} catch (error) {
console.error('Install APK error:', error);
// 尝试使用浏览器下载
Linking.openURL(apkUri.replace('file://', ''));
}
}
/**
* 显示更新提示对话框
*/
function showUpdateDialog(result: VersionCheckResult): void {
const sizeText = formatFileSize(result.size);
showConfirm({
title: '发现新版本',
message: `当前版本: ${result.currentVersion}\n最新版本: ${result.latestVersion}\n安装包大小: ${sizeText}\n\n是否立即下载更新`,
confirmText: '立即更新',
cancelText: '稍后提醒',
onConfirm: () => {
downloadAndInstallAPK(result.downloadUrl, result.latestVersion);
},
});
}
/**
* 检查 APK 更新(主入口)
* @param force 是否强制检查(忽略时间间隔)
*/
export async function checkForAPKUpdate(force: boolean = false): Promise<VersionCheckResult | null> {
// 仅在 Android 平台检查
if (Platform.OS !== 'android') {
return null;
}
// 检查是否应该执行检查
if (!force) {
const should = await shouldCheck();
if (!should) {
console.log('APK update check skipped (too frequent)');
return null;
}
}
// 记录检查时间
await recordCheckTime();
// 获取最新版本信息
const latestAPK = await fetchLatestAPKVersion();
if (!latestAPK) {
console.log('No APK version info available');
return null;
}
const currentVersion = getCurrentVersion();
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
// 比较版本
const comparison = compareVersions(currentVersion, latestVersion);
const result: VersionCheckResult = {
hasUpdate: comparison < 0,
currentVersion,
latestVersion,
downloadUrl: latestAPK.downloadUrl,
size: latestAPK.size,
};
// 如果有更新,显示对话框
if (result.hasUpdate) {
showUpdateDialog(result);
}
return result;
}
/**
* 手动检查更新(用户主动触发)
*/
export async function manualCheckForUpdate(): Promise<void> {
const result = await checkForAPKUpdate(true);
if (!result) {
Alert.alert('检查失败', '无法获取版本信息,请检查网络连接');
return;
}
if (!result.hasUpdate) {
Alert.alert('已是最新', `当前版本 ${result.currentVersion} 已是最新版本`);
}
}
export const apkUpdateService = {
checkForAPKUpdate,
manualCheckForUpdate,
};

View File

@@ -78,7 +78,13 @@ function normalizeAnnouncementCursor(
// 群组服务类(纯 API 层) // 群组服务类(纯 API 层)
// SQLite/内存缓存编排由 groupManager 统一处理 // SQLite/内存缓存编排由 groupManager 统一处理
class GroupService { class GroupService {
async fetchGroupsFromApi(page = 1, pageSize = 20): Promise<GroupListResponse> { // ==================== 群组管理 ====================
/**
* 获取群组列表
* GET /api/v1/groups
*/
async getGroups(page = 1, pageSize = 20): Promise<GroupListResponse> {
const response = await api.get<GroupListResponse>('/groups', { const response = await api.get<GroupListResponse>('/groups', {
page, page,
page_size: pageSize, page_size: pageSize,
@@ -86,16 +92,20 @@ class GroupService {
return normalizeGroupListResponse(response.data); return normalizeGroupListResponse(response.data);
} }
async fetchGroupFromApi(id: string): Promise<GroupResponse> { /**
* 获取群组详情
* GET /api/v1/groups/:id
*/
async getGroup(id: string): Promise<GroupResponse> {
const response = await api.get<GroupResponse>(`/groups/${encodeURIComponent(id)}`); const response = await api.get<GroupResponse>(`/groups/${encodeURIComponent(id)}`);
return normalizeGroup(response.data); return normalizeGroup(response.data);
} }
async fetchMembersFromApi( /**
groupId: string, * 获取群组成员列表
page = 1, * GET /api/v1/groups/:id/members
pageSize = 50 */
): Promise<GroupMemberListResponse> { async getMembers(groupId: string, page = 1, pageSize = 50): Promise<GroupMemberListResponse> {
const response = await api.get<GroupMemberListResponse>( const response = await api.get<GroupMemberListResponse>(
`/groups/${encodeURIComponent(groupId)}/members`, `/groups/${encodeURIComponent(groupId)}/members`,
{ {
@@ -106,8 +116,6 @@ class GroupService {
return normalizeMemberListResponse(response.data); return normalizeMemberListResponse(response.data);
} }
// ==================== 群组管理 ====================
/** /**
* 创建群组 * 创建群组
* POST /api/v1/groups * POST /api/v1/groups
@@ -117,22 +125,6 @@ class GroupService {
return normalizeGroup(response.data); return normalizeGroup(response.data);
} }
/**
* 获取群组列表
* GET /api/v1/groups
*/
async getGroups(page = 1, pageSize = 20): Promise<GroupListResponse> {
return this.fetchGroupsFromApi(page, pageSize);
}
/**
* 获取群组详情
* GET /api/v1/groups/:id
*/
async getGroup(id: string): Promise<GroupResponse> {
return this.fetchGroupFromApi(id);
}
/** /**
* 获取当前用户在群组中的成员信息 * 获取当前用户在群组中的成员信息
* GET /api/v1/groups/:id/me * GET /api/v1/groups/:id/me
@@ -211,14 +203,6 @@ class GroupService {
await api.post(`/groups/${encodeURIComponent(groupId)}/leave`); await api.post(`/groups/${encodeURIComponent(groupId)}/leave`);
} }
/**
* 获取群组成员列表
* GET /api/v1/groups/:id/members
*/
async getMembers(groupId: string, page = 1, pageSize = 50): Promise<GroupMemberListResponse> {
return this.fetchMembersFromApi(groupId, page, pageSize);
}
/** /**
* 移除群成员(踢人) * 移除群成员(踢人)
* POST /api/v1/groups/:id/members/kick * POST /api/v1/groups/:id/members/kick

View File

@@ -110,3 +110,7 @@ export {
// 统一提示/弹窗服务 // 统一提示/弹窗服务
export { showPrompt } from './promptService'; export { showPrompt } from './promptService';
export { showConfirm } from './dialogService'; export { showConfirm } from './dialogService';
// APK 更新检查服务
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';

View File

@@ -27,6 +27,7 @@ import {
saveConversationCache, saveConversationCache,
saveConversationsWithRelatedCache, saveConversationsWithRelatedCache,
updateConversationCacheUnreadCount, updateConversationCacheUnreadCount,
saveUsersCache,
} from './database'; } from './database';
/** 远端会话列表分页offset / cursor统一结果拉取成功后均已执行本地缓存同步 */ /** 远端会话列表分页offset / cursor统一结果拉取成功后均已执行本地缓存同步 */

127
src/stores/baseManager.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* BaseManager - 通用 Manager 基类
*
* 提取自 postManager/groupManager/userManager 的重复逻辑:
* - 请求去重 (dedupe)
* - 缓存条目管理 (CacheEntry)
* - 过期检查 (isExpired)
*/
import { CacheBus, CacheEvent } from './cacheBus';
/** 缓存条目结构 */
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
/** 创建缓存条目 */
export function createCacheEntry<T>(data: T, ttl: number): CacheEntry<T> {
return { data, timestamp: Date.now(), ttl };
}
/** 检查缓存是否过期 */
export function isCacheExpired(entry: CacheEntry<any>): boolean {
return Date.now() - entry.timestamp > entry.ttl;
}
/**
* 通用 Manager 基类
*
* 子类只需关注业务逻辑,无需重复实现缓存和去重机制
*/
export abstract class BaseManager<
TSnapshot,
TEvent extends CacheEvent = CacheEvent
> extends CacheBus<TSnapshot, TEvent> {
/** 待处理请求 Map用于去重 */
protected pendingRequests = new Map<string, Promise<any>>();
/**
* 请求去重:相同 key 的并发请求会被合并为一个
* @param key 唯一标识
* @param fetcher 实际请求函数
*/
protected async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const pending = this.pendingRequests.get(key) as Promise<T> | 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<TSnapshot, TEvent> {
/** 内存缓存 Map */
protected memoryCache = new Map<string, CacheEntry<any>>();
/**
* 获取缓存
* @param key 缓存 key
* @returns 缓存数据,过期或不存在返回 null
*/
protected getFromCache<T>(key: string): T | null {
const entry = this.memoryCache.get(key) as CacheEntry<T> | 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<T>(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);
}
}

View File

@@ -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<GroupListPage>;
/** 至少成功 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<GroupListPage> {
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<GroupListPage> {
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<GroupListPage> {
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<GroupMemberListPage>;
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<GroupMemberListPage> {
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<GroupMemberListPage> {
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<GroupMemberListPage> {
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);
}

View File

@@ -1,3 +1,11 @@
/**
* GroupManager - 群组管理核心模块
*
* 架构特点:
* - 使用 CachedManager 基类,消除重复的缓存/去重逻辑
* - 支持 Sources 模式进行分页加载
*/
import { import {
GroupListResponse, GroupListResponse,
GroupMemberListResponse, GroupMemberListResponse,
@@ -14,7 +22,19 @@ import {
saveGroupsCache, saveGroupsCache,
saveUsersCache, saveUsersCache,
} from '../services/database'; } 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 { interface GroupManagerSnapshot {
groupIds: string[]; groupIds: string[];
@@ -28,20 +48,18 @@ type GroupManagerEvent =
| CacheEvent<GroupManagerSnapshot> | CacheEvent<GroupManagerSnapshot>
| CacheEvent<{ error: unknown; context: string }>; | CacheEvent<{ error: unknown; context: string }>;
// ==================== 常量 ====================
const GROUP_TTL = 60 * 1000; const GROUP_TTL = 60 * 1000;
const MEMBERS_TTL = 120 * 1000; const MEMBERS_TTL = 120 * 1000;
interface CacheEntry<T> { // ==================== Manager 实现 ====================
data: T;
timestamp: number;
ttl: number;
}
class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> { class GroupManager extends CachedManager<GroupManagerSnapshot, GroupManagerEvent> {
private groupsListCache: CacheEntry<GroupResponse[]> | null = null; /** 群组详情缓存 */
private groupDetailCache = new Map<string, CacheEntry<GroupResponse | null>>(); private groupDetailCache = new Map<string, CacheEntry<GroupResponse | null>>();
/** 群组成员缓存 */
private groupMembersCache = new Map<string, CacheEntry<GroupMemberResponse[]>>(); private groupMembersCache = new Map<string, CacheEntry<GroupMemberResponse[]>>();
private pendingRequests = new Map<string, Promise<any>>();
protected getSnapshot(): GroupManagerSnapshot { protected getSnapshot(): GroupManagerSnapshot {
return { return {
@@ -50,67 +68,48 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
}; };
} }
private isExpired(entry: CacheEntry<any>): boolean { // ==================== 群组列表相关 ====================
return Date.now() - entry.timestamp > entry.ttl;
/**
* 获取群组列表
*/
async getGroups(page = 1, pageSize = GROUP_LIST_PAGE_SIZE, forceRefresh = false): Promise<GroupListResponse> {
const key = `list:${page}:${pageSize}`;
// 第一页且有有效缓存
if (page === 1 && !forceRefresh && this.hasValidCache(key)) {
const groups = this.getFromCache<GroupResponse[]>(key)!;
return this.buildGroupListResponse(groups, pageSize);
} }
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> { // 第一页且缓存过期:后台刷新
const pending = this.pendingRequests.get(key) as Promise<T> | undefined; if (page === 1 && !forceRefresh && this.hasExpiredCache(key)) {
if (pending) return pending;
const request = fetcher().finally(() => {
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, request);
return request;
}
async getGroups(page = 1, pageSize = 20, forceRefresh = false): Promise<GroupListResponse> {
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.groupsListCache && this.isExpired(this.groupsListCache)) {
this.refreshGroupsInBackground(page, pageSize); this.refreshGroupsInBackground(page, pageSize);
return { const groups = this.getFromCache<GroupResponse[]>(key)!;
list: this.groupsListCache.data, return this.buildGroupListResponse(groups, pageSize);
total: this.groupsListCache.data.length,
page: 1,
page_size: pageSize,
total_pages: 1,
};
} }
// 第一页:尝试本地缓存
if (page === 1 && !forceRefresh) { if (page === 1 && !forceRefresh) {
const localGroups = await getAllGroupsCache(); const localGroups = await getAllGroupsCache();
if (localGroups.length > 0) { if (localGroups.length > 0) {
this.groupsListCache = { data: localGroups, timestamp: Date.now(), ttl: GROUP_TTL }; this.setCache(key, localGroups, GROUP_TTL);
this.notify({ this.notify({
type: 'list_updated', type: 'list_updated',
payload: { groups: localGroups }, payload: { groups: localGroups },
timestamp: Date.now(), timestamp: Date.now(),
}); });
this.refreshGroupsInBackground(page, pageSize); this.refreshGroupsInBackground(page, pageSize);
return { return this.buildGroupListResponse(localGroups, pageSize);
list: localGroups,
total: localGroups.length,
page: 1,
page_size: pageSize,
total_pages: 1,
};
} }
} }
// 发起请求
return this.dedupe(`groups:list:${page}:${pageSize}`, async () => { 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 || []; const groups = response.list || [];
if (page === 1) { if (page === 1) {
this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL }; this.setCache(key, groups, GROUP_TTL);
} }
await saveGroupsCache(groups).catch(() => {}); await saveGroupsCache(groups).catch(() => {});
this.notify({ this.notify({
@@ -122,12 +121,23 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
}); });
} }
private refreshGroupsInBackground(page = 1, pageSize = 20): void { private buildGroupListResponse(groups: GroupResponse[], pageSize: number): GroupListResponse {
this.dedupe(`groups:list:bg:${page}:${pageSize}`, async () => { return {
const response = await groupService.fetchGroupsFromApi(page, pageSize); 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 || []; const groups = response.list || [];
if (page === 1) { if (page === 1) {
this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL }; this.setCache(key, groups, GROUP_TTL);
saveGroupsCache(groups).catch(() => {}); saveGroupsCache(groups).catch(() => {});
} }
this.notify({ this.notify({
@@ -145,39 +155,58 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
}); });
} }
/**
* 创建群组列表数据源(用于 Sources 模式)
*/
createGroupListSource(
kind: RemoteGroupListSourceKind = 'cursor',
pageSize: number = GROUP_LIST_PAGE_SIZE
): IGroupListPagedSource {
return createRemoteGroupListSource(kind, pageSize);
}
// ==================== 群组详情相关 ====================
/**
* 获取群组详情
*/
async getGroup(groupId: string, forceRefresh = false): Promise<GroupResponse> { async getGroup(groupId: string, forceRefresh = false): Promise<GroupResponse> {
const id = groupId; const cached = this.groupDetailCache.get(groupId);
const cached = this.groupDetailCache.get(id);
if (!forceRefresh && cached && !this.isExpired(cached) && cached.data) { // 有效缓存
if (!forceRefresh && cached && !isCacheExpired(cached) && cached.data) {
return 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; return cached.data;
} }
// 尝试本地缓存
if (!forceRefresh) { if (!forceRefresh) {
const localGroup = await getGroupCache(id); const localGroup = await getGroupCache(groupId);
if (localGroup) { if (localGroup) {
this.groupDetailCache.set(id, { data: localGroup, timestamp: Date.now(), ttl: GROUP_TTL }); this.groupDetailCache.set(groupId, createCacheEntry(localGroup, GROUP_TTL));
this.notify({ this.notify({
type: 'detail_updated', type: 'detail_updated',
payload: { groupId: id, group: localGroup }, payload: { groupId, group: localGroup },
timestamp: Date.now(), timestamp: Date.now(),
}); });
this.refreshGroupInBackground(id); this.refreshGroupInBackground(groupId);
return localGroup; return localGroup;
} }
} }
return this.dedupe(`groups:detail:${id}`, async () => { // 发起请求
const group = await groupService.fetchGroupFromApi(id); return this.dedupe(`groups:detail:${groupId}`, async () => {
this.groupDetailCache.set(id, { data: group, timestamp: Date.now(), ttl: GROUP_TTL }); const group = await groupService.getGroup(groupId);
this.groupDetailCache.set(groupId, createCacheEntry(group, GROUP_TTL));
await saveGroupCache(group).catch(() => {}); await saveGroupCache(group).catch(() => {});
this.notify({ this.notify({
type: 'detail_updated', type: 'detail_updated',
payload: { groupId: id, group }, payload: { groupId, group },
timestamp: Date.now(), timestamp: Date.now(),
}); });
return group; return group;
@@ -186,8 +215,8 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
private refreshGroupInBackground(groupId: string): void { private refreshGroupInBackground(groupId: string): void {
this.dedupe(`groups:detail:bg:${groupId}`, async () => { this.dedupe(`groups:detail:bg:${groupId}`, async () => {
const group = await groupService.fetchGroupFromApi(groupId); const group = await groupService.getGroup(groupId);
this.groupDetailCache.set(groupId, { data: group, timestamp: Date.now(), ttl: GROUP_TTL }); this.groupDetailCache.set(groupId, createCacheEntry(group, GROUP_TTL));
saveGroupCache(group).catch(() => {}); saveGroupCache(group).catch(() => {});
this.notify({ this.notify({
type: 'detail_updated', type: 'detail_updated',
@@ -204,61 +233,53 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
}); });
} }
async getMembers(groupId: string, page = 1, pageSize = 50, forceRefresh = false): Promise<GroupMemberListResponse> { // ==================== 群组成员相关 ====================
const id = groupId;
const key = `${id}:${page}:${pageSize}`; /**
* 获取群组成员列表
*/
async getMembers(
groupId: string,
page = 1,
pageSize = GROUP_MEMBER_LIST_PAGE_SIZE,
forceRefresh = false
): Promise<GroupMemberListResponse> {
const key = `members:${groupId}:${page}:${pageSize}`;
const cached = this.groupMembersCache.get(key); const cached = this.groupMembersCache.get(key);
if (!forceRefresh && cached && !this.isExpired(cached)) {
return { // 有效缓存
list: cached.data, if (!forceRefresh && cached && !isCacheExpired(cached)) {
total: cached.data.length, return this.buildMemberListResponse(cached.data, page, pageSize);
page,
page_size: pageSize,
total_pages: 1,
};
} }
if (!forceRefresh && cached && this.isExpired(cached)) { // 过期缓存:后台刷新
this.refreshMembersInBackground(id, page, pageSize); if (!forceRefresh && cached && isCacheExpired(cached)) {
return { this.refreshMembersInBackground(groupId, page, pageSize);
list: cached.data, return this.buildMemberListResponse(cached.data, page, pageSize);
total: cached.data.length,
page,
page_size: pageSize,
total_pages: 1,
};
} }
// 第一页:尝试本地缓存
if (page === 1 && !forceRefresh) { if (page === 1 && !forceRefresh) {
const localMembers = await getGroupMembersCache(id); const localMembers = await getGroupMembersCache(groupId);
if (localMembers.length > 0) { if (localMembers.length > 0) {
this.groupMembersCache.set(key, { this.groupMembersCache.set(key, createCacheEntry(localMembers, MEMBERS_TTL));
data: localMembers,
timestamp: Date.now(),
ttl: MEMBERS_TTL,
});
this.notify({ this.notify({
type: 'detail_updated', type: 'detail_updated',
payload: { groupId: id, members: localMembers }, payload: { groupId, members: localMembers },
timestamp: Date.now(), timestamp: Date.now(),
}); });
this.refreshMembersInBackground(id, page, pageSize); this.refreshMembersInBackground(groupId, page, pageSize);
return { return this.buildMemberListResponse(localMembers, page, pageSize);
list: localMembers,
total: localMembers.length,
page,
page_size: pageSize,
total_pages: 1,
};
} }
} }
// 发起请求
return this.dedupe(`groups:members:${key}`, async () => { 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 || []; 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) { if (page === 1) {
await saveGroupMembersCache(id, members).catch(() => {}); await saveGroupMembersCache(groupId, members).catch(() => {});
} }
await saveUsersCache( await saveUsersCache(
members members
@@ -267,19 +288,37 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
).catch(() => {}); ).catch(() => {});
this.notify({ this.notify({
type: 'detail_updated', type: 'detail_updated',
payload: { groupId: id, members }, payload: { groupId, members },
timestamp: Date.now(), timestamp: Date.now(),
}); });
return response; return response;
}); });
} }
private refreshMembersInBackground(groupId: string, page = 1, pageSize = 50): void { private buildMemberListResponse(
this.dedupe(`groups:members:bg:${groupId}:${page}:${pageSize}`, async () => { members: GroupMemberResponse[],
const response = await groupService.fetchMembersFromApi(groupId, page, pageSize); 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 members = response.list || [];
const key = `${groupId}:${page}:${pageSize}`; this.groupMembersCache.set(key, createCacheEntry(members, MEMBERS_TTL));
this.groupMembersCache.set(key, { data: members, timestamp: Date.now(), ttl: MEMBERS_TTL });
if (page === 1) { if (page === 1) {
saveGroupMembersCache(groupId, members).catch(() => {}); saveGroupMembersCache(groupId, members).catch(() => {});
} }
@@ -303,21 +342,46 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
}); });
} }
/**
* 创建群组成员列表数据源(用于 Sources 模式)
*/
createGroupMemberListSource(
groupId: string,
kind: RemoteGroupListSourceKind = 'cursor',
pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE
): IGroupMemberListPagedSource {
return createRemoteGroupMemberListSource(groupId, kind, pageSize);
}
// ==================== 缓存管理 ====================
/**
* 使缓存失效
*/
invalidate(groupId?: string): void { invalidate(groupId?: string): void {
if (!groupId) { if (!groupId) {
this.groupsListCache = null; this.clearCache();
this.groupDetailCache.clear(); this.groupDetailCache.clear();
this.groupMembersCache.clear(); this.groupMembersCache.clear();
return; return;
} }
this.groupDetailCache.delete(groupId); this.groupDetailCache.delete(groupId);
[...this.groupMembersCache.keys()].forEach((key) => { [...this.groupMembersCache.keys()].forEach((key) => {
if (key.startsWith(`${groupId}:`)) { if (key.startsWith(`members:${groupId}:`)) {
this.groupMembersCache.delete(key); this.groupMembersCache.delete(key);
} }
}); });
} }
/**
* 清除所有数据
*/
clear(): void {
this.clearCache();
this.groupDetailCache.clear();
this.groupMembersCache.clear();
this.clearPendingRequests();
}
} }
export const groupManager = new GroupManager(); export const groupManager = new GroupManager();

View File

@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface HomeTabPressState {
/** 用于触发首页回到顶部的事件计数器 */
pressCount: number;
/** 触发首页 Tab 点击事件 */
triggerPress: () => void;
}
export const useHomeTabPressStore = create<HomeTabPressState>((set) => ({
pressCount: 0,
triggerPress: () => set((state) => ({ pressCount: state.pressCount + 1 })),
}));

View File

@@ -44,6 +44,9 @@ export { userManager } from './userManager';
export { export {
useHomeTabBarVisibilityStore, useHomeTabBarVisibilityStore,
} from './homeTabBarVisibilityStore'; } from './homeTabBarVisibilityStore';
export {
useHomeTabPressStore,
} from './homeTabPressStore';
export { export {
useAppColors, useAppColors,
useThemePreference, useThemePreference,

View File

@@ -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<PostListPage>;
/** 至少成功 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<PostListPage> {
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<PostListPage> {
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);
}

View File

@@ -1,6 +1,24 @@
/**
* PostManager - 帖子管理核心模块
*
* 架构特点:
* - 使用 CachedManager 基类,消除重复的缓存/去重逻辑
* - 支持 Sources 模式进行分页加载
*/
import { Post } from '../types'; import { Post } from '../types';
import { postService } from '../services/postService'; 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 { interface PostListPayload {
key: string; key: string;
@@ -23,66 +41,57 @@ type PostManagerEvent =
| CacheEvent<PostManagerSnapshot> | CacheEvent<PostManagerSnapshot>
| CacheEvent<{ error: unknown; context: string }>; | CacheEvent<{ error: unknown; context: string }>;
// ==================== 常量 ====================
const LIST_TTL = 30 * 1000; const LIST_TTL = 30 * 1000;
const DETAIL_TTL = 60 * 1000; const DETAIL_TTL = 60 * 1000;
interface CacheEntry<T> { // ==================== Manager 实现 ====================
data: T;
timestamp: number;
ttl: number;
}
class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> { class PostManager extends CachedManager<PostManagerSnapshot, PostManagerEvent> {
private listCache = new Map<string, CacheEntry<Post[]>>(); /** 详情缓存 */
private detailCache = new Map<string, CacheEntry<Post | null>>(); private detailCache = new Map<string, CacheEntry<Post | null>>();
private pendingRequests = new Map<string, Promise<any>>();
protected getSnapshot(): PostManagerSnapshot { protected getSnapshot(): PostManagerSnapshot {
return { return {
listKeys: [...this.listCache.keys()], listKeys: [...this.memoryCache.keys()],
detailKeys: [...this.detailCache.keys()], detailKeys: [...this.detailCache.keys()],
}; };
} }
private isExpired(entry: CacheEntry<any>): boolean { // ==================== 列表相关 ====================
return Date.now() - entry.timestamp > entry.ttl;
}
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const pending = this.pendingRequests.get(key) as Promise<T> | 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 { private listKey(type: string, page: number, pageSize: number): string {
return `${type}:${page}:${pageSize}`; return `list:${type}:${page}:${pageSize}`;
} }
/**
* 获取帖子列表(传统分页模式)
*/
async getPosts( async getPosts(
type = 'hot', type: PostListTab = 'hot',
page = 1, page = 1,
pageSize = 20, pageSize = POST_LIST_PAGE_SIZE,
forceRefresh = false forceRefresh = false
): Promise<Post[]> { ): Promise<Post[]> {
const key = this.listKey(type, page, pageSize); 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<Post[]>(key)!;
} }
if (!forceRefresh && cached && this.isExpired(cached)) { // 过期缓存:后台刷新,立即返回旧数据
if (!forceRefresh && this.hasExpiredCache(key)) {
this.refreshPostsInBackground(key, type, page, pageSize); this.refreshPostsInBackground(key, type, page, pageSize);
return cached.data; return this.getFromCache<Post[]>(key)!;
} }
return this.dedupe(`posts:list:${key}`, async () => { // 无缓存:发起请求
return this.dedupe(`posts:${key}`, async () => {
const response = await postService.getPosts(page, pageSize, type); const response = await postService.getPosts(page, pageSize, type);
const posts = response.list || []; const posts = response.list || [];
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL }); this.setCache(key, posts, LIST_TTL);
this.notify({ this.notify({
type: 'list_updated', type: 'list_updated',
payload: { key, posts }, payload: { key, posts },
@@ -92,11 +101,16 @@ class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
}); });
} }
private refreshPostsInBackground(key: string, type: string, page: number, pageSize: number): void { private refreshPostsInBackground(
this.dedupe(`posts:list:bg:${key}`, async () => { key: string,
type: PostListTab,
page: number,
pageSize: number
): void {
this.dedupe(`posts:bg:${key}`, async () => {
const response = await postService.getPosts(page, pageSize, type); const response = await postService.getPosts(page, pageSize, type);
const posts = response.list || []; const posts = response.list || [];
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL }); this.setCache(key, posts, LIST_TTL);
this.notify({ this.notify({
type: 'list_updated', type: 'list_updated',
payload: { key, posts }, payload: { key, posts },
@@ -112,20 +126,39 @@ class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
}); });
} }
/**
* 创建帖子列表数据源(用于 Sources 模式)
*/
createPostListSource(
options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {},
kind: RemotePostListSourceKind = 'cursor'
): IPostListPagedSource {
return createRemotePostListSource(kind, options);
}
// ==================== 详情相关 ====================
/**
* 获取帖子详情
*/
async getPostDetail(postId: string, forceRefresh = false): Promise<Post | null> { async getPostDetail(postId: string, forceRefresh = false): Promise<Post | null> {
const cached = this.detailCache.get(postId); const cached = this.detailCache.get(postId);
if (!forceRefresh && cached && !this.isExpired(cached)) {
// 有效缓存
if (!forceRefresh && cached && !isCacheExpired(cached)) {
return cached.data; return cached.data;
} }
if (!forceRefresh && cached && this.isExpired(cached)) { // 过期缓存:后台刷新
if (!forceRefresh && cached && isCacheExpired(cached) && cached.data) {
this.refreshPostDetailInBackground(postId); this.refreshPostDetailInBackground(postId);
return cached.data; return cached.data;
} }
// 无缓存:发起请求
return this.dedupe(`posts:detail:${postId}`, async () => { return this.dedupe(`posts:detail:${postId}`, async () => {
const post = await postService.getPost(postId); 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({ this.notify({
type: 'detail_updated', type: 'detail_updated',
payload: { postId, post }, payload: { postId, post },
@@ -138,7 +171,7 @@ class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
private refreshPostDetailInBackground(postId: string): void { private refreshPostDetailInBackground(postId: string): void {
this.dedupe(`posts:detail:bg:${postId}`, async () => { this.dedupe(`posts:detail:bg:${postId}`, async () => {
const post = await postService.getPost(postId); 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({ this.notify({
type: 'detail_updated', type: 'detail_updated',
payload: { postId, post }, payload: { postId, post },
@@ -154,21 +187,28 @@ class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
}); });
} }
// ==================== 缓存管理 ====================
/**
* 使缓存失效
*/
invalidate(postId?: string): void { invalidate(postId?: string): void {
if (postId) { if (postId) {
this.detailCache.delete(postId); this.detailCache.delete(postId);
return; return;
} }
this.listCache.clear(); this.clearCache();
this.detailCache.clear(); this.detailCache.clear();
} }
/**
* 清除所有数据
*/
clear(): void { clear(): void {
this.listCache.clear(); this.clearCache();
this.detailCache.clear(); this.detailCache.clear();
this.pendingRequests.clear(); this.clearPendingRequests();
} }
} }
export const postManager = new PostManager(); export const postManager = new PostManager();

View File

@@ -1,7 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { useColorScheme } from 'react-native'; import { Appearance, AppState, AppStateStatus } from 'react-native';
import { create } from 'zustand'; import { create } from 'zustand';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { lightColors, darkColors, type AppColors } from '../theme/palettes'; import { lightColors, darkColors, type AppColors } from '../theme/palettes';
import { buildPaperTheme } from '../theme/paperTheme'; import { buildPaperTheme } from '../theme/paperTheme';
import type { MD3Theme } from 'react-native-paper'; import type { MD3Theme } from 'react-native-paper';
@@ -46,13 +46,19 @@ type ThemeState = {
hydrate: () => Promise<void>; hydrate: () => Promise<void>;
}; };
const initialSystem: ResolvedScheme = 'light'; /** 获取系统主题 */
function getSystemScheme(): ResolvedScheme {
const scheme = Appearance.getColorScheme();
return scheme === 'dark' ? 'dark' : 'light';
}
const initialSystemScheme = getSystemScheme();
export const useThemeStore = create<ThemeState>((set, get) => ({ export const useThemeStore = create<ThemeState>((set, get) => ({
preference: 'system', preference: 'system',
systemScheme: initialSystem, systemScheme: initialSystemScheme,
hydrated: false, hydrated: false,
...buildState('system', initialSystem), ...buildState('system', initialSystemScheme),
setSystemScheme: (systemScheme) => { setSystemScheme: (systemScheme) => {
const { preference, systemScheme: cur } = get(); const { preference, systemScheme: cur } = get();
@@ -86,10 +92,11 @@ export const useThemeStore = create<ThemeState>((set, get) => ({
} catch { } catch {
/* ignore */ /* ignore */
} }
const { systemScheme } = get(); const systemScheme = getSystemScheme();
set({ set({
preference, preference,
hydrated: true, hydrated: true,
systemScheme,
...buildState(preference, systemScheme), ...buildState(preference, systemScheme),
}); });
}, },
@@ -97,17 +104,42 @@ export const useThemeStore = create<ThemeState>((set, get) => ({
/** 在根布局中同步系统配色并恢复持久化偏好 */ /** 在根布局中同步系统配色并恢复持久化偏好 */
export function ThemeBootstrap() { export function ThemeBootstrap() {
const system = useColorScheme();
const setSystemScheme = useThemeStore((s) => s.setSystemScheme); const setSystemScheme = useThemeStore((s) => s.setSystemScheme);
const hydrate = useThemeStore((s) => s.hydrate); const hydrate = useThemeStore((s) => s.hydrate);
const appState = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => { useEffect(() => {
void hydrate(); void hydrate();
}, [hydrate]); }, [hydrate]);
// 监听系统主题变化
useEffect(() => { useEffect(() => {
setSystemScheme(system === 'dark' ? 'dark' : 'light'); const subscription = Appearance.addChangeListener(({ colorScheme }) => {
}, [system, setSystemScheme]); const newScheme = colorScheme === 'dark' ? 'dark' : 'light';
setSystemScheme(newScheme);
});
return () => {
subscription.remove();
};
}, [setSystemScheme]);
// 监听应用状态变化,从后台恢复时重新检测系统主题
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === 'active'
) {
setSystemScheme(getSystemScheme());
}
appState.current = nextAppState;
});
return () => {
subscription.remove();
};
}, [setSystemScheme]);
return null; return null;
} }
@@ -131,3 +163,6 @@ export function useSetThemePreference() {
export function usePaperThemeFromStore() { export function usePaperThemeFromStore() {
return useThemeStore((s) => s.paperTheme); return useThemeStore((s) => s.paperTheme);
} }
// 导出调试函数
export { getSystemScheme };

View File

@@ -76,8 +76,8 @@ export const lightColors = {
link: '#4A88C7', link: '#4A88C7',
success: '#34C759', success: '#34C759',
danger: '#FF3B30', danger: '#FF3B30',
bubbleOutgoing: '#E8E8E8', bubbleOutgoing: '#7CB9FF', // 柔和的淡蓝色
bubbleIncoming: '#FFFFFF', bubbleIncoming: '#FFFFFF', // 对方发的消息 - 纯白
replyTint: '#DFF2FF', replyTint: '#DFF2FF',
replyTintActive: '#CFEAFF', replyTintActive: '#CFEAFF',
replyBorder: '#7FB6E6', replyBorder: '#7FB6E6',
@@ -168,8 +168,8 @@ export const darkColors = {
link: '#5AC8FA', link: '#5AC8FA',
success: '#32D74B', success: '#32D74B',
danger: '#FF453A', danger: '#FF453A',
bubbleOutgoing: '#3A3A3C', bubbleOutgoing: '#2E5A8C', // 暗色模式:深蓝气泡
bubbleIncoming: '#2C2C2E', bubbleIncoming: '#2C2C2E', // 暗色模式:灰色气泡
replyTint: '#1A3A52', replyTint: '#1A3A52',
replyTintActive: '#224060', replyTintActive: '#224060',
replyBorder: '#4A88C7', replyBorder: '#4A88C7',

View File

@@ -2,6 +2,39 @@
* 与明暗无关的设计 token * 与明暗无关的设计 token
*/ */
// 字体配置 - 使用系统字体栈,优化字重
export const fontFamily = {
// iOS 系统字体
ios: {
regular: 'System',
medium: 'System',
semibold: 'System',
bold: 'System',
},
// Android 系统字体
android: {
regular: 'Roboto',
medium: 'Roboto',
semibold: 'Roboto',
bold: 'Roboto',
},
// 通用字体栈
system: {
regular: 'System',
medium: 'System',
semibold: 'System',
bold: 'System',
},
};
// 字体字重 - 使用数值更精确控制
export const fontWeights = {
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
};
export const fontSizes = { export const fontSizes = {
xs: 10, xs: 10,
sm: 12, sm: 12,

View File

@@ -6,6 +6,7 @@
// 导出DTO类型作为主要类型 // 导出DTO类型作为主要类型
export * from './dto'; export * from './dto';
export * from './schedule'; export * from './schedule';
export * from './material';
// 兼容旧类型(用于内部组件) // 兼容旧类型(用于内部组件)
import type { import type {

103
src/types/material.ts Normal file
View 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', // 深红
];