iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 16

Day 16: 30天打造SaaS產品前端篇-即時協作課程排程系統與進階拖拽互動實作

  • 分享至 

  • xImage
  •  

前情提要

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

即時協作 UI 組件

協作用戶游標

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

高級選擇系統

多選管理 Hook

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

今日總結

今天我們實作了完整的即時協作課程排程系統,包括:

核心功能

  1. 即時協作系統

    • WebSocket 即時通信
    • 多用戶游標和選擇同步
    • 衝突檢測與解決
  2. 進階拖拽互動

    • 多選拖拽支援
    • 智能放置區域
    • 視覺化衝突預警
  3. 協作 UI 組件

    • 用戶游標顯示
    • 協作狀態面板
    • 即時活動追蹤
  4. 智能選擇系統

    • 多選管理
    • 範圍選擇
    • 鍵盤快捷鍵
  5. 衝突解決機制

    • 自動衝突檢測
    • 智能解決建議
    • 用戶友善的解決界面

技術特色

  • 即時性: 低延遲的協作體驗
  • 智能化: 自動衝突檢測與建議
  • 流暢性: smooth 的拖拽和動畫效果
  • 可用性: 直觀的多選和協作 UI

上一篇
Day 15:30天打造SaaS產品前端篇-課程模板系統與批次操作實作
下一篇
Day 17: 30天打造SaaS產品前端篇-微服務架構升級與 WebSocket 通信最佳化實作
系列文
30 天製作工作室 SaaS 產品 (前端篇)18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言