在 Day 15 我們建構了完整的課程模板系統,今天我們將進一步實作即時協作功能和進階的拖拽互動體驗。這個系統將支援多用戶同時編輯課程排程、即時衝突檢測、以及流暢的拖拽操作體驗。我們將使用 WebSocket、React DnD 進階功能、以及複雜的狀態管理來打造一個企業級的協作排程系統。
// packages/kyo-core/src/collaboration/types.ts
export interface CollaborationSession {
id: string;
tenantId: string;
sessionName: string;
createdBy: string;
participants: CollaborationParticipant[];
activeUsers: Set<string>;
lastActivity: Date;
permissions: SessionPermissions;
conflictResolution: ConflictResolutionStrategy;
}
export interface CollaborationParticipant {
userId: string;
userName: string;
role: 'owner' | 'editor' | 'viewer';
joinedAt: Date;
lastSeen: Date;
cursor?: CursorPosition;
selection?: SelectionRange;
color: string; // 用戶標識顏色
avatar?: string;
}
export interface CursorPosition {
x: number;
y: number;
componentId?: string;
componentType?: 'course' | 'timeslot' | 'template';
timestamp: Date;
}
export interface SelectionRange {
startTime: Date;
endTime: Date;
selectedCourses: string[];
selectedTimeSlots: TimeSlot[];
selectionType: 'single' | 'range' | 'multiple';
}
export interface CollaborationEvent {
id: string;
sessionId: string;
userId: string;
type: CollaborationEventType;
timestamp: Date;
data: any;
sequence: number;
}
export type CollaborationEventType =
| 'course_move'
| 'course_create'
| 'course_update'
| 'course_delete'
| 'template_apply'
| 'selection_change'
| 'cursor_move'
| 'user_join'
| 'user_leave'
| 'conflict_detected'
| 'conflict_resolved';
// src/services/collaboration/CollaborationService.ts
import { io, Socket } from 'socket.io-client';
import { EventEmitter } from 'events';
export class CollaborationService extends EventEmitter {
private socket: Socket | null = null;
private sessionId: string | null = null;
private currentUser: CollaborationParticipant | null = null;
private participants: Map<string, CollaborationParticipant> = new Map();
private eventSequence: number = 0;
private pendingOperations: Map<string, PendingOperation> = new Map();
private conflictResolver: ConflictResolver;
constructor(
private readonly apiBaseUrl: string,
private readonly authToken: string
) {
super();
this.conflictResolver = new ConflictResolver();
}
/**
* 建立協作會話連線
*/
async joinSession(
sessionId: string,
userInfo: { userId: string; userName: string; role: string }
): Promise<void> {
try {
this.sessionId = sessionId;
// 建立 WebSocket 連線
this.socket = io(`${this.apiBaseUrl}/collaboration`, {
auth: {
token: this.authToken
},
transports: ['websocket'],
timeout: 20000
});
// 設置連線事件處理
this.setupSocketEventHandlers();
// 等待連線建立
await new Promise<void>((resolve, reject) => {
this.socket!.on('connect', resolve);
this.socket!.on('connect_error', reject);
});
// 加入協作會話
const joinResult = await this.emitWithResponse('join_session', {
sessionId,
userInfo
});
this.currentUser = joinResult.participant;
this.participants = new Map(
joinResult.existingParticipants.map((p: CollaborationParticipant) => [p.userId, p])
);
this.emit('session_joined', {
sessionId,
participant: this.currentUser,
existingParticipants: Array.from(this.participants.values())
});
} catch (error) {
throw new Error(`Failed to join collaboration session: ${error.message}`);
}
}
/**
* 設置 Socket 事件處理器
*/
private setupSocketEventHandlers(): void {
if (!this.socket) return;
// 用戶加入/離開事件
this.socket.on('user_joined', (data: { participant: CollaborationParticipant }) => {
this.participants.set(data.participant.userId, data.participant);
this.emit('user_joined', data.participant);
});
this.socket.on('user_left', (data: { userId: string }) => {
this.participants.delete(data.userId);
this.emit('user_left', data.userId);
});
// 游標和選擇事件
this.socket.on('cursor_moved', (data: { userId: string; position: CursorPosition }) => {
const participant = this.participants.get(data.userId);
if (participant) {
participant.cursor = data.position;
this.emit('cursor_moved', data);
}
});
this.socket.on('selection_changed', (data: { userId: string; selection: SelectionRange }) => {
const participant = this.participants.get(data.userId);
if (participant) {
participant.selection = data.selection;
this.emit('selection_changed', data);
}
});
// 課程操作事件
this.socket.on('course_moved', (data: CollaborationEvent) => {
this.handleCollaborationEvent(data);
});
this.socket.on('course_created', (data: CollaborationEvent) => {
this.handleCollaborationEvent(data);
});
this.socket.on('course_updated', (data: CollaborationEvent) => {
this.handleCollaborationEvent(data);
});
this.socket.on('course_deleted', (data: CollaborationEvent) => {
this.handleCollaborationEvent(data);
});
// 衝突處理事件
this.socket.on('conflict_detected', (data: ConflictDetectionEvent) => {
this.handleConflictDetection(data);
});
this.socket.on('operation_rejected', (data: { operationId: string; reason: string }) => {
this.handleOperationRejection(data);
});
// 連線狀態事件
this.socket.on('disconnect', (reason) => {
this.emit('disconnected', reason);
this.attemptReconnection();
});
this.socket.on('reconnect', () => {
this.emit('reconnected');
this.resyncSessionState();
});
}
/**
* 發送課程移動事件
*/
async moveCourse(
courseId: string,
newTimeSlot: TimeSlot,
metadata?: any
): Promise<void> {
const operationId = this.generateOperationId();
const event: CollaborationEvent = {
id: operationId,
sessionId: this.sessionId!,
userId: this.currentUser!.userId,
type: 'course_move',
timestamp: new Date(),
data: {
courseId,
newTimeSlot,
metadata,
previousTimeSlot: await this.getCurrentTimeSlot(courseId)
},
sequence: ++this.eventSequence
};
// 先進行本地預測性更新
this.emit('optimistic_course_move', {
courseId,
newTimeSlot,
operationId
});
// 記錄待處理操作
this.pendingOperations.set(operationId, {
id: operationId,
type: 'course_move',
timestamp: new Date(),
data: event.data
});
try {
// 發送到服務器
await this.emitWithResponse('course_move', event);
// 操作成功確認
this.pendingOperations.delete(operationId);
this.emit('operation_confirmed', operationId);
} catch (error) {
// 操作失敗,回滾本地更改
this.pendingOperations.delete(operationId);
this.emit('operation_failed', { operationId, error });
this.emit('rollback_course_move', { courseId, operationId });
}
}
/**
* 處理協作事件
*/
private async handleCollaborationEvent(event: CollaborationEvent): Promise<void> {
// 忽略自己的操作事件
if (event.userId === this.currentUser?.userId) {
return;
}
// 檢查事件順序
if (event.sequence <= this.eventSequence) {
console.warn('Received out-of-order event:', event);
return;
}
this.eventSequence = Math.max(this.eventSequence, event.sequence);
// 檢查是否有衝突
const conflicts = await this.detectConflicts(event);
if (conflicts.length > 0) {
await this.handleConflicts(event, conflicts);
return;
}
// 應用遠程操作
switch (event.type) {
case 'course_move':
this.emit('remote_course_move', {
courseId: event.data.courseId,
newTimeSlot: event.data.newTimeSlot,
userId: event.userId,
timestamp: event.timestamp
});
break;
case 'course_create':
this.emit('remote_course_create', {
course: event.data.course,
userId: event.userId,
timestamp: event.timestamp
});
break;
case 'course_update':
this.emit('remote_course_update', {
courseId: event.data.courseId,
updates: event.data.updates,
userId: event.userId,
timestamp: event.timestamp
});
break;
case 'course_delete':
this.emit('remote_course_delete', {
courseId: event.data.courseId,
userId: event.userId,
timestamp: event.timestamp
});
break;
}
}
/**
* 發送游標位置更新
*/
updateCursorPosition(position: CursorPosition): void {
if (!this.socket || !this.currentUser) return;
// 節流發送,避免過於頻繁
if (this.cursorUpdateThrottle) {
clearTimeout(this.cursorUpdateThrottle);
}
this.cursorUpdateThrottle = setTimeout(() => {
this.socket!.emit('cursor_move', {
sessionId: this.sessionId,
position
});
}, 50); // 20 FPS
}
/**
* 發送選擇範圍更新
*/
updateSelection(selection: SelectionRange): void {
if (!this.socket || !this.currentUser) return;
this.socket.emit('selection_change', {
sessionId: this.sessionId,
selection
});
}
private cursorUpdateThrottle: NodeJS.Timeout | null = null;
}
// src/components/CourseScheduler/AdvancedDragDrop/MultiSelectDragLayer.tsx
import React, { useState, useEffect } from 'react';
import { useDragLayer } from 'react-dnd';
import { Box, Card, Text, Badge, Group, Stack } from '@mantine/core';
import { motion, AnimatePresence } from 'framer-motion';
interface DragPreview {
courses: Course[];
dragOffset: { x: number; y: number };
isDragging: boolean;
dragType: string;
}
export const MultiSelectDragLayer: React.FC = () => {
const {
itemType,
isDragging,
item,
initialOffset,
currentOffset,
differenceFromInitialOffset,
} = useDragLayer((monitor) => ({
item: monitor.getItem(),
itemType: monitor.getItemType(),
initialOffset: monitor.getInitialSourceClientOffset(),
currentOffset: monitor.getSourceClientOffset(),
differenceFromInitialOffset: monitor.getDifferenceFromInitialOffset(),
isDragging: monitor.isDragging(),
}));
if (!isDragging || itemType !== 'multi-course') {
return null;
}
const renderDragPreview = () => {
if (!item || !currentOffset) return null;
const { selectedCourses, primaryCourse } = item;
const courses = selectedCourses as Course[];
return (
<motion.div
initial={{ scale: 1, opacity: 1 }}
animate={{
scale: 1.05,
opacity: 0.9,
rotateZ: 2
}}
style={{
position: 'fixed',
pointerEvents: 'none',
zIndex: 10000,
left: currentOffset.x,
top: currentOffset.y,
transform: 'translate(-50%, -50%)',
}}
>
<Stack gap="xs">
{/* 主要課程卡片 */}
<Card
shadow="lg"
radius="md"
p="sm"
style={{
backgroundColor: primaryCourse.color || '#2196f3',
border: '3px solid #fff',
minWidth: '200px',
maxWidth: '250px',
}}
>
<Group justify="space-between" wrap="nowrap">
<div style={{ flex: 1 }}>
<Text size="sm" fw={600} c="white" lineClamp={1}>
{primaryCourse.name}
</Text>
<Text size="xs" c="white" opacity={0.9}>
{format(parseISO(primaryCourse.startTime), 'HH:mm')} -
{format(parseISO(primaryCourse.endTime), 'HH:mm')}
</Text>
</div>
{courses.length > 1 && (
<Badge
size="lg"
variant="filled"
color="white"
c="dark"
style={{
fontWeight: 700,
fontSize: '12px'
}}
>
+{courses.length - 1}
</Badge>
)}
</Group>
</Card>
{/* 多選指示器 */}
{courses.length > 1 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card
shadow="md"
radius="sm"
p="xs"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.8)',
backdropFilter: 'blur(8px)',
}}
>
<Group gap="xs" justify="center">
<Text size="xs" c="white" fw={500}>
移動 {courses.length} 個課程
</Text>
{courses.slice(1, 4).map((course, index) => (
<Box
key={course.id}
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: course.color || '#666',
opacity: 0.8 - (index * 0.2),
}}
/>
))}
{courses.length > 4 && (
<Text size="xs" c="white" opacity={0.7}>
...
</Text>
)}
</Group>
</Card>
</motion.div>
)}
</Stack>
</motion.div>
);
};
return (
<AnimatePresence>
{renderDragPreview()}
</AnimatePresence>
);
};
// src/components/CourseScheduler/AdvancedDragDrop/SmartDropZone.tsx
import React, { useState, useCallback, useMemo } from 'react';
import { useDrop } from 'react-dnd';
import { Box, Text, Indicator } from '@mantine/core';
import { motion, AnimatePresence } from 'framer-motion';
import { useCollaboration } from '../../../hooks/useCollaboration';
interface SmartDropZoneProps {
timeSlot: TimeSlot;
day: Date;
existingCourses: Course[];
onDrop: (courses: Course[], targetTimeSlot: TimeSlot) => void;
onConflictDetected: (conflict: ScheduleConflict) => void;
}
export const SmartDropZone: React.FC<SmartDropZoneProps> = ({
timeSlot,
day,
existingCourses,
onDrop,
onConflictDetected,
}) => {
const [dropPreview, setDropPreview] = useState<DropPreview | null>(null);
const [conflictState, setConflictState] = useState<ConflictState>({ conflicts: [], severity: 'none' });
const { participants, updateCursorPosition } = useCollaboration();
// 檢測拖拽衝突
const detectDropConflicts = useCallback(
(draggedCourses: Course[], targetSlot: TimeSlot): ScheduleConflict[] => {
const conflicts: ScheduleConflict[] = [];
draggedCourses.forEach(course => {
// 時間衝突檢測
const timeConflicts = existingCourses.filter(existing => {
const courseStart = new Date(targetSlot.start);
const courseEnd = new Date(courseStart.getTime() + course.duration * 60000);
const existingStart = parseISO(existing.startTime);
const existingEnd = parseISO(existing.endTime);
return (courseStart < existingEnd && courseEnd > existingStart);
});
if (timeConflicts.length > 0) {
conflicts.push({
type: 'time_overlap',
severity: 'error',
description: `課程「${course.name}」與現有課程時間重疊`,
affectedCourses: [course.id, ...timeConflicts.map(c => c.id)],
suggestedResolution: 'adjust_time'
});
}
// 教練衝突檢測
const trainerConflicts = existingCourses.filter(existing =>
existing.trainerId === course.trainerId &&
course.id !== existing.id
);
if (trainerConflicts.length > 0) {
conflicts.push({
type: 'trainer_conflict',
severity: 'warning',
description: `教練「${course.trainer?.name}」在此時段已有其他課程`,
affectedCourses: [course.id, ...trainerConflicts.map(c => c.id)],
suggestedResolution: 'change_trainer'
});
}
// 場地容量檢測
const capacityUsed = existingCourses
.filter(existing => existing.roomId === course.roomId)
.reduce((sum, c) => sum + c.currentParticipants, 0);
if (capacityUsed + course.currentParticipants > course.maxParticipants) {
conflicts.push({
type: 'capacity_exceeded',
severity: 'warning',
description: `場地容量不足`,
affectedCourses: [course.id],
suggestedResolution: 'change_room'
});
}
});
return conflicts;
},
[existingCourses]
);
const [{ isOver, canDrop, draggedItem }, drop] = useDrop({
accept: ['course', 'multi-course', 'template'],
hover: (item: any, monitor) => {
if (!monitor.isOver({ shallow: true })) return;
// 更新游標位置給協作系統
const clientOffset = monitor.getClientOffset();
if (clientOffset) {
updateCursorPosition({
x: clientOffset.x,
y: clientOffset.y,
componentId: `timeslot-${timeSlot.start.getTime()}`,
componentType: 'timeslot',
timestamp: new Date()
});
}
// 生成拖拽預覽
const draggedCourses = item.selectedCourses || [item.course || item];
const conflicts = detectDropConflicts(draggedCourses, timeSlot);
setDropPreview({
courses: draggedCourses,
targetTimeSlot: timeSlot,
conflicts,
canDrop: conflicts.every(c => c.severity !== 'error')
});
setConflictState({
conflicts,
severity: conflicts.length === 0 ? 'none' :
conflicts.some(c => c.severity === 'error') ? 'error' : 'warning'
});
},
drop: (item: any) => {
const draggedCourses = item.selectedCourses || [item.course || item];
const conflicts = detectDropConflicts(draggedCourses, timeSlot);
// 檢查是否有阻止性衝突
const blockingConflicts = conflicts.filter(c => c.severity === 'error');
if (blockingConflicts.length > 0) {
onConflictDetected({
type: 'drop_blocked',
conflicts: blockingConflicts,
targetTimeSlot: timeSlot,
affectedCourses: draggedCourses
});
return;
}
// 執行放置操作
onDrop(draggedCourses, timeSlot);
// 如果有警告級衝突,通知用戶
if (conflicts.some(c => c.severity === 'warning')) {
onConflictDetected({
type: 'drop_warning',
conflicts: conflicts.filter(c => c.severity === 'warning'),
targetTimeSlot: timeSlot,
affectedCourses: draggedCourses
});
}
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
draggedItem: monitor.getItem(),
}),
});
// 計算其他用戶的游標位置
const otherUserCursors = useMemo(() => {
return Array.from(participants.values())
.filter(p => p.cursor && p.userId !== 'current-user') // 假設有當前用戶ID
.filter(p => p.cursor!.componentId === `timeslot-${timeSlot.start.getTime()}`);
}, [participants, timeSlot]);
const getDropZoneStyle = () => {
let backgroundColor = 'transparent';
let borderColor = 'transparent';
let borderStyle = 'solid';
if (isOver) {
switch (conflictState.severity) {
case 'error':
backgroundColor = 'rgba(244, 67, 54, 0.1)';
borderColor = '#f44336';
borderStyle = 'dashed';
break;
case 'warning':
backgroundColor = 'rgba(255, 193, 7, 0.1)';
borderColor = '#ffc107';
borderStyle = 'dashed';
break;
default:
backgroundColor = 'rgba(76, 175, 80, 0.1)';
borderColor = '#4caf50';
borderStyle = 'dashed';
}
}
return {
backgroundColor,
border: `2px ${borderStyle} ${borderColor}`,
borderRadius: '4px',
minHeight: '60px',
position: 'relative' as const,
transition: 'all 0.2s ease',
};
};
return (
<Box ref={drop} style={getDropZoneStyle()}>
{/* 拖拽預覽 */}
<AnimatePresence>
{isOver && dropPreview && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10,
pointerEvents: 'none',
}}
>
<DropPreviewComponent
preview={dropPreview}
conflicts={conflictState.conflicts}
/>
</motion.div>
)}
</AnimatePresence>
{/* 其他用戶游標 */}
{otherUserCursors.map(participant => (
<UserCursor
key={participant.userId}
participant={participant}
position={participant.cursor!}
/>
))}
{/* 衝突指示器 */}
{conflictState.conflicts.length > 0 && isOver && (
<Indicator
color={conflictState.severity === 'error' ? 'red' : 'yellow'}
size={8}
style={{
position: 'absolute',
top: 4,
right: 4,
}}
/>
)}
</Box>
);
};
// src/components/CourseScheduler/AdvancedDragDrop/DropPreviewComponent.tsx
import React from 'react';
import { Card, Text, Group, Stack, Alert, Badge } from '@mantine/core';
import { IconAlertTriangle, IconInfoCircle, IconCheck } from '@tabler/icons-react';
import { motion } from 'framer-motion';
interface DropPreviewComponentProps {
preview: DropPreview;
conflicts: ScheduleConflict[];
}
export const DropPreviewComponent: React.FC<DropPreviewComponentProps> = ({
preview,
conflicts,
}) => {
const { courses, canDrop } = preview;
const primaryCourse = courses[0];
const getPreviewStyle = () => ({
opacity: canDrop ? 0.9 : 0.6,
filter: canDrop ? 'none' : 'grayscale(50%)',
});
const getStatusIcon = () => {
if (!canDrop) return <IconAlertTriangle size={14} color="#f44336" />;
if (conflicts.length > 0) return <IconInfoCircle size={14} color="#ff9800" />;
return <IconCheck size={14} color="#4caf50" />;
};
const getStatusMessage = () => {
if (!canDrop) return '無法放置 - 有衝突需要解決';
if (conflicts.length > 0) return `有 ${conflicts.length} 個警告`;
return '可以放置';
};
return (
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
style={getPreviewStyle()}
>
<Stack gap="xs" align="center">
{/* 主預覽卡片 */}
<Card
shadow="xl"
radius="md"
p="sm"
style={{
backgroundColor: primaryCourse.color || '#2196f3',
border: `3px solid ${canDrop ? '#4caf50' : '#f44336'}`,
minWidth: '180px',
maxWidth: '220px',
}}
>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} c="white" lineClamp={1}>
{primaryCourse.name}
</Text>
{courses.length > 1 && (
<Badge
size="sm"
variant="filled"
color="white"
c="dark"
>
+{courses.length - 1}
</Badge>
)}
</Group>
<Group gap="xs" align="center">
{getStatusIcon()}
<Text size="xs" c="white" opacity={0.9}>
{getStatusMessage()}
</Text>
</Group>
</Stack>
</Card>
{/* 衝突詳情 */}
{conflicts.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Alert
size="xs"
color={canDrop ? 'yellow' : 'red'}
icon={<IconAlertTriangle size={12} />}
styles={{
root: {
padding: '8px',
maxWidth: '220px',
},
message: {
fontSize: '11px',
},
}}
>
<Stack gap={2}>
{conflicts.slice(0, 2).map((conflict, index) => (
<Text key={index} size="xs">
• {conflict.description}
</Text>
))}
{conflicts.length > 2 && (
<Text size="xs" opacity={0.7}>
還有 {conflicts.length - 2} 個其他問題...
</Text>
)}
</Stack>
</Alert>
</motion.div>
)}
{/* 多課程指示 */}
{courses.length > 1 && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.15 }}
>
<Group gap="xs" align="center">
{courses.slice(1, 4).map((course, index) => (
<motion.div
key={course.id}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 + index * 0.05 }}
style={{
width: 12,
height: 12,
borderRadius: '2px',
backgroundColor: course.color || '#666',
border: '1px solid white',
opacity: 0.8,
}}
/>
))}
{courses.length > 4 && (
<Text size="xs" c="dimmed">
+{courses.length - 4} more
</Text>
)}
</Group>
</motion.div>
)}
</Stack>
</motion.div>
);
};
// src/components/CourseScheduler/Collaboration/UserCursor.tsx
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Text, Avatar } from '@mantine/core';
interface UserCursorProps {
participant: CollaborationParticipant;
position: CursorPosition;
}
export const UserCursor: React.FC<UserCursorProps> = ({
participant,
position,
}) => {
// 計算游標是否在最近時間內活動
const isRecent = Date.now() - position.timestamp.getTime() < 5000;
if (!isRecent) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
style={{
position: 'absolute',
left: position.x,
top: position.y,
zIndex: 9999,
pointerEvents: 'none',
}}
>
{/* 游標箭頭 */}
<motion.div
animate={{
x: [0, 2, 0],
y: [0, -2, 0],
}}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
style={{
filter: `drop-shadow(0 2px 4px rgba(0,0,0,0.3))`,
}}
>
<path
d="M2 2L18 8L8 10L6 18Z"
fill={participant.color}
stroke="white"
strokeWidth="1"
/>
</svg>
</motion.div>
{/* 用戶標籤 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
style={{
position: 'absolute',
left: 22,
top: -8,
backgroundColor: participant.color,
color: 'white',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: 500,
whiteSpace: 'nowrap',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Avatar
size={14}
src={participant.avatar}
color={participant.color}
radius="xl"
>
{participant.userName.charAt(0).toUpperCase()}
</Avatar>
<span>{participant.userName}</span>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
// src/components/CourseScheduler/Collaboration/CollaborationPanel.tsx
import React, { useState } from 'react';
import {
Card,
Stack,
Group,
Text,
Avatar,
Badge,
ActionIcon,
Tooltip,
Indicator,
Collapse,
ScrollArea,
Alert,
} from '@mantine/core';
import {
IconUsers,
IconChevronDown,
IconChevronUp,
IconAlertTriangle,
IconEye,
IconEdit,
IconCrown,
} from '@tabler/icons-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useCollaboration } from '../../../hooks/useCollaboration';
export const CollaborationPanel: React.FC = () => {
const [isExpanded, setIsExpanded] = useState(true);
const {
participants,
currentUser,
conflicts,
sessionInfo,
isConnected,
} = useCollaboration();
const activeParticipants = Array.from(participants.values()).filter(
p => Date.now() - p.lastSeen.getTime() < 30000 // 30秒內活躍
);
const getRoleIcon = (role: string) => {
switch (role) {
case 'owner':
return <IconCrown size={12} color="#ffd700" />;
case 'editor':
return <IconEdit size={12} color="#4caf50" />;
case 'viewer':
return <IconEye size={12} color="#9e9e9e" />;
default:
return null;
}
};
const getConnectionStatus = () => {
if (!isConnected) {
return {
color: 'red',
label: '連線中斷',
description: '正在嘗試重新連線...'
};
}
if (conflicts.length > 0) {
return {
color: 'yellow',
label: '有衝突',
description: `${conflicts.length} 個衝突需要處理`
};
}
return {
color: 'green',
label: '同步中',
description: '所有更改已同步'
};
};
const connectionStatus = getConnectionStatus();
return (
<Card shadow="sm" padding="md" radius="md" withBorder>
<Stack gap="sm">
{/* 標題列 */}
<Group justify="space-between">
<Group gap="xs">
<Indicator
color={connectionStatus.color}
size={8}
processing={!isConnected}
>
<IconUsers size={18} />
</Indicator>
<div>
<Text size="sm" fw={500}>協作會話</Text>
<Text size="xs" c="dimmed">{connectionStatus.description}</Text>
</div>
</Group>
<Group gap="xs">
<Badge
size="sm"
variant="light"
color={connectionStatus.color}
>
{activeParticipants.length} 人在線
</Badge>
<ActionIcon
variant="subtle"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
</ActionIcon>
</Group>
</Group>
{/* 衝突警告 */}
<AnimatePresence>
{conflicts.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
<Alert
icon={<IconAlertTriangle size={16} />}
color="yellow"
size="sm"
>
<Text size="xs">
檢測到 {conflicts.length} 個操作衝突,部分更改可能需要手動解決
</Text>
</Alert>
</motion.div>
)}
</AnimatePresence>
{/* 參與者列表 */}
<Collapse in={isExpanded}>
<ScrollArea.Autosize mah={200}>
<Stack gap="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
參與者 ({activeParticipants.length})
</Text>
<AnimatePresence>
{activeParticipants.map((participant) => (
<motion.div
key={participant.userId}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<ParticipantItem
participant={participant}
isCurrentUser={participant.userId === currentUser?.userId}
/>
</motion.div>
))}
</AnimatePresence>
{activeParticipants.length === 0 && (
<Text size="xs" c="dimmed" ta="center" py="md">
目前只有您在編輯
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Collapse>
</Stack>
</Card>
);
};
// 參與者項目組件
const ParticipantItem: React.FC<{
participant: CollaborationParticipant;
isCurrentUser: boolean;
}> = ({ participant, isCurrentUser }) => {
const lastSeenText = React.useMemo(() => {
const secondsAgo = Math.floor((Date.now() - participant.lastSeen.getTime()) / 1000);
if (secondsAgo < 10) return '剛剛';
if (secondsAgo < 60) return `${secondsAgo}秒前`;
return `${Math.floor(secondsAgo / 60)}分鐘前`;
}, [participant.lastSeen]);
return (
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Indicator
color={participant.color}
size={8}
offset={2}
position="bottom-end"
processing={Date.now() - participant.lastSeen.getTime() < 5000}
>
<Avatar
size={24}
src={participant.avatar}
color={participant.color}
radius="xl"
>
{participant.userName.charAt(0).toUpperCase()}
</Avatar>
</Indicator>
<div style={{ flex: 1, minWidth: 0 }}>
<Group gap={4} align="center">
<Text size="xs" fw={500} lineClamp={1}>
{participant.userName}
{isCurrentUser && ' (您)'}
</Text>
<Tooltip label={`角色: ${participant.role}`}>
{getRoleIcon(participant.role)}
</Tooltip>
</Group>
<Text size="xs" c="dimmed">
{lastSeenText}
</Text>
</div>
</Group>
{participant.selection && (
<Tooltip label={`選中 ${participant.selection.selectedCourses.length} 個課程`}>
<Badge size="xs" variant="light" color={participant.color}>
{participant.selection.selectedCourses.length}
</Badge>
</Tooltip>
)}
</Group>
);
};
// src/hooks/useMultiSelection.ts
import { useState, useCallback, useRef, useEffect } from 'react';
import { useCollaboration } from './useCollaboration';
export interface SelectionState {
selectedCourses: Set<string>;
selectionRange: SelectionRange | null;
isSelecting: boolean;
selectionMode: 'single' | 'multi' | 'range';
}
export const useMultiSelection = (courses: Course[]) => {
const [selectionState, setSelectionState] = useState<SelectionState>({
selectedCourses: new Set(),
selectionRange: null,
isSelecting: false,
selectionMode: 'single'
});
const { updateSelection } = useCollaboration();
const lastSelectionRef = useRef<string | null>(null);
// 選擇單個課程
const selectCourse = useCallback((
courseId: string,
mode: 'replace' | 'add' | 'toggle' = 'replace'
) => {
setSelectionState(prev => {
const newSelected = new Set(prev.selectedCourses);
switch (mode) {
case 'replace':
newSelected.clear();
newSelected.add(courseId);
break;
case 'add':
newSelected.add(courseId);
break;
case 'toggle':
if (newSelected.has(courseId)) {
newSelected.delete(courseId);
} else {
newSelected.add(courseId);
}
break;
}
const newState = {
...prev,
selectedCourses: newSelected,
selectionMode: newSelected.size > 1 ? 'multi' as const : 'single' as const
};
// 更新協作選擇狀態
const selectedCoursesArray = Array.from(newSelected);
const selectionRange = selectedCoursesArray.length > 0 ? {
startTime: new Date(Math.min(...selectedCoursesArray.map(id => {
const course = courses.find(c => c.id === id);
return course ? parseISO(course.startTime).getTime() : Date.now();
}))),
endTime: new Date(Math.max(...selectedCoursesArray.map(id => {
const course = courses.find(c => c.id === id);
return course ? parseISO(course.endTime).getTime() : Date.now();
}))),
selectedCourses: selectedCoursesArray,
selectedTimeSlots: [],
selectionType: newSelected.size > 1 ? 'multiple' as const : 'single' as const
} : null;
if (selectionRange) {
updateSelection(selectionRange);
}
return newState;
});
lastSelectionRef.current = courseId;
}, [courses, updateSelection]);
// 範圍選擇
const selectRange = useCallback((courseId: string) => {
if (!lastSelectionRef.current) {
selectCourse(courseId, 'replace');
return;
}
const startCourse = courses.find(c => c.id === lastSelectionRef.current);
const endCourse = courses.find(c => c.id === courseId);
if (!startCourse || !endCourse) return;
const startTime = parseISO(startCourse.startTime);
const endTime = parseISO(endCourse.startTime);
// 找出時間範圍內的所有課程
const minTime = startTime < endTime ? startTime : endTime;
const maxTime = startTime > endTime ? startTime : endTime;
const coursesInRange = courses
.filter(course => {
const courseTime = parseISO(course.startTime);
return courseTime >= minTime && courseTime <= maxTime;
})
.map(course => course.id);
setSelectionState(prev => ({
...prev,
selectedCourses: new Set(coursesInRange),
selectionMode: 'range',
selectionRange: {
startTime: minTime,
endTime: maxTime,
selectedCourses: coursesInRange,
selectedTimeSlots: [],
selectionType: 'range'
}
}));
// 更新協作狀態
updateSelection({
startTime: minTime,
endTime: maxTime,
selectedCourses: coursesInRange,
selectedTimeSlots: [],
selectionType: 'range'
});
}, [courses, selectCourse, updateSelection]);
// 清除選擇
const clearSelection = useCallback(() => {
setSelectionState({
selectedCourses: new Set(),
selectionRange: null,
isSelecting: false,
selectionMode: 'single'
});
lastSelectionRef.current = null;
}, []);
// 全選
const selectAll = useCallback(() => {
const allCourseIds = courses.map(c => c.id);
setSelectionState({
selectedCourses: new Set(allCourseIds),
selectionRange: null,
isSelecting: false,
selectionMode: 'multi'
});
// 更新協作狀態
if (allCourseIds.length > 0) {
updateSelection({
startTime: new Date(Math.min(...courses.map(c => parseISO(c.startTime).getTime()))),
endTime: new Date(Math.max(...courses.map(c => parseISO(c.endTime).getTime()))),
selectedCourses: allCourseIds,
selectedTimeSlots: [],
selectionType: 'multiple'
});
}
}, [courses, updateSelection]);
// 反選
const invertSelection = useCallback(() => {
const allCourseIds = new Set(courses.map(c => c.id));
const currentSelected = selectionState.selectedCourses;
const inverted = new Set(
Array.from(allCourseIds).filter(id => !currentSelected.has(id))
);
setSelectionState(prev => ({
...prev,
selectedCourses: inverted,
selectionMode: inverted.size > 1 ? 'multi' : 'single'
}));
}, [courses, selectionState.selectedCourses]);
// 鍵盤快捷鍵處理
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Ctrl/Cmd + A: 全選
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
event.preventDefault();
selectAll();
}
// Escape: 清除選擇
if (event.key === 'Escape') {
clearSelection();
}
// Delete: 刪除選中項目
if (event.key === 'Delete' && selectionState.selectedCourses.size > 0) {
// 這裡可以觸發刪除事件
console.log('Delete selected courses:', Array.from(selectionState.selectedCourses));
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectAll, clearSelection, selectionState.selectedCourses]);
return {
selectionState,
selectCourse,
selectRange,
clearSelection,
selectAll,
invertSelection,
selectedCount: selectionState.selectedCourses.size,
isSelected: (courseId: string) => selectionState.selectedCourses.has(courseId),
getSelectedCourses: () => courses.filter(c => selectionState.selectedCourses.has(c.id)),
};
};
// src/components/CourseScheduler/ConflictResolution/ConflictResolver.tsx
import React, { useState, useCallback } from 'react';
import {
Modal,
Stack,
Group,
Text,
Button,
Alert,
Card,
Badge,
Timeline,
ActionIcon,
Divider,
ScrollArea,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconUsers,
IconClock,
IconMapPin,
IconArrowRight,
IconCheck,
IconX,
IconRotateClockwise,
IconBulb,
} from '@tabler/icons-react';
import { motion, AnimatePresence } from 'framer-motion';
interface ConflictResolverProps {
conflicts: ScheduleConflict[];
onResolve: (resolutions: ConflictResolution[]) => void;
onCancel: () => void;
opened: boolean;
}
export const ConflictResolver: React.FC<ConflictResolverProps> = ({
conflicts,
onResolve,
onCancel,
opened,
}) => {
const [resolutions, setResolutions] = useState<Map<string, ConflictResolution>>(new Map());
const [autoResolveEnabled, setAutoResolveEnabled] = useState(false);
// 自動解決衝突建議
const getAutoResolutionSuggestions = useCallback((conflict: ScheduleConflict) => {
const suggestions: ResolutionSuggestion[] = [];
switch (conflict.type) {
case 'time_overlap':
suggestions.push(
{
id: 'adjust_time',
type: 'time_adjustment',
title: '調整時間',
description: '自動找到最近的可用時段',
confidence: 0.8,
impact: 'low',
automated: true
},
{
id: 'split_time',
type: 'time_split',
title: '分割時段',
description: '將重疊課程分為兩個較短時段',
confidence: 0.6,
impact: 'medium',
automated: false
}
);
break;
case 'trainer_conflict':
suggestions.push(
{
id: 'find_alternative_trainer',
type: 'trainer_replacement',
title: '尋找代課教練',
description: '從可用教練中自動分配',
confidence: 0.7,
impact: 'low',
automated: true
},
{
id: 'reschedule_course',
type: 'reschedule',
title: '重新排程',
description: '移動到教練有空的時段',
confidence: 0.9,
impact: 'medium',
automated: true
}
);
break;
case 'capacity_exceeded':
suggestions.push(
{
id: 'increase_capacity',
type: 'capacity_increase',
title: '增加容量',
description: '如果場地允許,增加課程容量',
confidence: 0.5,
impact: 'low',
automated: false
},
{
id: 'split_class',
type: 'class_split',
title: '分班處理',
description: '將課程分為兩個班次',
confidence: 0.8,
impact: 'high',
automated: false
}
);
break;
}
return suggestions;
}, []);
// 應用解決方案
const applyResolution = useCallback((
conflictId: string,
suggestion: ResolutionSuggestion
) => {
const resolution: ConflictResolution = {
conflictId,
suggestionId: suggestion.id,
type: suggestion.type,
parameters: {},
timestamp: new Date(),
automated: suggestion.automated
};
setResolutions(prev => new Map(prev.set(conflictId, resolution)));
}, []);
// 自動解決所有衝突
const autoResolveAll = useCallback(async () => {
const autoResolutions = new Map<string, ConflictResolution>();
for (const conflict of conflicts) {
const suggestions = getAutoResolutionSuggestions(conflict);
const bestSuggestion = suggestions
.filter(s => s.automated)
.sort((a, b) => b.confidence - a.confidence)[0];
if (bestSuggestion) {
autoResolutions.set(conflict.id, {
conflictId: conflict.id,
suggestionId: bestSuggestion.id,
type: bestSuggestion.type,
parameters: {},
timestamp: new Date(),
automated: true
});
}
}
setResolutions(autoResolutions);
setAutoResolveEnabled(true);
}, [conflicts, getAutoResolutionSuggestions]);
const handleResolve = () => {
onResolve(Array.from(resolutions.values()));
};
const getConflictIcon = (type: string) => {
switch (type) {
case 'time_overlap':
return <IconClock size={16} color="#f44336" />;
case 'trainer_conflict':
return <IconUsers size={16} color="#ff9800" />;
case 'capacity_exceeded':
return <IconMapPin size={16} color="#2196f3" />;
default:
return <IconAlertTriangle size={16} color="#9e9e9e" />;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'error':
return 'red';
case 'warning':
return 'yellow';
default:
return 'blue';
}
};
return (
<Modal
opened={opened}
onClose={onCancel}
title="解決排程衝突"
size="xl"
centered
>
<Stack gap="md">
{/* 概覽 */}
<Alert
icon={<IconAlertTriangle size={16} />}
color="yellow"
title={`發現 ${conflicts.length} 個衝突`}
>
<Group justify="space-between">
<Text size="sm">
需要您的協助來解決這些排程衝突,以確保系統正常運作
</Text>
<Button
size="xs"
variant="light"
leftSection={<IconBulb size={14} />}
onClick={autoResolveAll}
disabled={autoResolveEnabled}
>
自動解決
</Button>
</Group>
</Alert>
{/* 衝突列表 */}
<ScrollArea.Autosize mah={400}>
<Stack gap="md">
<AnimatePresence>
{conflicts.map((conflict, index) => (
<motion.div
key={conflict.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<ConflictItem
conflict={conflict}
suggestions={getAutoResolutionSuggestions(conflict)}
selectedResolution={resolutions.get(conflict.id)}
onSelectResolution={(suggestion) =>
applyResolution(conflict.id, suggestion)
}
/>
</motion.div>
))}
</AnimatePresence>
</Stack>
</ScrollArea.Autosize>
<Divider />
{/* 操作按鈕 */}
<Group justify="space-between">
<Group>
<Badge
color={resolutions.size === conflicts.length ? 'green' : 'gray'}
variant="light"
>
{resolutions.size} / {conflicts.length} 已解決
</Badge>
{autoResolveEnabled && (
<Badge color="blue" variant="light">
已啟用自動解決
</Badge>
)}
</Group>
<Group>
<Button variant="subtle" onClick={onCancel}>
取消
</Button>
<Button
onClick={handleResolve}
disabled={resolutions.size === 0}
color="blue"
>
應用解決方案
</Button>
</Group>
</Group>
</Stack>
</Modal>
);
};
// 衝突項目組件
const ConflictItem: React.FC<{
conflict: ScheduleConflict;
suggestions: ResolutionSuggestion[];
selectedResolution?: ConflictResolution;
onSelectResolution: (suggestion: ResolutionSuggestion) => void;
}> = ({ conflict, suggestions, selectedResolution, onSelectResolution }) => {
return (
<Card withBorder padding="md" radius="md">
<Stack gap="sm">
{/* 衝突標題 */}
<Group justify="space-between">
<Group>
{getConflictIcon(conflict.type)}
<div>
<Text size="sm" fw={500}>{conflict.description}</Text>
<Text size="xs" c="dimmed">
影響 {conflict.affectedCourses.length} 個課程
</Text>
</div>
</Group>
<Badge
color={getSeverityColor(conflict.severity)}
variant="light"
>
{conflict.severity === 'error' ? '嚴重' : '警告'}
</Badge>
</Group>
{/* 解決方案 */}
<div>
<Text size="xs" c="dimmed" mb="xs">解決方案:</Text>
<Stack gap="xs">
{suggestions.map((suggestion) => (
<SuggestionItem
key={suggestion.id}
suggestion={suggestion}
isSelected={selectedResolution?.suggestionId === suggestion.id}
onSelect={() => onSelectResolution(suggestion)}
/>
))}
</Stack>
</div>
</Stack>
</Card>
);
};
// 建議項目組件
const SuggestionItem: React.FC<{
suggestion: ResolutionSuggestion;
isSelected: boolean;
onSelect: () => void;
}> = ({ suggestion, isSelected, onSelect }) => {
return (
<Card
padding="xs"
radius="sm"
style={{
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : 'var(--mantine-color-gray-0)',
border: isSelected ? '1px solid var(--mantine-color-blue-3)' : '1px solid var(--mantine-color-gray-3)',
cursor: 'pointer',
}}
onClick={onSelect}
>
<Group justify="space-between">
<div style={{ flex: 1 }}>
<Group gap="xs">
<Text size="sm" fw={500}>{suggestion.title}</Text>
<Badge
size="xs"
color={suggestion.automated ? 'green' : 'gray'}
variant="light"
>
{suggestion.automated ? '自動' : '手動'}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={2}>
{suggestion.description}
</Text>
</div>
<Group gap="xs">
<Tooltip label={`信心度: ${Math.round(suggestion.confidence * 100)}%`}>
<Badge
size="sm"
color={suggestion.confidence > 0.7 ? 'green' : 'yellow'}
variant="light"
>
{Math.round(suggestion.confidence * 100)}%
</Badge>
</Tooltip>
{isSelected && <IconCheck size={16} color="var(--mantine-color-blue-6)" />}
</Group>
</Group>
</Card>
);
};
今天我們實作了完整的即時協作課程排程系統,包括:
即時協作系統
進階拖拽互動
協作 UI 組件
智能選擇系統
衝突解決機制