Compare commits
2 Commits
4b89b50006
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2014be28c4 | |||
| cf466a3959 |
@@ -272,42 +272,6 @@ 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:
|
||||||
|
|||||||
1
app.json
1
app.json
@@ -59,7 +59,6 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"./plugins/withMainActivityConfigChange",
|
|
||||||
[
|
[
|
||||||
"expo-router",
|
"expo-router",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo } 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, useHomeTabPressStore } from '../../../src/stores';
|
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } 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,17 +19,10 @@ 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 };
|
||||||
@@ -88,9 +81,6 @@ export default function TabsLayout() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
listeners={{
|
|
||||||
tabPress: handleHomeTabPress,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="apps"
|
name="apps"
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function MaterialDetailRoute() {
|
|
||||||
return <MaterialDetailScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MaterialsScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function MaterialsRoute() {
|
|
||||||
return <MaterialsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function SubjectMaterialsRoute() {
|
|
||||||
return <SubjectMaterialsScreen />;
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ 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();
|
||||||
|
|
||||||
@@ -138,26 +137,6 @@ 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();
|
||||||
@@ -169,7 +148,6 @@ 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
10
package-lock.json
generated
@@ -29,7 +29,6 @@
|
|||||||
"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",
|
||||||
@@ -5748,15 +5747,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -18,7 +18,7 @@ interface CommentItemProps {
|
|||||||
comment: Comment;
|
comment: Comment;
|
||||||
onUserPress: () => void;
|
onUserPress: () => void;
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
|
onLike: () => void;
|
||||||
floorNumber?: number; // 楼层号
|
floorNumber?: number; // 楼层号
|
||||||
isAuthor?: boolean; // 是否是楼主
|
isAuthor?: boolean; // 是否是楼主
|
||||||
replyToUser?: string; // 回复给哪位用户
|
replyToUser?: string; // 回复给哪位用户
|
||||||
@@ -504,25 +504,6 @@ 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
|
||||||
@@ -611,7 +592,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{/* 操作按钮 - 更紧凑 */}
|
{/* 操作按钮 - 更紧凑 */}
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{/* 点赞 */}
|
{/* 点赞 */}
|
||||||
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||||
size={14}
|
size={14}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
|
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||||
import { useAppColors, fontSizes, fontWeights } from '../../theme';
|
import { useAppColors, fontSizes } from '../../theme';
|
||||||
|
|
||||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||||
|
|
||||||
@@ -16,45 +16,38 @@ 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: fontWeights.bold,
|
fontWeight: '700',
|
||||||
lineHeight: fontSizes['4xl'] * 1.3,
|
lineHeight: fontSizes['4xl'] * 1.4,
|
||||||
letterSpacing: -0.5,
|
|
||||||
},
|
},
|
||||||
h2: {
|
h2: {
|
||||||
fontSize: fontSizes['3xl'],
|
fontSize: fontSizes['3xl'],
|
||||||
fontWeight: fontWeights.semibold,
|
fontWeight: '600',
|
||||||
lineHeight: fontSizes['3xl'] * 1.3,
|
lineHeight: fontSizes['3xl'] * 1.4,
|
||||||
letterSpacing: -0.3,
|
|
||||||
},
|
},
|
||||||
h3: {
|
h3: {
|
||||||
fontSize: fontSizes['2xl'],
|
fontSize: fontSizes['2xl'],
|
||||||
fontWeight: fontWeights.semibold,
|
fontWeight: '600',
|
||||||
lineHeight: fontSizes['2xl'] * 1.3,
|
lineHeight: fontSizes['2xl'] * 1.3,
|
||||||
letterSpacing: -0.2,
|
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: fontWeights.regular,
|
fontWeight: '400',
|
||||||
lineHeight: fontSizes.md * 1.6,
|
lineHeight: fontSizes.md * 1.5,
|
||||||
letterSpacing: 0.1,
|
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
fontWeight: fontWeights.regular,
|
fontWeight: '400',
|
||||||
lineHeight: fontSizes.sm * 1.5,
|
lineHeight: fontSizes.sm * 1.4,
|
||||||
letterSpacing: 0.2,
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
fontWeight: fontWeights.medium,
|
fontWeight: '500',
|
||||||
lineHeight: fontSizes.xs * 1.4,
|
lineHeight: fontSizes.xs * 1.4,
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,7 +55,6 @@ const Text: React.FC<CustomTextProps> = ({
|
|||||||
children,
|
children,
|
||||||
variant = 'body',
|
variant = 'body',
|
||||||
color,
|
color,
|
||||||
weight,
|
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
onPress,
|
onPress,
|
||||||
style,
|
style,
|
||||||
@@ -72,7 +64,6 @@ 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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
/**
|
|
||||||
* 学习资料 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();
|
|
||||||
@@ -14,7 +14,6 @@ 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';
|
|
||||||
|
|
||||||
// ==================== 预取配置 ====================
|
// ==================== 预取配置 ====================
|
||||||
|
|
||||||
@@ -135,7 +134,7 @@ const prefetchService = new PrefetchService();
|
|||||||
* 预取帖子数据
|
* 预取帖子数据
|
||||||
* @param types 帖子类型数组
|
* @param types 帖子类型数组
|
||||||
*/
|
*/
|
||||||
function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void {
|
function prefetchPosts(types: string[] = ['latest', 'hot']): void {
|
||||||
types.forEach((type, index) => {
|
types.forEach((type, index) => {
|
||||||
prefetchService.schedule({
|
prefetchService.schedule({
|
||||||
key: `posts:${type}:1`,
|
key: `posts:${type}:1`,
|
||||||
@@ -280,7 +279,7 @@ export function usePrefetch() {
|
|||||||
const hasInitialPrefetched = useRef(false);
|
const hasInitialPrefetched = useRef(false);
|
||||||
|
|
||||||
/** 预取帖子 */
|
/** 预取帖子 */
|
||||||
const prefetchPostsData = useCallback((types?: PostListTab[]) => {
|
const prefetchPostsData = useCallback((types?: string[]) => {
|
||||||
prefetchPosts(types);
|
prefetchPosts(types);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -155,17 +155,3 @@ 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)}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,13 +37,6 @@ 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 = () => {
|
||||||
|
|||||||
@@ -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, useHomeTabPressStore } from '../../stores';
|
import { useUserStore, useHomeTabBarVisibilityStore } 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,14 +212,9 @@ 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);
|
||||||
@@ -274,19 +269,6 @@ 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);
|
||||||
@@ -837,7 +819,6 @@ 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,
|
||||||
@@ -918,7 +899,6 @@ 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}
|
||||||
|
|||||||
@@ -895,48 +895,61 @@ 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 newIsLiked = !is_liked;
|
const updateCommentLike = (c: Comment): Comment => {
|
||||||
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
|
// 如果是目标评论/回复
|
||||||
|
if (c.id === comment.id) {
|
||||||
// 递归更新评论树中的点赞状态
|
return {
|
||||||
const updateLikeInTree = (c: Comment): Comment => {
|
...c,
|
||||||
if (c.id === id) {
|
is_liked: !c.is_liked,
|
||||||
return { ...c, is_liked: newIsLiked, likes_count: newLikesCount };
|
likes_count: c.is_liked ? c.likes_count - 1 : c.likes_count + 1
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (c.replies?.length) {
|
// 如果有回复,递归查找
|
||||||
return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) };
|
if (c.replies && c.replies.length > 0) {
|
||||||
|
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 {
|
||||||
const success = newIsLiked
|
if (oldIsLiked) {
|
||||||
? await commentService.likeComment(id)
|
await commentService.unlikeComment(comment.id);
|
||||||
: await commentService.unlikeComment(id);
|
} else {
|
||||||
|
await commentService.likeComment(comment.id);
|
||||||
if (!success) {
|
|
||||||
throw new Error('点赞操作失败');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('评论点赞操作失败:', error);
|
console.error('评论点赞操作失败:', error);
|
||||||
// 回滚状态
|
// 失败时回滚状态
|
||||||
const rollbackLikeInTree = (c: Comment): Comment => {
|
const rollbackCommentLike = (c: Comment): Comment => {
|
||||||
if (c.id === id) {
|
if (c.id === comment.id) {
|
||||||
return { ...c, is_liked, likes_count };
|
return {
|
||||||
|
...c,
|
||||||
|
is_liked: oldIsLiked,
|
||||||
|
likes_count: oldLikesCount
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (c.replies?.length) {
|
if (c.replies && c.replies.length > 0) {
|
||||||
return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) };
|
return {
|
||||||
|
...c,
|
||||||
|
replies: c.replies.map(r => rollbackCommentLike(r))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
setComments(prev => prev.map(c => rollbackLikeInTree(c)));
|
setComments(prev => prev.map(c => rollbackCommentLike(c)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1360,7 +1373,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<CommentItem
|
<CommentItem
|
||||||
comment={item}
|
comment={item}
|
||||||
onLike={handleLikeComment}
|
onLike={() => handleLikeComment(item)}
|
||||||
onReply={() => handleReply(item)}
|
onReply={() => handleReply(item)}
|
||||||
onUserPress={() => authorId && handleUserPress(authorId)}
|
onUserPress={() => authorId && handleUserPress(authorId)}
|
||||||
floorNumber={index + 1}
|
floorNumber={index + 1}
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
/**
|
|
||||||
* 资料详情页:展示资料详细信息
|
|
||||||
*/
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
/**
|
|
||||||
* 学习资料主页:学科分类网格
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
/**
|
|
||||||
* 学科资料列表页:展示某个学科下的所有资料
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* 学习资料屏幕导出
|
|
||||||
*/
|
|
||||||
export { MaterialsScreen } from './MaterialsScreen';
|
|
||||||
export { SubjectMaterialsScreen } from './SubjectMaterialsScreen';
|
|
||||||
export { MaterialDetailScreen } from './MaterialDetailScreen';
|
|
||||||
@@ -404,7 +404,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
source={senderInfo.avatar}
|
source={senderInfo.avatar}
|
||||||
size={40}
|
size={36}
|
||||||
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={40}
|
size={36}
|
||||||
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={40}
|
size={36}
|
||||||
name={currentUser?.nickname || ''}
|
name={currentUser?.nickname || ''}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -847,11 +847,11 @@ function createSegmentStyles(colors: AppColors) {
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 文本 - 适配暗色模式
|
// 文本 - Telegram风格:统一深色字体
|
||||||
textContent: {
|
textContent: {
|
||||||
fontSize: 17.5,
|
fontSize: 16,
|
||||||
lineHeight: 25,
|
lineHeight: 23,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.2,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
textMe: {
|
textMe: {
|
||||||
@@ -861,22 +861,27 @@ function createSegmentStyles(colors: AppColors) {
|
|||||||
color: colors.chat.textPrimary,
|
color: colors.chat.textPrimary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// @提及 - 微信/QQ 风格:更精致的高亮
|
// @提及 - Telegram风格:蓝色高亮
|
||||||
atText: {
|
atText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 22,
|
lineHeight: 23,
|
||||||
fontWeight: '500',
|
fontWeight: '600',
|
||||||
|
paddingHorizontal: 2,
|
||||||
},
|
},
|
||||||
atTextMe: {
|
atTextMe: {
|
||||||
color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰
|
color: '#4A88C7',
|
||||||
|
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.15)',
|
backgroundColor: 'rgba(74, 136, 199, 0.2)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
paddingHorizontal: 3,
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片 - QQ风格:保持原始宽高比
|
// 图片 - QQ风格:保持原始宽高比
|
||||||
|
|||||||
@@ -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: 14,
|
fontSize: 13,
|
||||||
color: colors.chat.textSecondary, // 使用主题次要文字颜色
|
color: colors.chat.link,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 消息气泡 - 微信风格:更小的圆角 + 更紧凑的内边距
|
// 消息气泡 - 外层极轻阴影(纯文字气泡更接近扁平 IM)
|
||||||
messageBubbleOuter: {
|
messageBubbleOuter: {
|
||||||
borderRadius: 12,
|
borderRadius: 16,
|
||||||
minWidth: 44,
|
minWidth: 60,
|
||||||
shadowColor: colors.chat.shadow,
|
shadowColor: colors.chat.shadow,
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 0.5 },
|
||||||
shadowOpacity: 0.06,
|
shadowOpacity: 0.04,
|
||||||
shadowRadius: 2,
|
shadowRadius: 1,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
myBubbleOuter: {
|
myBubbleOuter: {
|
||||||
borderBottomRightRadius: 4, // 微信的小尾巴更尖
|
borderBottomRightRadius: 4,
|
||||||
},
|
},
|
||||||
theirBubbleOuter: {
|
theirBubbleOuter: {
|
||||||
borderTopLeftRadius: 4,
|
borderTopLeftRadius: 4,
|
||||||
},
|
},
|
||||||
// 内层:更紧凑的微信风格内边距
|
// 内层:背景 + 裁剪,防止回复条/@ 高亮在圆角外露出颜色
|
||||||
messageBubbleInner: {
|
messageBubbleInner: {
|
||||||
borderRadius: 12,
|
borderRadius: 16,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
paddingHorizontal: 10, // 微信:更窄的左右边距
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 7, // 微信:更紧的上下边距
|
paddingVertical: spacing.sm + 4,
|
||||||
minWidth: 44,
|
minWidth: 60,
|
||||||
},
|
},
|
||||||
myBubbleInner: {
|
myBubbleInner: {
|
||||||
backgroundColor: colors.chat.bubbleOutgoing,
|
backgroundColor: colors.chat.replyTint,
|
||||||
borderBottomRightRadius: 4,
|
borderBottomRightRadius: 4,
|
||||||
},
|
},
|
||||||
theirBubbleInner: {
|
theirBubbleInner: {
|
||||||
backgroundColor: colors.chat.bubbleIncoming,
|
backgroundColor: colors.chat.card,
|
||||||
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: 18,
|
fontSize: 16,
|
||||||
lineHeight: 25,
|
lineHeight: 23,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.2,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
theirBubbleText: {
|
theirBubbleText: {
|
||||||
color: colors.chat.textPrimary, // 使用主题文字颜色
|
color: colors.chat.textPrimary, // 主文案
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
lineHeight: 25,
|
lineHeight: 23,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.2,
|
||||||
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.12,
|
shadowOpacity: 0.15,
|
||||||
shadowRadius: 6,
|
shadowRadius: 6,
|
||||||
elevation: 4,
|
elevation: 5,
|
||||||
},
|
},
|
||||||
myImageBubble: {
|
myImageBubble: {
|
||||||
borderBottomRightRadius: 8,
|
borderBottomRightRadius: 6, // 右下角尖,箭头在右下
|
||||||
},
|
},
|
||||||
theirImageBubble: {
|
theirImageBubble: {
|
||||||
borderTopLeftRadius: 8,
|
borderTopLeftRadius: 6, // 左上角尖,箭头在左上
|
||||||
},
|
},
|
||||||
messageImage: {
|
messageImage: {
|
||||||
width: 220,
|
width: 220,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
borderRadius,
|
borderRadius,
|
||||||
useThemePreference,
|
useThemePreference,
|
||||||
useSetThemePreference,
|
useSetThemePreference,
|
||||||
useThemeStore,
|
|
||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
@@ -205,41 +204,29 @@ 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');
|
{ text: '取消', style: 'cancel' },
|
||||||
const systemScheme = Appearance?.getColorScheme?.() || 'undefined';
|
{
|
||||||
const resolvedScheme = useThemeStore.getState().resolvedScheme;
|
text: '跟随系统',
|
||||||
const storedPref = useThemeStore.getState().preference;
|
onPress: () => {
|
||||||
const storedSystem = useThemeStore.getState().systemScheme;
|
void setThemePreference('system');
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
'主题模式',
|
|
||||||
`选择应用界面明暗\n\n[调试信息]\n系统主题: ${systemScheme}\n存储偏好: ${storedPref}\n存储系统: ${storedSystem}\n实际应用: ${resolvedScheme}`,
|
|
||||||
[
|
|
||||||
{ text: '取消', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: '跟随系统',
|
|
||||||
onPress: () => {
|
|
||||||
void setThemePreference('system');
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
text: '浅色',
|
{
|
||||||
onPress: () => {
|
text: '浅色',
|
||||||
void setThemePreference('light');
|
onPress: () => {
|
||||||
},
|
void setThemePreference('light');
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
text: '深色',
|
{
|
||||||
onPress: () => {
|
text: '深色',
|
||||||
void setThemePreference('dark');
|
onPress: () => {
|
||||||
},
|
void setThemePreference('dark');
|
||||||
},
|
},
|
||||||
]
|
},
|
||||||
);
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
case 'edit_profile':
|
case 'edit_profile':
|
||||||
router.push(hrefs.hrefProfileEdit());
|
router.push(hrefs.hrefProfileEdit());
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
@@ -78,13 +78,7 @@ 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,
|
||||||
@@ -92,20 +86,16 @@ 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,
|
||||||
* GET /api/v1/groups/:id/members
|
page = 1,
|
||||||
*/
|
pageSize = 50
|
||||||
async getMembers(groupId: string, page = 1, pageSize = 50): Promise<GroupMemberListResponse> {
|
): Promise<GroupMemberListResponse> {
|
||||||
const response = await api.get<GroupMemberListResponse>(
|
const response = await api.get<GroupMemberListResponse>(
|
||||||
`/groups/${encodeURIComponent(groupId)}/members`,
|
`/groups/${encodeURIComponent(groupId)}/members`,
|
||||||
{
|
{
|
||||||
@@ -116,6 +106,8 @@ class GroupService {
|
|||||||
return normalizeMemberListResponse(response.data);
|
return normalizeMemberListResponse(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 群组管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建群组
|
* 创建群组
|
||||||
* POST /api/v1/groups
|
* POST /api/v1/groups
|
||||||
@@ -125,6 +117,22 @@ 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
|
||||||
@@ -203,6 +211,14 @@ 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
|
||||||
|
|||||||
@@ -110,7 +110,3 @@ 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';
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
saveConversationCache,
|
saveConversationCache,
|
||||||
saveConversationsWithRelatedCache,
|
saveConversationsWithRelatedCache,
|
||||||
updateConversationCacheUnreadCount,
|
updateConversationCacheUnreadCount,
|
||||||
saveUsersCache,
|
|
||||||
} from './database';
|
} from './database';
|
||||||
|
|
||||||
/** 远端会话列表分页(offset / cursor)统一结果:拉取成功后均已执行本地缓存同步 */
|
/** 远端会话列表分页(offset / cursor)统一结果:拉取成功后均已执行本地缓存同步 */
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
/**
|
|
||||||
* 群组列表数据源抽象:游标、偏移分页实现同一契约,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);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* GroupManager - 群组管理核心模块
|
|
||||||
*
|
|
||||||
* 架构特点:
|
|
||||||
* - 使用 CachedManager 基类,消除重复的缓存/去重逻辑
|
|
||||||
* - 支持 Sources 模式进行分页加载
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GroupListResponse,
|
GroupListResponse,
|
||||||
GroupMemberListResponse,
|
GroupMemberListResponse,
|
||||||
@@ -22,19 +14,7 @@ import {
|
|||||||
saveGroupsCache,
|
saveGroupsCache,
|
||||||
saveUsersCache,
|
saveUsersCache,
|
||||||
} from '../services/database';
|
} from '../services/database';
|
||||||
import { CacheEvent } from './cacheBus';
|
import { CacheBus, 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[];
|
||||||
@@ -48,18 +28,20 @@ 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;
|
||||||
|
|
||||||
// ==================== Manager 实现 ====================
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
class GroupManager extends CachedManager<GroupManagerSnapshot, GroupManagerEvent> {
|
class GroupManager extends CacheBus<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 {
|
||||||
@@ -68,48 +50,67 @@ class GroupManager extends CachedManager<GroupManagerSnapshot, GroupManagerEvent
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 群组列表相关 ====================
|
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;
|
||||||
async getGroups(page = 1, pageSize = GROUP_LIST_PAGE_SIZE, forceRefresh = false): Promise<GroupListResponse> {
|
const request = fetcher().finally(() => {
|
||||||
const key = `list:${page}:${pageSize}`;
|
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.hasValidCache(key)) {
|
if (page === 1 && !forceRefresh && this.groupsListCache && !this.isExpired(this.groupsListCache)) {
|
||||||
const groups = this.getFromCache<GroupResponse[]>(key)!;
|
return {
|
||||||
return this.buildGroupListResponse(groups, pageSize);
|
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)) {
|
||||||
if (page === 1 && !forceRefresh && this.hasExpiredCache(key)) {
|
|
||||||
this.refreshGroupsInBackground(page, pageSize);
|
this.refreshGroupsInBackground(page, pageSize);
|
||||||
const groups = this.getFromCache<GroupResponse[]>(key)!;
|
return {
|
||||||
return this.buildGroupListResponse(groups, pageSize);
|
list: this.groupsListCache.data,
|
||||||
|
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.setCache(key, localGroups, GROUP_TTL);
|
this.groupsListCache = { data: localGroups, timestamp: Date.now(), ttl: 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 this.buildGroupListResponse(localGroups, pageSize);
|
return {
|
||||||
|
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.getGroups(page, pageSize);
|
const response = await groupService.fetchGroupsFromApi(page, pageSize);
|
||||||
const groups = response.list || [];
|
const groups = response.list || [];
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
this.setCache(key, groups, GROUP_TTL);
|
this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL };
|
||||||
}
|
}
|
||||||
await saveGroupsCache(groups).catch(() => {});
|
await saveGroupsCache(groups).catch(() => {});
|
||||||
this.notify({
|
this.notify({
|
||||||
@@ -121,23 +122,12 @@ class GroupManager extends CachedManager<GroupManagerSnapshot, GroupManagerEvent
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildGroupListResponse(groups: GroupResponse[], pageSize: number): GroupListResponse {
|
private refreshGroupsInBackground(page = 1, pageSize = 20): void {
|
||||||
return {
|
this.dedupe(`groups:list:bg:${page}:${pageSize}`, async () => {
|
||||||
list: groups,
|
const response = await groupService.fetchGroupsFromApi(page, pageSize);
|
||||||
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.setCache(key, groups, GROUP_TTL);
|
this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL };
|
||||||
saveGroupsCache(groups).catch(() => {});
|
saveGroupsCache(groups).catch(() => {});
|
||||||
}
|
}
|
||||||
this.notify({
|
this.notify({
|
||||||
@@ -155,58 +145,39 @@ class GroupManager extends CachedManager<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 cached = this.groupDetailCache.get(groupId);
|
const id = 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) {
|
||||||
if (!forceRefresh && cached && isCacheExpired(cached) && cached.data) {
|
this.refreshGroupInBackground(id);
|
||||||
this.refreshGroupInBackground(groupId);
|
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试本地缓存
|
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
const localGroup = await getGroupCache(groupId);
|
const localGroup = await getGroupCache(id);
|
||||||
if (localGroup) {
|
if (localGroup) {
|
||||||
this.groupDetailCache.set(groupId, createCacheEntry(localGroup, GROUP_TTL));
|
this.groupDetailCache.set(id, { data: localGroup, timestamp: Date.now(), ttl: GROUP_TTL });
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { groupId, group: localGroup },
|
payload: { groupId: id, group: localGroup },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
this.refreshGroupInBackground(groupId);
|
this.refreshGroupInBackground(id);
|
||||||
return localGroup;
|
return localGroup;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发起请求
|
return this.dedupe(`groups:detail:${id}`, async () => {
|
||||||
return this.dedupe(`groups:detail:${groupId}`, async () => {
|
const group = await groupService.fetchGroupFromApi(id);
|
||||||
const group = await groupService.getGroup(groupId);
|
this.groupDetailCache.set(id, { data: group, timestamp: Date.now(), ttl: GROUP_TTL });
|
||||||
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, group },
|
payload: { groupId: id, group },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
return group;
|
return group;
|
||||||
@@ -215,8 +186,8 @@ class GroupManager extends CachedManager<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.getGroup(groupId);
|
const group = await groupService.fetchGroupFromApi(groupId);
|
||||||
this.groupDetailCache.set(groupId, createCacheEntry(group, GROUP_TTL));
|
this.groupDetailCache.set(groupId, { data: group, timestamp: Date.now(), ttl: GROUP_TTL });
|
||||||
saveGroupCache(group).catch(() => {});
|
saveGroupCache(group).catch(() => {});
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
@@ -233,53 +204,61 @@ class GroupManager extends CachedManager<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 {
|
||||||
if (!forceRefresh && cached && !isCacheExpired(cached)) {
|
list: cached.data,
|
||||||
return this.buildMemberListResponse(cached.data, page, pageSize);
|
total: cached.data.length,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
total_pages: 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过期缓存:后台刷新
|
if (!forceRefresh && cached && this.isExpired(cached)) {
|
||||||
if (!forceRefresh && cached && isCacheExpired(cached)) {
|
this.refreshMembersInBackground(id, page, pageSize);
|
||||||
this.refreshMembersInBackground(groupId, page, pageSize);
|
return {
|
||||||
return this.buildMemberListResponse(cached.data, page, pageSize);
|
list: cached.data,
|
||||||
|
total: cached.data.length,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
total_pages: 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一页:尝试本地缓存
|
|
||||||
if (page === 1 && !forceRefresh) {
|
if (page === 1 && !forceRefresh) {
|
||||||
const localMembers = await getGroupMembersCache(groupId);
|
const localMembers = await getGroupMembersCache(id);
|
||||||
if (localMembers.length > 0) {
|
if (localMembers.length > 0) {
|
||||||
this.groupMembersCache.set(key, createCacheEntry(localMembers, MEMBERS_TTL));
|
this.groupMembersCache.set(key, {
|
||||||
|
data: localMembers,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
ttl: MEMBERS_TTL,
|
||||||
|
});
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { groupId, members: localMembers },
|
payload: { groupId: id, members: localMembers },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
this.refreshMembersInBackground(groupId, page, pageSize);
|
this.refreshMembersInBackground(id, page, pageSize);
|
||||||
return this.buildMemberListResponse(localMembers, page, pageSize);
|
return {
|
||||||
|
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.getMembers(groupId, page, pageSize);
|
const response = await groupService.fetchMembersFromApi(id, page, pageSize);
|
||||||
const members = response.list || [];
|
const members = response.list || [];
|
||||||
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) {
|
||||||
await saveGroupMembersCache(groupId, members).catch(() => {});
|
await saveGroupMembersCache(id, members).catch(() => {});
|
||||||
}
|
}
|
||||||
await saveUsersCache(
|
await saveUsersCache(
|
||||||
members
|
members
|
||||||
@@ -288,37 +267,19 @@ class GroupManager extends CachedManager<GroupManagerSnapshot, GroupManagerEvent
|
|||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { groupId, members },
|
payload: { groupId: id, members },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildMemberListResponse(
|
private refreshMembersInBackground(groupId: string, page = 1, pageSize = 50): void {
|
||||||
members: GroupMemberResponse[],
|
this.dedupe(`groups:members:bg:${groupId}:${page}:${pageSize}`, async () => {
|
||||||
page: number,
|
const response = await groupService.fetchMembersFromApi(groupId, page, pageSize);
|
||||||
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 || [];
|
||||||
this.groupMembersCache.set(key, createCacheEntry(members, MEMBERS_TTL));
|
const key = `${groupId}:${page}:${pageSize}`;
|
||||||
|
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(() => {});
|
||||||
}
|
}
|
||||||
@@ -342,46 +303,21 @@ class GroupManager extends CachedManager<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.clearCache();
|
this.groupsListCache = null;
|
||||||
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(`members:${groupId}:`)) {
|
if (key.startsWith(`${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();
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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 })),
|
|
||||||
}));
|
|
||||||
@@ -44,9 +44,6 @@ export { userManager } from './userManager';
|
|||||||
export {
|
export {
|
||||||
useHomeTabBarVisibilityStore,
|
useHomeTabBarVisibilityStore,
|
||||||
} from './homeTabBarVisibilityStore';
|
} from './homeTabBarVisibilityStore';
|
||||||
export {
|
|
||||||
useHomeTabPressStore,
|
|
||||||
} from './homeTabPressStore';
|
|
||||||
export {
|
export {
|
||||||
useAppColors,
|
useAppColors,
|
||||||
useThemePreference,
|
useThemePreference,
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* 帖子列表数据源抽象:游标、偏移分页实现同一契约,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);
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,6 @@
|
|||||||
/**
|
|
||||||
* PostManager - 帖子管理核心模块
|
|
||||||
*
|
|
||||||
* 架构特点:
|
|
||||||
* - 使用 CachedManager 基类,消除重复的缓存/去重逻辑
|
|
||||||
* - 支持 Sources 模式进行分页加载
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Post } from '../types';
|
import { Post } from '../types';
|
||||||
import { postService } from '../services/postService';
|
import { postService } from '../services/postService';
|
||||||
import { CacheEvent } from './cacheBus';
|
import { CacheBus, 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;
|
||||||
@@ -41,57 +23,66 @@ 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;
|
||||||
|
|
||||||
// ==================== Manager 实现 ====================
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
class PostManager extends CachedManager<PostManagerSnapshot, PostManagerEvent> {
|
class PostManager extends CacheBus<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.memoryCache.keys()],
|
listKeys: [...this.listCache.keys()],
|
||||||
detailKeys: [...this.detailCache.keys()],
|
detailKeys: [...this.detailCache.keys()],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 列表相关 ====================
|
private isExpired(entry: CacheEntry<any>): boolean {
|
||||||
|
return Date.now() - entry.timestamp > entry.ttl;
|
||||||
private listKey(type: string, page: number, pageSize: number): string {
|
}
|
||||||
return `list:${type}:${page}:${pageSize}`;
|
|
||||||
|
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 {
|
||||||
|
return `${type}:${page}:${pageSize}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取帖子列表(传统分页模式)
|
|
||||||
*/
|
|
||||||
async getPosts(
|
async getPosts(
|
||||||
type: PostListTab = 'hot',
|
type = 'hot',
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = POST_LIST_PAGE_SIZE,
|
pageSize = 20,
|
||||||
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)) {
|
||||||
if (!forceRefresh && this.hasValidCache(key)) {
|
return cached.data;
|
||||||
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 this.getFromCache<Post[]>(key)!;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无缓存:发起请求
|
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.setCache(key, posts, LIST_TTL);
|
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL });
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'list_updated',
|
type: 'list_updated',
|
||||||
payload: { key, posts },
|
payload: { key, posts },
|
||||||
@@ -101,16 +92,11 @@ class PostManager extends CachedManager<PostManagerSnapshot, PostManagerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshPostsInBackground(
|
private refreshPostsInBackground(key: string, type: string, page: number, pageSize: number): void {
|
||||||
key: string,
|
this.dedupe(`posts:list:bg:${key}`, async () => {
|
||||||
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.setCache(key, posts, LIST_TTL);
|
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL });
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'list_updated',
|
type: 'list_updated',
|
||||||
payload: { key, posts },
|
payload: { key, posts },
|
||||||
@@ -126,39 +112,20 @@ class PostManager extends CachedManager<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, createCacheEntry(post, DETAIL_TTL));
|
this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL });
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { postId, post },
|
payload: { postId, post },
|
||||||
@@ -171,7 +138,7 @@ class PostManager extends CachedManager<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, createCacheEntry(post, DETAIL_TTL));
|
this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL });
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { postId, post },
|
payload: { postId, post },
|
||||||
@@ -187,28 +154,21 @@ class PostManager extends CachedManager<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.clearCache();
|
this.listCache.clear();
|
||||||
this.detailCache.clear();
|
this.detailCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除所有数据
|
|
||||||
*/
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.clearCache();
|
this.listCache.clear();
|
||||||
this.detailCache.clear();
|
this.detailCache.clear();
|
||||||
this.clearPendingRequests();
|
this.pendingRequests.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const postManager = new PostManager();
|
export const postManager = new PostManager();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Appearance, AppState, AppStateStatus } from 'react-native';
|
import { useColorScheme } from 'react-native';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } 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,19 +46,13 @@ 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: initialSystemScheme,
|
systemScheme: initialSystem,
|
||||||
hydrated: false,
|
hydrated: false,
|
||||||
...buildState('system', initialSystemScheme),
|
...buildState('system', initialSystem),
|
||||||
|
|
||||||
setSystemScheme: (systemScheme) => {
|
setSystemScheme: (systemScheme) => {
|
||||||
const { preference, systemScheme: cur } = get();
|
const { preference, systemScheme: cur } = get();
|
||||||
@@ -92,11 +86,10 @@ export const useThemeStore = create<ThemeState>((set, get) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
const systemScheme = getSystemScheme();
|
const { systemScheme } = get();
|
||||||
set({
|
set({
|
||||||
preference,
|
preference,
|
||||||
hydrated: true,
|
hydrated: true,
|
||||||
systemScheme,
|
|
||||||
...buildState(preference, systemScheme),
|
...buildState(preference, systemScheme),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -104,42 +97,17 @@ 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(() => {
|
||||||
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
|
setSystemScheme(system === 'dark' ? 'dark' : 'light');
|
||||||
const newScheme = colorScheme === 'dark' ? 'dark' : 'light';
|
}, [system, setSystemScheme]);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -163,6 +131,3 @@ export function useSetThemePreference() {
|
|||||||
export function usePaperThemeFromStore() {
|
export function usePaperThemeFromStore() {
|
||||||
return useThemeStore((s) => s.paperTheme);
|
return useThemeStore((s) => s.paperTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出调试函数
|
|
||||||
export { getSystemScheme };
|
|
||||||
@@ -76,8 +76,8 @@ export const lightColors = {
|
|||||||
link: '#4A88C7',
|
link: '#4A88C7',
|
||||||
success: '#34C759',
|
success: '#34C759',
|
||||||
danger: '#FF3B30',
|
danger: '#FF3B30',
|
||||||
bubbleOutgoing: '#7CB9FF', // 柔和的淡蓝色
|
bubbleOutgoing: '#E8E8E8',
|
||||||
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: '#2E5A8C', // 暗色模式:深蓝气泡
|
bubbleOutgoing: '#3A3A3C',
|
||||||
bubbleIncoming: '#2C2C2E', // 暗色模式:灰色气泡
|
bubbleIncoming: '#2C2C2E',
|
||||||
replyTint: '#1A3A52',
|
replyTint: '#1A3A52',
|
||||||
replyTintActive: '#224060',
|
replyTintActive: '#224060',
|
||||||
replyBorder: '#4A88C7',
|
replyBorder: '#4A88C7',
|
||||||
|
|||||||
@@ -2,39 +2,6 @@
|
|||||||
* 与明暗无关的设计 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,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// 导出DTO类型作为主要类型
|
// 导出DTO类型作为主要类型
|
||||||
export * from './dto';
|
export * from './dto';
|
||||||
export * from './schedule';
|
export * from './schedule';
|
||||||
export * from './material';
|
|
||||||
|
|
||||||
// 兼容旧类型(用于内部组件)
|
// 兼容旧类型(用于内部组件)
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* 学习资料类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 文件类型枚举
|
|
||||||
export type MaterialFileType = 'pdf' | 'word' | 'ppt';
|
|
||||||
|
|
||||||
// 学科分类
|
|
||||||
export interface MaterialSubject {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: string; // MaterialCommunityIcons name
|
|
||||||
color: string; // 主题色
|
|
||||||
description?: string;
|
|
||||||
sort_order: number;
|
|
||||||
is_active: boolean;
|
|
||||||
material_count: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件资料
|
|
||||||
export interface MaterialFile {
|
|
||||||
id: string;
|
|
||||||
subject_id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
file_type: MaterialFileType;
|
|
||||||
file_size: number; // 字节数
|
|
||||||
file_url: string;
|
|
||||||
file_name: string;
|
|
||||||
download_count: number;
|
|
||||||
author_id?: string;
|
|
||||||
author_name?: string;
|
|
||||||
tags?: string[];
|
|
||||||
status: 'active' | 'inactive';
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
subject?: MaterialSubject;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 学科资料列表响应
|
|
||||||
export interface SubjectMaterialsResponse {
|
|
||||||
subject: MaterialSubject;
|
|
||||||
materials: MaterialFile[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索结果响应
|
|
||||||
export interface SearchMaterialsResponse {
|
|
||||||
materials: MaterialFile[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
page_size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件类型图标映射
|
|
||||||
export const MATERIAL_FILE_TYPE_ICONS: Record<MaterialFileType, string> = {
|
|
||||||
pdf: 'file-pdf-box',
|
|
||||||
word: 'file-word-box',
|
|
||||||
ppt: 'file-powerpoint-box',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 文件类型颜色映射
|
|
||||||
export const MATERIAL_FILE_TYPE_COLORS: Record<MaterialFileType, string> = {
|
|
||||||
pdf: '#E53935',
|
|
||||||
word: '#1E88E5',
|
|
||||||
ppt: '#FF9800',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 文件类型名称映射
|
|
||||||
export const MATERIAL_FILE_TYPE_NAMES: Record<MaterialFileType, string> = {
|
|
||||||
pdf: 'PDF',
|
|
||||||
word: 'Word',
|
|
||||||
ppt: 'PPT',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 文件大小格式化
|
|
||||||
export function formatFileSize(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 学科颜色预设
|
|
||||||
export const SUBJECT_COLORS = [
|
|
||||||
'#FF6B6B', // 红色
|
|
||||||
'#4ECDC4', // 青色
|
|
||||||
'#45B7D1', // 蓝色
|
|
||||||
'#96CEB4', // 绿色
|
|
||||||
'#FFEAA7', // 黄色
|
|
||||||
'#DDA0DD', // 紫色
|
|
||||||
'#F39C12', // 橙色
|
|
||||||
'#3498DB', // 深蓝
|
|
||||||
'#27AE60', // 深绿
|
|
||||||
'#9B59B6', // 深紫
|
|
||||||
'#1ABC9C', // 青绿
|
|
||||||
'#E74C3C', // 深红
|
|
||||||
];
|
|
||||||
Reference in New Issue
Block a user