feat(navigation): add schedule tab and stack navigator
Add new ScheduleTab to main navigation with ScheduleStackNavigator for managing course schedule screens. Includes CourseDetailScreen with modal presentation. Also exports scheduleService and related types for the new schedule feature. fix(message): add group_id parameter to invite and request handlers Pass group_id from extra_data when calling respondInvite and reviewJoinRequest APIs, as the backend now requires this parameter. refactor(message): extract mergeMessagesById helper method Centralize message merging logic into a reusable private method to avoid duplication and ensure consistent message deduplication behavior.
This commit is contained in:
@@ -40,12 +40,14 @@ import type {
|
||||
MainTabParamList,
|
||||
HomeStackParamList,
|
||||
MessageStackParamList,
|
||||
ScheduleStackParamList,
|
||||
ProfileStackParamList,
|
||||
AuthStackParamList,
|
||||
} from './types';
|
||||
|
||||
// ==================== 导入屏幕组件 ====================
|
||||
import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home';
|
||||
import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule';
|
||||
import {
|
||||
MessageListScreen,
|
||||
ChatScreen,
|
||||
@@ -69,6 +71,7 @@ const RootStack = createNativeStackNavigator<RootStackParamList>();
|
||||
const AuthStack = createNativeStackNavigator<AuthStackParamList>();
|
||||
const HomeStack = createNativeStackNavigator<HomeStackParamList>();
|
||||
const MessageStack = createNativeStackNavigator<MessageStackParamList>();
|
||||
const ScheduleStack = createNativeStackNavigator<ScheduleStackParamList>();
|
||||
const ProfileStack = createNativeStackNavigator<ProfileStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
@@ -79,7 +82,7 @@ const SIDEBAR_COLLAPSED_WIDTH = 72;
|
||||
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
||||
|
||||
// ==================== 导航项类型 ====================
|
||||
type TabName = 'HomeTab' | 'MessageTab' | 'ProfileTab';
|
||||
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
||||
|
||||
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>['name'];
|
||||
|
||||
@@ -306,6 +309,7 @@ function Sidebar({
|
||||
const navItems: NavItem[] = [
|
||||
{ name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' },
|
||||
{ name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline', badge: unreadCount },
|
||||
{ name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' },
|
||||
{ name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' },
|
||||
];
|
||||
|
||||
@@ -418,6 +422,37 @@ function Text({ style, children, numberOfLines }: { style?: any; children: React
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 课程表Stack导航 ====================
|
||||
function ScheduleStackNavigator() {
|
||||
return (
|
||||
<ScheduleStack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<ScheduleStack.Screen
|
||||
name="Schedule"
|
||||
component={ScheduleScreen}
|
||||
options={{
|
||||
title: '课表',
|
||||
}}
|
||||
/>
|
||||
<ScheduleStack.Screen
|
||||
name="CourseDetail"
|
||||
component={CourseDetailScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'transparentModal',
|
||||
animation: 'fade',
|
||||
contentStyle: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ScheduleStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 响应式 Tab Navigator ====================
|
||||
interface ResponsiveTabNavigatorProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -441,7 +476,7 @@ function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) {
|
||||
useEffect(() => {
|
||||
const targetTab = route.params?.screen;
|
||||
if (!targetTab) return;
|
||||
if ((targetTab === 'HomeTab' || targetTab === 'MessageTab' || targetTab === 'ProfileTab') && targetTab !== activeTab) {
|
||||
if ((targetTab === 'HomeTab' || targetTab === 'MessageTab' || targetTab === 'ScheduleTab' || targetTab === 'ProfileTab') && targetTab !== activeTab) {
|
||||
setActiveTab(targetTab);
|
||||
}
|
||||
}, [route.params?.screen, activeTab]);
|
||||
@@ -482,6 +517,8 @@ function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) {
|
||||
return <HomeStackNavigator />;
|
||||
case 'MessageTab':
|
||||
return <MessageStackNavigator />;
|
||||
case 'ScheduleTab':
|
||||
return <ScheduleStackNavigator />;
|
||||
case 'ProfileTab':
|
||||
return <ProfileStackNavigator />;
|
||||
default:
|
||||
@@ -609,6 +646,22 @@ function MainTabNavigator() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="ScheduleTab"
|
||||
component={ScheduleStackNavigator}
|
||||
options={{
|
||||
tabBarLabel: '课表',
|
||||
tabBarIcon: ({ color, size, focused }) => (
|
||||
<View style={[styles.tabIconContainer, focused && styles.tabIconActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'calendar-today' : 'calendar-today'}
|
||||
size={focused ? 26 : 24}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="ProfileTab"
|
||||
component={ProfileStackNavigator}
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
import { NavigatorScreenParams } from '@react-navigation/native';
|
||||
import type { SystemMessageResponse } from '../types/dto';
|
||||
import type { Course } from '../types/schedule';
|
||||
|
||||
// ==================== 主Tab导航参数 ====================
|
||||
export type MainTabParamList = {
|
||||
HomeTab: NavigatorScreenParams<HomeStackParamList>;
|
||||
MessageTab: NavigatorScreenParams<MessageStackParamList>;
|
||||
ScheduleTab: NavigatorScreenParams<ScheduleStackParamList>;
|
||||
ProfileTab: NavigatorScreenParams<ProfileStackParamList>;
|
||||
};
|
||||
|
||||
@@ -59,6 +61,12 @@ export type ProfileStackParamList = {
|
||||
BlockedUsers: undefined;
|
||||
};
|
||||
|
||||
// ==================== 课程表Stack ====================
|
||||
export type ScheduleStackParamList = {
|
||||
Schedule: undefined;
|
||||
CourseDetail: { course: Course; relatedCourses: Course[] };
|
||||
};
|
||||
|
||||
// ==================== 认证Stack ====================
|
||||
export type AuthStackParamList = {
|
||||
Login: undefined;
|
||||
|
||||
@@ -65,13 +65,18 @@ const GroupInviteDetailScreen: React.FC = () => {
|
||||
|
||||
const handleDecision = async (approve: boolean) => {
|
||||
const flag = extra_data?.flag;
|
||||
const groupId = extra_data?.group_id;
|
||||
if (!flag) {
|
||||
Alert.alert('提示', '缺少邀请标识,无法处理');
|
||||
return;
|
||||
}
|
||||
if (!groupId) {
|
||||
Alert.alert('提示', '缺少群组标识,无法处理');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await groupService.respondInvite({ flag, approve });
|
||||
await groupService.respondInvite(groupId, { flag, approve });
|
||||
Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
]);
|
||||
|
||||
@@ -77,20 +77,25 @@ const GroupRequestDetailScreen: React.FC = () => {
|
||||
|
||||
const handleDecision = async (approve: boolean) => {
|
||||
const flag = extra_data?.flag;
|
||||
const groupId = extra_data?.group_id;
|
||||
if (!flag) {
|
||||
Alert.alert('提示', '缺少请求标识,无法处理');
|
||||
return;
|
||||
}
|
||||
if (!groupId) {
|
||||
Alert.alert('提示', '缺少群组标识,无法处理');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (message.system_type === 'group_invite') {
|
||||
await groupService.respondInvite({
|
||||
await groupService.respondInvite(groupId, {
|
||||
flag,
|
||||
approve,
|
||||
});
|
||||
} else {
|
||||
await groupService.reviewJoinRequest({
|
||||
await groupService.reviewJoinRequest(groupId, {
|
||||
flag,
|
||||
approve,
|
||||
});
|
||||
|
||||
239
src/screens/schedule/CourseDetailScreen.tsx
Normal file
239
src/screens/schedule/CourseDetailScreen.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import type { ScheduleStackParamList } from '../../navigation/types';
|
||||
import { colors, spacing, borderRadius, fontSizes, shadows } from '../../theme';
|
||||
import { scheduleService } from '../../services/scheduleService';
|
||||
|
||||
type CourseDetailRouteProp = RouteProp<ScheduleStackParamList, 'CourseDetail'>;
|
||||
|
||||
const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
const getMergedSectionIndex = (section: number) => Math.ceil(section / 2);
|
||||
|
||||
const formatWeekRanges = (weeks: number[]) => {
|
||||
if (weeks.length === 0) return '';
|
||||
const sorted = [...weeks].sort((a, b) => a - b);
|
||||
const ranges: string[] = [];
|
||||
let start = sorted[0];
|
||||
let prev = sorted[0];
|
||||
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const current = sorted[i];
|
||||
if (current === prev + 1) {
|
||||
prev = current;
|
||||
continue;
|
||||
}
|
||||
ranges.push(start === prev ? `${start}` : `${start}-${prev}`);
|
||||
start = current;
|
||||
prev = current;
|
||||
}
|
||||
ranges.push(start === prev ? `${start}` : `${start}-${prev}`);
|
||||
return ranges.join(', ');
|
||||
};
|
||||
|
||||
const CourseDetailScreen: React.FC = () => {
|
||||
const route = useRoute<CourseDetailRouteProp>();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
|
||||
const { course, relatedCourses } = route.params;
|
||||
|
||||
const timeLines = relatedCourses.length > 0 ? relatedCourses : [course];
|
||||
|
||||
const handleDeleteCourse = () => {
|
||||
Alert.alert('删除课程', '确认删除这条课程安排吗?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await scheduleService.deleteCourse(course.id);
|
||||
Alert.alert('删除成功', '课程已删除');
|
||||
navigation.goBack();
|
||||
} catch (error) {
|
||||
console.error('删除课程失败:', error);
|
||||
Alert.alert('删除失败', '删除课程失败,请稍后重试');
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<TouchableOpacity style={styles.mask} activeOpacity={1} onPress={() => navigation.goBack()}>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.card} onPress={e => e.stopPropagation()}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<View style={styles.iconBadge}>
|
||||
<MaterialCommunityIcons name="book-open-page-variant" size={18} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text style={styles.title}>课程详情</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.closeButton} hitSlop={10}>
|
||||
<MaterialCommunityIcons name="close" size={18} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>课程名称</Text>
|
||||
<Text style={styles.value} numberOfLines={2}>{course.name}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>教师</Text>
|
||||
<Text style={styles.value}>{course.teacher || '未填写'}</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.row, styles.rowLast]}>
|
||||
<Text style={styles.label}>上课时间</Text>
|
||||
<View style={styles.timeList}>
|
||||
{timeLines.map((item, index) => (
|
||||
<View key={`${item.id}-${index}`} style={styles.timeItemCard}>
|
||||
<Text style={styles.timeItemMain}>
|
||||
第{formatWeekRanges(item.weeks)}周 · {WEEKDAY_NAMES[item.dayOfWeek]} 第
|
||||
{getMergedSectionIndex(item.startSection)}节
|
||||
</Text>
|
||||
{item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<TouchableOpacity style={styles.deleteButton} onPress={handleDeleteCourse} activeOpacity={0.85}>
|
||||
<MaterialCommunityIcons name="delete-outline" size={16} color={colors.error.main} />
|
||||
<Text style={styles.deleteButtonText}>删除本条课程</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
mask: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.32)',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}AA`,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconBadge: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: borderRadius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
closeButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
row: {
|
||||
marginBottom: spacing.lg,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
rowLast: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
label: {
|
||||
width: 76,
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.secondary,
|
||||
lineHeight: 22,
|
||||
marginTop: 1,
|
||||
},
|
||||
value: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
lineHeight: 24,
|
||||
},
|
||||
timeList: {
|
||||
flex: 1,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
timeItemCard: {
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}20`,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: `${colors.primary.main}08`,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
timeItemMain: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
timeItemSub: {
|
||||
marginTop: spacing.xs,
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actionRow: {
|
||||
marginTop: spacing.lg,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
deleteButton: {
|
||||
height: 38,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.error.main}10`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.error.main}25`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
deleteButtonText: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '700',
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default CourseDetailScreen;
|
||||
1065
src/screens/schedule/ScheduleScreen.tsx
Normal file
1065
src/screens/schedule/ScheduleScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
7
src/screens/schedule/index.ts
Normal file
7
src/screens/schedule/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 课程表模块导出
|
||||
*/
|
||||
|
||||
export { default as ScheduleScreen } from './ScheduleScreen';
|
||||
export { default as CourseDetailScreen } from './CourseDetailScreen';
|
||||
export { default } from './ScheduleScreen';
|
||||
@@ -88,6 +88,10 @@ export type {
|
||||
CreateAnnouncementRequest,
|
||||
} from './groupService';
|
||||
|
||||
// 课表服务
|
||||
export { scheduleService } from './scheduleService';
|
||||
export type { CreateScheduleCourseRequest } from './scheduleService';
|
||||
|
||||
// 后台保活服务
|
||||
export {
|
||||
backgroundService,
|
||||
|
||||
70
src/services/scheduleService.ts
Normal file
70
src/services/scheduleService.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { api } from './api';
|
||||
import { Course } from '../types/schedule';
|
||||
|
||||
interface ScheduleCourseDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
teacher?: string;
|
||||
location?: string;
|
||||
day_of_week: number;
|
||||
start_section: number;
|
||||
end_section: number;
|
||||
weeks: number[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface ScheduleCourseListResponse {
|
||||
list: ScheduleCourseDTO[];
|
||||
}
|
||||
|
||||
interface CreateScheduleCourseRequest {
|
||||
name: string;
|
||||
teacher?: string;
|
||||
location?: string;
|
||||
day_of_week: number;
|
||||
start_section: number;
|
||||
end_section: number;
|
||||
weeks: number[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface CreateScheduleCourseResponse {
|
||||
course: ScheduleCourseDTO;
|
||||
}
|
||||
|
||||
const toCourse = (dto: ScheduleCourseDTO): Course => ({
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
teacher: dto.teacher,
|
||||
location: dto.location,
|
||||
dayOfWeek: dto.day_of_week,
|
||||
startSection: dto.start_section,
|
||||
endSection: dto.end_section,
|
||||
weeks: dto.weeks ?? [],
|
||||
color: dto.color,
|
||||
});
|
||||
|
||||
class ScheduleService {
|
||||
async getCourses(week?: number): Promise<Course[]> {
|
||||
const params = week ? { week } : undefined;
|
||||
const response = await api.get<ScheduleCourseListResponse>('/schedule/courses', params);
|
||||
return response.data.list.map(toCourse);
|
||||
}
|
||||
|
||||
async createCourse(input: CreateScheduleCourseRequest): Promise<Course> {
|
||||
const response = await api.post<CreateScheduleCourseResponse>('/schedule/courses', input);
|
||||
return toCourse(response.data.course);
|
||||
}
|
||||
|
||||
async updateCourse(courseId: string, input: CreateScheduleCourseRequest): Promise<Course> {
|
||||
const response = await api.put<CreateScheduleCourseResponse>(`/schedule/courses/${courseId}`, input);
|
||||
return toCourse(response.data.course);
|
||||
}
|
||||
|
||||
async deleteCourse(courseId: string): Promise<void> {
|
||||
await api.delete(`/schedule/courses/${courseId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const scheduleService = new ScheduleService();
|
||||
export type { CreateScheduleCourseRequest };
|
||||
@@ -231,6 +231,19 @@ class MessageManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 message_id 合并消息并按 seq 排序。
|
||||
* 后者覆盖前者,可用来吸收更完整的同 ID 消息体(例如服务端回包/SSE)。
|
||||
*/
|
||||
private mergeMessagesById(base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] {
|
||||
if (incoming.length === 0) return base;
|
||||
const merged = new Map<string, MessageResponse>();
|
||||
[...base, ...incoming].forEach(msg => {
|
||||
merged.set(String(msg.id), msg);
|
||||
});
|
||||
return Array.from(merged.values()).sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
private updateConversationList() {
|
||||
// 会话排序:置顶优先,再按最后消息时间排序
|
||||
const list = Array.from(this.state.conversations.values()).sort((a, b) => {
|
||||
@@ -637,10 +650,10 @@ class MessageManager {
|
||||
|
||||
// 2. 立即更新内存中的消息列表(关键:确保ChatScreen能立即看到)
|
||||
const existingMessages = this.state.messagesMap.get(normalizedConversationId) || [];
|
||||
const messageExists = existingMessages.some(m => m.id === id);
|
||||
const messageExists = existingMessages.some(m => String(m.id) === String(id));
|
||||
|
||||
if (!messageExists) {
|
||||
const updatedMessages = [...existingMessages, newMessage].sort((a, b) => a.seq - b.seq);
|
||||
const updatedMessages = this.mergeMessagesById(existingMessages, [newMessage]);
|
||||
this.state.messagesMap.set(normalizedConversationId, updatedMessages);
|
||||
|
||||
// 3. 立即通知订阅者(关键:解决竞态条件)
|
||||
@@ -1115,12 +1128,7 @@ class MessageManager {
|
||||
|
||||
try {
|
||||
const mergeMessages = (base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] => {
|
||||
if (incoming.length === 0) return base;
|
||||
const merged = new Map<string, MessageResponse>();
|
||||
[...base, ...incoming].forEach(msg => {
|
||||
merged.set(String(msg.id), msg);
|
||||
});
|
||||
return Array.from(merged.values()).sort((a, b) => a.seq - b.seq);
|
||||
return this.mergeMessagesById(base, incoming);
|
||||
};
|
||||
|
||||
// 1. 先从本地数据库加载(确保立即有数据展示)
|
||||
@@ -1366,7 +1374,7 @@ class MessageManager {
|
||||
|
||||
// 合并到现有消息
|
||||
const existingMessages = this.state.messagesMap.get(conversationId) || [];
|
||||
const mergedMessages = [...formattedMessages, ...existingMessages].sort((a, b) => a.seq - b.seq);
|
||||
const mergedMessages = this.mergeMessagesById(existingMessages, formattedMessages);
|
||||
this.state.messagesMap.set(conversationId, mergedMessages);
|
||||
|
||||
this.notifySubscribers({
|
||||
@@ -1402,7 +1410,7 @@ class MessageManager {
|
||||
|
||||
// 合并消息
|
||||
const existingMessages = this.state.messagesMap.get(conversationId) || [];
|
||||
const mergedMessages = [...serverMessages, ...existingMessages].sort((a, b) => a.seq - b.seq);
|
||||
const mergedMessages = this.mergeMessagesById(existingMessages, serverMessages);
|
||||
this.state.messagesMap.set(conversationId, mergedMessages);
|
||||
|
||||
this.notifySubscribers({
|
||||
@@ -1481,7 +1489,7 @@ class MessageManager {
|
||||
status: 'normal',
|
||||
};
|
||||
|
||||
const updatedMessages = [...existingMessages, newMessage].sort((a, b) => a.seq - b.seq);
|
||||
const updatedMessages = this.mergeMessagesById(existingMessages, [newMessage]);
|
||||
this.state.messagesMap.set(conversationId, updatedMessages);
|
||||
|
||||
// 关键:立即广播消息列表更新,确保 ChatScreen 立刻显示新消息
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
// 导出DTO类型作为主要类型
|
||||
export * from './dto';
|
||||
export * from './schedule';
|
||||
|
||||
// 兼容旧类型(用于内部组件)
|
||||
import type {
|
||||
|
||||
125
src/types/schedule.ts
Normal file
125
src/types/schedule.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 课程表类型定义
|
||||
*/
|
||||
|
||||
// 课程数据接口
|
||||
export interface Course {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
teacher?: string;
|
||||
dayOfWeek: number; // 0-6 周一到周日
|
||||
startSection: number; // 开始节次 (1-12)
|
||||
endSection: number; // 结束节次 (1-12)
|
||||
weeks: number[]; // 上课周数
|
||||
color?: string; // 卡片颜色
|
||||
}
|
||||
|
||||
// 时间段配置
|
||||
export interface TimeSlot {
|
||||
section: number; // 节次
|
||||
startTime: string; // 如 "08:00"
|
||||
endTime: string; // 如 "09:45"
|
||||
}
|
||||
|
||||
// 周配置
|
||||
export interface WeekConfig {
|
||||
currentWeek: number; // 当前周
|
||||
totalWeeks: number; // 总周数 (通常16-20周)
|
||||
semesterStartDate: string; // 学期开始日期
|
||||
}
|
||||
|
||||
// 课程表状态
|
||||
export interface ScheduleState {
|
||||
courses: Course[];
|
||||
currentWeek: number;
|
||||
selectedWeek: number;
|
||||
timeSlots: TimeSlot[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 课程卡片颜色预设
|
||||
export const COURSE_COLORS = [
|
||||
'#FF6B6B',
|
||||
'#FF8E72',
|
||||
'#FFA552',
|
||||
'#FFB347',
|
||||
'#FFD166',
|
||||
'#F6E05E',
|
||||
'#D9ED92',
|
||||
'#B5E48C',
|
||||
'#99D98C',
|
||||
'#76C893',
|
||||
'#52B69A',
|
||||
'#34A0A4',
|
||||
'#4ECDC4',
|
||||
'#2EC4B6',
|
||||
'#45B7D1',
|
||||
'#48CAE4',
|
||||
'#4EA8DE',
|
||||
'#5390D9',
|
||||
'#4361EE',
|
||||
'#3A0CA3',
|
||||
'#5E60CE',
|
||||
'#6930C3',
|
||||
'#7400B8',
|
||||
'#9D4EDD',
|
||||
'#C77DFF',
|
||||
'#DDA0DD',
|
||||
'#E0AAFF',
|
||||
'#F15BB5',
|
||||
'#FF5D8F',
|
||||
'#EF476F',
|
||||
'#E76F51',
|
||||
'#A8DADC',
|
||||
'#8EC5FC',
|
||||
'#98D8C8',
|
||||
'#96CEB4',
|
||||
'#85C1E2',
|
||||
'#70D6FF',
|
||||
'#7BDFF2',
|
||||
'#B2F7EF',
|
||||
'#F7D6E0',
|
||||
];
|
||||
|
||||
// 默认时间段配置 (12节)
|
||||
export const DEFAULT_TIME_SLOTS: TimeSlot[] = [
|
||||
{ section: 1, startTime: '08:00', endTime: '08:45' },
|
||||
{ section: 2, startTime: '08:55', endTime: '09:40' },
|
||||
{ section: 3, startTime: '10:00', endTime: '10:45' },
|
||||
{ section: 4, startTime: '10:55', endTime: '11:40' },
|
||||
{ section: 5, startTime: '14:00', endTime: '14:45' },
|
||||
{ section: 6, startTime: '14:55', endTime: '15:40' },
|
||||
{ section: 7, startTime: '16:00', endTime: '16:45' },
|
||||
{ section: 8, startTime: '16:55', endTime: '17:40' },
|
||||
{ section: 9, startTime: '19:00', endTime: '19:45' },
|
||||
{ section: 10, startTime: '19:55', endTime: '20:40' },
|
||||
{ section: 11, startTime: '20:50', endTime: '21:35' },
|
||||
{ section: 12, startTime: '21:45', endTime: '22:30' },
|
||||
];
|
||||
|
||||
// 星期名称
|
||||
export const WEEKDAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
|
||||
// 获取当前是第几周
|
||||
export function getCurrentWeek(semesterStartDate: string, totalWeeks: number = 20): number {
|
||||
const start = new Date(semesterStartDate);
|
||||
const now = new Date();
|
||||
const diffTime = now.getTime() - start.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
const week = Math.floor(diffDays / 7) + 1;
|
||||
return Math.min(Math.max(week, 1), totalWeeks);
|
||||
}
|
||||
|
||||
// 获取课程颜色
|
||||
export function getCourseColor(courseId: string, color?: string): string {
|
||||
if (color) return color;
|
||||
const hash = courseId.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
return COURSE_COLORS[hash % COURSE_COLORS.length];
|
||||
}
|
||||
|
||||
// 检查课程在某周是否有课
|
||||
export function hasCourseInWeek(course: Course, week: number): boolean {
|
||||
return course.weeks.includes(week);
|
||||
}
|
||||
Reference in New Issue
Block a user