From 260ad581f6bdab6ab53ae90b891946ba536dc2b3 Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 11:56:21 +0800 Subject: [PATCH 01/60] feat(contest): refresh student dashboard --- .../apps/contests/exporters/data_service.py | 5 +- backend/apps/contests/exporters/dto.py | 2 +- .../0077_remove_contest_anonymous_mode.py | 19 + backend/apps/contests/models.py | 16 - backend/apps/contests/serializers.py | 71 +- .../apps/contests/services/export_service.py | 3 +- .../services/participant_dashboard.py | 4 +- backend/apps/contests/services/scoreboard.py | 11 +- .../test_contest_viewset_actions.py | 33 +- .../tests/participation/test_participation.py | 27 - .../standings/test_contest_legacy_flows.py | 48 -- .../tests/test_participant_dashboard_api.py | 1 - backend/apps/contests/views/contest.py | 44 - backend/apps/contests/views/exam_answer.py | 17 +- .../management/commands/seed_loadtest_data.py | 2 +- frontend/src/core/entities/contest.entity.ts | 11 +- frontend/src/core/ports/contest.repository.ts | 3 +- .../contest/joinContest.usecase.test.ts | 5 +- .../usecases/contest/joinContest.usecase.ts | 7 +- .../src/features/app/components/UserMenu.tsx | 69 -- .../contest/components/ContestScoreboard.tsx | 5 +- .../contest/components/ExamModeWrapper.tsx | 6 +- .../admin/AdminInsightRail.module.scss | 32 +- .../components/admin/AdminInsightRail.tsx | 106 ++- .../admin/AdminOverviewCommandCenter.test.tsx | 34 +- .../admin/AdminOverviewCommandCenter.tsx | 18 +- .../admin/examEditor/hooks/useExamAutoSave.ts | 1 - .../settings/ContestSettingsModal.stories.tsx | 3 +- .../admin/settings/DisplaySettingsPanel.tsx | 16 - .../ContestResultDashboardPanel.tsx | 2 - .../statistics/QuestionStatisticsDetail.tsx | 2 +- .../statistics/contestResultDashboard.mock.ts | 49 +- .../contestResultDashboard.transform.ts | 4 +- .../statistics/useContestResultDashboard.ts | 4 - .../statistics/useExamStatistics.test.ts | 2 +- .../admin/statistics/useExamStatistics.ts | 4 +- .../components/layout/ContestExitModal.tsx | 2 +- .../contest/components/layout/ContestHero.tsx | 57 +- .../layout/ContestLayout.module.scss | 7 + .../components/layout/ContestLayout.tsx | 77 +- .../modals/ContestRegistrationModal.tsx | 41 +- .../participants/ParticipantDashboardPane.tsx | 9 +- .../ParticipantOperationsPane.tsx | 10 +- .../ParticipantsListPane.test.tsx | 7 +- .../participants/ParticipantsListPane.tsx | 6 +- .../StudentContestDashboard.module.scss | 353 ++++++++ .../StudentContestDashboardView.test.tsx | 378 +++++++++ .../StudentContestDashboardView.tsx | 760 ++++++++++++++++++ .../studentDashboardState.test.ts | 113 +++ .../studentDashboard/studentDashboardState.ts | 110 +++ .../domain/contestRuntimePolicy.test.ts | 1 + .../contest/domain/contestRuntimePolicy.ts | 5 +- .../contest/hooks/useContestExamActions.ts | 3 +- .../screens/ContestDashboardScreen.tsx | 113 +-- .../screens/ContestPreRegistrationScreen.tsx | 16 +- .../panels/AdminContestSettingsScreen.tsx | 1 - .../admin/panels/AdminOverviewScreen.tsx | 53 +- .../admin/panels/AdminProctoringPanel.tsx | 4 - .../adminOverviewDashboard.model.test.ts | 2 +- .../panels/adminOverviewDashboard.model.ts | 5 +- .../screens/paperExam/usePaperExamFlow.ts | 2 +- .../settings/ContestExamGradingScreen.tsx | 1 - .../grading/GradingByQuestionTabScreen.tsx | 11 +- .../grading/GradingByStudentTabScreen.tsx | 15 +- .../settings/grading/GradingCardViewOnly.tsx | 8 +- .../grading/GradingMatrixViewScreen.test.tsx | 8 +- .../grading/GradingMatrixViewScreen.tsx | 2 - .../grading/GradingSplitPanelScreen.test.tsx | 2 +- .../grading/GradingSplitPanelScreen.tsx | 6 +- .../settings/grading/buildGradingRows.ts | 6 +- .../screens/settings/grading/gradingTypes.ts | 2 +- .../settings/grading/objectiveRegrade.test.ts | 2 +- .../grading/useAiGradingScreenData.ts | 4 +- .../settings/grading/useGradingData.ts | 23 +- .../src/infrastructure/api/dto/contest.dto.ts | 5 - .../api/repositories/contest.repository.ts | 2 +- .../contestParticipants.repository.ts | 12 - .../api/repositories/exam.repository.ts | 5 +- .../repositories/examAnswers.repository.ts | 6 +- .../mappers/contest.mapper.test.ts | 18 + .../infrastructure/mappers/contest.mapper.ts | 11 +- frontend/src/shared/mocks/contest.mock.ts | 1 - 82 files changed, 2159 insertions(+), 812 deletions(-) create mode 100644 backend/apps/contests/migrations/0077_remove_contest_anonymous_mode.py create mode 100644 frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss create mode 100644 frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx create mode 100644 frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.tsx create mode 100644 frontend/src/features/contest/components/studentDashboard/studentDashboardState.test.ts create mode 100644 frontend/src/features/contest/components/studentDashboard/studentDashboardState.ts diff --git a/backend/apps/contests/exporters/data_service.py b/backend/apps/contests/exporters/data_service.py index afa05b45..666c3c35 100644 --- a/backend/apps/contests/exporters/data_service.py +++ b/backend/apps/contests/exporters/data_service.py @@ -105,11 +105,12 @@ def get_participants(self) -> List[ParticipantDTO]: result = [] for p in participants: + profile = getattr(p.user, 'profile', None) result.append(ParticipantDTO( user_id=p.user.id, username=p.user.username, email=getattr(p.user, 'email', ''), - nickname=getattr(p, 'nickname', '') or '', + display_name=getattr(profile, 'display_name', '') or '', exam_status=getattr(p, 'exam_status', ''), started_at=getattr(p, 'started_at', None), left_at=getattr(p, 'left_at', None), @@ -178,7 +179,7 @@ def calculate_standings(self, user_id: Optional[int] = None) -> StandingsDTO: stats[p.user_id] = UserStandingDTO( user_id=p.user_id, username=p.username, - display_name=p.nickname or p.username, + display_name=p.display_name or p.username, problems=problem_stats, ) diff --git a/backend/apps/contests/exporters/dto.py b/backend/apps/contests/exporters/dto.py index a9596022..db3d8a9b 100644 --- a/backend/apps/contests/exporters/dto.py +++ b/backend/apps/contests/exporters/dto.py @@ -74,7 +74,7 @@ class ParticipantDTO: user_id: int username: str email: str = '' - nickname: str = '' + display_name: str = '' exam_status: str = '' started_at: Optional[datetime] = None left_at: Optional[datetime] = None diff --git a/backend/apps/contests/migrations/0077_remove_contest_anonymous_mode.py b/backend/apps/contests/migrations/0077_remove_contest_anonymous_mode.py new file mode 100644 index 00000000..8dd96514 --- /dev/null +++ b/backend/apps/contests/migrations/0077_remove_contest_anonymous_mode.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("contests", "0076_unique_exam_question_order"), + ] + + operations = [ + migrations.RemoveField( + model_name="contest", + name="anonymous_mode_enabled", + ), + migrations.RemoveField( + model_name="contestparticipant", + name="nickname", + ), + ] diff --git a/backend/apps/contests/models.py b/backend/apps/contests/models.py index 6c78b552..c5d2cd12 100644 --- a/backend/apps/contests/models.py +++ b/backend/apps/contests/models.py @@ -215,13 +215,6 @@ class QuestionEditLockTrigger(models.TextChoices): help_text='TA 完成批改後手動設為 True,學生才能查看成績' ) - # Anonymous mode settings - anonymous_mode_enabled = models.BooleanField( - default=False, - verbose_name='啟用匿名模式', - help_text='啟用後學生可使用暱稱參與競賽,排行榜和提交列表顯示暱稱' - ) - created_at = models.DateTimeField(auto_now_add=True, verbose_name='建立時間') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新時間') @@ -483,15 +476,6 @@ class ContestParticipant(models.Model): help_text='手動或系統自動交卷原因' ) - # Anonymous mode nickname - nickname = models.CharField( - max_length=50, - blank=True, - default='', - verbose_name='暱稱', - help_text='匿名模式下顯示的名稱,預設為用戶名' - ) - # Explicit exam state (primary state field for UI) exam_status = models.CharField( max_length=20, diff --git a/backend/apps/contests/serializers.py b/backend/apps/contests/serializers.py index e56b4c83..cfd03bb7 100644 --- a/backend/apps/contests/serializers.py +++ b/backend/apps/contests/serializers.py @@ -95,7 +95,6 @@ class ContestDetailSerializer(serializers.ModelSerializer): accepted_at = serializers.SerializerMethodField() submitted_at = serializers.SerializerMethodField() auto_unlock_at = serializers.SerializerMethodField() - my_nickname = serializers.SerializerMethodField() problems = serializers.SerializerMethodField() participant_count = serializers.SerializerMethodField() admins = serializers.SerializerMethodField() @@ -139,7 +138,6 @@ class Meta: 'created_at', 'updated_at', # Computed fields - 'my_nickname', 'current_user_role', 'permissions', 'has_joined', @@ -159,7 +157,6 @@ class Meta: 'max_cheat_warnings', 'allow_auto_unlock', 'auto_unlock_minutes', - 'anonymous_mode_enabled', 'participant_count', 'admins', 'is_classroom_bound', @@ -193,11 +190,6 @@ def _get_current_registration(self, obj): cache[cache_key] = obj.registrations.filter(user=user).first() return cache[cache_key] - def get_my_nickname(self, obj): - """Get nickname for current user.""" - registration = self._get_current_registration(obj) - return registration.nickname if registration else None - def get_lock_reason(self, obj): """Get lock reason for current user.""" registration = self._get_current_registration(obj) @@ -414,7 +406,6 @@ class Meta: 'allow_auto_unlock', 'auto_unlock_minutes', 'status', - 'anonymous_mode_enabled', 'results_published', ] read_only_fields = ['id'] @@ -864,25 +855,8 @@ class Meta: read_only_fields = ['author', 'status', 'answered_at', 'author_username', 'author_display_name', 'problem_title'] def get_author_display_name(self, obj): - """根據匿名模式返回適當的作者名稱""" - request = self.context.get('request') - contest = obj.contest - - if not contest.anonymous_mode_enabled: - return obj.author.username - - # 管理者可見真實名稱 - if request and request.user.is_authenticated: - from .permissions import get_user_role_in_contest - role = get_user_role_in_contest(request.user, contest) - if role in ['admin', 'owner', 'teacher']: - return obj.author.username - - # 查找該用戶的暱稱 - participant = ContestParticipant.objects.filter( - contest=contest, user=obj.author - ).first() - return participant.nickname if participant else obj.author.username + profile = getattr(obj.author, 'profile', None) + return getattr(profile, 'display_name', '') or obj.author.username class ClarificationCreateSerializer(serializers.ModelSerializer): @@ -1070,9 +1044,7 @@ class ContestParticipantSerializer(serializers.ModelSerializer): user = UserSerializer(read_only=True) user_id = serializers.IntegerField(source='user.id', read_only=True) username = serializers.CharField(source='user.username', read_only=True) - nickname = serializers.CharField(read_only=True) display_name = serializers.SerializerMethodField() - user_display_name = serializers.SerializerMethodField() account_role = serializers.CharField(source='user.role', read_only=True) auth_provider = serializers.CharField(source='user.auth_provider', read_only=True) total_score = serializers.SerializerMethodField() @@ -1090,7 +1062,7 @@ class Meta: 'user_id', 'username', 'user', 'score', 'total_score', 'rank', 'joined_at', 'exam_status', 'lock_reason', 'violation_count', 'submit_reason', 'auto_unlock_at', 'remaining_unlock_seconds', - 'nickname', 'display_name', 'user_display_name', 'account_role', 'auth_provider', + 'display_name', 'account_role', 'auth_provider', 'connection_status', 'last_heartbeat_at', 'live_monitoring_online', 'live_monitoring_sources', ] @@ -1180,23 +1152,8 @@ def get_total_score(self, obj): return total def get_display_name(self, obj): - """根據權限返回適當的顯示名稱""" - request = self.context.get('request') - contest = obj.contest - - # 非匿名模式,直接返回真實用戶名 - if not contest.anonymous_mode_enabled: - return obj.user.username - - # 管理者可見真實名稱 - if request and request.user.is_authenticated: - from .permissions import get_user_role_in_contest - role = get_user_role_in_contest(request.user, contest) - if role in ['admin', 'owner', 'teacher']: - return obj.user.username - - # 其他用戶看暱稱 (nickname 預設為 username) - return obj.nickname or obj.user.username + profile = getattr(obj.user, 'profile', None) + return getattr(profile, 'display_name', '') or "" def get_auto_unlock_at(self, obj): """Calculate auto unlock time if applicable.""" @@ -1218,13 +1175,6 @@ def get_remaining_unlock_seconds(self, obj): return int((unlock_at - now).total_seconds()) - def get_user_display_name(self, obj): - profile = getattr(obj.user, 'profile', None) - if not profile: - return "" - return profile.display_name or "" - - # ============================================================================ # Exam Answer Serializers # ============================================================================ @@ -1258,7 +1208,7 @@ class ExamAnswerDetailSerializer(serializers.ModelSerializer): ) participant_user_id = serializers.SerializerMethodField() participant_username = serializers.SerializerMethodField() - participant_nickname = serializers.SerializerMethodField() + participant_display_name = serializers.SerializerMethodField() class Meta: model = ExamAnswer @@ -1268,7 +1218,7 @@ class Meta: 'answer', 'is_correct', 'score', 'feedback', 'question_snapshot', 'graded_by_username', 'graded_at', - 'participant_user_id', 'participant_username', 'participant_nickname', + 'participant_user_id', 'participant_username', 'participant_display_name', 'created_at', 'updated_at', ] read_only_fields = fields @@ -1304,15 +1254,16 @@ def get_participant_user_id(self, obj): def get_participant_username(self, obj): return obj.participant.user.username - def get_participant_nickname(self, obj): - return obj.participant.nickname or obj.participant.user.username + def get_participant_display_name(self, obj): + profile = getattr(obj.participant.user, 'profile', None) + return getattr(profile, 'display_name', '') or "" class ExamAnswerGradingSerializer(serializers.ModelSerializer): """Slim serializer for grading screens. Drops redundant per-row duplicates (question_prompt/type/options/explanation/ - max_score/question_snapshot and participant_username/nickname). Consumers + max_score/question_snapshot and participant_username/display_name). Consumers should join question info via GET /exam-questions/ and participant info via contest participants list — both are O(题数) / O(学生数) rather than O(answers).""" diff --git a/backend/apps/contests/services/export_service.py b/backend/apps/contests/services/export_service.py index 035dba57..9b3e216e 100644 --- a/backend/apps/contests/services/export_service.py +++ b/backend/apps/contests/services/export_service.py @@ -163,7 +163,8 @@ def build_paper_exam_results_csv_response(contest): participant_count = len(students) for p in students: - display_name = p.nickname or p.user.username + profile = getattr(p.user, 'profile', None) + display_name = getattr(profile, 'display_name', '') or '' status_label = p.get_exam_status_display() p_answers = answer_lookup.get(p.id, {}) graded_count = sum( diff --git a/backend/apps/contests/services/participant_dashboard.py b/backend/apps/contests/services/participant_dashboard.py index aa7a2ab9..871e6bfb 100644 --- a/backend/apps/contests/services/participant_dashboard.py +++ b/backend/apps/contests/services/participant_dashboard.py @@ -79,9 +79,7 @@ def _serialize_participant(participant: ContestParticipant) -> dict[str, Any]: return { "user_id": participant.user_id, "username": participant.user.username, - "nickname": participant.nickname or participant.user.username, - "display_name": participant.nickname or participant.user.username, - "user_display_name": getattr(profile, "display_name", "") or "", + "display_name": getattr(profile, "display_name", "") or "", "account_role": getattr(participant.user, "role", "student"), "auth_provider": getattr(participant.user, "auth_provider", "email"), "email": getattr(participant.user, "email", ""), diff --git a/backend/apps/contests/services/scoreboard.py b/backend/apps/contests/services/scoreboard.py index 427e90ed..dc477ba4 100644 --- a/backend/apps/contests/services/scoreboard.py +++ b/backend/apps/contests/services/scoreboard.py @@ -88,7 +88,6 @@ def calculate(contest: Contest, user_scope: ScoreboardScope) -> ScoreboardResult stats[participant.user.id] = { "user": UserSerializer(participant.user).data, "display_name": display_name, - "nickname": participant.nickname, "solved": 0, "rank": participant.rank, "score": participant.score, @@ -177,11 +176,5 @@ def _build_display_name( is_privileged: bool, use_export_display: bool, ) -> str: - if use_export_display: - return participant.nickname or participant.user.username - - if not contest.anonymous_mode_enabled: - return participant.user.username - if is_privileged: - return participant.user.username - return participant.nickname or participant.user.username + profile = getattr(participant.user, "profile", None) + return getattr(profile, "display_name", "") or participant.user.username diff --git a/backend/apps/contests/tests/management/test_contest_viewset_actions.py b/backend/apps/contests/tests/management/test_contest_viewset_actions.py index fd64de60..3b630736 100644 --- a/backend/apps/contests/tests/management/test_contest_viewset_actions.py +++ b/backend/apps/contests/tests/management/test_contest_viewset_actions.py @@ -276,7 +276,7 @@ def test_owner_can_list_participants( assert response.status_code == status.HTTP_200_OK assert len(response.data) == 1 assert response.data[0]["user"]["id"] == student.id - assert response.data[0]["user_display_name"] == "Student Display" + assert response.data[0]["display_name"] == "Student Display" assert response.data[0]["account_role"] == student.role assert response.data[0]["auth_provider"] == student.auth_provider @@ -455,37 +455,6 @@ def test_register_rejects_non_published_contest( assert response.data["message"] == "Contest is not published" -@pytest.mark.django_db -def test_update_nickname_handles_not_registered_and_blank_nickname( - api_client: APIClient, - contest: Contest, - student: User, -) -> None: - contest.anonymous_mode_enabled = True - contest.save(update_fields=["anonymous_mode_enabled"]) - api_client.force_authenticate(user=student) - - not_registered = api_client.post( - f"/api/v1/contests/{contest.id}/update_nickname/", - {"nickname": "newname"}, - format="json", - ) - assert not_registered.status_code == status.HTTP_400_BAD_REQUEST - assert not_registered.data["error"] == "Not registered for this contest" - - participant = ContestParticipant.objects.create(contest=contest, user=student, nickname="old") - response = api_client.post( - f"/api/v1/contests/{contest.id}/update_nickname/", - {"nickname": " "}, - format="json", - ) - - assert response.status_code == status.HTTP_200_OK - participant.refresh_from_db() - assert participant.nickname == student.username - assert response.data["nickname"] == student.username - - @pytest.mark.django_db def test_enter_privileged_user_and_leave_without_registration( api_client: APIClient, diff --git a/backend/apps/contests/tests/participation/test_participation.py b/backend/apps/contests/tests/participation/test_participation.py index b735c5a9..4fb18049 100644 --- a/backend/apps/contests/tests/participation/test_participation.py +++ b/backend/apps/contests/tests/participation/test_participation.py @@ -252,30 +252,3 @@ def test_enter_and_leave_contest(self): # Try to enter again response = self.client.post(url_enter) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_nickname_requires_anonymous_mode(self): - ContestParticipant.objects.create(contest=self.public_contest, user=self.user) - url = reverse('contests:contest-update-nickname', args=[self.public_contest.id]) - response = self.client.post(url, {'nickname': 'alias'}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data.get('error'), 'Anonymous mode is not enabled for this contest') - - def test_update_nickname_rejects_long_value(self): - contest = Contest.objects.create( - name='Anonymous Contest', - start_time=timezone.now(), - end_time=timezone.now() + timedelta(hours=2), - owner=self.admin, - visibility='public', - status='published', - anonymous_mode_enabled=True, - ) - ContestParticipant.objects.create(contest=contest, user=self.user) - long_nickname = 'n' * 51 - url = reverse('contests:contest-update-nickname', args=[contest.id]) - response = self.client.post(url, {'nickname': long_nickname}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data.get('error'), - 'Nickname is too long (max 50 characters)', - ) diff --git a/backend/apps/contests/tests/standings/test_contest_legacy_flows.py b/backend/apps/contests/tests/standings/test_contest_legacy_flows.py index a1a27795..4269b04a 100644 --- a/backend/apps/contests/tests/standings/test_contest_legacy_flows.py +++ b/backend/apps/contests/tests/standings/test_contest_legacy_flows.py @@ -62,7 +62,6 @@ class Meta: contest = factory.SubFactory(ContestFactory) user = factory.SubFactory(UserFactory) exam_status = ExamStatus.IN_PROGRESS - nickname = "" class ContestProblemBindingFactory(factory.django.DjangoModelFactory): @@ -223,53 +222,6 @@ def test_standings_shows_problem_titles_for_admins( assert problem.get("title") is not None -@pytest.mark.django_db -def test_anonymous_mode_uses_nickname_for_students( - api_client: APIClient, -) -> None: - teacher = UserFactory(role="teacher") - viewer = UserFactory() - other = UserFactory() - contest, _ = create_contest_with_problem( - owner=teacher, - scoreboard_visible_during_contest=True, - anonymous_mode_enabled=True, - ) - ContestParticipantFactory(contest=contest, user=viewer, nickname="alpha") - ContestParticipantFactory(contest=contest, user=other, nickname="beta") - - api_client.force_authenticate(user=viewer) - response = api_client.get(f"/api/v1/contests/{contest.id}/standings/") - - assert response.status_code == 200 - display_names = {entry["display_name"] for entry in response.data.get("standings", [])} - assert "alpha" in display_names - assert "beta" in display_names - - -@pytest.mark.django_db -def test_anonymous_mode_admin_sees_real_names( - api_client: APIClient, -) -> None: - teacher = UserFactory(role="teacher") - admin = UserFactory(role="admin", is_staff=True) - participant = UserFactory() - contest, _ = create_contest_with_problem( - owner=teacher, - scoreboard_visible_during_contest=True, - anonymous_mode_enabled=True, - ) - ContestParticipantFactory(contest=contest, user=participant, nickname="alias") - - api_client.force_authenticate(user=admin) - response = api_client.get(f"/api/v1/contests/{contest.id}/standings/") - - assert response.status_code == 200 - display_names = {entry["display_name"] for entry in response.data.get("standings", [])} - assert participant.username in display_names - assert "alias" not in display_names - - @pytest.mark.django_db def test_standings_excludes_test_submissions_from_score( api_client: APIClient, diff --git a/backend/apps/contests/tests/test_participant_dashboard_api.py b/backend/apps/contests/tests/test_participant_dashboard_api.py index 6898b2df..f51cbbb3 100644 --- a/backend/apps/contests/tests/test_participant_dashboard_api.py +++ b/backend/apps/contests/tests/test_participant_dashboard_api.py @@ -63,7 +63,6 @@ def _create_participant(self, contest: Contest) -> ContestParticipant: user=self.student, exam_status=ExamStatus.IN_PROGRESS, started_at=timezone.now() - timedelta(minutes=20), - nickname="Stu Dashboard", score=12, violation_count=2, ) diff --git a/backend/apps/contests/views/contest.py b/backend/apps/contests/views/contest.py index 17b16314..c80a9c43 100644 --- a/backend/apps/contests/views/contest.py +++ b/backend/apps/contests/views/contest.py @@ -212,7 +212,6 @@ def _ensure_classroom_bound_participant(self, contest: Contest, user): if contest.delivery_mode == "practice" else AssignmentState.ACCEPTED ), - "nickname": user.username, }, ) return participant, created, None @@ -855,9 +854,6 @@ def register(self, request, pk=None): ) if error_response is not None: return error_response - if participant.nickname != user.username and not participant.nickname: - participant.nickname = user.username - participant.save(update_fields=["nickname"]) if not created: raise DRFValidationError('Already registered') ContestActivityViewSet.log_activity( @@ -871,46 +867,6 @@ def register(self, request, pk=None): status=status.HTTP_201_CREATED, ) - @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated], url_path='update_nickname') - def update_nickname(self, request, pk=None): - """ - Allow user to update their nickname in an anonymous contest. - """ - contest = self.get_object() - user = request.user - - if not contest.anonymous_mode_enabled: - return Response( - {'error': 'Anonymous mode is not enabled for this contest'}, - status=status.HTTP_400_BAD_REQUEST - ) - - try: - participant = ContestParticipant.objects.get(contest=contest, user=user) - except ContestParticipant.DoesNotExist: - return Response( - {'error': 'Not registered for this contest'}, - status=status.HTTP_400_BAD_REQUEST - ) - - nickname = request.data.get('nickname', '').strip() - if not nickname: - nickname = user.username - - if len(nickname) > 50: - return Response( - {'error': 'Nickname is too long (max 50 characters)'}, - status=status.HTTP_400_BAD_REQUEST - ) - - participant.nickname = nickname - participant.save() - - return Response({ - 'status': 'updated', - 'nickname': nickname - }) - @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) def enter(self, request, pk=None): """ diff --git a/backend/apps/contests/views/exam_answer.py b/backend/apps/contests/views/exam_answer.py index 86f4ca5f..a83ba3b8 100644 --- a/backend/apps/contests/views/exam_answer.py +++ b/backend/apps/contests/views/exam_answer.py @@ -64,6 +64,11 @@ def _student_participants_qs(self, contest): user__role='student', ).select_related('user') + @staticmethod + def _participant_display_name(participant): + profile = getattr(participant.user, 'profile', None) + return getattr(profile, 'display_name', '') or '' + @staticmethod def _build_score_distribution(scores, max_score): buckets = [ @@ -151,8 +156,7 @@ def _build_option_distribution(cls, question, answers, participants_by_id): participants.append({ 'participant_id': participant.id, 'username': participant.user.username, - 'nickname': participant.nickname or None, - 'display_name': participant.nickname or participant.user.username, + 'display_name': cls._participant_display_name(participant), }) if isinstance(correct_answer, list): @@ -544,8 +548,7 @@ def question_detail(self, request, contest_pk=None): { 'participant_id': participant.id, 'username': participant.user.username, - 'nickname': participant.nickname or None, - 'display_name': participant.nickname or participant.user.username, + 'display_name': self._participant_display_name(participant), } for participant in participants if participant.id not in answered_participant_ids @@ -560,10 +563,8 @@ def question_detail(self, request, contest_pk=None): 'exam_answer_id': answer['id'], 'participant_id': answer['participant_id'], 'username': participants_by_id[answer['participant_id']].user.username, - 'nickname': participants_by_id[answer['participant_id']].nickname or None, - 'display_name': ( - participants_by_id[answer['participant_id']].nickname - or participants_by_id[answer['participant_id']].user.username + 'display_name': self._participant_display_name( + participants_by_id[answer['participant_id']], ), 'score': float(answer['score']) if answer['score'] is not None else None, 'graded_at': answer['graded_at'], diff --git a/backend/apps/core/management/commands/seed_loadtest_data.py b/backend/apps/core/management/commands/seed_loadtest_data.py index c87614e1..dbfc132d 100644 --- a/backend/apps/core/management/commands/seed_loadtest_data.py +++ b/backend/apps/core/management/commands/seed_loadtest_data.py @@ -292,7 +292,7 @@ def _register_participants(self, contest): for student in students: if student.id not in existing: new_participants.append( - ContestParticipant(contest=contest, user=student, nickname=student.username) + ContestParticipant(contest=contest, user=student) ) if new_participants: ContestParticipant.objects.bulk_create(new_participants, ignore_conflicts=True) diff --git a/frontend/src/core/entities/contest.entity.ts b/frontend/src/core/entities/contest.entity.ts index 7306a933..4915c7f3 100644 --- a/frontend/src/core/entities/contest.entity.ts +++ b/frontend/src/core/entities/contest.entity.ts @@ -98,7 +98,7 @@ export interface ContestParticipant { userId: string; username: string; email?: string; - userDisplayName?: string; + displayName?: string; accountRole?: string; authProvider?: string; connectionStatus?: "offline" | "online" | "live"; @@ -115,9 +115,6 @@ export interface ContestParticipant { lockReason?: string; violationCount: number; submitReason?: string; - // Anonymous mode fields - nickname?: string; - displayName?: string; } export type ParticipantDashboardDetail = @@ -324,10 +321,6 @@ export interface ContestDetail extends Contest { screenShareRecoveryGraceMs?: number; scoreboardVisibleDuringContest: boolean; - // Anonymous mode - anonymousModeEnabled?: boolean; - myNickname?: string; - // Advanced settings allowMultipleJoins: boolean; maxCheatWarnings: number; @@ -546,7 +539,6 @@ export interface ScoreboardProblemCell { export interface ScoreboardRow { userId: string; displayName: string; - nickname?: string; solvedCount: number; totalScore: number; penalty: number; @@ -663,7 +655,6 @@ export interface ContestUpdateRequest { warningTimeoutSeconds?: number; screenShareRecoveryGraceMs?: number; scoreboardVisibleDuringContest?: boolean; - anonymousModeEnabled?: boolean; allowMultipleJoins?: boolean; maxCheatWarnings?: number; allowAutoUnlock?: boolean; diff --git a/frontend/src/core/ports/contest.repository.ts b/frontend/src/core/ports/contest.repository.ts index b93cfd9a..ecd3d917 100644 --- a/frontend/src/core/ports/contest.repository.ts +++ b/frontend/src/core/ports/contest.repository.ts @@ -33,7 +33,7 @@ export interface IContestRepository { toggleStatus(id: string): Promise<{ status: string }>; registerContest( id: string, - data?: { password?: string; nickname?: string } + data?: { password?: string } ): Promise; enterContest(id: string, data?: { password?: string }): Promise; archiveContest(id: string): Promise; @@ -199,7 +199,6 @@ export interface ContestUpdatePayload { warningTimeoutSeconds?: number; screenShareRecoveryGraceMs?: number; scoreboardVisibleDuringContest?: boolean; - anonymousModeEnabled?: boolean; allowMultipleJoins?: boolean; maxCheatWarnings?: number; allowAutoUnlock?: boolean; diff --git a/frontend/src/core/usecases/contest/joinContest.usecase.test.ts b/frontend/src/core/usecases/contest/joinContest.usecase.test.ts index 33e35203..88f8a681 100644 --- a/frontend/src/core/usecases/contest/joinContest.usecase.test.ts +++ b/frontend/src/core/usecases/contest/joinContest.usecase.test.ts @@ -22,6 +22,7 @@ describe("joinContest.usecase", () => { endTime: "", status: "published", visibility: "private", + hasJoined: false, isRegistered: false, }, undefined @@ -40,6 +41,7 @@ describe("joinContest.usecase", () => { endTime: "", status: "published", visibility: "public", + hasJoined: true, isRegistered: true, }, undefined @@ -58,6 +60,7 @@ describe("joinContest.usecase", () => { endTime: "", status: "published", visibility: "public", + hasJoined: false, isRegistered: false, }, undefined @@ -72,12 +75,10 @@ describe("joinContest.usecase", () => { const result = await joinContestUseCase({ contestId: "c1", password: "secret", - nickname: "neo", }); expect(registerContest).toHaveBeenCalledWith("c1", { password: "secret", - nickname: "neo", }); expect(result).toEqual({ success: true }); }); diff --git a/frontend/src/core/usecases/contest/joinContest.usecase.ts b/frontend/src/core/usecases/contest/joinContest.usecase.ts index 48de063c..3feedbef 100644 --- a/frontend/src/core/usecases/contest/joinContest.usecase.ts +++ b/frontend/src/core/usecases/contest/joinContest.usecase.ts @@ -17,7 +17,6 @@ import type { ContestDetail } from "@/core/entities/contest.entity"; export interface JoinContestInput { contestId: string; password?: string; - nickname?: string; } export interface JoinContestOutput { @@ -43,7 +42,7 @@ export function validateJoinContest( } // Check if already registered - if (contest.isRegistered) { + if (contest.hasJoined) { return { valid: false, error: "Already registered for this contest", @@ -60,10 +59,10 @@ export function validateJoinContest( export async function joinContestUseCase( input: JoinContestInput ): Promise { - const { contestId, password, nickname } = input; + const { contestId, password } = input; try { - await registerContest(contestId, { password, nickname }); + await registerContest(contestId, { password }); return { success: true, diff --git a/frontend/src/features/app/components/UserMenu.tsx b/frontend/src/features/app/components/UserMenu.tsx index 53171791..859df1a3 100644 --- a/frontend/src/features/app/components/UserMenu.tsx +++ b/frontend/src/features/app/components/UserMenu.tsx @@ -3,13 +3,10 @@ import { HeaderGlobalAction, HeaderPanel, Modal, - TextInput, - InlineLoading, } from "@carbon/react"; import { Login, Logout, - Edit, Code, Book, RecentlyViewed, @@ -26,7 +23,6 @@ import { useTranslation } from "react-i18next"; import { useUserPreferences } from "@/features/auth/hooks/useUserPreferences"; import type { ContestDetail } from "@/core/entities/contest.entity"; import { getClassroomContestDashboardPath } from "@/features/contest/domain/contestRoutePolicy"; -import { updateNickname } from "@/infrastructure/api/repositories"; import { Avatar } from "@/shared/ui/avatar"; import "./UserMenu.scss"; @@ -44,7 +40,6 @@ export const UserMenu: React.FC = ({ onExpandedChange, contestMode = false, contest, - onContestRefresh, settingsOnly = false, }) => { const navigate = useNavigate(); @@ -57,15 +52,6 @@ export const UserMenu: React.FC = ({ const [isExpandedInternal, setIsExpandedInternal] = useState(false); const [isLogoutModalOpen, setIsLogoutModalOpen] = useState(false); - const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false); - const [nickname, setNickname] = useState(contest?.myNickname || ""); - const [nicknameLoading, setNicknameLoading] = useState(false); - - useEffect(() => { - if (contest?.myNickname) { - setNickname(contest.myNickname); - } - }, [contest?.myNickname]); const isExpanded = isExpandedInternal && !otherPanelExpanded; @@ -120,27 +106,6 @@ export const UserMenu: React.FC = ({ window.location.assign("/dev/storybook/"); }; - const handleNicknameUpdate = async () => { - if (!contest) return; - setNicknameLoading(true); - try { - await updateNickname(contest.id, nickname); - onContestRefresh?.(); - setIsNicknameModalOpen(false); - } catch (error) { - console.error("Failed to update nickname", error); - alert(tContest("avatar.updateFailed")); - } finally { - setNicknameLoading(false); - } - }; - - const canEditNickname = - contestMode && - contest?.anonymousModeEnabled && - (contest.examStatus !== "in_progress" || - contest.currentUserRole === "admin"); - const getRoleLabel = (role: string | undefined) => { if (!role) return t("user.role.student"); return t(`user.role.${role}`); @@ -189,18 +154,6 @@ export const UserMenu: React.FC = ({ {getRoleLabel(user.role)} - {/* Contest nickname */} - {contestMode && canEditNickname && ( - - )} - {contestMode && contest?.boundClassroomId && ( + ) : null} ); } diff --git a/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.test.tsx b/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.test.tsx index 14695a9c..3b7b9bed 100644 --- a/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.test.tsx +++ b/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { ContestParticipant } from "@/core/entities/contest.entity"; import type { AdminOverviewDashboardData } from "@/features/contest/screens/admin/panels/adminOverviewDashboard.model"; +import type { InsightCardAction } from "./AdminInsightRail"; import AdminOverviewCommandCenter from "./AdminOverviewCommandCenter"; const participantDashboardState = vi.hoisted(() => ({ @@ -186,7 +187,7 @@ const participants: ContestParticipant[] = [ { userId: "1", username: "ming", - userDisplayName: "王小明", + displayName: "王小明", connectionStatus: "online", liveMonitoringOnline: true, score: 82, @@ -198,7 +199,7 @@ const participants: ContestParticipant[] = [ { userId: "2", username: "hua", - userDisplayName: "陳小華", + displayName: "陳小華", connectionStatus: "offline", score: 74, joinedAt: "2026-05-03T09:00:00+08:00", @@ -217,12 +218,14 @@ describe("AdminOverviewCommandCenter", () => { adminLoading = false, gradingLoading = false, antiCheatEnabled = true, + gradingAction, }: { onOpenPanel?: (panel: string) => void; overrideData?: AdminOverviewDashboardData; adminLoading?: boolean; gradingLoading?: boolean; antiCheatEnabled?: boolean; + gradingAction?: InsightCardAction; } = {}) => render( { participants={participants} primary={primary} questionStatsGallery={questionStatsGallery} + gradingAction={gradingAction} />, ); @@ -259,8 +263,13 @@ describe("AdminOverviewCommandCenter", () => { screen.getByRole("progressbar", { name: "時間進度" }), ).toHaveAttribute("aria-valuenow", "62"); expect(screen.getAllByText("違規事件").length).toBeGreaterThan(0); - const distributionOverview = screen.getByLabelText("考試進度"); + const distributionOverview = screen.getByLabelText("考生分佈總覽"); expect(distributionOverview).toBeInTheDocument(); + expect( + within(distributionOverview).getByRole("progressbar", { + name: "作答進度", + }), + ).toHaveAttribute("aria-valuenow", "17"); expect( within(distributionOverview).queryByText("離線"), ).not.toBeInTheDocument(); @@ -320,7 +329,7 @@ describe("AdminOverviewCommandCenter", () => { it("keeps only essential drilldown content in the left overview column", () => { renderCommandCenter(); - expect(screen.getByLabelText("考試進度")).toBeInTheDocument(); + expect(screen.getByLabelText("考生分佈總覽")).toBeInTheDocument(); expect(screen.queryByText("auto_submit")).not.toBeInTheDocument(); expect(screen.getByText("陳小華")).toBeInTheDocument(); expect(screen.queryByText("競賽發布")).not.toBeInTheDocument(); @@ -333,6 +342,21 @@ describe("AdminOverviewCommandCenter", () => { expect(screen.queryByText("題目統計")).not.toBeInTheDocument(); }); + it("shows the publish results action under grading progress", async () => { + const onPublishResults = vi.fn(); + + renderCommandCenter({ + gradingAction: { + label: "發布成績", + onClick: onPublishResults, + }, + }); + + await userEvent.click(screen.getByRole("button", { name: "發布成績" })); + + expect(onPublishResults).toHaveBeenCalledTimes(1); + }); + it("groups participants by status and switches the card metric", async () => { renderCommandCenter(); @@ -427,7 +451,7 @@ describe("AdminOverviewCommandCenter", () => { expect(screen.getByLabelText("考生列表載入中")).toBeInTheDocument(); expect(screen.getByLabelText("批改資料載入中")).toBeInTheDocument(); - expect(screen.getByLabelText("考試進度")).toBeInTheDocument(); + expect(screen.getByLabelText("考生分佈總覽")).toBeInTheDocument(); }); it("keeps generic panel entries out of the live dashboard", () => { diff --git a/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.tsx b/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.tsx index eda35555..ff35fb1c 100644 --- a/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.tsx +++ b/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.tsx @@ -44,6 +44,7 @@ import type { } from "@/core/entities/contest.entity"; import AdminInsightRail, { PriorityEventsInsightCard, + type InsightCardAction, } from "@/features/contest/components/admin/AdminInsightRail"; import AdminSegmentedDashboard from "@/features/contest/components/admin/AdminSegmentedDashboard"; import ParticipantDashboardPane from "@/features/contest/components/participants/ParticipantDashboardPane"; @@ -85,14 +86,14 @@ interface AdminOverviewCommandCenterProps { primary: ReactNode; questionStatsGallery?: ReactNode; resultOverview?: ReactNode; + gradingAction?: InsightCardAction; } const isStudentParticipant = (participant: ContestParticipant) => !participant.accountRole || participant.accountRole === "student"; -const displayName = (participant: ContestParticipant) => +const getProfileDisplayName = (participant: ContestParticipant) => participant.displayName || - participant.userDisplayName || participant.username || participant.userId; @@ -309,6 +310,7 @@ export default function AdminOverviewCommandCenter({ primary, questionStatsGallery, resultOverview, + gradingAction, }: AdminOverviewCommandCenterProps) { const { t } = useTranslation("contest"); const { showToast } = useToast(); @@ -558,7 +560,10 @@ export default function AdminOverviewCommandCenter({ .sort( (left, right) => getParticipantSortScore(right) - getParticipantSortScore(left) || - displayName(left).localeCompare(displayName(right), "zh-TW"), + getProfileDisplayName(left).localeCompare( + getProfileDisplayName(right), + "zh-TW", + ), ); const filteredStudentParticipants = useMemo(() => { const normalizedQuery = participantSearch.trim().toLowerCase(); @@ -573,7 +578,7 @@ export default function AdminOverviewCommandCenter({ } if (!normalizedQuery) return true; return [ - displayName(participant), + getProfileDisplayName(participant), participant.username, participant.email, participant.lockReason, @@ -783,7 +788,7 @@ export default function AdminOverviewCommandCenter({ >
- {displayName(participant)} + {getProfileDisplayName(participant)} @{participant.username} setParticipantSearch("")} @@ -976,6 +981,7 @@ export default function AdminOverviewCommandCenter({ {resultOverview} = { warningTimeoutSeconds: "warningTimeoutSeconds", screenShareRecoveryGraceMs: "screenShareRecoveryGraceMs", scoreboardVisibleDuringContest: "scoreboardVisibleDuringContest", - anonymousModeEnabled: "anonymousModeEnabled", maxCheatWarnings: "maxCheatWarnings", allowMultipleJoins: "allowMultipleJoins", allowAutoUnlock: "allowAutoUnlock", diff --git a/frontend/src/features/contest/components/admin/settings/ContestSettingsModal.stories.tsx b/frontend/src/features/contest/components/admin/settings/ContestSettingsModal.stories.tsx index 079950ed..82cb23fd 100644 --- a/frontend/src/features/contest/components/admin/settings/ContestSettingsModal.stories.tsx +++ b/frontend/src/features/contest/components/admin/settings/ContestSettingsModal.stories.tsx @@ -31,7 +31,6 @@ function useFormState() { warningTimeoutSeconds: mockContest.warningTimeoutSeconds, screenShareRecoveryGraceMs: mockContest.screenShareRecoveryGraceMs, scoreboardVisibleDuringContest: mockContest.scoreboardVisibleDuringContest, - anonymousModeEnabled: mockContest.anonymousModeEnabled, allowMultipleJoins: mockContest.allowMultipleJoins, maxCheatWarnings: mockContest.maxCheatWarnings, allowAutoUnlock: mockContest.allowAutoUnlock, @@ -188,7 +187,7 @@ export const Display: Story = { parameters: { docs: { description: { - story: "顯示設定 panel:排行榜可見性、匿名模式。", + story: "顯示設定 panel:排行榜可見性。", }, }, }, diff --git a/frontend/src/features/contest/components/admin/settings/DisplaySettingsPanel.tsx b/frontend/src/features/contest/components/admin/settings/DisplaySettingsPanel.tsx index 4d63f360..04aee1df 100644 --- a/frontend/src/features/contest/components/admin/settings/DisplaySettingsPanel.tsx +++ b/frontend/src/features/contest/components/admin/settings/DisplaySettingsPanel.tsx @@ -32,22 +32,6 @@ export default function DisplaySettingsPanel({ /> - onRetry("anonymousModeEnabled")} - > - onChange("anonymousModeEnabled", checked)} - /> - ); } diff --git a/frontend/src/features/contest/components/admin/statistics/ContestResultDashboardPanel.tsx b/frontend/src/features/contest/components/admin/statistics/ContestResultDashboardPanel.tsx index b6b1a7e1..881ab790 100644 --- a/frontend/src/features/contest/components/admin/statistics/ContestResultDashboardPanel.tsx +++ b/frontend/src/features/contest/components/admin/statistics/ContestResultDashboardPanel.tsx @@ -930,7 +930,6 @@ function OptionDistributionList({ participants: Array<{ participantId: number; username: string; - nickname: string | null; displayName: string; }>; }>; @@ -1330,7 +1329,6 @@ function formatAnswerContent( type ObjectiveParticipantRow = { participantId: number; username: string; - nickname: string | null; displayName: string; isOmitted: boolean; selectedOptions: string[]; diff --git a/frontend/src/features/contest/components/admin/statistics/QuestionStatisticsDetail.tsx b/frontend/src/features/contest/components/admin/statistics/QuestionStatisticsDetail.tsx index 8610514a..c4e33e4c 100644 --- a/frontend/src/features/contest/components/admin/statistics/QuestionStatisticsDetail.tsx +++ b/frontend/src/features/contest/components/admin/statistics/QuestionStatisticsDetail.tsx @@ -86,7 +86,7 @@ export default function QuestionStatisticsDetail({ {(stat.subjectiveEntries ?? []).map((entry, idx) => ( ; }>; @@ -85,7 +83,6 @@ export type QuestionDetailMock = omittedParticipants?: Array<{ participantId: number; username: string; - nickname: string | null; displayName: string; }>; }) @@ -246,21 +243,21 @@ export function createContestResultDashboardMock( kind: "single_choice", scoreBands: makeScoreBands([1, 2, 5, 8, 12, 20], 2), responses: [ - { participantId: 1, username: "amy", nickname: "Amy", displayName: "Amy", score: 10, gradedAt: "2026-04-01T01:00:00.000Z", feedback: "", answer: { selected: "B" } }, - { participantId: 2, username: "ben", nickname: null, displayName: "ben", score: 0, gradedAt: "2026-04-01T01:01:00.000Z", feedback: "", answer: { selected: "A" } }, + { participantId: 1, username: "amy", displayName: "Amy", score: 10, gradedAt: "2026-04-01T01:00:00.000Z", feedback: "", answer: { selected: "B" } }, + { participantId: 2, username: "ben", displayName: "ben", score: 0, gradedAt: "2026-04-01T01:01:00.000Z", feedback: "", answer: { selected: "A" } }, ], optionDistribution: [ - { label: "A. 編譯器在執行期做最佳化", count: 6, percent: 13, isCorrect: false, participants: [{ participantId: 2, username: "ben", nickname: null, displayName: "ben" }] }, - { label: "B. 變數作用域由區塊決定", count: 24, percent: 50, isCorrect: true, participants: [{ participantId: 1, username: "amy", nickname: "Amy", displayName: "Amy" }] }, + { label: "A. 編譯器在執行期做最佳化", count: 6, percent: 13, isCorrect: false, participants: [{ participantId: 2, username: "ben", displayName: "ben" }] }, + { label: "B. 變數作用域由區塊決定", count: 24, percent: 50, isCorrect: true, participants: [{ participantId: 1, username: "amy", displayName: "Amy" }] }, { label: "C. 遞迴一定比迴圈慢", count: 14, percent: 29, isCorrect: false, participants: [] }, { label: "D. 陣列長度可在執行期改變", count: 2, percent: 4, isCorrect: false, participants: [] }, ], omittedCount: 4, omittedParticipants: [ - { participantId: 101, username: "amy", nickname: "Amy", displayName: "Amy" }, - { participantId: 102, username: "ben", nickname: null, displayName: "ben" }, - { participantId: 103, username: "carol", nickname: "Carol", displayName: "Carol" }, - { participantId: 104, username: "derek", nickname: null, displayName: "derek" }, + { participantId: 101, username: "amy", displayName: "Amy" }, + { participantId: 102, username: "ben", displayName: "ben" }, + { participantId: 103, username: "carol", displayName: "Carol" }, + { participantId: 104, username: "derek", displayName: "derek" }, ], }, q2: { @@ -268,8 +265,8 @@ export function createContestResultDashboardMock( kind: "essay", scoreBands: makeScoreBands([5, 8, 14, 11, 9], 3), responses: [ - { participantId: 11, username: "amy", nickname: "Amy", displayName: "Amy", score: 12, gradedAt: "2026-04-01T01:10:00.000Z", feedback: "", answer: { text: "程序有獨立位址空間;執行緒共享資源。" } }, - { participantId: 12, username: "ben", nickname: null, displayName: "ben", score: null, gradedAt: null, feedback: "", answer: { text: "待批改作答內容。" } }, + { participantId: 11, username: "amy", displayName: "Amy", score: 12, gradedAt: "2026-04-01T01:10:00.000Z", feedback: "", answer: { text: "程序有獨立位址空間;執行緒共享資源。" } }, + { participantId: 12, username: "ben", displayName: "ben", score: null, gradedAt: null, feedback: "", answer: { text: "待批改作答內容。" } }, ], gradingProgress: { graded: 29, total: 47 }, }, @@ -278,7 +275,7 @@ export function createContestResultDashboardMock( kind: "short_answer", scoreBands: makeScoreBands([3, 7, 14, 16, 7], 2), responses: [ - { participantId: 21, username: "carol", nickname: "Carol", displayName: "Carol", score: 8, gradedAt: "2026-04-01T01:20:00.000Z", feedback: "", answer: { text: "O(n log n)" } }, + { participantId: 21, username: "carol", displayName: "Carol", score: 8, gradedAt: "2026-04-01T01:20:00.000Z", feedback: "", answer: { text: "O(n log n)" } }, ], gradingProgress: { graded: 47, total: 47 }, }, @@ -287,17 +284,17 @@ export function createContestResultDashboardMock( kind: "multiple_choice", scoreBands: makeScoreBands([6, 5, 10, 16, 12], 3), responses: [ - { participantId: 31, username: "eva", nickname: "Eva", displayName: "Eva", score: 15, gradedAt: "2026-04-01T01:30:00.000Z", feedback: "", answer: { selected: ["A", "B", "D"] } }, + { participantId: 31, username: "eva", displayName: "Eva", score: 15, gradedAt: "2026-04-01T01:30:00.000Z", feedback: "", answer: { selected: ["A", "B", "D"] } }, ], optionDistribution: [ - { label: "A. Stack 適合 DFS", count: 34, percent: 69, isCorrect: true, participants: [{ participantId: 31, username: "eva", nickname: "Eva", displayName: "Eva" }] }, - { label: "B. Queue 適合 BFS", count: 33, percent: 67, isCorrect: true, participants: [{ participantId: 31, username: "eva", nickname: "Eva", displayName: "Eva" }] }, + { label: "A. Stack 適合 DFS", count: 34, percent: 69, isCorrect: true, participants: [{ participantId: 31, username: "eva", displayName: "Eva" }] }, + { label: "B. Queue 適合 BFS", count: 33, percent: 67, isCorrect: true, participants: [{ participantId: 31, username: "eva", displayName: "Eva" }] }, { label: "C. HashMap 保持排序", count: 21, percent: 43, isCorrect: false, participants: [] }, - { label: "D. Heap 可維護極值", count: 30, percent: 61, isCorrect: true, participants: [{ participantId: 31, username: "eva", nickname: "Eva", displayName: "Eva" }] }, + { label: "D. Heap 可維護極值", count: 30, percent: 61, isCorrect: true, participants: [{ participantId: 31, username: "eva", displayName: "Eva" }] }, ], omittedCount: 1, omittedParticipants: [ - { participantId: 105, username: "eva", nickname: "Eva", displayName: "Eva" }, + { participantId: 105, username: "eva", displayName: "Eva" }, ], }, q5: { @@ -305,7 +302,7 @@ export function createContestResultDashboardMock( kind: "essay", scoreBands: makeScoreBands([2, 4, 10, 16, 16], 3), responses: [ - { participantId: 41, username: "fred", nickname: null, displayName: "fred", score: 14, gradedAt: "2026-04-01T01:40:00.000Z", feedback: "", answer: { text: "避免 circular wait。" } }, + { participantId: 41, username: "fred", displayName: "fred", score: 14, gradedAt: "2026-04-01T01:40:00.000Z", feedback: "", answer: { text: "避免 circular wait。" } }, ], gradingProgress: { graded: 48, total: 48 }, }, @@ -314,16 +311,16 @@ export function createContestResultDashboardMock( kind: "true_false", scoreBands: makeScoreBands([7, 0, 0, 13, 28], 1), responses: [ - { participantId: 51, username: "gina", nickname: "Gina", displayName: "Gina", score: 5, gradedAt: "2026-04-01T01:50:00.000Z", feedback: "", answer: { selected: true } }, + { participantId: 51, username: "gina", displayName: "Gina", score: 5, gradedAt: "2026-04-01T01:50:00.000Z", feedback: "", answer: { selected: true } }, ], optionDistribution: [ - { label: "A. True", count: 31, percent: 65, isCorrect: true, participants: [{ participantId: 51, username: "gina", nickname: "Gina", displayName: "Gina" }] }, + { label: "A. True", count: 31, percent: 65, isCorrect: true, participants: [{ participantId: 51, username: "gina", displayName: "Gina" }] }, { label: "B. False", count: 17, percent: 35, isCorrect: false, participants: [] }, ], omittedCount: 2, omittedParticipants: [ - { participantId: 106, username: "harry", nickname: null, displayName: "harry" }, - { participantId: 107, username: "iris", nickname: "Iris", displayName: "Iris" }, + { participantId: 106, username: "harry", displayName: "harry" }, + { participantId: 107, username: "iris", displayName: "Iris" }, ], }, q7: { @@ -331,7 +328,7 @@ export function createContestResultDashboardMock( kind: "essay", scoreBands: makeScoreBands([2, 5, 9, 8, 4], 3), responses: [ - { participantId: 61, username: "jack", nickname: null, displayName: "jack", score: 13, gradedAt: "2026-04-01T02:00:00.000Z", feedback: "", answer: { text: "說明 CAP 與補償。" } }, + { participantId: 61, username: "jack", displayName: "jack", score: 13, gradedAt: "2026-04-01T02:00:00.000Z", feedback: "", answer: { text: "說明 CAP 與補償。" } }, ], gradingProgress: { graded: 28, total: 47 }, }, @@ -340,7 +337,7 @@ export function createContestResultDashboardMock( kind: "short_answer", scoreBands: makeScoreBands([10, 8, 7, 11, 9], 2), responses: [ - { participantId: 71, username: "kate", nickname: "Kate", displayName: "Kate", score: 6, gradedAt: "2026-04-01T02:10:00.000Z", feedback: "", answer: { text: "page replacement 會在 page fault 後觸發。" } }, + { participantId: 71, username: "kate", displayName: "Kate", score: 6, gradedAt: "2026-04-01T02:10:00.000Z", feedback: "", answer: { text: "page replacement 會在 page fault 後觸發。" } }, ], gradingProgress: { graded: 33, total: 45 }, }, diff --git a/frontend/src/features/contest/components/admin/statistics/contestResultDashboard.transform.ts b/frontend/src/features/contest/components/admin/statistics/contestResultDashboard.transform.ts index 7bc7526f..d0c21f3e 100644 --- a/frontend/src/features/contest/components/admin/statistics/contestResultDashboard.transform.ts +++ b/frontend/src/features/contest/components/admin/statistics/contestResultDashboard.transform.ts @@ -79,8 +79,8 @@ export function transformToDashboardData(input: TransformInput): DashboardMockDa const responses = answers.map((answer) => ({ participantId: answer.participant_user_id, username: answer.participant_username, - nickname: answer.participant_nickname || null, - displayName: answer.participant_nickname || answer.participant_username, + displayName: + answer.participant_display_name || answer.participant_username, score: answer.score, gradedAt: answer.graded_at, feedback: answer.feedback || "", diff --git a/frontend/src/features/contest/components/admin/statistics/useContestResultDashboard.ts b/frontend/src/features/contest/components/admin/statistics/useContestResultDashboard.ts index 80cdadcb..d437f4dd 100644 --- a/frontend/src/features/contest/components/admin/statistics/useContestResultDashboard.ts +++ b/frontend/src/features/contest/components/admin/statistics/useContestResultDashboard.ts @@ -210,7 +210,6 @@ function mapQuestionDetail( responses: dto.responses.map((response) => ({ participantId: response.participant_id, username: response.username, - nickname: response.nickname, displayName: response.display_name, score: response.score, gradedAt: response.graded_at, @@ -225,7 +224,6 @@ function mapQuestionDetail( participants: (item.participants ?? []).map((participant) => ({ participantId: participant.participant_id, username: participant.username, - nickname: participant.nickname, displayName: participant.display_name, })), })), @@ -233,7 +231,6 @@ function mapQuestionDetail( omittedParticipants: (dto.omitted_participants ?? []).map((item) => ({ participantId: item.participant_id, username: item.username, - nickname: item.nickname, displayName: item.display_name, })), }; @@ -246,7 +243,6 @@ function mapQuestionDetail( responses: dto.responses.map((response) => ({ participantId: response.participant_id, username: response.username, - nickname: response.nickname, displayName: response.display_name, score: response.score, gradedAt: response.graded_at, diff --git a/frontend/src/features/contest/components/admin/statistics/useExamStatistics.test.ts b/frontend/src/features/contest/components/admin/statistics/useExamStatistics.test.ts index 6f9ae25c..91e64cba 100644 --- a/frontend/src/features/contest/components/admin/statistics/useExamStatistics.test.ts +++ b/frontend/src/features/contest/components/admin/statistics/useExamStatistics.test.ts @@ -18,7 +18,7 @@ function buildAnswerRow(partial: Partial = {}): GradingAnswerR id: partial.id ?? "a-1", studentId: partial.studentId ?? "u-1", studentUsername: partial.studentUsername ?? "student", - studentNickname: partial.studentNickname ?? "Student", + studentDisplayName: partial.studentDisplayName ?? "Student", questionId: partial.questionId ?? "q-1", questionIndex: partial.questionIndex ?? 1, questionPrompt: partial.questionPrompt ?? "Question 1", diff --git a/frontend/src/features/contest/components/admin/statistics/useExamStatistics.ts b/frontend/src/features/contest/components/admin/statistics/useExamStatistics.ts index a17919e5..3f50b0be 100644 --- a/frontend/src/features/contest/components/admin/statistics/useExamStatistics.ts +++ b/frontend/src/features/contest/components/admin/statistics/useExamStatistics.ts @@ -15,7 +15,7 @@ export interface OptionStat { } export interface SubjectiveEntry { - studentNickname: string; + studentDisplayName: string; studentUsername: string; answerText: string; score: number | null; @@ -62,7 +62,7 @@ export function useExamStatistics() { optionDistribution = buildOptionDistribution(answers); } else { subjectiveEntries = answers.map((a) => ({ - studentNickname: a.studentNickname ?? "", + studentDisplayName: a.studentDisplayName ?? "", studentUsername: a.studentUsername ?? "", answerText: extractAnswerText(a), score: a.score, diff --git a/frontend/src/features/contest/components/layout/ContestExitModal.tsx b/frontend/src/features/contest/components/layout/ContestExitModal.tsx index 16608a87..5f4671be 100644 --- a/frontend/src/features/contest/components/layout/ContestExitModal.tsx +++ b/frontend/src/features/contest/components/layout/ContestExitModal.tsx @@ -48,7 +48,7 @@ const ContestExitModal: React.FC = ({ } // Student - Not joined - if (!contest?.hasJoined && !contest?.isRegistered) { + if (!contest?.hasJoined) { return t("exitModal.studentNotJoined"); } diff --git a/frontend/src/features/contest/components/layout/ContestHero.tsx b/frontend/src/features/contest/components/layout/ContestHero.tsx index cb7bc6fc..d31ac6bd 100644 --- a/frontend/src/features/contest/components/layout/ContestHero.tsx +++ b/frontend/src/features/contest/components/layout/ContestHero.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from "react"; -import { Button, Modal, TextInput, Select, SelectItem } from "@carbon/react"; +import { Button, Modal, Select, SelectItem } from "@carbon/react"; import { PlayFilled, Flag, @@ -20,7 +20,7 @@ import { } from "@/core/entities/contest.entity"; import { HeroBase } from "@/shared/layout/HeroBase"; import { KpiCard } from "@/shared/ui/dataCard"; -import { updateNickname, downloadMyReport } from "@/infrastructure/api/repositories"; +import { downloadMyReport } from "@/infrastructure/api/repositories"; import { useInterval } from "@/shared/hooks/useInterval"; import styles from "./ContestHero.module.scss"; @@ -56,7 +56,6 @@ interface ContestHeroProps { onEndExam?: () => void; onGoToAnswering?: () => void; onTabChange?: (tab: string) => void; - onRefreshContest?: () => Promise; maxWidth?: string; } @@ -66,7 +65,6 @@ const ContestHero: React.FC = ({ onStartExam, onEndExam, onGoToAnswering, - onRefreshContest, maxWidth, }) => { const { t } = useTranslation("contest"); @@ -75,11 +73,6 @@ const ContestHero: React.FC = ({ const [progress, setProgress] = useState(0); const [showEndConfirm, setShowEndConfirm] = useState(false); - // Update Nickname States - const [showUpdateNicknameModal, setShowUpdateNicknameModal] = useState(false); - const [newNickname, setNewNickname] = useState(""); - const [isUpdatingNickname, setIsUpdatingNickname] = useState(false); - // Report download state const [reportDownloading, setReportDownloading] = useState(false); const [showReportModal, setShowReportModal] = useState(false); @@ -234,7 +227,7 @@ const ContestHero: React.FC = ({ // Check if contest has ended (time-based) if (isEnded) { const hasSubmitted = contest.examStatus === "submitted" && - (contest.hasJoined || contest.isRegistered); + contest.hasJoined; if (hasSubmitted) { return (
- {/* Update Nickname Modal */} - setShowUpdateNicknameModal(false)} - onRequestSubmit={async () => { - if (!contest) return; - setIsUpdatingNickname(true); - try { - await updateNickname(contest.id, newNickname); - setShowUpdateNicknameModal(false); - if (onRefreshContest) { - await onRefreshContest(); - } else { - // Fallback if no refresh function provided (shouldn't happen in updated layout) - console.warn("No refresh function provided"); - } - } catch (error) { - console.error("Failed to update nickname", error); - const message = - error instanceof Error ? error.message : t("hero.updateFailed"); - showError(message); - } finally { - setIsUpdatingNickname(false); - } - }} - primaryButtonDisabled={isUpdatingNickname} - > -
-

{t("hero.nicknameHint")}

- setNewNickname(e.target.value)} - /> -
-
- {/* Report Download Modal */} { isSolvePage, isPaperExamPage, hasEnded, - isUpcoming, isAdmin, userScore, totalMaxScore, @@ -76,8 +71,11 @@ const ContestLayout = () => { const [monitoringModalOpen, setMonitoringModalOpen] = useState(false); const [errorModalOpen, setErrorModalOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + const isContestParticipant = !!contest?.hasJoined; const lockContestMenu = - !!contest?.cheatDetectionEnabled && contest.examStatus === "in_progress"; + isContestParticipant && + !!contest?.cheatDetectionEnabled && + contest.examStatus === "in_progress"; const { timeLeft, isCountdownToStart, unlockTimeLeft } = useContestTimers({ contest, @@ -191,7 +189,17 @@ const ContestLayout = () => { const renderMainContent = () => { const outletContent = ( - + navigate(adminPath) : undefined, + isAdmin, + }} + /> ); @@ -199,66 +207,23 @@ const ContestLayout = () => { return outletContent; } - if (isUpcoming && contest) { - return ( -
- navigate(adminPath) : undefined - } - /> -
- ); - } - - if (contest && !contest.hasJoined) { - return ( -
- navigate(adminPath) : undefined - } - /> -
- ); - } - if (isSolvePage) { return outletContent; } return ( - - } - stickyHeader={ - contest ? : undefined - } - > +
{outletContent} - +
); }; const examModeProps = { contestId: contestId || "", - cheatDetectionEnabled: !!contest?.cheatDetectionEnabled, - isExamMonitored: !!contest?.isExamMonitored, - requiresFullscreen: !!contest?.requiresFullscreen, + cheatDetectionEnabled: + isContestParticipant && !!contest?.cheatDetectionEnabled, + isExamMonitored: isContestParticipant && !!contest?.isExamMonitored, + requiresFullscreen: isContestParticipant && !!contest?.requiresFullscreen, hasEnded, lockReason: contest?.lockReason, examStatus: contest?.examStatus, diff --git a/frontend/src/features/contest/components/modals/ContestRegistrationModal.tsx b/frontend/src/features/contest/components/modals/ContestRegistrationModal.tsx index 2b381751..1a31b777 100644 --- a/frontend/src/features/contest/components/modals/ContestRegistrationModal.tsx +++ b/frontend/src/features/contest/components/modals/ContestRegistrationModal.tsx @@ -8,12 +8,12 @@ export interface ContestRegistrationModalProps { open: boolean; contest: ContestDetail; onClose: () => void; - onSubmit: (data: { nickname?: string; password?: string }) => void; + onSubmit: (data: { password?: string }) => void; } /** * 競賽報名確認 Modal - * 支援私有競賽密碼輸入、匿名模式暱稱設定 + * 支援私有競賽密碼輸入 */ export const ContestRegistrationModal: React.FC = ({ open, @@ -23,23 +23,17 @@ export const ContestRegistrationModal: React.FC = }) => { const { t } = useTranslation("contest"); const requiresPassword = contest.requiresPassword ?? contest.visibility === "private"; - const [nickname, setNickname] = useState(""); const [password, setPassword] = useState(""); const handleSubmit = () => { onSubmit({ - nickname: nickname || undefined, password: password || undefined, }); - // Reset form - setNickname(""); setPassword(""); }; const handleClose = () => { onClose(); - // Reset form - setNickname(""); setPassword(""); }; @@ -73,37 +67,6 @@ export const ContestRegistrationModal: React.FC =
)} - {/* Anonymous mode nickname input */} - {contest.anonymousModeEnabled && ( -
-

- {t( - "registration.anonymousHint", - "本競賽已啟用匿名模式,您可以設定一個暱稱。排行榜和提交列表將顯示您的暱稱而非真實帳號。", - )} -

- ) => - setNickname(e.target.value) - } - maxLength={50} - /> -

- {t("registration.nicknameHint", "您可以在報名後隨時修改暱稱。")} -

-
- )} - {/* Exam mode warning */} {contest.cheatDetectionEnabled && ( = ({
- {participant.userDisplayName || - participant.displayName || - participant.nickname || - participant.username} + {participant.displayName || participant.username} = ({ {[ { icon: UserMultiple, - label: t("dashboard.username", "使用者"), + label: t("dashboard.username", "使用者名稱"), value: participant.username || "-", }, { icon: UserMultiple, label: t("dashboard.displayName", "顯示名稱"), - value: participant.userDisplayName || "-", + value: participant.displayName || "-", }, { icon: UserMultiple, diff --git a/frontend/src/features/contest/components/participants/ParticipantOperationsPane.tsx b/frontend/src/features/contest/components/participants/ParticipantOperationsPane.tsx index 95aa88ce..4297ef26 100644 --- a/frontend/src/features/contest/components/participants/ParticipantOperationsPane.tsx +++ b/frontend/src/features/contest/components/participants/ParticipantOperationsPane.tsx @@ -47,10 +47,8 @@ interface ParticipantOperationsPaneProps { showViolationKpi?: boolean; } -const getParticipantDisplayName = (participant: ContestParticipant) => - participant.userDisplayName || +const getProfileDisplayName = (participant: ContestParticipant) => participant.displayName || - participant.nickname || participant.username; const formatClockTime = (value?: string | null) => { @@ -127,7 +125,7 @@ const ParticipantOperationsPane = ({ } const participant = dashboard.participant; - const displayName = getParticipantDisplayName(participant); + const profileDisplayName = getProfileDisplayName(participant); const primaryAction = dashboard.actions.canUnlock ? { label: t("participants.actions.unlock", "解除鎖定"), @@ -238,7 +236,9 @@ const ParticipantOperationsPane = ({
-

{displayName}

+

+ {profileDisplayName} +

@{participant.username}

diff --git a/frontend/src/features/contest/components/participants/ParticipantsListPane.test.tsx b/frontend/src/features/contest/components/participants/ParticipantsListPane.test.tsx index 8f3f4459..121d9da8 100644 --- a/frontend/src/features/contest/components/participants/ParticipantsListPane.test.tsx +++ b/frontend/src/features/contest/components/participants/ParticipantsListPane.test.tsx @@ -27,7 +27,6 @@ const participant: ContestParticipant = { userId: "1", username: "student1", displayName: "Student One", - userDisplayName: "Real Name", accountRole: "student", authProvider: "google", score: 90, @@ -50,7 +49,9 @@ describe("ParticipantsListPane", () => { ); expect(screen.getByText("顯示 1 / 3 位")).toBeInTheDocument(); - fireEvent.click(screen.getByText("Real Name")); + expect(screen.getByText("Student One")).toBeInTheDocument(); + expect(screen.getByText("@student1")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Student One")); expect(onSelect).toHaveBeenCalledWith("1"); }); @@ -65,7 +66,7 @@ describe("ParticipantsListPane", () => { />, ); - expect(screen.queryByText("顯示名稱 Real Name")).not.toBeInTheDocument(); + expect(screen.queryByText("顯示名稱 Student One")).not.toBeInTheDocument(); expect(screen.queryByText("身份 student")).not.toBeInTheDocument(); expect(screen.queryByText("註冊身份 SSO")).not.toBeInTheDocument(); }); diff --git a/frontend/src/features/contest/components/participants/ParticipantsListPane.tsx b/frontend/src/features/contest/components/participants/ParticipantsListPane.tsx index 6032d908..b218f8f6 100644 --- a/frontend/src/features/contest/components/participants/ParticipantsListPane.tsx +++ b/frontend/src/features/contest/components/participants/ParticipantsListPane.tsx @@ -29,10 +29,8 @@ interface ParticipantsListPaneProps { onSelect: (userId: string) => void; } -const getParticipantDisplayName = (participant: ContestParticipant) => - participant.userDisplayName || +const getProfileDisplayName = (participant: ContestParticipant) => participant.displayName || - participant.nickname || participant.username; const NEEDS_ATTENTION_STATUSES = new Set(["locked", "paused"]); @@ -113,7 +111,7 @@ const ParticipantsListPane: React.FC = ({
- {getParticipantDisplayName(participant)} + {getProfileDisplayName(participant)} @{participant.username} diff --git a/frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss new file mode 100644 index 00000000..cb94477c --- /dev/null +++ b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss @@ -0,0 +1,353 @@ +.root { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.dashboard { + max-width: 1056px; + margin: 0 auto; + padding: 1rem; +} + +.summaryPanel, +.infoGrid, +.progressGrid { + border: 1px solid var(--cds-border-subtle); +} + +.summaryPanel { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem); +} + +.summaryMain, +.actionPanel, +.infoCell, +.progressPanel, +.rulesPanel { + padding: 1.5rem; +} + +.summaryMain { + min-width: 0; + border-right: 1px solid var(--cds-border-subtle); +} + +.titleRow, +.sectionHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.titleRow { + align-items: center; + flex-wrap: wrap; + justify-content: flex-start; + margin-bottom: 1rem; +} + +.title { + margin: 0; + color: var(--cds-text-primary); + font-size: var(--cds-heading-04-font-size, 1.75rem); + font-weight: 600; + line-height: var(--cds-heading-04-line-height, 1.28572); +} + +.phaseTitle, +.sectionTitle { + margin: 0; + color: var(--cds-text-primary); + font-size: var(--cds-heading-compact-02-font-size, 1rem); + font-weight: 600; + line-height: var(--cds-heading-compact-02-line-height, 1.375); +} + +.phaseDescription, +.sectionDescription, +.recordMeta, +.emptyText, +.metricLabel { + color: var(--cds-text-secondary); +} + +.phaseDescription { + margin: 0.25rem 0 0; + max-width: 44rem; + font-size: var(--cds-body-01-font-size, 0.875rem); + line-height: var(--cds-body-01-line-height, 1.42857); +} + +.markdown, +.rulesContent { + margin-top: 1rem; + color: var(--cds-text-primary); +} + +.markdown { + max-width: 48rem; +} + +.actionPanel { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1.5rem; +} + +.actionStack, +.modalStack { + display: grid; + gap: 0.75rem; +} + +.timerValue { + margin: 0.25rem 0 0; + color: var(--cds-text-primary); + font-family: var(--cds-code-font-family, monospace); + font-size: var(--cds-heading-05-font-size, 2rem); + font-weight: 600; + line-height: 1.2; +} + +.infoGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-top: 0; +} + +.infoCell { + min-width: 0; + border-right: 1px solid var(--cds-border-subtle); +} + +.infoCell:last-child { + border-right: 0; +} + +.metricLabel { + margin: 0; + font-size: var(--cds-label-01-font-size, 0.75rem); + line-height: var(--cds-label-01-line-height, 1.33333); +} + +.metricValue { + margin: 0.375rem 0 0; + color: var(--cds-text-primary); + font-size: var(--cds-body-compact-02-font-size, 1rem); + font-weight: 600; + line-height: var(--cds-body-compact-02-line-height, 1.375); +} + +.progressGrid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(20rem, 24rem); + border-top: 0; +} + +.progressPanel { + border-right: 1px solid var(--cds-border-subtle); +} + +.progressTrack { + position: relative; + block-size: 0.5rem; + margin-top: 1.5rem; + overflow: hidden; + background: var(--cds-layer-accent-01); +} + +.progressFill { + block-size: 100%; + min-inline-size: 0; + background: var(--cds-support-success); +} + +.statGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.rulesPanel { + display: grid; + gap: 1rem; + align-content: start; +} + +.warningIcon { + color: var(--cds-support-warning); +} + +.successIcon { + color: var(--cds-support-success); +} + +.inlineRecordsPanel { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--cds-border-subtle); +} + +.inlineRecordsHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.inlineRecordsTitle { + margin: 0; + color: var(--cds-text-primary); + font-size: var(--cds-heading-compact-01-font-size, 0.875rem); + font-weight: 600; + line-height: var(--cds-heading-compact-01-line-height, 1.28572); +} + +.recordList { + display: grid; + margin-top: 1rem; + border-top: 1px solid var(--cds-border-subtle); +} + +.recordRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid var(--cds-border-subtle); +} + +.recordRow:last-child { + border-bottom: 0; +} + +.recordTitle { + color: var(--cds-text-primary); + font-size: var(--cds-body-compact-02-font-size, 1rem); + font-weight: 600; + line-height: var(--cds-body-compact-02-line-height, 1.375); +} + +.recordMeta { + margin-top: 0.25rem; + font-size: var(--cds-body-compact-01-font-size, 0.875rem); +} + +.recordScore { + color: var(--cds-text-primary); + font-family: var(--cds-code-font-family, monospace); + font-weight: 600; + white-space: nowrap; +} + +.problemReportList { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.problemReportItem { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 1rem; + border: 1px solid var(--cds-border-subtle); +} + +.problemReportMeta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; +} + +.errorText { + margin: 1rem 0 0; + color: var(--cds-text-error); +} + +.emptyText { + margin: 1rem 0 0; +} + +@media (max-width: 1056px) { + .summaryPanel, + .progressGrid { + grid-template-columns: 1fr; + } + + .summaryMain, + .progressPanel { + border-right: 0; + border-bottom: 1px solid var(--cds-border-subtle); + } + + .infoGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .infoCell:nth-child(2) { + border-right: 0; + } + + .infoCell:nth-child(n + 3) { + border-top: 1px solid var(--cds-border-subtle); + } +} + +@media (max-width: 672px) { + .dashboard { + padding: 0.75rem; + } + + .summaryMain, + .actionPanel, + .infoCell, + .progressPanel, + .rulesPanel { + padding: 1rem; + } + + .infoGrid, + .statGrid { + grid-template-columns: 1fr; + } + + .infoCell { + border-right: 0; + border-top: 1px solid var(--cds-border-subtle); + } + + .infoCell:first-child { + border-top: 0; + } + + .recordRow { + grid-template-columns: 1fr; + } + + .recordScore { + white-space: normal; + } + + .problemReportItem { + grid-template-columns: 1fr; + } + + .problemReportMeta { + justify-content: flex-start; + } + + .inlineRecordsHeader { + display: grid; + } + +} diff --git a/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx new file mode 100644 index 00000000..568ed566 --- /dev/null +++ b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx @@ -0,0 +1,378 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChangeEventHandler, ReactNode } from "react"; +import type { + ContestDetail, +} from "@/core/entities/contest.entity"; +import { getExamQuestions } from "@/infrastructure/api/repositories/examQuestions.repository"; +import { + getExamResults, + getMyExamAnswers, +} from "@/infrastructure/api/repositories/examAnswers.repository"; +import StudentContestDashboard from "./StudentContestDashboardView"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, +})); + +vi.mock("@carbon/react", () => ({ + Button: ({ + children, + disabled, + onClick, + }: { + children: ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), + InlineNotification: ({ + title, + subtitle, + }: { + title: ReactNode; + subtitle: ReactNode; + }) => ( +
+ {title} + {subtitle} +
+ ), + Modal: ({ + open, + children, + }: { + open: boolean; + children: ReactNode; + }) => (open ?
{children}
: null), + Select: ({ + children, + value, + onChange, + }: { + children: ReactNode; + value: string; + onChange?: ChangeEventHandler; + }) => ( + + ), + SelectItem: ({ value, text }: { value: string; text: string }) => ( + + ), + Tag: ({ children }: { children: ReactNode }) => {children}, + TextInput: () => , +})); + +vi.mock("@carbon/icons-react", () => { + const Icon = () =>
{contest.rules && ( diff --git a/frontend/src/features/contest/screens/admin/panels/AdminContestSettingsScreen.tsx b/frontend/src/features/contest/screens/admin/panels/AdminContestSettingsScreen.tsx index 4e4025d9..ce4fcb4a 100644 --- a/frontend/src/features/contest/screens/admin/panels/AdminContestSettingsScreen.tsx +++ b/frontend/src/features/contest/screens/admin/panels/AdminContestSettingsScreen.tsx @@ -249,7 +249,6 @@ const ContestSettingsOverlay = ({ open, onClose }: ContestSettingsOverlayProps) warningTimeoutSeconds: contest.warningTimeoutSeconds ?? 20, screenShareRecoveryGraceMs: contest.screenShareRecoveryGraceMs ?? 30_000, scoreboardVisibleDuringContest: contest.scoreboardVisibleDuringContest ?? false, - anonymousModeEnabled: contest.anonymousModeEnabled ?? false, allowMultipleJoins: contest.allowMultipleJoins ?? false, maxCheatWarnings: contest.maxCheatWarnings ?? 0, allowAutoUnlock: contest.allowAutoUnlock ?? false, diff --git a/frontend/src/features/contest/screens/admin/panels/AdminOverviewScreen.tsx b/frontend/src/features/contest/screens/admin/panels/AdminOverviewScreen.tsx index 2e0866c2..796c8a8d 100644 --- a/frontend/src/features/contest/screens/admin/panels/AdminOverviewScreen.tsx +++ b/frontend/src/features/contest/screens/admin/panels/AdminOverviewScreen.tsx @@ -25,7 +25,7 @@ import type { AdminPanelProps, } from "@/features/contest/modules/types"; import { useGradingData } from "@/features/contest/screens/settings/grading"; -import { addContestParticipant } from "@/infrastructure/api/repositories"; +import { addContestParticipant, updateContest } from "@/infrastructure/api/repositories"; import { exportContestResults } from "@/infrastructure/api/repositories/contestExports.repository"; import { useToast } from "@/shared/contexts/ToastContext"; import { buildAdminOverviewDashboard } from "./adminOverviewDashboard.model"; @@ -82,6 +82,7 @@ export default function AdminOverviewScreen({ const [, setSearchParams] = useSearchParams(); const [refreshing, setRefreshing] = useState(false); const [exporting, setExporting] = useState(false); + const [publishingResults, setPublishingResults] = useState(false); const [resultRefreshKey, setResultRefreshKey] = useState(0); const [addParticipantOpen, setAddParticipantOpen] = useState(false); const classroomBound = Boolean(contest?.isClassroomBound); @@ -197,6 +198,46 @@ export default function AdminOverviewScreen({ } }, [contest?.id, exporting, showToast, t]); + const handleToggleResultsPublished = useCallback(async () => { + if (!contest?.id || publishingResults) return; + const nextPublished = !contest.resultsPublished; + setPublishingResults(true); + try { + await updateContest(contest.id, { resultsPublished: nextPublished }); + await Promise.all([refreshContest(), refreshAllAdminData()]); + setResultRefreshKey((current) => current + 1); + showToast({ + kind: "success", + title: t("common.success", "成功"), + subtitle: nextPublished + ? t("adminOverview.actions.publishResultsSuccess", "成績已發布") + : t("adminOverview.actions.revokeResultsSuccess", "已撤回成績發布"), + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : nextPublished + ? t("adminOverview.actions.publishResultsFailed", "發布失敗") + : t("adminOverview.actions.revokeResultsFailed", "撤回失敗"); + showToast({ + kind: "error", + title: t("common.error", "錯誤"), + subtitle: message, + }); + } finally { + setPublishingResults(false); + } + }, [ + contest?.id, + contest?.resultsPublished, + publishingResults, + refreshAllAdminData, + refreshContest, + showToast, + t, + ]); + useEffect(() => { return registerPanelRefresh("overview", handleRefresh); }, [handleRefresh, registerPanelRefresh]); @@ -311,6 +352,16 @@ export default function AdminOverviewScreen({ onOpenPanel={openPanel} participants={participants} primary={null} + gradingAction={{ + label: contest.resultsPublished + ? t("adminOverview.actions.revokeResults", "撤回發布") + : t("adminOverview.actions.publishResults", "發布成績"), + loadingLabel: t("action.processing", "處理中..."), + onClick: () => void handleToggleResultsPublished(), + disabled: !contest.id, + loading: publishingResults, + kind: contest.resultsPublished ? "danger--tertiary" : "primary", + }} resultOverview={ { }; const getParticipantDisplayName = (participant: ContestParticipant) => - participant.userDisplayName || participant.displayName || - participant.nickname || participant.username; const isParticipantLive = (participant: ContestParticipant) => @@ -123,9 +121,7 @@ const isContestInExamWindow = (contest: ContestDetail | null | undefined, now: n const getParticipantSearchText = (participant: ContestParticipant) => [ participant.username, - participant.userDisplayName, participant.displayName, - participant.nickname, participant.email, participant.examStatus, ] diff --git a/frontend/src/features/contest/screens/admin/panels/adminOverviewDashboard.model.test.ts b/frontend/src/features/contest/screens/admin/panels/adminOverviewDashboard.model.test.ts index f53794cd..737d907f 100644 --- a/frontend/src/features/contest/screens/admin/panels/adminOverviewDashboard.model.test.ts +++ b/frontend/src/features/contest/screens/admin/panels/adminOverviewDashboard.model.test.ts @@ -63,7 +63,7 @@ const participant = ( ({ userId, username: `student-${userId}`, - userDisplayName: `學生 ${userId}`, + displayName: `學生 ${userId}`, accountRole: "student", connectionStatus: "online", score: 0, diff --git a/frontend/src/features/contest/screens/admin/panels/adminOverviewDashboard.model.ts b/frontend/src/features/contest/screens/admin/panels/adminOverviewDashboard.model.ts index dbe8b090..35311cab 100644 --- a/frontend/src/features/contest/screens/admin/panels/adminOverviewDashboard.model.ts +++ b/frontend/src/features/contest/screens/admin/panels/adminOverviewDashboard.model.ts @@ -180,9 +180,8 @@ const studentParticipants = (participants: ContestParticipant[]) => !participant.accountRole || participant.accountRole === "student", ); -const displayName = (participant: ContestParticipant) => +const getProfileDisplayName = (participant: ContestParticipant) => participant.displayName || - participant.userDisplayName || participant.username || participant.userId; @@ -381,7 +380,7 @@ export const getTeacherAttentionRows = ({ const common = { id: participant.userId, userId: participant.userId, - studentName: displayName(participant), + studentName: getProfileDisplayName(participant), timeLabel: formatTime( latestEvent?.timestamp || participantTimestamps.lockedAt || diff --git a/frontend/src/features/contest/screens/paperExam/usePaperExamFlow.ts b/frontend/src/features/contest/screens/paperExam/usePaperExamFlow.ts index 9e25ef04..50d1a4f5 100644 --- a/frontend/src/features/contest/screens/paperExam/usePaperExamFlow.ts +++ b/frontend/src/features/contest/screens/paperExam/usePaperExamFlow.ts @@ -45,7 +45,7 @@ export const usePaperExamFlow = () => { const clearError = () => setError(null); - const register = async (data?: { nickname?: string; password?: string }) => { + const register = async (data?: { password?: string }) => { const id = guardContestId(); setLoading(true); setError(null); diff --git a/frontend/src/features/contest/screens/settings/ContestExamGradingScreen.tsx b/frontend/src/features/contest/screens/settings/ContestExamGradingScreen.tsx index 270187f3..c940fd10 100644 --- a/frontend/src/features/contest/screens/settings/ContestExamGradingScreen.tsx +++ b/frontend/src/features/contest/screens/settings/ContestExamGradingScreen.tsx @@ -433,7 +433,6 @@ const ContestExamGradingScreen: React.FC = () => { username: row.displayName, }, displayName: row.displayName, - nickname: row.nickname, solved: row.solvedCount, total_score: row.totalScore, time: row.penalty, diff --git a/frontend/src/features/contest/screens/settings/grading/GradingByQuestionTabScreen.tsx b/frontend/src/features/contest/screens/settings/grading/GradingByQuestionTabScreen.tsx index 5e2ce3f0..0e758ca9 100644 --- a/frontend/src/features/contest/screens/settings/grading/GradingByQuestionTabScreen.tsx +++ b/frontend/src/features/contest/screens/settings/grading/GradingByQuestionTabScreen.tsx @@ -36,7 +36,6 @@ interface GradingByQuestionTabScreenProps { students: { studentId: string; username: string; - nickname: string; displayName?: string; }[]; onGrade: (answerId: string, score: number, feedback: string) => void; @@ -124,7 +123,7 @@ export default function GradingByQuestionTabScreen({ id: `absent-${s.studentId}-${activeQuestionId}`, studentId: s.studentId, studentUsername: s.username, - studentNickname: s.displayName || s.nickname || s.username, + studentDisplayName: s.displayName || s.username, questionId: activeQuestionId, questionIndex: qInfo?.questionIndex ?? 0, questionPrompt: qInfo?.prompt ?? "", @@ -151,7 +150,7 @@ export default function GradingByQuestionTabScreen({ rows = rows.filter( (r) => r.studentUsername.toLowerCase().includes(q) || - r.studentNickname.toLowerCase().includes(q) + r.studentDisplayName.toLowerCase().includes(q) ); } @@ -315,7 +314,7 @@ export default function GradingByQuestionTabScreen({ className={isAbsent ? styles.absentItem : undefined} > - {a.studentNickname} + {a.studentDisplayName} {a.studentUsername} @@ -383,7 +382,7 @@ export default function GradingByQuestionTabScreen({ selectedQuestion ? `Q${selectedQuestion.questionIndex}` : "—" } studentLabel={ - selectedAnswer?.studentNickname ?? t("grading.noSelection", "未選擇") + selectedAnswer?.studentDisplayName ?? t("grading.noSelection", "未選擇") } questionContent={sidebarContent} studentContent={middlePaneContent} @@ -405,7 +404,7 @@ export default function GradingByQuestionTabScreen({ ? `Q${selectedQuestion.questionIndex}` : t("grading.question", "題目"), secondary: selectedAnswer - ? `${selectedAnswer.studentNickname} (${selectedAnswer.studentUsername})` + ? `${selectedAnswer.studentDisplayName} (${selectedAnswer.studentUsername})` : undefined, }} /> diff --git a/frontend/src/features/contest/screens/settings/grading/GradingByStudentTabScreen.tsx b/frontend/src/features/contest/screens/settings/grading/GradingByStudentTabScreen.tsx index 1533867d..ea04c519 100644 --- a/frontend/src/features/contest/screens/settings/grading/GradingByStudentTabScreen.tsx +++ b/frontend/src/features/contest/screens/settings/grading/GradingByStudentTabScreen.tsx @@ -35,7 +35,7 @@ import mini from "./GradingMini.module.scss"; interface StudentSummary { studentId: string; username: string; - nickname: string; + displayName: string; totalScore: number; maxPossible: number; gradedCount: number; @@ -48,7 +48,6 @@ interface GradingByStudentTabScreenProps { students: { studentId: string; username: string; - nickname: string; displayName?: string; }[]; onGrade: (answerId: string, score: number, feedback: string) => void; @@ -91,7 +90,7 @@ export default function GradingByStudentTabScreen({ return { studentId: s.studentId, username: s.username, - nickname: s.nickname, + displayName: s.displayName || s.username, totalScore, maxPossible, gradedCount, @@ -106,7 +105,7 @@ export default function GradingByStudentTabScreen({ return studentSummaries.filter( (s) => s.username.toLowerCase().includes(q) || - s.nickname.toLowerCase().includes(q) + s.displayName.toLowerCase().includes(q) ); }, [studentSummaries, searchQuery]); const activeSelectedStudentId = useMemo(() => { @@ -141,7 +140,7 @@ export default function GradingByStudentTabScreen({ id: `absent-${activeSelectedStudentId}-${question.questionId}`, studentId: activeSelectedStudentId, studentUsername: activeStudent?.username ?? "", - studentNickname: activeStudent?.nickname ?? "", + studentDisplayName: activeStudent?.displayName ?? "", questionId: question.questionId, questionIndex: question.questionIndex, questionPrompt: question.prompt, @@ -265,8 +264,8 @@ export default function GradingByStudentTabScreen({ onClick={() => handleStudentSelect(s.studentId)} > - {s.nickname || s.username} - {s.nickname && s.nickname !== s.username && ( + {s.displayName || s.username} + {s.displayName && s.displayName !== s.username && ( {s.username} )} @@ -448,7 +447,7 @@ export default function GradingByStudentTabScreen({ hasNextStudent={hasNextStudent} contextPath={{ primary: selectedStudentSummary - ? `${selectedStudentSummary.nickname} (${selectedStudentSummary.username})` + ? `${selectedStudentSummary.displayName} (${selectedStudentSummary.username})` : t("grading.student", "學生"), secondary: currentAnswer ? `Q${currentAnswer.questionIndex}` diff --git a/frontend/src/features/contest/screens/settings/grading/GradingCardViewOnly.tsx b/frontend/src/features/contest/screens/settings/grading/GradingCardViewOnly.tsx index 2af10ebf..ab885d77 100644 --- a/frontend/src/features/contest/screens/settings/grading/GradingCardViewOnly.tsx +++ b/frontend/src/features/contest/screens/settings/grading/GradingCardViewOnly.tsx @@ -50,10 +50,10 @@ const GradingCardViewOnly: React.FC = ({ const user = useMemo( () => - row.studentNickname === row.studentUsername - ? row.studentNickname - : `${row.studentNickname} (${row.studentUsername})`, - [row.studentNickname, row.studentUsername], + row.studentDisplayName === row.studentUsername + ? row.studentDisplayName + : `${row.studentDisplayName} (${row.studentUsername})`, + [row.studentDisplayName, row.studentUsername], ); const answerText = useMemo(() => stringifyAnswer(row.answerContent), [row.answerContent]); diff --git a/frontend/src/features/contest/screens/settings/grading/GradingMatrixViewScreen.test.tsx b/frontend/src/features/contest/screens/settings/grading/GradingMatrixViewScreen.test.tsx index b20bd213..0f4d4984 100644 --- a/frontend/src/features/contest/screens/settings/grading/GradingMatrixViewScreen.test.tsx +++ b/frontend/src/features/contest/screens/settings/grading/GradingMatrixViewScreen.test.tsx @@ -50,8 +50,8 @@ const questionProgress: QuestionProgress[] = [ ]; const students = [ - { studentId: "s-1", username: "u1", nickname: "Student A" }, - { studentId: "s-2", username: "u2", nickname: "Student B" }, + { studentId: "s-1", username: "u1", displayName: "Student A" }, + { studentId: "s-2", username: "u2", displayName: "Student B" }, ]; const answersByQuestion = new Map([ @@ -62,7 +62,7 @@ const answersByQuestion = new Map([ id: "a-1", studentId: "s-1", studentUsername: "u1", - studentNickname: "Student A", + studentDisplayName: "Student A", questionId: "q-1", questionIndex: 1, questionPrompt: "Question 1", @@ -81,7 +81,7 @@ const answersByQuestion = new Map([ id: "a-2", studentId: "s-2", studentUsername: "u2", - studentNickname: "Student B", + studentDisplayName: "Student B", questionId: "q-1", questionIndex: 1, questionPrompt: "Question 1", diff --git a/frontend/src/features/contest/screens/settings/grading/GradingMatrixViewScreen.tsx b/frontend/src/features/contest/screens/settings/grading/GradingMatrixViewScreen.tsx index 0cf7e90b..78027e47 100644 --- a/frontend/src/features/contest/screens/settings/grading/GradingMatrixViewScreen.tsx +++ b/frontend/src/features/contest/screens/settings/grading/GradingMatrixViewScreen.tsx @@ -8,7 +8,6 @@ interface GradingMatrixViewScreenProps { students: { studentId: string; username: string; - nickname: string; displayName?: string; }[]; answersByQuestion: Map; @@ -221,7 +220,6 @@ export default function GradingMatrixViewScreen({ const username = (student.username && student.username.trim()) || student.studentId; const displayNameCandidate = (student.displayName && student.displayName.trim()) || - (student.nickname && student.nickname.trim()) || ""; const displayName = displayNameCandidate && displayNameCandidate !== username diff --git a/frontend/src/features/contest/screens/settings/grading/GradingSplitPanelScreen.test.tsx b/frontend/src/features/contest/screens/settings/grading/GradingSplitPanelScreen.test.tsx index f1f3aa40..92b3b351 100644 --- a/frontend/src/features/contest/screens/settings/grading/GradingSplitPanelScreen.test.tsx +++ b/frontend/src/features/contest/screens/settings/grading/GradingSplitPanelScreen.test.tsx @@ -39,7 +39,7 @@ const answer: GradingAnswerRow = { id: "a-1", studentId: "s-1", studentUsername: "student1", - studentNickname: "Student One", + studentDisplayName: "Student One", questionId: "q-1", questionIndex: 1, questionPrompt: "Question prompt", diff --git a/frontend/src/features/contest/screens/settings/grading/GradingSplitPanelScreen.tsx b/frontend/src/features/contest/screens/settings/grading/GradingSplitPanelScreen.tsx index 23241b87..fa75bc16 100644 --- a/frontend/src/features/contest/screens/settings/grading/GradingSplitPanelScreen.tsx +++ b/frontend/src/features/contest/screens/settings/grading/GradingSplitPanelScreen.tsx @@ -355,9 +355,9 @@ export default function GradingSplitPanelScreen({ transition={{ layout: { duration: 0.18, ease: "easeOut" } }} > - {answer.studentNickname === answer.studentUsername - ? answer.studentNickname - : `${answer.studentNickname} (${answer.studentUsername})`} + {answer.studentDisplayName === answer.studentUsername + ? answer.studentDisplayName + : `${answer.studentDisplayName} (${answer.studentUsername})`}
diff --git a/frontend/src/features/contest/screens/settings/grading/buildGradingRows.ts b/frontend/src/features/contest/screens/settings/grading/buildGradingRows.ts index 6c5ec27e..5fa8705f 100644 --- a/frontend/src/features/contest/screens/settings/grading/buildGradingRows.ts +++ b/frontend/src/features/contest/screens/settings/grading/buildGradingRows.ts @@ -13,7 +13,7 @@ import type { GradingAnswerRow, QuestionType } from "./gradingTypes"; export function buildGradingRows( allAnswers: ExamAnswerGrading[], questions: ExamQuestion[], - participantMap?: Map, + participantMap?: Map, ): GradingAnswerRow[] { const questionsMap = new Map(); questions.forEach((q, i) => @@ -32,13 +32,13 @@ export function buildGradingRows( const studentId = a.participantUserId; const student = participantMap?.get(studentId); const studentUsername = student?.username ?? "unknown"; - const studentNickname = student?.nickname ?? studentUsername; + const studentDisplayName = student?.displayName ?? studentUsername; return { id: a.id, studentId, studentUsername, - studentNickname, + studentDisplayName, questionId: a.questionId, questionIndex: qIdx + 1, questionPrompt: q?.prompt ?? "", diff --git a/frontend/src/features/contest/screens/settings/grading/gradingTypes.ts b/frontend/src/features/contest/screens/settings/grading/gradingTypes.ts index b4b6a30c..e1e65157 100644 --- a/frontend/src/features/contest/screens/settings/grading/gradingTypes.ts +++ b/frontend/src/features/contest/screens/settings/grading/gradingTypes.ts @@ -23,7 +23,7 @@ export interface GradingAnswerRow { id: string; studentId: string; studentUsername: string; - studentNickname: string; + studentDisplayName: string; questionId: string; questionIndex: number; // 1-based questionPrompt: string; diff --git a/frontend/src/features/contest/screens/settings/grading/objectiveRegrade.test.ts b/frontend/src/features/contest/screens/settings/grading/objectiveRegrade.test.ts index 0b32136d..e337c023 100644 --- a/frontend/src/features/contest/screens/settings/grading/objectiveRegrade.test.ts +++ b/frontend/src/features/contest/screens/settings/grading/objectiveRegrade.test.ts @@ -6,7 +6,7 @@ const buildRow = (partial: Partial): GradingAnswerRow => ({ id: partial.id ?? "a1", studentId: partial.studentId ?? "u1", studentUsername: partial.studentUsername ?? "student", - studentNickname: partial.studentNickname ?? "student", + studentDisplayName: partial.studentDisplayName ?? "student", questionId: partial.questionId ?? "q1", questionIndex: partial.questionIndex ?? 1, questionPrompt: partial.questionPrompt ?? "prompt", diff --git a/frontend/src/features/contest/screens/settings/grading/useAiGradingScreenData.ts b/frontend/src/features/contest/screens/settings/grading/useAiGradingScreenData.ts index f5320c80..e7c0c30b 100644 --- a/frontend/src/features/contest/screens/settings/grading/useAiGradingScreenData.ts +++ b/frontend/src/features/contest/screens/settings/grading/useAiGradingScreenData.ts @@ -52,11 +52,11 @@ export function useAiGradingScreenData( const contestAdminContext = useContext(ContestAdminContext); const participantMap = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const p of contestAdminContext?.participants ?? []) { map.set(String(p.userId), { username: p.username, - nickname: p.nickname ?? p.displayName ?? p.username, + displayName: p.displayName ?? p.username, }); } return map; diff --git a/frontend/src/features/contest/screens/settings/grading/useGradingData.ts b/frontend/src/features/contest/screens/settings/grading/useGradingData.ts index b26bd900..dc90b4ec 100644 --- a/frontend/src/features/contest/screens/settings/grading/useGradingData.ts +++ b/frontend/src/features/contest/screens/settings/grading/useGradingData.ts @@ -154,11 +154,11 @@ export function useGradingData(options: UseGradingDataOptions = {}) { // Build participant map const participantMap = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const p of participants) { map.set(String(p.userId), { username: p.username, - nickname: p.nickname ?? p.displayName ?? p.username, + displayName: p.displayName ?? p.username, }); } return map; @@ -222,8 +222,8 @@ export function useGradingData(options: UseGradingDataOptions = {}) { id: `coding-${submission.id}`, studentId: submission.userId, studentUsername: student?.username ?? submission.username ?? "unknown", - studentNickname: - student?.nickname ?? + studentDisplayName: + student?.displayName ?? submission.username ?? "unknown", questionId: canonicalProblemId, @@ -297,9 +297,9 @@ export function useGradingData(options: UseGradingDataOptions = {}) { id: `coding-standing-${studentId}-${canonicalProblemId}`, studentId, studentUsername: - student?.username || standingRow.displayName || standingRow.nickname || "unknown", - studentNickname: - student?.nickname || standingRow.displayName || standingRow.nickname || "unknown", + student?.username || standingRow.displayName || "unknown", + studentDisplayName: + student?.displayName || standingRow.displayName || "unknown", questionId: canonicalProblemId, questionIndex: problemMeta?.index ?? 0, questionPrompt: problemMeta?.title ?? canonicalProblemId, @@ -498,16 +498,14 @@ export function useGradingData(options: UseGradingDataOptions = {}) { // Start from all participants const map = new Map< string, - { studentId: string; username: string; nickname: string; displayName?: string; accountRole?: string } + { studentId: string; username: string; displayName?: string; accountRole?: string } >(); for (const p of participants) { const id = String(p.userId); map.set(id, { studentId: id, username: p.username, - nickname: p.nickname ?? p.displayName ?? p.username, - displayName: - p.userDisplayName ?? p.displayName ?? p.nickname ?? p.username, + displayName: p.displayName ?? p.username, accountRole: p.accountRole, }); } @@ -517,8 +515,7 @@ export function useGradingData(options: UseGradingDataOptions = {}) { map.set(a.studentId, { studentId: a.studentId, username: a.studentUsername, - nickname: a.studentNickname, - displayName: a.studentNickname || a.studentUsername, + displayName: a.studentDisplayName || a.studentUsername, }); } } diff --git a/frontend/src/infrastructure/api/dto/contest.dto.ts b/frontend/src/infrastructure/api/dto/contest.dto.ts index 0474618b..554fae63 100644 --- a/frontend/src/infrastructure/api/dto/contest.dto.ts +++ b/frontend/src/infrastructure/api/dto/contest.dto.ts @@ -107,7 +107,6 @@ export interface ContestDetailDto extends ContestDto { warning_timeout_seconds?: number; screen_share_recovery_grace_ms?: number; scoreboard_visible_during_contest?: boolean; - anonymous_mode_enabled?: boolean; allow_multiple_joins?: boolean; max_cheat_warnings?: number; allow_auto_unlock?: boolean; @@ -118,7 +117,6 @@ export interface ContestDetailDto extends ContestDto { question_edit_lock_trigger?: "coding_submission" | "exam_answer" | null; is_exam_questions_frozen?: boolean; exam_questions_count?: number; - my_nickname?: string; has_started?: boolean; started_at?: string; left_at?: string; @@ -175,7 +173,6 @@ export interface ContestParticipantDto { display_name?: string; }; }; - user_display_name?: string; account_role?: string; auth_provider?: string; connection_status?: "offline" | "online" | "live"; @@ -190,7 +187,6 @@ export interface ContestParticipantDto { lock_reason?: string; violation_count?: number; submit_reason?: string; - nickname?: string; display_name?: string; started_at?: string; left_at?: string; @@ -297,7 +293,6 @@ export interface ScoreboardRowDto { user?: { id?: number | string; username?: string }; user_id?: number | string; display_name?: string; - nickname?: string; solved?: number; solved_count?: number; total_score?: number; diff --git a/frontend/src/infrastructure/api/repositories/contest.repository.ts b/frontend/src/infrastructure/api/repositories/contest.repository.ts index c21a748b..c9bb8680 100644 --- a/frontend/src/infrastructure/api/repositories/contest.repository.ts +++ b/frontend/src/infrastructure/api/repositories/contest.repository.ts @@ -84,7 +84,7 @@ export const toggleStatus = async ( export const registerContest = async ( id: string, - data?: { password?: string; nickname?: string } + data?: { password?: string } ): Promise => { await requestJson( httpClient.post(`/api/v1/contests/${id}/register/`, data), diff --git a/frontend/src/infrastructure/api/repositories/contestParticipants.repository.ts b/frontend/src/infrastructure/api/repositories/contestParticipants.repository.ts index e26b8c4f..cdeeb5aa 100644 --- a/frontend/src/infrastructure/api/repositories/contestParticipants.repository.ts +++ b/frontend/src/infrastructure/api/repositories/contestParticipants.repository.ts @@ -35,18 +35,6 @@ export const unlockParticipant = async ( ); }; -export const updateNickname = async ( - contestId: string, - nickname: string -): Promise => { - return requestJson( - httpClient.post(`/api/v1/contests/${contestId}/update_nickname/`, { - nickname, - }), - "Failed to update nickname" - ); -}; - export const updateParticipant = async ( contestId: string, userId: number, diff --git a/frontend/src/infrastructure/api/repositories/exam.repository.ts b/frontend/src/infrastructure/api/repositories/exam.repository.ts index 43c13c95..af9941af 100644 --- a/frontend/src/infrastructure/api/repositories/exam.repository.ts +++ b/frontend/src/infrastructure/api/repositories/exam.repository.ts @@ -62,7 +62,7 @@ export interface ExamAnswerDto { graded_at: string | null; participant_user_id: number; participant_username: string; - participant_nickname: string; + participant_display_name: string; created_at: string; updated_at: string; } @@ -119,7 +119,6 @@ export interface ExamDashboardQuestionDetailDto { responses: Array<{ participant_id: number; username: string; - nickname: string | null; display_name: string; score: number | null; graded_at: string | null; @@ -134,7 +133,6 @@ export interface ExamDashboardQuestionDetailDto { participants: Array<{ participant_id: number; username: string; - nickname: string | null; display_name: string; }>; }>; @@ -142,7 +140,6 @@ export interface ExamDashboardQuestionDetailDto { omitted_participants?: Array<{ participant_id: number; username: string; - nickname: string | null; display_name: string; }>; grading_progress?: { diff --git a/frontend/src/infrastructure/api/repositories/examAnswers.repository.ts b/frontend/src/infrastructure/api/repositories/examAnswers.repository.ts index ce018aff..258a1e7b 100644 --- a/frontend/src/infrastructure/api/repositories/examAnswers.repository.ts +++ b/frontend/src/infrastructure/api/repositories/examAnswers.repository.ts @@ -34,7 +34,7 @@ export interface ExamAnswerDetailDto extends ExamAnswerDto { question_snapshot?: QuestionSnapshotDto | null; participant_user_id?: number; participant_username?: string; - participant_nickname?: string; + participant_display_name?: string; } export interface ExamAnswer { @@ -68,7 +68,7 @@ export interface ExamAnswerDetail extends ExamAnswer { questionSnapshot?: QuestionSnapshot | null; participantUserId?: string; participantUsername?: string; - participantNickname?: string; + participantDisplayName?: string; } /** @@ -159,7 +159,7 @@ const mapAnswerDetailDto = (dto: ExamAnswerDetailDto): ExamAnswerDetail => ({ : null, participantUserId: dto.participant_user_id != null ? String(dto.participant_user_id) : undefined, participantUsername: dto.participant_username, - participantNickname: dto.participant_nickname, + participantDisplayName: dto.participant_display_name, }); // ── Student API ── diff --git a/frontend/src/infrastructure/mappers/contest.mapper.test.ts b/frontend/src/infrastructure/mappers/contest.mapper.test.ts index 6628014a..f77bc3d2 100644 --- a/frontend/src/infrastructure/mappers/contest.mapper.test.ts +++ b/frontend/src/infrastructure/mappers/contest.mapper.test.ts @@ -3,10 +3,28 @@ import { mapContestAnticheatConfigDto, mapContestDetailDto, mapContestOverviewMetricsDto, + mapContestParticipantDto, mapContestUpdateRequestToDto, } from "./contest.mapper"; describe("contest mapper", () => { + describe("mapContestParticipantDto", () => { + it("maps participant display name from profile display_name", () => { + const result = mapContestParticipantDto({ + user_id: 1, + username: "student1", + display_name: "Student One", + score: 0, + joined_at: "2026-05-03T08:50:00+08:00", + exam_status: "not_started", + violation_count: 0, + }); + + expect(result.displayName).toBe("Student One"); + expect(result.username).toBe("student1"); + }); + }); + describe("mapContestOverviewMetricsDto", () => { it("maps overview metrics payload with heartbeat and time progress", () => { const dto = { diff --git a/frontend/src/infrastructure/mappers/contest.mapper.ts b/frontend/src/infrastructure/mappers/contest.mapper.ts index 1211f241..aaf466ee 100644 --- a/frontend/src/infrastructure/mappers/contest.mapper.ts +++ b/frontend/src/infrastructure/mappers/contest.mapper.ts @@ -122,7 +122,6 @@ export function mapContestDetailDto(dto: ContestDetailDto): ContestDetail { ? dto.screen_share_recovery_grace_ms : 30_000, scoreboardVisibleDuringContest: !!dto.scoreboard_visible_during_contest, - anonymousModeEnabled: !!dto.anonymous_mode_enabled, allowMultipleJoins: !!dto.allow_multiple_joins, maxCheatWarnings: dto.max_cheat_warnings || 0, @@ -136,7 +135,6 @@ export function mapContestDetailDto(dto: ContestDetailDto): ContestDetail { dto.question_edit_locked ?? dto.is_exam_questions_frozen ), examQuestionsCount: dto.exam_questions_count ?? 0, - myNickname: dto.my_nickname, hasStarted: !!dto.has_started, startedAt: dto.started_at, @@ -684,8 +682,7 @@ export function mapContestParticipantDto( userId: dto.user_id?.toString() || "", username: dto.username || "", email: dto.user?.email, - userDisplayName: - dto.user_display_name || dto.user?.profile?.display_name || "", + displayName: dto.display_name || dto.user?.profile?.display_name || "", accountRole: dto.account_role || dto.user?.role || "", authProvider: dto.auth_provider || dto.user?.auth_provider || "", connectionStatus: @@ -706,8 +703,6 @@ export function mapContestParticipantDto( lockReason: dto.lock_reason, violationCount: dto.violation_count || 0, submitReason: dto.submit_reason, - nickname: dto.nickname, - displayName: dto.display_name, }; } @@ -953,8 +948,7 @@ export function mapScoreboardDto(dto: ScoreboardDto): ScoreboardData { rank: s.rank || 0, user: s.user || { id: 0, username: "Unknown" }, userId: s.user?.id?.toString() || "", - displayName: s.display_name || s.nickname || s.user?.username || "", - nickname: s.nickname, + displayName: s.display_name || s.user?.username || "", solvedCount: s.solved || s.solved_count || 0, totalScore: s.total_score || 0, penalty: s.time || 0, @@ -1159,7 +1153,6 @@ export function mapContestUpdateRequestToDto( warning_timeout_seconds: request.warningTimeoutSeconds, screen_share_recovery_grace_ms: request.screenShareRecoveryGraceMs, scoreboard_visible_during_contest: request.scoreboardVisibleDuringContest, - anonymous_mode_enabled: request.anonymousModeEnabled, allow_multiple_joins: request.allowMultipleJoins, max_cheat_warnings: request.maxCheatWarnings, allow_auto_unlock: request.allowAutoUnlock, diff --git a/frontend/src/shared/mocks/contest.mock.ts b/frontend/src/shared/mocks/contest.mock.ts index 85730178..a614f844 100644 --- a/frontend/src/shared/mocks/contest.mock.ts +++ b/frontend/src/shared/mocks/contest.mock.ts @@ -54,7 +54,6 @@ export const createMockContest = ( warningTimeoutSeconds: 20, screenShareRecoveryGraceMs: 30000, scoreboardVisibleDuringContest: false, - anonymousModeEnabled: false, allowMultipleJoins: false, maxCheatWarnings: 3, allowAutoUnlock: true, From 6945c5dbd4d346992bdb6347463e6e58948f64d0 Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 11:59:45 +0800 Subject: [PATCH 02/60] fix(contest-admin): remove duplicate answer progress rail --- .../admin/AdminInsightRail.module.scss | 24 ------------------- .../components/admin/AdminInsightRail.tsx | 18 -------------- .../admin/AdminOverviewCommandCenter.test.tsx | 7 ++++-- 3 files changed, 5 insertions(+), 44 deletions(-) diff --git a/frontend/src/features/contest/components/admin/AdminInsightRail.module.scss b/frontend/src/features/contest/components/admin/AdminInsightRail.module.scss index 70eb8164..717762aa 100644 --- a/frontend/src/features/contest/components/admin/AdminInsightRail.module.scss +++ b/frontend/src/features/contest/components/admin/AdminInsightRail.module.scss @@ -86,30 +86,6 @@ line-height: 0; } -.answerProgress { - display: grid; - gap: 0.5rem; - padding-top: 0.5rem; -} - -.answerProgressHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; -} - -.answerProgressHeader span { - color: var(--cds-text-secondary); - font-size: var(--cds-body-compact-01-font-size, 0.875rem); -} - -.answerProgressHeader strong { - color: var(--cds-text-primary); - font-size: var(--cds-body-compact-01-font-size, 0.875rem); - font-weight: 600; -} - /* 比例 meter 會插入多個 layout spacer(條上方、圖例上方);皆以 display 收斂且不與 inline height 競爭 */ .distributionChartFrame :global(.chart-holder .graph-frame > .layout-child.spacer), .distributionChartFrame diff --git a/frontend/src/features/contest/components/admin/AdminInsightRail.tsx b/frontend/src/features/contest/components/admin/AdminInsightRail.tsx index 1c06c146..165f5065 100644 --- a/frontend/src/features/contest/components/admin/AdminInsightRail.tsx +++ b/frontend/src/features/contest/components/admin/AdminInsightRail.tsx @@ -158,10 +158,6 @@ const DistributionOverview = ({ "adminOverview.widgets.studentDistribution", "考生分佈總覽", ); - const answerProgressLabel = t( - "adminOverview.widgets.answerProgress", - "作答進度", - ); const visibleDistribution = distribution.filter( (item) => item.key !== "offline", ); @@ -230,20 +226,6 @@ const DistributionOverview = ({ }} />
-
-
- {answerProgressLabel} - {completionPercent}% -
- = 100 ? "finished" : "active"} - className={styles.rightPanelProgressBar} - /> -
) : (
尚無考生分佈資料
diff --git a/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.test.tsx b/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.test.tsx index 3b7b9bed..d4897a85 100644 --- a/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.test.tsx +++ b/frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.test.tsx @@ -266,10 +266,13 @@ describe("AdminOverviewCommandCenter", () => { const distributionOverview = screen.getByLabelText("考生分佈總覽"); expect(distributionOverview).toBeInTheDocument(); expect( - within(distributionOverview).getByRole("progressbar", { + within(distributionOverview).getByTestId("proportional-meter-chart"), + ).toBeInTheDocument(); + expect( + within(distributionOverview).queryByRole("progressbar", { name: "作答進度", }), - ).toHaveAttribute("aria-valuenow", "17"); + ).not.toBeInTheDocument(); expect( within(distributionOverview).queryByText("離線"), ).not.toBeInTheDocument(); From 9c77abd8ebda7672eb71e59605e12c30c00b94dc Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 13:53:49 +0800 Subject: [PATCH 03/60] docs(contest): add dashboard typography & layout consolidation design Spec for the Container/Block/Tabs primitive set that replaces the duplicated title/subtitle/time/divider/tab+toolbar SCSS across the student contest dashboard and the admin contest dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-contest-dashboard-typography-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-05-contest-dashboard-typography-design.md diff --git a/docs/superpowers/specs/2026-05-05-contest-dashboard-typography-design.md b/docs/superpowers/specs/2026-05-05-contest-dashboard-typography-design.md new file mode 100644 index 00000000..3bd61e13 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-contest-dashboard-typography-design.md @@ -0,0 +1,203 @@ +# 競賽儀表板版面與排版整頓設計 + +- Date: 2026-05-05 +- Scope: `frontend/src/features/contest/`(學生競賽儀表板 + 競賽管理儀表板 + 對應的 tab 區) +- Goal: 消除兩端各自實作的 title / subtitle / time / divider / tab+toolbar 樣式重複,建立可長期沿用的儀表板版面元件。 + +## 目前問題 + +目前學生端與管理端各自寫一套 SCSS: + +- 頁面標題 size 不一(學生 1.75rem `heading-04`、管理寫死 `1.25rem`)。 +- 區塊標題、metric label/value 各自命名(`.title` / `.sectionTitle` / `.recordTitle` / `.panelHeader h3` / `.examScheduleItem strong` …),且管理端多處直接寫 `1.25rem`、`1.5rem` 等 hardcoded 值。 +- `border-right` / `border-bottom` + `:last-child { border: 0 }` 這類 divider 樣板在 `detailRow` / `examStatusMatrix` / `panelGrid` / 學生端 `.layout` 等多處重寫。 +- Tab + toolbar 模式(`.tabRow / .tabRowToolbar / .tabRowSearch / .tabRowFilterMenu`)以及 mobile 換行 media query 在 participants / clarifications / proctoring 等 admin tab 各寫一份。 +- 同一個 className(例如 `.title`、`.subtitle`)在多個 module 各自定義不同字型,閱讀程式碼時無從預期實際長相。 + +## 設計方向 + +採三層元件架構,由 Container 處理結構(佈局 + divider + 邊框),Block 處理內容外殼(padding + header slot),Typography primitives 處理字型細節。Tab + toolbar 不另立層級,而是 `DashboardBlock` 的一種頭部選擇。 + +兩端視覺對齊原則: + +- 頁面標題使用學生端尺寸(`heading-04` 1.75rem)但採細體字(weight 400)。 +- 其餘標題、metric、副標、時間,全部對齊管理端較緊湊的尺寸;移除所有 hardcode,全部走 Carbon token。 + +### Type Scale + +| 角色 | Token | 計算值 | weight | 字型 | +| --- | --- | --- | --- | --- | +| PageTitle | `--cds-heading-04-font-size` | 1.75rem | 400(細體)| sans | +| PageSubtitle | `--cds-body-01-font-size` | 0.875rem | 400 | sans | +| SectionTitle | `--cds-heading-compact-02-font-size` | 1rem | 600 | sans | +| SectionDescription | `--cds-body-compact-01-font-size` | 0.875rem | 400 | sans | +| MetricLabel | `--cds-label-01-font-size` | 0.75rem | 400 | sans | +| MetricValue(預設)| `--cds-heading-compact-02-font-size` | 1rem | 600 | sans | +| MetricValue(大)| `--cds-heading-03-font-size` | 1.25rem | 600 | sans | +| TimeDisplay(大)| `--cds-heading-03-font-size` | 1.25rem | 600 | `--cds-code-font-family` | +| TimeDisplay(header 內聯)| `--cds-body-compact-01-font-size` | 0.875rem | 400 | `--cds-code-font-family` | + +實作端規定:所有 typography 樣式必須來自 Carbon token;新增 stylelint 規則 `declaration-property-value-disallowed-list` 在 `font-size` / `font-weight` 上禁止寫死的 rem / 數字 weight。 + +## 元件規格 + +統一前綴 `Dashboard*`。所有元件放置於 `frontend/src/shared/components/dashboard/`(新建)。各元件一個 `.tsx` + 對應 `.module.scss`,禁止呼叫端透過 `className` / `style` 覆寫核心樣式。 + +### Layer 1:結構容器 + +#### `DashboardPage` + +最外層 wrapper。處理 max-width clamp 與整體 padding,提供 scroll 區。對應目前學生端 `.root` + `.dashboard`、admin 的 `.page` + `.content`。 + +```tsx + + {/* children: DashboardContainer 或多個區塊 */} + +``` + +#### `DashboardContainer` + +```tsx + + {children} + +``` + +行為: + +- `stack`:垂直排列 children;`dividers="auto"` 時於每對相鄰 children 之間補水平分隔線(最後一個不補)。 +- `split`:水平排列;`dividers="auto"` 時於相鄰 children 之間補垂直分隔線。 +- `grid`:依 `columns` 排列;`dividers="auto"` 時水平與垂直分隔線都補,多列換行時兩個方向都處理(matrix 風格)。 +- 可任意 nest。`bordered` 由外層控制,內層不重複畫框。 +- 不接收 `padding` / `gap` / `style` / `className`,避免逃生口導致呼叫端再開分支。 + +對應目前 SCSS 中要刪除的: + +- 學生端 `.layout`(split + bordered + 中間分隔線)。 +- 學生端 `.detailRow` + `.detailCell` 的 grid + border-right + last-child 樣板。 +- 管理端 `.examStatusMatrix` / `.examStatusMetric`、`.examScheduleGrid` / `.examScheduleItem` 的 grid + border-right。 +- `.panelGrid`、`.entryGrid` 的外框 + 內部 divider。 +- 上述伴隨的 mobile 拆欄 media query(由 Container 內部處理)。 + +#### `DashboardBlock` + +```tsx + + {/* children 可以是 BlockHeader + 任意內容,或 DashboardTabs */} + +``` + +行為: + +- `padding`:`default`(一般 panel)/ `compact`(密度高的清單)/ `flush`(內部子組件自帶 padding,不再外加)。 +- 不畫 border、不管自己跟兄弟之間的分隔線;那是父層 Container 的事。 +- Block 是 leaf 級的內容外殼;children 可自由組合(shadcn 風)。 + +### Layer 2:Block 頭部 + +#### `BlockHeader` + +```tsx +} +/> +``` + +`titleSize="page"` → 使用 `PageTitle` 樣式;`titleSize="section"` → `SectionTitle` 樣式。 + +對應目前 SCSS 要刪除的: + +- 學生端 `.titleRow` / `.title` / `.tagRow`(actions slot 取代 tagRow 的固定位置)。 +- 學生端 `.sectionHeader` / `.sectionTitle` / `.sectionDescription` / `.inlineRecordsHeader` / `.inlineRecordsTitle` / `.chartHeader`。 +- 管理端 `.dashboardTitleBlock` / `.dashboardTitleRow h2` / `.dashboardDescription`、`.panelHeader h3` / `.panelHeader p`(`AdminOverviewCommandCenter`、`AdminPreparationDashboard`、`OverviewActionWidgets`、`OverviewInsightsPanel` 的同型樣式)。 + +### Layer 3:Tab 區 + +`DashboardTabs` 為 context provider,協調 `DashboardTabBar` 與 `DashboardTabPanel`。 + +```tsx + + + + + + + } + /> + + {/* 自由內容 */} + {/* 自由內容 */} + + +``` + +責任分配: + +- `DashboardTabs`:context(active id、onChange)。不畫任何視覺。 +- `DashboardTabBar`:tab 列排版、底線 border-bottom、toolbar slot 的對齊與 `max-width` clamp、mobile 換行(toolbar 自動換到下一行佔滿寬度)。內部仍使用 Carbon ` / / ` 以保留鍵盤導覽與 aria。 +- `DashboardTabPanel`:依 context 中 `activeId` 與自身 `tabId` 比對,match 才 render。 +- `DashboardToolbar`:flex row、預設右對齊、提供 borderless 子元件樣式 context(取代目前 `:global(.cds--search-input) { border: none; background: transparent; }` 等覆蓋)。`children` 開放給任意內容(例如 export 按鈕);常用的 `Search` / `FilterMenu` 以 sub-component 形式提供,內部已套好 toolbar 預設樣式。 + +對應目前 SCSS 要刪除的: + +- `AdminOverviewCommandCenter.module.scss` 的 `.tabRow / .tabRowToolbar / .tabRowToolbarActive / .tabRowSearch / .tabRowFilterMenu` 與其 `@media (max-width: 672px)` 規則。 +- 其他 admin panel(participants / clarifications / proctoring)若各自重抄一份,亦同步移除。 + +### Layer 4:Typography primitives + +放 `frontend/src/shared/components/dashboard/typography/`。多數情境會被 `BlockHeader` 與 `MetricBlock` 等高階元件包用,呼叫端極少直接使用。 + +- `PageTitle`:預設 `

`,可由 `as` 改。 +- `SectionTitle`:預設 `

`。 +- `MetricBlock`:包裝 label + value(+ 可選 trend / icon slot),props `label`、`value`、`size="default" | "lg"`、`align="start" | "end"`。對應學生端 `.metricLabel + .metricValue`、管理端 `.examStatusMetric span+strong`、`.examScheduleItem span+strong`、`.examProgressTitle + .examProgressValue`、`participantSingleMetric`、`OverviewActionWidgets` 與 `OverviewInsightsPanel` 的數值 + 標籤組合。 +- `TimeDisplay`:props `variant="countdown" | "header"`、`value`、`label`。`countdown` 走 `heading-03` + monospace;`header` 走 `body-compact-01` + monospace。對應學生端 `.timerValue`、`ContestHero` 的 `.timeLabel / .timeValue`、`ContestLayout` 的 `.headerTimerDisplay`。 + +所有 typography primitive 僅接 `as` / `size` / `align` 等語意 props,不接受 `style` / `className`。 + +## 整頓範圍 + +直接以新元件取代並刪除對應 SCSS 規則: + +- `frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss` +- `frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.tsx` +- `frontend/src/features/contest/components/participants/ContestParticipantsDashboard.module.scss`(若使用 panelHeader / sectionHeader) +- `frontend/src/features/contest/components/admin/AdminOverviewCommandCenter.module.scss` 與 `.tsx` +- `frontend/src/features/contest/components/admin/AdminPreparationDashboard.module.scss` 與 `.tsx` +- `frontend/src/features/contest/components/admin/AdminInsightRail.module.scss` +- `frontend/src/features/contest/components/admin/OverviewInsightsPanel.module.scss` +- `frontend/src/features/contest/components/admin/OverviewActionWidgets.module.scss` +- `frontend/src/features/contest/screens/admin/panels/AdminOverviewScreen.module.scss` 與 `.tsx` +- `frontend/src/features/contest/screens/admin/panels/AdminClarificationsPanel.module.scss` +- `frontend/src/features/contest/screens/admin/panels/AdminProctoringPanel.module.scss` +- `frontend/src/features/contest/components/layout/ContestHero.module.scss` 與相關 `.tsx` +- `frontend/src/features/contest/components/layout/ContestLayout.module.scss`(`.headerTimerDisplay` 部分換 ``,其餘 layout 樣式保留) + +僅替換 typography / divider / tab+toolbar 相關規則;色彩、interactive state、specific layout(例如 `.scoreDistributionPanel` 的圖表容器尺寸)等保留不動。 + +## 預期效果 + +- 兩端頁面標題、區塊標題、metric、時間視覺一致;學生端僅以 weight 400 維持柔和感。 +- `font-size` / `font-weight` / `border-right` / `border-bottom` + `:last-child` 的重複樣板從 contest 模組消失。 +- 新儀表板(教師工作區、批改、統計、未來其他應用)可直接組合 `DashboardPage` / `Container` / `Block` / `BlockHeader` / `Tabs` / `Toolbar`,無需從零寫 module SCSS。 +- 加上 stylelint 規則後,`font-size`、`font-weight` 寫死值無法再進入 contest 模組,避免回退。 + +## 開放議題 + +1. `DashboardContainer` `layout="grid"` 在多列 wrap 時的 divider 演算法在 RTL 下是否需要特例驗證。 +2. 引入 stylelint 規則的範圍:先限縮在 `frontend/src/shared/components/dashboard/**` 與 `frontend/src/features/contest/**`,待穩定後再擴及全 features。 +3. Storybook:每個新元件需有對應 story,列為實作計畫的一部分。 From c285428b72c70f328190758188fd2842e1adc80f Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 13:58:42 +0800 Subject: [PATCH 04/60] docs(contest): add dashboard primitives implementation plan Step-by-step plan covering 6 phases: typography, structure, tabs/toolbar, and migrations of student dashboard / admin overview / contest hero. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...est-dashboard-typography-implementation.md | 1492 +++++++++++++++++ 1 file changed, 1492 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-05-contest-dashboard-typography-implementation.md diff --git a/docs/superpowers/plans/2026-05-05-contest-dashboard-typography-implementation.md b/docs/superpowers/plans/2026-05-05-contest-dashboard-typography-implementation.md new file mode 100644 index 00000000..5a9853d5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-contest-dashboard-typography-implementation.md @@ -0,0 +1,1492 @@ +# 競賽儀表板版面與排版整頓 — 實作計畫 + +> **Spec:** `docs/superpowers/specs/2026-05-05-contest-dashboard-typography-design.md` + +**Goal:** 把學生競賽儀表板與競賽管理儀表板各自重複的 title/subtitle/time/divider/tab+toolbar 樣式,遷移到一組可重用的 dashboard primitive(DashboardPage / Container / Block / BlockHeader / Tabs / Toolbar / 4 個 typography primitive)。 + +**Architecture:** 新元件放 `frontend/src/shared/components/dashboard/`,採三層 + tab 子系統設計。所有 typography 樣式來自 Carbon token;divider 由 Container 注入;tab+toolbar 樣式由 TabBar/Toolbar 內部處理。 + +**Tech Stack:** React 19 + TypeScript + CSS Modules + SCSS + `@carbon/react` + Vitest + Storybook (CSF3)。 + +--- + +## 執行順序 + +1. Phase 1:typography primitives(PageTitle / SectionTitle / MetricBlock / TimeDisplay) +2. Phase 2:結構元件(DashboardPage / Container / Block / BlockHeader) +3. Phase 3:Tabs + Toolbar 子系統 +4. Phase 4:遷移學生競賽儀表板(小範圍驗證) +5. Phase 5:遷移管理端(AdminOverviewScreen header / CommandCenter / Preparation / ActionWidgets / InsightsPanel) +6. Phase 6:遷移 ContestHero / ContestLayout 時間相關 + +每個 Phase 結束都 commit。Phase 1–3 是純新增、零侵入;Phase 4 之後才動既有畫面。 + +--- + +## Phase 1:Typography Primitives + +**Files:** + +- Create: `frontend/src/shared/components/dashboard/typography/PageTitle.tsx` +- Create: `frontend/src/shared/components/dashboard/typography/PageTitle.module.scss` +- Create: `frontend/src/shared/components/dashboard/typography/SectionTitle.tsx` +- Create: `frontend/src/shared/components/dashboard/typography/SectionTitle.module.scss` +- Create: `frontend/src/shared/components/dashboard/typography/MetricBlock.tsx` +- Create: `frontend/src/shared/components/dashboard/typography/MetricBlock.module.scss` +- Create: `frontend/src/shared/components/dashboard/typography/TimeDisplay.tsx` +- Create: `frontend/src/shared/components/dashboard/typography/TimeDisplay.module.scss` +- Create: `frontend/src/shared/components/dashboard/typography/index.ts` +- Create: `frontend/src/shared/components/dashboard/typography/typography.test.tsx` +- Create: `frontend/src/shared/components/dashboard/typography/typography.stories.tsx` + +### Task 1.1:PageTitle + +責任:頁面層級主標題;預設 `

`;學生端 size + 細體字。 + +- [ ] **Step 1:寫 SCSS** + +```scss +// PageTitle.module.scss +.root { + margin: 0; + color: var(--cds-text-primary); + font-size: var(--cds-heading-04-font-size, 1.75rem); + font-weight: 400; + line-height: var(--cds-heading-04-line-height, 1.28572); +} +``` + +- [ ] **Step 2:寫 component** + +```tsx +// PageTitle.tsx +import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react"; +import styles from "./PageTitle.module.scss"; + +type Props = { + as?: E; + children: ReactNode; +} & Omit, "as" | "children" | "className" | "style">; + +export function PageTitle({ + as, + children, + ...rest +}: Props) { + const Tag = (as ?? "h1") as ElementType; + return ( + + {children} + + ); +} +``` + +### Task 1.2:SectionTitle + +責任:區塊標題;預設 `

`;對齊 `heading-compact-02`。 + +- [ ] **Step 1:寫 SCSS** + +```scss +// SectionTitle.module.scss +.root { + margin: 0; + color: var(--cds-text-primary); + font-size: var(--cds-heading-compact-02-font-size, 1rem); + font-weight: 600; + line-height: var(--cds-heading-compact-02-line-height, 1.375); +} +``` + +- [ ] **Step 2:寫 component** + +```tsx +// SectionTitle.tsx +import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react"; +import styles from "./SectionTitle.module.scss"; + +type Props = { + as?: E; + children: ReactNode; +} & Omit, "as" | "children" | "className" | "style">; + +export function SectionTitle({ + as, + children, + ...rest +}: Props) { + const Tag = (as ?? "h2") as ElementType; + return ( + + {children} + + ); +} +``` + +### Task 1.3:MetricBlock + +責任:label + value + 可選 trend slot;size default(1rem) / lg(1.25rem);align start / end。 + +- [ ] **Step 1:寫 SCSS** + +```scss +// MetricBlock.module.scss +.root { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.alignEnd { + justify-items: end; + text-align: end; +} + +.label { + margin: 0; + color: var(--cds-text-secondary); + font-size: var(--cds-label-01-font-size, 0.75rem); + font-weight: 400; + line-height: var(--cds-label-01-line-height, 1.33333); +} + +.value { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin: 0; + color: var(--cds-text-primary); + font-weight: 600; +} + +.valueDefault { + font-size: var(--cds-heading-compact-02-font-size, 1rem); + line-height: var(--cds-heading-compact-02-line-height, 1.375); +} + +.valueLg { + font-size: var(--cds-heading-03-font-size, 1.25rem); + line-height: var(--cds-heading-03-line-height, 1.4); +} +``` + +- [ ] **Step 2:寫 component** + +```tsx +// MetricBlock.tsx +import type { ReactNode } from "react"; +import styles from "./MetricBlock.module.scss"; + +export interface MetricBlockProps { + label: ReactNode; + value: ReactNode; + size?: "default" | "lg"; + align?: "start" | "end"; + trailing?: ReactNode; +} + +export function MetricBlock({ + label, + value, + size = "default", + align = "start", + trailing, +}: MetricBlockProps) { + const valueClass = + size === "lg" ? styles.valueLg : styles.valueDefault; + const rootClass = [styles.root, align === "end" && styles.alignEnd] + .filter(Boolean) + .join(" "); + return ( +
+ {label} + + {value} + {trailing} + +
+ ); +} +``` + +### Task 1.4:TimeDisplay + +責任:時間數字(monospace);variant countdown / header;可選 label。 + +- [ ] **Step 1:寫 SCSS** + +```scss +// TimeDisplay.module.scss +.root { + display: inline-flex; + flex-direction: column; + min-width: 0; +} + +.label { + color: var(--cds-text-secondary); + font-size: var(--cds-label-01-font-size, 0.75rem); + font-weight: 400; + line-height: var(--cds-label-01-line-height, 1.33333); +} + +.value { + color: var(--cds-text-primary); + font-family: var(--cds-code-font-family, monospace); +} + +.countdown { + font-size: var(--cds-heading-03-font-size, 1.25rem); + font-weight: 600; + line-height: var(--cds-heading-03-line-height, 1.4); +} + +.header { + font-size: var(--cds-body-compact-01-font-size, 0.875rem); + font-weight: 400; + line-height: var(--cds-body-compact-01-line-height, 1.28572); +} +``` + +- [ ] **Step 2:寫 component** + +```tsx +// TimeDisplay.tsx +import type { ReactNode } from "react"; +import styles from "./TimeDisplay.module.scss"; + +export interface TimeDisplayProps { + value: ReactNode; + variant?: "countdown" | "header"; + label?: ReactNode; +} + +export function TimeDisplay({ + value, + variant = "countdown", + label, +}: TimeDisplayProps) { + const valueClass = + variant === "header" ? styles.header : styles.countdown; + return ( + + {label && {label}} + {value} + + ); +} +``` + +### Task 1.5:Barrel export + +- [ ] **Step 1:寫 index.ts** + +```ts +// frontend/src/shared/components/dashboard/typography/index.ts +export { PageTitle } from "./PageTitle"; +export { SectionTitle } from "./SectionTitle"; +export { MetricBlock, type MetricBlockProps } from "./MetricBlock"; +export { TimeDisplay, type TimeDisplayProps } from "./TimeDisplay"; +``` + +### Task 1.6:行為測試 + +涵蓋:預設 tag、`as` 切換、MetricBlock align、TimeDisplay 有/無 label、TimeDisplay variant。 + +- [ ] **Step 1:寫測試** + +```tsx +// typography.test.tsx +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + MetricBlock, + PageTitle, + SectionTitle, + TimeDisplay, +} from "./index"; + +describe("PageTitle", () => { + it("defaults to h1", () => { + render(Hello); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Hello"); + }); + + it("respects as prop", () => { + render(Hello); + expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument(); + }); +}); + +describe("SectionTitle", () => { + it("defaults to h2", () => { + render(Section); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Section"); + }); +}); + +describe("MetricBlock", () => { + it("renders label and value", () => { + render(); + expect(screen.getByText("參賽人數")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + }); + + it("renders trailing slot", () => { + render( + T} />, + ); + expect(screen.getByTestId("t")).toBeInTheDocument(); + }); +}); + +describe("TimeDisplay", () => { + it("renders value", () => { + render(); + expect(screen.getByText("01:23:45")).toBeInTheDocument(); + }); + + it("renders optional label", () => { + render(); + expect(screen.getByText("剩餘時間")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2:跑測試** + +```bash +cd frontend && npx vitest run src/shared/components/dashboard/typography +``` + +預期:全綠。 + +### Task 1.7:Storybook stories + +- [ ] **Step 1:寫 stories** + +```tsx +// typography.stories.tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { ArrowUp } from "@carbon/icons-react"; +import { + MetricBlock, + PageTitle, + SectionTitle, + TimeDisplay, +} from "./index"; + +const meta: Meta = { + title: "Dashboard/Typography", + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +export const Headings: Story = { + render: () => ( +
+ 2026 春季程式競賽 + 參賽進度 +
+ ), +}; + +export const Metrics: Story = { + render: () => ( +
+ + + } + /> + +
+ ), +}; + +export const Times: Story = { + render: () => ( +
+ + +
+ ), +}; +``` + +### Task 1.8:Commit + +- [ ] **Step 1:commit** + +```bash +git add frontend/src/shared/components/dashboard/typography +git commit -m "feat(dashboard): add typography primitives" +``` + +--- + +## Phase 2:結構元件 Container / Block / BlockHeader + +**Files:** + +- Create: `frontend/src/shared/components/dashboard/DashboardPage.tsx` +- Create: `frontend/src/shared/components/dashboard/DashboardPage.module.scss` +- Create: `frontend/src/shared/components/dashboard/DashboardContainer.tsx` +- Create: `frontend/src/shared/components/dashboard/DashboardContainer.module.scss` +- Create: `frontend/src/shared/components/dashboard/DashboardBlock.tsx` +- Create: `frontend/src/shared/components/dashboard/DashboardBlock.module.scss` +- Create: `frontend/src/shared/components/dashboard/BlockHeader.tsx` +- Create: `frontend/src/shared/components/dashboard/BlockHeader.module.scss` +- Create: `frontend/src/shared/components/dashboard/index.ts` +- Create: `frontend/src/shared/components/dashboard/structure.test.tsx` +- Create: `frontend/src/shared/components/dashboard/structure.stories.tsx` + +### Task 2.1:DashboardPage + +責任:scroll 容器 + max-width clamp + 頁面 padding。 + +- [ ] **Step 1:SCSS** + +```scss +// DashboardPage.module.scss +.root { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.inner { + max-width: 1180px; + margin: 0 auto; + padding: 1rem; +} + +@media (max-width: 672px) { + .inner { + padding: 0.75rem; + } +} +``` + +- [ ] **Step 2:component** + +```tsx +// DashboardPage.tsx +import type { ReactNode } from "react"; +import styles from "./DashboardPage.module.scss"; + +export interface DashboardPageProps { + children: ReactNode; + ariaLabel?: string; +} + +export function DashboardPage({ children, ariaLabel }: DashboardPageProps) { + return ( +
+
{children}
+
+ ); +} +``` + +### Task 2.2:DashboardContainer + +責任:layout=stack/split/grid + dividers="auto"|"none" + bordered;不接 padding/style/className。 + +關鍵實作:dividers 用 `> *:not(:last-child)` 補 border(stack:bottom;split:right;grid:right + bottom 都補,外層 wrap 時自動)。 + +- [ ] **Step 1:SCSS** + +```scss +// DashboardContainer.module.scss +.root { + display: flex; + min-width: 0; +} + +.bordered { + border: 1px solid var(--cds-border-subtle); +} + +// stack +.stack { + flex-direction: column; +} + +.stack.dividers > *:not(:last-child) { + border-bottom: 1px solid var(--cds-border-subtle); +} + +// split +.split { + flex-direction: row; + align-items: stretch; +} + +.split.dividers > *:not(:last-child) { + border-right: 1px solid var(--cds-border-subtle); +} + +@media (max-width: 1056px) { + .split { + flex-direction: column; + } + + .split.dividers > *:not(:last-child) { + border-right: 0; + border-bottom: 1px solid var(--cds-border-subtle); + } +} + +// grid +.grid { + display: grid; +} + +.gridCols2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.gridCols3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.gridCols4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.gridColsAuto { + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); +} + +.grid.dividers > * { + border-right: 1px solid var(--cds-border-subtle); + border-bottom: 1px solid var(--cds-border-subtle); +} + +// 最右一欄 / 最下一列消除多餘 border 由具體 column 數決定 +.grid.dividers.gridCols2 > *:nth-child(2n) { border-right: 0; } +.grid.dividers.gridCols3 > *:nth-child(3n) { border-right: 0; } +.grid.dividers.gridCols4 > *:nth-child(4n) { border-right: 0; } + +// 最後一列無下邊框(簡化處理:所有 child 加 bottom,再用負的 margin 方式較複雜;改用最後 child 取消) +.grid.dividers > *:last-child { border-right: 0; } + +// 最後一整列移除底邊:以 nth-last-child + nth-child 的精確算法在 wrap 情境較難覆蓋全部欄數;保留底邊時讓外層 .bordered 包裹效果視覺仍可接受。 +// (保留 bottom border,整體外觀類似 Carbon DataTable 的網格視覺) + +@media (max-width: 672px) { + .grid:not(.gridColsAuto) { + grid-template-columns: 1fr; + } + + .grid.dividers:not(.gridColsAuto) > * { + border-right: 0; + } +} +``` + +- [ ] **Step 2:component** + +```tsx +// DashboardContainer.tsx +import type { ReactNode } from "react"; +import styles from "./DashboardContainer.module.scss"; + +type Layout = "stack" | "split" | "grid"; +type Columns = 2 | 3 | 4 | "auto"; + +export interface DashboardContainerProps { + layout: Layout; + columns?: Columns; + dividers?: "auto" | "none"; + bordered?: boolean; + children: ReactNode; + ariaLabel?: string; +} + +const COLS_CLASS: Record = { + 2: styles.gridCols2, + 3: styles.gridCols3, + 4: styles.gridCols4, + auto: styles.gridColsAuto, +}; + +export function DashboardContainer({ + layout, + columns, + dividers = "none", + bordered = false, + children, + ariaLabel, +}: DashboardContainerProps) { + const classes = [ + styles.root, + styles[layout], + dividers === "auto" && styles.dividers, + bordered && styles.bordered, + layout === "grid" && columns && COLS_CLASS[columns], + ] + .filter(Boolean) + .join(" "); + return ( +
+ {children} +
+ ); +} +``` + +### Task 2.3:DashboardBlock + +責任:padding (default/compact/flush);不畫 border。 + +- [ ] **Step 1:SCSS** + +```scss +// DashboardBlock.module.scss +.root { + min-width: 0; + display: flex; + flex-direction: column; +} + +.paddingDefault { + padding: 1.25rem 1.5rem; +} + +.paddingCompact { + padding: 0.75rem 1rem; +} + +.paddingFlush { + padding: 0; +} + +@media (max-width: 672px) { + .paddingDefault { + padding: 1rem; + } +} +``` + +- [ ] **Step 2:component** + +```tsx +// DashboardBlock.tsx +import type { ReactNode } from "react"; +import styles from "./DashboardBlock.module.scss"; + +type Padding = "default" | "compact" | "flush"; + +export interface DashboardBlockProps { + padding?: Padding; + children: ReactNode; + ariaLabel?: string; +} + +const PAD: Record = { + default: styles.paddingDefault, + compact: styles.paddingCompact, + flush: styles.paddingFlush, +}; + +export function DashboardBlock({ + padding = "default", + children, + ariaLabel, +}: DashboardBlockProps) { + return ( +
+ {children} +
+ ); +} +``` + +### Task 2.4:BlockHeader + +責任:title + description + actions slot;可選 titleSize=page|section、titleAs。 + +- [ ] **Step 1:SCSS** + +```scss +// BlockHeader.module.scss +.root { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.text { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.description { + margin: 0; + color: var(--cds-text-secondary); + font-size: var(--cds-body-compact-01-font-size, 0.875rem); + line-height: var(--cds-body-compact-01-line-height, 1.28572); +} + +.actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex: 0 0 auto; +} +``` + +- [ ] **Step 2:component** + +```tsx +// BlockHeader.tsx +import type { ElementType, ReactNode } from "react"; +import { PageTitle } from "./typography/PageTitle"; +import { SectionTitle } from "./typography/SectionTitle"; +import styles from "./BlockHeader.module.scss"; + +export interface BlockHeaderProps { + title: ReactNode; + titleAs?: ElementType; + titleSize?: "page" | "section"; + description?: ReactNode; + actions?: ReactNode; +} + +export function BlockHeader({ + title, + titleAs, + titleSize = "section", + description, + actions, +}: BlockHeaderProps) { + const TitleComp = titleSize === "page" ? PageTitle : SectionTitle; + return ( +
+
+ {title} + {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} +``` + +### Task 2.5:Barrel export + +- [ ] **Step 1:寫 index.ts** + +```ts +// frontend/src/shared/components/dashboard/index.ts +export * from "./typography"; +export { DashboardPage } from "./DashboardPage"; +export { + DashboardContainer, + type DashboardContainerProps, +} from "./DashboardContainer"; +export { DashboardBlock, type DashboardBlockProps } from "./DashboardBlock"; +export { BlockHeader, type BlockHeaderProps } from "./BlockHeader"; +``` + +### Task 2.6:行為測試 + +- [ ] **Step 1:寫測試** + +```tsx +// structure.test.tsx +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + BlockHeader, + DashboardBlock, + DashboardContainer, + DashboardPage, +} from "./index"; + +describe("DashboardPage", () => { + it("renders children inside main", () => { + render(x); + expect(screen.getByRole("main", { name: "page" })).toHaveTextContent("x"); + }); +}); + +describe("DashboardContainer", () => { + it("renders children", () => { + render( + +
a
+
b
+
, + ); + expect(screen.getByText("a")).toBeInTheDocument(); + expect(screen.getByText("b")).toBeInTheDocument(); + }); +}); + +describe("DashboardBlock", () => { + it("renders as section", () => { + render(body); + expect(screen.getByRole("region", { name: "block" })).toHaveTextContent("body"); + }); +}); + +describe("BlockHeader", () => { + it("renders title and description", () => { + render(); + expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); + expect(screen.getByText("desc")).toBeInTheDocument(); + }); + + it("renders actions slot", () => { + render( + A} + />, + ); + expect(screen.getByTestId("a")).toBeInTheDocument(); + }); + + it("uses h1 when titleSize=page", () => { + render(); + expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2:跑測試** + +```bash +cd frontend && npx vitest run src/shared/components/dashboard +``` + +預期:全綠。 + +### Task 2.7:Storybook stories + +- [ ] **Step 1:寫 stories** + +```tsx +// structure.stories.tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { Add } from "@carbon/icons-react"; +import { Button } from "@carbon/react"; +import { + BlockHeader, + DashboardBlock, + DashboardContainer, + DashboardPage, + MetricBlock, +} from "./index"; + +const meta: Meta = { + title: "Dashboard/Structure", + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const SplitWithStackInside: Story = { + render: () => ( + + + + + 新增} + /> + + + + + + + + + + + + 內容 + + + + ), +}; + +export const Grid2x2Matrix: Story = { + render: () => ( + + + + + + + ), +}; +``` + +### Task 2.8:Commit + +- [ ] **Step 1:commit** + +```bash +git add frontend/src/shared/components/dashboard +git commit -m "feat(dashboard): add page/container/block/header primitives" +``` + +--- + +## Phase 3:Tabs + Toolbar + +**Files:** + +- Create: `frontend/src/shared/components/dashboard/tabs/DashboardTabs.tsx` +- Create: `frontend/src/shared/components/dashboard/tabs/DashboardTabBar.tsx` +- Create: `frontend/src/shared/components/dashboard/tabs/DashboardTabBar.module.scss` +- Create: `frontend/src/shared/components/dashboard/tabs/DashboardTabPanel.tsx` +- Create: `frontend/src/shared/components/dashboard/tabs/DashboardToolbar.tsx` +- Create: `frontend/src/shared/components/dashboard/tabs/DashboardToolbar.module.scss` +- Create: `frontend/src/shared/components/dashboard/tabs/index.ts` +- Create: `frontend/src/shared/components/dashboard/tabs/tabs.test.tsx` +- Create: `frontend/src/shared/components/dashboard/tabs/tabs.stories.tsx` +- Modify: `frontend/src/shared/components/dashboard/index.ts`(加上 tabs 出口) + +### Task 3.1:DashboardTabs context provider + +- [ ] **Step 1:context** + +```tsx +// DashboardTabs.tsx +import { createContext, useContext, type ReactNode } from "react"; + +interface Ctx { + activeId: string; + onChange: (id: string) => void; +} +const TabsCtx = createContext(null); + +export interface DashboardTabsProps { + activeId: string; + onChange: (id: string) => void; + children: ReactNode; +} + +export function DashboardTabs({ + activeId, + onChange, + children, +}: DashboardTabsProps) { + return ( + + {children} + + ); +} + +export function useDashboardTabs(): Ctx { + const ctx = useContext(TabsCtx); + if (!ctx) { + throw new Error("DashboardTabs context missing"); + } + return ctx; +} +``` + +### Task 3.2:DashboardTabBar + +- [ ] **Step 1:SCSS** + +```scss +// DashboardTabBar.module.scss +.root { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--cds-border-subtle); +} + +.tabs { + flex: 0 1 auto; + min-width: 0; +} + +.toolbar { + display: inline-flex; + align-items: center; + flex: 0 1 auto; + margin: 0 1rem; + align-self: center; + max-width: 24rem; + width: 100%; +} + +@media (max-width: 672px) { + .root { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + .toolbar { + margin: 0 1rem 0.5rem; + max-width: none; + } +} +``` + +- [ ] **Step 2:component(用 Carbon Tabs)** + +```tsx +// DashboardTabBar.tsx +import type { ReactNode } from "react"; +import { Tabs, TabList, Tab } from "@carbon/react"; +import { useDashboardTabs } from "./DashboardTabs"; +import styles from "./DashboardTabBar.module.scss"; + +export interface TabDef { + id: string; + label: ReactNode; + badge?: ReactNode; +} + +export interface DashboardTabBarProps { + tabs: TabDef[]; + toolbar?: ReactNode; +} + +export function DashboardTabBar({ tabs, toolbar }: DashboardTabBarProps) { + const { activeId, onChange } = useDashboardTabs(); + const selectedIndex = Math.max( + 0, + tabs.findIndex((t) => t.id === activeId), + ); + return ( +
+ { + const next = tabs[selectedIndex]; + if (next) onChange(next.id); + }} + className={styles.tabs} + > + + {tabs.map((t) => ( + + {t.label} + {t.badge !== undefined && <> ({t.badge})} + + ))} + + + {toolbar &&
{toolbar}
} +
+ ); +} +``` + +### Task 3.3:DashboardTabPanel + +- [ ] **Step 1:component** + +```tsx +// DashboardTabPanel.tsx +import type { ReactNode } from "react"; +import { useDashboardTabs } from "./DashboardTabs"; + +export interface DashboardTabPanelProps { + tabId: string; + children: ReactNode; +} + +export function DashboardTabPanel({ tabId, children }: DashboardTabPanelProps) { + const { activeId } = useDashboardTabs(); + if (activeId !== tabId) return null; + return <>{children}; +} +``` + +### Task 3.4:DashboardToolbar + +- [ ] **Step 1:SCSS** + +```scss +// DashboardToolbar.module.scss +.root { + display: inline-flex; + align-items: stretch; + width: 100%; + gap: 0; + border: 0; + background: transparent; +} + +.search { + flex: 1 1 auto; + min-width: 0; +} + +.search :global(.cds--search-input) { + border: none; + background: transparent; +} + +.search :global(.cds--search-magnifier) { + inset-inline-start: 0.75rem; +} + +.filterMenu { + flex: 0 0 auto; + border-left: 0; +} +``` + +- [ ] **Step 2:component** + +```tsx +// DashboardToolbar.tsx +import type { ChangeEvent, ReactNode } from "react"; +import { Search } from "@carbon/react"; +import styles from "./DashboardToolbar.module.scss"; + +export interface DashboardToolbarProps { + children: ReactNode; +} + +function Root({ children }: DashboardToolbarProps) { + return
{children}
; +} + +interface ToolbarSearchProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + ariaLabel?: string; +} + +function ToolbarSearch({ + value, + onChange, + placeholder, + ariaLabel, +}: ToolbarSearchProps) { + return ( +
+ ) => onChange(e.target.value)} + /> +
+ ); +} + +interface ToolbarFilterSlotProps { + children: ReactNode; +} +function ToolbarFilter({ children }: ToolbarFilterSlotProps) { + return
{children}
; +} + +export const DashboardToolbar = Object.assign(Root, { + Search: ToolbarSearch, + Filter: ToolbarFilter, +}); +``` + +### Task 3.5:Barrel exports + +- [ ] **Step 1:tabs/index.ts** + +```ts +export { + DashboardTabs, + type DashboardTabsProps, +} from "./DashboardTabs"; +export { + DashboardTabBar, + type DashboardTabBarProps, + type TabDef, +} from "./DashboardTabBar"; +export { + DashboardTabPanel, + type DashboardTabPanelProps, +} from "./DashboardTabPanel"; +export { + DashboardToolbar, + type DashboardToolbarProps, +} from "./DashboardToolbar"; +``` + +- [ ] **Step 2:在 dashboard/index.ts 加入** + +```ts +export * from "./tabs"; +``` + +### Task 3.6:行為測試 + +- [ ] **Step 1:tabs.test.tsx** + +```tsx +import { describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { + DashboardTabBar, + DashboardTabPanel, + DashboardTabs, + DashboardToolbar, +} from "./index"; + +describe("Dashboard tabs", () => { + function setup(active: string, onChange = vi.fn()) { + render( + + + undefined} + placeholder="search" + /> + + } + /> + PANEL_A + PANEL_B + , + ); + return { onChange }; + } + + it("only renders active panel", () => { + setup("a"); + expect(screen.getByText("PANEL_A")).toBeInTheDocument(); + expect(screen.queryByText("PANEL_B")).not.toBeInTheDocument(); + }); + + it("renders toolbar slot", () => { + setup("a"); + expect(screen.getByPlaceholderText("search")).toBeInTheDocument(); + }); + + it("fires onChange when clicking another tab", () => { + const { onChange } = setup("a"); + fireEvent.click(screen.getByRole("tab", { name: "B" })); + expect(onChange).toHaveBeenCalledWith("b"); + }); +}); +``` + +- [ ] **Step 2:跑測試** + +```bash +cd frontend && npx vitest run src/shared/components/dashboard/tabs +``` + +### Task 3.7:Stories + +- [ ] **Step 1:tabs.stories.tsx** + +```tsx +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { + DashboardBlock, + DashboardTabBar, + DashboardTabPanel, + DashboardTabs, + DashboardToolbar, +} from "../index"; + +const meta: Meta = { + title: "Dashboard/Tabs", + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const TabsWithToolbar: Story = { + render: () => { + const [active, setActive] = useState("all"); + const [q, setQ] = useState(""); + return ( + + + + + + } + /> + +
全部內容
+
+ +
進行中
+
+ +
已結束
+
+
+
+ ); + }, +}; +``` + +### Task 3.8:Commit + +```bash +git add frontend/src/shared/components/dashboard/tabs frontend/src/shared/components/dashboard/index.ts +git commit -m "feat(dashboard): add tabs and toolbar primitives" +``` + +--- + +## Phase 4:遷移學生競賽儀表板 + +**Files:** + +- Modify: `frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.tsx` +- Modify: `frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss`(移除已用元件取代的規則) +- Read for context: `frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx` + +### Task 4.1:取代外層 layout + +- [ ] **Step 1:把 `
...` 換成 `...` + +- [ ] **Step 2:左欄改 ``,右欄同理。 + +### Task 4.2:取代 titleRow / detailRow / 各種 metric + +- [ ] **Step 1**:頁面標題用 `} />`。 +- [ ] **Step 2**:原 `.detailRow` 三格 metric 改 `` 內含三個 ``。 +- [ ] **Step 3**:原右欄 summaryMetric / summaryChart / scoreDistributionPanel / actionStack / rulesPanel 各包成 ``,header 用 `` 或 ``。 +- [ ] **Step 4**:`.timerValue` 換 ``。 +- [ ] **Step 5**:`.recordTitle` / `.recordMeta` 區仍走自有 layout(這部分不在整頓範圍),但 `.metricLabel + .metricValue` 全部用 ``。 + +### Task 4.3:清理舊 SCSS + +- [ ] **Step 1**:刪除 `StudentContestDashboard.module.scss` 中已不使用的:`.title`、`.titleRow`、`.tagRow`、`.sectionHeader`、`.sectionTitle`、`.inlineRecordsTitle`、`.sectionDescription`、`.detailRow`、`.detailCell`、`.summaryMetric`、`.scoreDistributionPanel`、`.actionStack`、`.rulesPanel`、`.timerValue`、`.metricLabel`、`.metricValue`、`.layout`、`.mainPanel`、`.summaryPanel`、`.dashboard`、`.root`,以及對應的 media query 內覆寫。 +- [ ] **Step 2**:保留 `.recordTitle`、`.recordScore`、`.recordMeta`、`.problemReportList`、`.problemReportItem`、`.problemReportMeta`、`.questionList`、`.errorText`、`.emptyText`、`.warningIcon`、`.successIcon`、`.progressTrack`、`.progressFill`、`.chartSkeleton`、`.scoreDistributionChart`、`.rulesContent`、`.inlineRecordsPanel`(這些是 record-list 與 chart 自己的 layout,不在整頓範圍)。 + +### Task 4.4:跑既有測試 + +- [ ] **Step 1:跑 student dashboard 測試** + +```bash +cd frontend && npx vitest run src/features/contest/components/studentDashboard +``` + +預期:全綠(測試斷言應為 text content 或 aria,不依賴具體 className)。 + +如有測試斷言依賴 `styles.title` className,更新為 `getByRole("heading", { name: ... })`。 + +### Task 4.5:lint / typecheck + +```bash +cd frontend && npx tsc -b --noEmit && npm run lint -- src/features/contest/components/studentDashboard src/shared/components/dashboard +``` + +### Task 4.6:Commit + +```bash +git add frontend/src/features/contest/components/studentDashboard +git commit -m "refactor(contest): migrate student dashboard to dashboard primitives" +``` + +--- + +## Phase 5:遷移管理端主畫面 + +涵蓋: + +- `screens/admin/panels/AdminOverviewScreen.tsx` 與 `.module.scss`:頁面 header 改 `BlockHeader titleSize="page"`,刪除 `.dashboardTitleBlock / .dashboardTitleRow h2 / .dashboardDescription / .contestHeader`。 +- `components/admin/AdminOverviewCommandCenter.tsx` 與 `.module.scss`: + - `.examStatusMatrix + .examStatusMetric` 換 `DashboardContainer layout="grid" columns={2} dividers="auto"` + `MetricBlock size="lg"`。 + - `.examScheduleGrid + .examScheduleItem` 換 `DashboardContainer layout="grid" columns={2} dividers="auto"` + `MetricBlock size="lg"`。 + - `.examProgressBlock + .examProgressTitle + .examProgressValue` 換 `DashboardBlock` + `MetricBlock size="lg"`。 + - `.panelHeader h3 / .panelHeader p` 全換 `BlockHeader`,刪 SCSS 對應規則。 +- `components/admin/AdminPreparationDashboard.module.scss`:`.panelHeader` 同上。 +- `components/admin/OverviewActionWidgets.module.scss`:`.title / .subtitle / .widgetTitle / 數值` 改用 `BlockHeader` + `MetricBlock`。 +- `components/admin/OverviewInsightsPanel.module.scss`:`.title / .actionTileTitle / 數值` 同上。 +- `components/admin/AdminInsightRail.module.scss`:影響部分(標題與描述)。 + +每個檔案改完後 commit: + +```bash +git commit -m "refactor(contest-admin): migrate to dashboard primitives" +``` + +完成後跑: + +```bash +cd frontend && npx vitest run src/features/contest && npx tsc -b --noEmit +``` + +--- + +## Phase 6:遷移 ContestHero / ContestLayout 時間元件 + +**Files:** + +- Modify: `frontend/src/features/contest/components/layout/ContestHero.tsx` +- Modify: `frontend/src/features/contest/components/layout/ContestHero.module.scss`:刪 `.timeLabel / .timeValue`。 +- Modify: `frontend/src/features/contest/components/layout/ContestLayout.tsx`:`.headerTimerDisplay` 處改 ``。 +- Modify: `frontend/src/features/contest/components/layout/ContestLayout.module.scss`:刪 `.headerTimerDisplay`。 + +完成後 commit: + +```bash +git commit -m "refactor(contest): use TimeDisplay primitive for hero and layout timers" +``` + +--- + +## 收尾 + +- [ ] 跑全部 contest 測試:`cd frontend && npx vitest run src/features/contest` +- [ ] 跑 typecheck:`cd frontend && npx tsc -b --noEmit` +- [ ] 啟動 storybook 目視:`bash .codex/skills/qjudge-env-compose-owner/scripts/qjudge-dc.sh dev logs -f storybook`,逐個檢查 `Dashboard/Typography`、`Dashboard/Structure`、`Dashboard/Tabs` 故事。 +- [ ] 啟動 dev:visual check 學生 dashboard 與 admin overview。 + +完成 Phase 1–6 後,stylelint 規則與其餘 admin tab(participants/clarifications/proctoring)的 toolbar 遷移為獨立後續工作。 From 20bee77f6eb6983c7ba2a2b47dce305853442633 Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 13:59:44 +0800 Subject: [PATCH 05/60] feat(dashboard): add typography primitives PageTitle / SectionTitle / MetricBlock / TimeDisplay built on Carbon typography tokens. PageTitle uses heading-04 size with weight 400; others align with the more compact admin scale. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../typography/MetricBlock.module.scss | 37 +++++++++++ .../dashboard/typography/MetricBlock.tsx | 32 ++++++++++ .../typography/PageTitle.module.scss | 7 +++ .../dashboard/typography/PageTitle.tsx | 23 +++++++ .../typography/SectionTitle.module.scss | 7 +++ .../dashboard/typography/SectionTitle.tsx | 23 +++++++ .../typography/TimeDisplay.module.scss | 29 +++++++++ .../dashboard/typography/TimeDisplay.tsx | 22 +++++++ .../components/dashboard/typography/index.ts | 4 ++ .../typography/typography.stories.tsx | 50 +++++++++++++++ .../dashboard/typography/typography.test.tsx | 63 +++++++++++++++++++ 11 files changed, 297 insertions(+) create mode 100644 frontend/src/shared/components/dashboard/typography/MetricBlock.module.scss create mode 100644 frontend/src/shared/components/dashboard/typography/MetricBlock.tsx create mode 100644 frontend/src/shared/components/dashboard/typography/PageTitle.module.scss create mode 100644 frontend/src/shared/components/dashboard/typography/PageTitle.tsx create mode 100644 frontend/src/shared/components/dashboard/typography/SectionTitle.module.scss create mode 100644 frontend/src/shared/components/dashboard/typography/SectionTitle.tsx create mode 100644 frontend/src/shared/components/dashboard/typography/TimeDisplay.module.scss create mode 100644 frontend/src/shared/components/dashboard/typography/TimeDisplay.tsx create mode 100644 frontend/src/shared/components/dashboard/typography/index.ts create mode 100644 frontend/src/shared/components/dashboard/typography/typography.stories.tsx create mode 100644 frontend/src/shared/components/dashboard/typography/typography.test.tsx diff --git a/frontend/src/shared/components/dashboard/typography/MetricBlock.module.scss b/frontend/src/shared/components/dashboard/typography/MetricBlock.module.scss new file mode 100644 index 00000000..25d20250 --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/MetricBlock.module.scss @@ -0,0 +1,37 @@ +.root { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.alignEnd { + justify-items: end; + text-align: end; +} + +.label { + margin: 0; + color: var(--cds-text-secondary); + font-size: var(--cds-label-01-font-size, 0.75rem); + font-weight: 400; + line-height: var(--cds-label-01-line-height, 1.33333); +} + +.value { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin: 0; + color: var(--cds-text-primary); + font-weight: 600; +} + +.valueDefault { + font-size: var(--cds-heading-compact-02-font-size, 1rem); + line-height: var(--cds-heading-compact-02-line-height, 1.375); +} + +.valueLg { + font-size: var(--cds-heading-03-font-size, 1.25rem); + line-height: var(--cds-heading-03-line-height, 1.4); +} diff --git a/frontend/src/shared/components/dashboard/typography/MetricBlock.tsx b/frontend/src/shared/components/dashboard/typography/MetricBlock.tsx new file mode 100644 index 00000000..bc9ed1cf --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/MetricBlock.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import styles from "./MetricBlock.module.scss"; + +export interface MetricBlockProps { + label: ReactNode; + value: ReactNode; + size?: "default" | "lg"; + align?: "start" | "end"; + trailing?: ReactNode; +} + +export function MetricBlock({ + label, + value, + size = "default", + align = "start", + trailing, +}: MetricBlockProps) { + const valueClass = size === "lg" ? styles.valueLg : styles.valueDefault; + const rootClass = [styles.root, align === "end" && styles.alignEnd] + .filter(Boolean) + .join(" "); + return ( +
+ {label} + + {value} + {trailing} + +
+ ); +} diff --git a/frontend/src/shared/components/dashboard/typography/PageTitle.module.scss b/frontend/src/shared/components/dashboard/typography/PageTitle.module.scss new file mode 100644 index 00000000..8ee0157f --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/PageTitle.module.scss @@ -0,0 +1,7 @@ +.root { + margin: 0; + color: var(--cds-text-primary); + font-size: var(--cds-heading-04-font-size, 1.75rem); + font-weight: 400; + line-height: var(--cds-heading-04-line-height, 1.28572); +} diff --git a/frontend/src/shared/components/dashboard/typography/PageTitle.tsx b/frontend/src/shared/components/dashboard/typography/PageTitle.tsx new file mode 100644 index 00000000..1ec382f9 --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/PageTitle.tsx @@ -0,0 +1,23 @@ +import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react"; +import styles from "./PageTitle.module.scss"; + +type Props = { + as?: E; + children: ReactNode; +} & Omit< + ComponentPropsWithoutRef, + "as" | "children" | "className" | "style" +>; + +export function PageTitle({ + as, + children, + ...rest +}: Props) { + const Tag = (as ?? "h1") as ElementType; + return ( + + {children} + + ); +} diff --git a/frontend/src/shared/components/dashboard/typography/SectionTitle.module.scss b/frontend/src/shared/components/dashboard/typography/SectionTitle.module.scss new file mode 100644 index 00000000..4f05fec0 --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/SectionTitle.module.scss @@ -0,0 +1,7 @@ +.root { + margin: 0; + color: var(--cds-text-primary); + font-size: var(--cds-heading-compact-02-font-size, 1rem); + font-weight: 600; + line-height: var(--cds-heading-compact-02-line-height, 1.375); +} diff --git a/frontend/src/shared/components/dashboard/typography/SectionTitle.tsx b/frontend/src/shared/components/dashboard/typography/SectionTitle.tsx new file mode 100644 index 00000000..6b089669 --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/SectionTitle.tsx @@ -0,0 +1,23 @@ +import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react"; +import styles from "./SectionTitle.module.scss"; + +type Props = { + as?: E; + children: ReactNode; +} & Omit< + ComponentPropsWithoutRef, + "as" | "children" | "className" | "style" +>; + +export function SectionTitle({ + as, + children, + ...rest +}: Props) { + const Tag = (as ?? "h2") as ElementType; + return ( + + {children} + + ); +} diff --git a/frontend/src/shared/components/dashboard/typography/TimeDisplay.module.scss b/frontend/src/shared/components/dashboard/typography/TimeDisplay.module.scss new file mode 100644 index 00000000..0937c6ba --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/TimeDisplay.module.scss @@ -0,0 +1,29 @@ +.root { + display: inline-flex; + flex-direction: column; + min-width: 0; +} + +.label { + color: var(--cds-text-secondary); + font-size: var(--cds-label-01-font-size, 0.75rem); + font-weight: 400; + line-height: var(--cds-label-01-line-height, 1.33333); +} + +.value { + color: var(--cds-text-primary); + font-family: var(--cds-code-font-family, monospace); +} + +.countdown { + font-size: var(--cds-heading-03-font-size, 1.25rem); + font-weight: 600; + line-height: var(--cds-heading-03-line-height, 1.4); +} + +.header { + font-size: var(--cds-body-compact-01-font-size, 0.875rem); + font-weight: 400; + line-height: var(--cds-body-compact-01-line-height, 1.28572); +} diff --git a/frontend/src/shared/components/dashboard/typography/TimeDisplay.tsx b/frontend/src/shared/components/dashboard/typography/TimeDisplay.tsx new file mode 100644 index 00000000..c6045ff1 --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/TimeDisplay.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import styles from "./TimeDisplay.module.scss"; + +export interface TimeDisplayProps { + value: ReactNode; + variant?: "countdown" | "header"; + label?: ReactNode; +} + +export function TimeDisplay({ + value, + variant = "countdown", + label, +}: TimeDisplayProps) { + const valueClass = variant === "header" ? styles.header : styles.countdown; + return ( + + {label && {label}} + {value} + + ); +} diff --git a/frontend/src/shared/components/dashboard/typography/index.ts b/frontend/src/shared/components/dashboard/typography/index.ts new file mode 100644 index 00000000..b093402a --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/index.ts @@ -0,0 +1,4 @@ +export { PageTitle } from "./PageTitle"; +export { SectionTitle } from "./SectionTitle"; +export { MetricBlock, type MetricBlockProps } from "./MetricBlock"; +export { TimeDisplay, type TimeDisplayProps } from "./TimeDisplay"; diff --git a/frontend/src/shared/components/dashboard/typography/typography.stories.tsx b/frontend/src/shared/components/dashboard/typography/typography.stories.tsx new file mode 100644 index 00000000..7256536e --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/typography.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ArrowUp } from "@carbon/icons-react"; +import { + MetricBlock, + PageTitle, + SectionTitle, + TimeDisplay, +} from "./index"; + +const meta: Meta = { + title: "Dashboard/Typography", + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +export const Headings: Story = { + render: () => ( +
+ 2026 春季程式競賽 + 參賽進度 +
+ ), +}; + +export const Metrics: Story = { + render: () => ( +
+ + + } + /> + +
+ ), +}; + +export const Times: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/frontend/src/shared/components/dashboard/typography/typography.test.tsx b/frontend/src/shared/components/dashboard/typography/typography.test.tsx new file mode 100644 index 00000000..549bfb1b --- /dev/null +++ b/frontend/src/shared/components/dashboard/typography/typography.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + MetricBlock, + PageTitle, + SectionTitle, + TimeDisplay, +} from "./index"; + +describe("PageTitle", () => { + it("defaults to h1", () => { + render(Hello); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Hello"); + }); + + it("respects as prop", () => { + render(Hello); + expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument(); + }); +}); + +describe("SectionTitle", () => { + it("defaults to h2", () => { + render(Section); + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Section"); + }); + + it("respects as prop", () => { + render(S); + expect(screen.getByRole("heading", { level: 3 })).toBeInTheDocument(); + }); +}); + +describe("MetricBlock", () => { + it("renders label and value", () => { + render(); + expect(screen.getByText("參賽人數")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + }); + + it("renders trailing slot", () => { + render( + T} + />, + ); + expect(screen.getByTestId("trail")).toBeInTheDocument(); + }); +}); + +describe("TimeDisplay", () => { + it("renders value", () => { + render(); + expect(screen.getByText("01:23:45")).toBeInTheDocument(); + }); + + it("renders optional label", () => { + render(); + expect(screen.getByText("剩餘時間")).toBeInTheDocument(); + }); +}); From 4adee50d20ee6795e8a3dbd910f61a05142867d5 Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 14:00:46 +0800 Subject: [PATCH 06/60] feat(dashboard): add page/container/block/header primitives DashboardPage handles scroll + max-width clamp; DashboardContainer exposes stack/split/grid layouts and auto-injects dividers between siblings; DashboardBlock provides padding variants without owning borders; BlockHeader composes title (page or section size) plus description and actions slots. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/BlockHeader.module.scss | 27 ++++++ .../components/dashboard/BlockHeader.tsx | 31 +++++++ .../dashboard/DashboardBlock.module.scss | 23 +++++ .../components/dashboard/DashboardBlock.tsx | 31 +++++++ .../dashboard/DashboardContainer.module.scss | 92 +++++++++++++++++++ .../dashboard/DashboardContainer.tsx | 45 +++++++++ .../dashboard/DashboardPage.module.scss | 18 ++++ .../components/dashboard/DashboardPage.tsx | 15 +++ .../src/shared/components/dashboard/index.ts | 8 ++ .../dashboard/structure.stories.tsx | 76 +++++++++++++++ .../components/dashboard/structure.test.tsx | 85 +++++++++++++++++ 11 files changed, 451 insertions(+) create mode 100644 frontend/src/shared/components/dashboard/BlockHeader.module.scss create mode 100644 frontend/src/shared/components/dashboard/BlockHeader.tsx create mode 100644 frontend/src/shared/components/dashboard/DashboardBlock.module.scss create mode 100644 frontend/src/shared/components/dashboard/DashboardBlock.tsx create mode 100644 frontend/src/shared/components/dashboard/DashboardContainer.module.scss create mode 100644 frontend/src/shared/components/dashboard/DashboardContainer.tsx create mode 100644 frontend/src/shared/components/dashboard/DashboardPage.module.scss create mode 100644 frontend/src/shared/components/dashboard/DashboardPage.tsx create mode 100644 frontend/src/shared/components/dashboard/index.ts create mode 100644 frontend/src/shared/components/dashboard/structure.stories.tsx create mode 100644 frontend/src/shared/components/dashboard/structure.test.tsx diff --git a/frontend/src/shared/components/dashboard/BlockHeader.module.scss b/frontend/src/shared/components/dashboard/BlockHeader.module.scss new file mode 100644 index 00000000..d05c4156 --- /dev/null +++ b/frontend/src/shared/components/dashboard/BlockHeader.module.scss @@ -0,0 +1,27 @@ +.root { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.text { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.description { + margin: 0; + color: var(--cds-text-secondary); + font-size: var(--cds-body-compact-01-font-size, 0.875rem); + line-height: var(--cds-body-compact-01-line-height, 1.28572); +} + +.actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex: 0 0 auto; +} diff --git a/frontend/src/shared/components/dashboard/BlockHeader.tsx b/frontend/src/shared/components/dashboard/BlockHeader.tsx new file mode 100644 index 00000000..b457c8a3 --- /dev/null +++ b/frontend/src/shared/components/dashboard/BlockHeader.tsx @@ -0,0 +1,31 @@ +import type { ElementType, ReactNode } from "react"; +import { PageTitle } from "./typography/PageTitle"; +import { SectionTitle } from "./typography/SectionTitle"; +import styles from "./BlockHeader.module.scss"; + +export interface BlockHeaderProps { + title: ReactNode; + titleAs?: ElementType; + titleSize?: "page" | "section"; + description?: ReactNode; + actions?: ReactNode; +} + +export function BlockHeader({ + title, + titleAs, + titleSize = "section", + description, + actions, +}: BlockHeaderProps) { + const TitleComp = titleSize === "page" ? PageTitle : SectionTitle; + return ( +
+
+ {title} + {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/src/shared/components/dashboard/DashboardBlock.module.scss b/frontend/src/shared/components/dashboard/DashboardBlock.module.scss new file mode 100644 index 00000000..9b776ddd --- /dev/null +++ b/frontend/src/shared/components/dashboard/DashboardBlock.module.scss @@ -0,0 +1,23 @@ +.root { + min-width: 0; + display: flex; + flex-direction: column; +} + +.paddingDefault { + padding: 1.25rem 1.5rem; +} + +.paddingCompact { + padding: 0.75rem 1rem; +} + +.paddingFlush { + padding: 0; +} + +@media (max-width: 672px) { + .paddingDefault { + padding: 1rem; + } +} diff --git a/frontend/src/shared/components/dashboard/DashboardBlock.tsx b/frontend/src/shared/components/dashboard/DashboardBlock.tsx new file mode 100644 index 00000000..adf5d39a --- /dev/null +++ b/frontend/src/shared/components/dashboard/DashboardBlock.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import styles from "./DashboardBlock.module.scss"; + +type Padding = "default" | "compact" | "flush"; + +export interface DashboardBlockProps { + padding?: Padding; + children: ReactNode; + ariaLabel?: string; +} + +const PAD: Record = { + default: styles.paddingDefault, + compact: styles.paddingCompact, + flush: styles.paddingFlush, +}; + +export function DashboardBlock({ + padding = "default", + children, + ariaLabel, +}: DashboardBlockProps) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/shared/components/dashboard/DashboardContainer.module.scss b/frontend/src/shared/components/dashboard/DashboardContainer.module.scss new file mode 100644 index 00000000..f05c4944 --- /dev/null +++ b/frontend/src/shared/components/dashboard/DashboardContainer.module.scss @@ -0,0 +1,92 @@ +.root { + display: flex; + min-width: 0; +} + +.bordered { + border: 1px solid var(--cds-border-subtle); +} + +.stack { + flex-direction: column; +} + +.stack.dividers > *:not(:last-child) { + border-bottom: 1px solid var(--cds-border-subtle); +} + +.split { + flex-direction: row; + align-items: stretch; +} + +.split > * { + flex: 1 1 0; + min-width: 0; +} + +.split.dividers > *:not(:last-child) { + border-right: 1px solid var(--cds-border-subtle); +} + +@media (max-width: 1056px) { + .split { + flex-direction: column; + } + + .split.dividers > *:not(:last-child) { + border-right: 0; + border-bottom: 1px solid var(--cds-border-subtle); + } +} + +.grid { + display: grid; +} + +.gridCols2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.gridCols3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.gridCols4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.gridColsAuto { + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); +} + +.grid.dividers > * { + border-right: 1px solid var(--cds-border-subtle); + border-bottom: 1px solid var(--cds-border-subtle); +} + +.grid.dividers.gridCols2 > *:nth-child(2n) { + border-right: 0; +} + +.grid.dividers.gridCols3 > *:nth-child(3n) { + border-right: 0; +} + +.grid.dividers.gridCols4 > *:nth-child(4n) { + border-right: 0; +} + +.grid.dividers > *:last-child { + border-right: 0; +} + +@media (max-width: 672px) { + .grid:not(.gridColsAuto) { + grid-template-columns: 1fr; + } + + .grid.dividers:not(.gridColsAuto) > * { + border-right: 0; + } +} diff --git a/frontend/src/shared/components/dashboard/DashboardContainer.tsx b/frontend/src/shared/components/dashboard/DashboardContainer.tsx new file mode 100644 index 00000000..9ec946f3 --- /dev/null +++ b/frontend/src/shared/components/dashboard/DashboardContainer.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; +import styles from "./DashboardContainer.module.scss"; + +type Layout = "stack" | "split" | "grid"; +type Columns = 2 | 3 | 4 | "auto"; + +export interface DashboardContainerProps { + layout: Layout; + columns?: Columns; + dividers?: "auto" | "none"; + bordered?: boolean; + children: ReactNode; + ariaLabel?: string; +} + +const COLS_CLASS: Record = { + 2: styles.gridCols2, + 3: styles.gridCols3, + 4: styles.gridCols4, + auto: styles.gridColsAuto, +}; + +export function DashboardContainer({ + layout, + columns, + dividers = "none", + bordered = false, + children, + ariaLabel, +}: DashboardContainerProps) { + const classes = [ + styles.root, + styles[layout], + dividers === "auto" && styles.dividers, + bordered && styles.bordered, + layout === "grid" && columns && COLS_CLASS[columns], + ] + .filter(Boolean) + .join(" "); + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/shared/components/dashboard/DashboardPage.module.scss b/frontend/src/shared/components/dashboard/DashboardPage.module.scss new file mode 100644 index 00000000..bdcc99fa --- /dev/null +++ b/frontend/src/shared/components/dashboard/DashboardPage.module.scss @@ -0,0 +1,18 @@ +.root { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.inner { + max-width: 1180px; + margin: 0 auto; + padding: 1rem; +} + +@media (max-width: 672px) { + .inner { + padding: 0.75rem; + } +} diff --git a/frontend/src/shared/components/dashboard/DashboardPage.tsx b/frontend/src/shared/components/dashboard/DashboardPage.tsx new file mode 100644 index 00000000..112d4681 --- /dev/null +++ b/frontend/src/shared/components/dashboard/DashboardPage.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import styles from "./DashboardPage.module.scss"; + +export interface DashboardPageProps { + children: ReactNode; + ariaLabel?: string; +} + +export function DashboardPage({ children, ariaLabel }: DashboardPageProps) { + return ( +
+
{children}
+
+ ); +} diff --git a/frontend/src/shared/components/dashboard/index.ts b/frontend/src/shared/components/dashboard/index.ts new file mode 100644 index 00000000..0d254b95 --- /dev/null +++ b/frontend/src/shared/components/dashboard/index.ts @@ -0,0 +1,8 @@ +export * from "./typography"; +export { DashboardPage, type DashboardPageProps } from "./DashboardPage"; +export { + DashboardContainer, + type DashboardContainerProps, +} from "./DashboardContainer"; +export { DashboardBlock, type DashboardBlockProps } from "./DashboardBlock"; +export { BlockHeader, type BlockHeaderProps } from "./BlockHeader"; diff --git a/frontend/src/shared/components/dashboard/structure.stories.tsx b/frontend/src/shared/components/dashboard/structure.stories.tsx new file mode 100644 index 00000000..6b08b0a4 --- /dev/null +++ b/frontend/src/shared/components/dashboard/structure.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Add } from "@carbon/icons-react"; +import { Button } from "@carbon/react"; +import { + BlockHeader, + DashboardBlock, + DashboardContainer, + DashboardPage, + MetricBlock, +} from "./index"; + +const meta: Meta = { + title: "Dashboard/Structure", + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const SplitWithStackInside: Story = { + render: () => ( + + + + + + 新增 + + } + /> + + + + + + + + + + + + + + + + + + 內容 + + + + ), +}; + +export const Grid2x2Matrix: Story = { + render: () => ( + + + + + + + + + + + + + + + ), +}; diff --git a/frontend/src/shared/components/dashboard/structure.test.tsx b/frontend/src/shared/components/dashboard/structure.test.tsx new file mode 100644 index 00000000..335d89ed --- /dev/null +++ b/frontend/src/shared/components/dashboard/structure.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + BlockHeader, + DashboardBlock, + DashboardContainer, + DashboardPage, +} from "./index"; + +describe("DashboardPage", () => { + it("renders children inside main with aria-label", () => { + render(x); + expect(screen.getByRole("main", { name: "page" })).toHaveTextContent("x"); + }); +}); + +describe("DashboardContainer", () => { + it("renders children for stack layout", () => { + render( + +
a
+
b
+
, + ); + expect(screen.getByText("a")).toBeInTheDocument(); + expect(screen.getByText("b")).toBeInTheDocument(); + }); + + it("renders children for split layout", () => { + render( + +
l
+
r
+
, + ); + expect(screen.getByText("l")).toBeInTheDocument(); + expect(screen.getByText("r")).toBeInTheDocument(); + }); + + it("renders children for grid layout with columns", () => { + render( + +
1
+
2
+
3
+
, + ); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); +}); + +describe("DashboardBlock", () => { + it("renders as accessible section", () => { + render(body); + expect(screen.getByRole("region", { name: "block" })).toHaveTextContent( + "body", + ); + }); +}); + +describe("BlockHeader", () => { + it("renders title and description", () => { + render(); + expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument(); + expect(screen.getByText("desc")).toBeInTheDocument(); + }); + + it("renders actions slot", () => { + render( + A} />, + ); + expect(screen.getByTestId("a")).toBeInTheDocument(); + }); + + it("uses h1 when titleSize=page", () => { + render(); + expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); + }); + + it("uses h2 by default", () => { + render(); + expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument(); + }); +}); From cc409098fdad35d54b074e2399ef4e152d63252c Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 14:01:40 +0800 Subject: [PATCH 07/60] feat(dashboard): add tabs and toolbar primitives DashboardTabs context provider coordinates DashboardTabBar (Carbon Tabs wrapper with toolbar slot and mobile stacking) and DashboardTabPanel (renders only when its tabId matches the active context). DashboardToolbar provides Search and Filter sub-components preset for the borderless in-tab-bar appearance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shared/components/dashboard/index.ts | 1 + .../tabs/DashboardTabBar.module.scss | 35 +++++++++++ .../dashboard/tabs/DashboardTabBar.tsx | 50 ++++++++++++++++ .../dashboard/tabs/DashboardTabPanel.tsx | 13 +++++ .../dashboard/tabs/DashboardTabs.tsx | 33 +++++++++++ .../tabs/DashboardToolbar.module.scss | 27 +++++++++ .../dashboard/tabs/DashboardToolbar.tsx | 49 ++++++++++++++++ .../shared/components/dashboard/tabs/index.ts | 11 ++++ .../dashboard/tabs/tabs.stories.tsx | 54 +++++++++++++++++ .../components/dashboard/tabs/tabs.test.tsx | 58 +++++++++++++++++++ 10 files changed, 331 insertions(+) create mode 100644 frontend/src/shared/components/dashboard/tabs/DashboardTabBar.module.scss create mode 100644 frontend/src/shared/components/dashboard/tabs/DashboardTabBar.tsx create mode 100644 frontend/src/shared/components/dashboard/tabs/DashboardTabPanel.tsx create mode 100644 frontend/src/shared/components/dashboard/tabs/DashboardTabs.tsx create mode 100644 frontend/src/shared/components/dashboard/tabs/DashboardToolbar.module.scss create mode 100644 frontend/src/shared/components/dashboard/tabs/DashboardToolbar.tsx create mode 100644 frontend/src/shared/components/dashboard/tabs/index.ts create mode 100644 frontend/src/shared/components/dashboard/tabs/tabs.stories.tsx create mode 100644 frontend/src/shared/components/dashboard/tabs/tabs.test.tsx diff --git a/frontend/src/shared/components/dashboard/index.ts b/frontend/src/shared/components/dashboard/index.ts index 0d254b95..2ed13d89 100644 --- a/frontend/src/shared/components/dashboard/index.ts +++ b/frontend/src/shared/components/dashboard/index.ts @@ -1,4 +1,5 @@ export * from "./typography"; +export * from "./tabs"; export { DashboardPage, type DashboardPageProps } from "./DashboardPage"; export { DashboardContainer, diff --git a/frontend/src/shared/components/dashboard/tabs/DashboardTabBar.module.scss b/frontend/src/shared/components/dashboard/tabs/DashboardTabBar.module.scss new file mode 100644 index 00000000..f4f66332 --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/DashboardTabBar.module.scss @@ -0,0 +1,35 @@ +.root { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--cds-border-subtle); +} + +.tabs { + flex: 0 1 auto; + min-width: 0; +} + +.toolbar { + display: inline-flex; + align-items: center; + flex: 0 1 auto; + margin: 0 1rem; + align-self: center; + max-width: 24rem; + width: 100%; +} + +@media (max-width: 672px) { + .root { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + .toolbar { + margin: 0 1rem 0.5rem; + max-width: none; + } +} diff --git a/frontend/src/shared/components/dashboard/tabs/DashboardTabBar.tsx b/frontend/src/shared/components/dashboard/tabs/DashboardTabBar.tsx new file mode 100644 index 00000000..7a58e960 --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/DashboardTabBar.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import { Tab, TabList, Tabs } from "@carbon/react"; +import { useDashboardTabs } from "./DashboardTabs"; +import styles from "./DashboardTabBar.module.scss"; + +export interface TabDef { + id: string; + label: ReactNode; + badge?: ReactNode; +} + +export interface DashboardTabBarProps { + tabs: TabDef[]; + toolbar?: ReactNode; + ariaLabel?: string; +} + +export function DashboardTabBar({ + tabs, + toolbar, + ariaLabel = "dashboard tabs", +}: DashboardTabBarProps) { + const { activeId, onChange } = useDashboardTabs(); + const selectedIndex = Math.max( + 0, + tabs.findIndex((t) => t.id === activeId), + ); + return ( +
+ { + const next = tabs[selectedIndex]; + if (next) onChange(next.id); + }} + className={styles.tabs} + > + + {tabs.map((t) => ( + + {t.label} + {t.badge !== undefined && <> ({t.badge})} + + ))} + + + {toolbar &&
{toolbar}
} +
+ ); +} diff --git a/frontend/src/shared/components/dashboard/tabs/DashboardTabPanel.tsx b/frontend/src/shared/components/dashboard/tabs/DashboardTabPanel.tsx new file mode 100644 index 00000000..9bf07dfc --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/DashboardTabPanel.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; +import { useDashboardTabs } from "./DashboardTabs"; + +export interface DashboardTabPanelProps { + tabId: string; + children: ReactNode; +} + +export function DashboardTabPanel({ tabId, children }: DashboardTabPanelProps) { + const { activeId } = useDashboardTabs(); + if (activeId !== tabId) return null; + return <>{children}; +} diff --git a/frontend/src/shared/components/dashboard/tabs/DashboardTabs.tsx b/frontend/src/shared/components/dashboard/tabs/DashboardTabs.tsx new file mode 100644 index 00000000..5b9def87 --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/DashboardTabs.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext, type ReactNode } from "react"; + +interface Ctx { + activeId: string; + onChange: (id: string) => void; +} +const TabsCtx = createContext(null); + +export interface DashboardTabsProps { + activeId: string; + onChange: (id: string) => void; + children: ReactNode; +} + +export function DashboardTabs({ + activeId, + onChange, + children, +}: DashboardTabsProps) { + return ( + + {children} + + ); +} + +export function useDashboardTabs(): Ctx { + const ctx = useContext(TabsCtx); + if (!ctx) { + throw new Error("DashboardTabs context missing"); + } + return ctx; +} diff --git a/frontend/src/shared/components/dashboard/tabs/DashboardToolbar.module.scss b/frontend/src/shared/components/dashboard/tabs/DashboardToolbar.module.scss new file mode 100644 index 00000000..43e03b77 --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/DashboardToolbar.module.scss @@ -0,0 +1,27 @@ +.root { + display: inline-flex; + align-items: stretch; + width: 100%; + gap: 0; + border: 0; + background: transparent; +} + +.search { + flex: 1 1 auto; + min-width: 0; +} + +.search :global(.cds--search-input) { + border: none; + background: transparent; +} + +.search :global(.cds--search-magnifier) { + inset-inline-start: 0.75rem; +} + +.filterMenu { + flex: 0 0 auto; + border-left: 0; +} diff --git a/frontend/src/shared/components/dashboard/tabs/DashboardToolbar.tsx b/frontend/src/shared/components/dashboard/tabs/DashboardToolbar.tsx new file mode 100644 index 00000000..17a6151a --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/DashboardToolbar.tsx @@ -0,0 +1,49 @@ +import type { ChangeEvent, ReactNode } from "react"; +import { Search } from "@carbon/react"; +import styles from "./DashboardToolbar.module.scss"; + +export interface DashboardToolbarProps { + children: ReactNode; +} + +function Root({ children }: DashboardToolbarProps) { + return
{children}
; +} + +interface ToolbarSearchProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + ariaLabel?: string; +} + +function ToolbarSearch({ + value, + onChange, + placeholder, + ariaLabel, +}: ToolbarSearchProps) { + return ( +
+ ) => onChange(e.target.value)} + /> +
+ ); +} + +interface ToolbarFilterSlotProps { + children: ReactNode; +} +function ToolbarFilter({ children }: ToolbarFilterSlotProps) { + return
{children}
; +} + +export const DashboardToolbar = Object.assign(Root, { + Search: ToolbarSearch, + Filter: ToolbarFilter, +}); diff --git a/frontend/src/shared/components/dashboard/tabs/index.ts b/frontend/src/shared/components/dashboard/tabs/index.ts new file mode 100644 index 00000000..c32a7f1d --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/index.ts @@ -0,0 +1,11 @@ +export { DashboardTabs, type DashboardTabsProps } from "./DashboardTabs"; +export { + DashboardTabBar, + type DashboardTabBarProps, + type TabDef, +} from "./DashboardTabBar"; +export { + DashboardTabPanel, + type DashboardTabPanelProps, +} from "./DashboardTabPanel"; +export { DashboardToolbar, type DashboardToolbarProps } from "./DashboardToolbar"; diff --git a/frontend/src/shared/components/dashboard/tabs/tabs.stories.tsx b/frontend/src/shared/components/dashboard/tabs/tabs.stories.tsx new file mode 100644 index 00000000..5a3fda46 --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/tabs.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { + DashboardBlock, + DashboardTabBar, + DashboardTabPanel, + DashboardTabs, + DashboardToolbar, +} from "../index"; + +const meta: Meta = { + title: "Dashboard/Tabs", + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const TabsWithToolbar: Story = { + render: () => { + const [active, setActive] = useState("all"); + const [q, setQ] = useState(""); + return ( + + + + + + } + /> + +
全部內容
+
+ +
進行中
+
+ +
已結束
+
+
+
+ ); + }, +}; diff --git a/frontend/src/shared/components/dashboard/tabs/tabs.test.tsx b/frontend/src/shared/components/dashboard/tabs/tabs.test.tsx new file mode 100644 index 00000000..5a749a4b --- /dev/null +++ b/frontend/src/shared/components/dashboard/tabs/tabs.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { + DashboardTabBar, + DashboardTabPanel, + DashboardTabs, + DashboardToolbar, +} from "./index"; + +function renderTabs(active: string, onChange = vi.fn()) { + render( + + + undefined} + placeholder="search" + /> + + } + /> + PANEL_A + PANEL_B + , + ); + return { onChange }; +} + +describe("Dashboard tabs", () => { + it("only renders active panel", () => { + renderTabs("a"); + expect(screen.getByText("PANEL_A")).toBeInTheDocument(); + expect(screen.queryByText("PANEL_B")).not.toBeInTheDocument(); + }); + + it("renders toolbar slot", () => { + renderTabs("a"); + expect(screen.getByPlaceholderText("search")).toBeInTheDocument(); + }); + + it("fires onChange when clicking another tab", () => { + const { onChange } = renderTabs("a"); + fireEvent.click(screen.getByRole("tab", { name: "B" })); + expect(onChange).toHaveBeenCalledWith("b"); + }); + + it("switches panel when active id changes", () => { + renderTabs("b"); + expect(screen.queryByText("PANEL_A")).not.toBeInTheDocument(); + expect(screen.getByText("PANEL_B")).toBeInTheDocument(); + }); +}); From 9347dead5a26a347be6513be96a536d5dc0d996c Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 14:07:35 +0800 Subject: [PATCH 08/60] refactor(contest): migrate student dashboard to dashboard primitives Replace the bespoke .root/.dashboard/.layout/.titleRow/.detailRow/.timerValue SCSS with DashboardPage, DashboardContainer (split + grid + stack), DashboardBlock, BlockHeader and TimeDisplay/MetricBlock primitives. Inner Carbon Tabs are kept; only the outer page layout, hero title and metric cells are migrated. Also switch BlockHeader description from

to

so it can host arbitrary nodes (e.g. tag rows) without nesting block elements in

. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../StudentContestDashboard.module.scss | 272 ++----- .../StudentContestDashboardView.test.tsx | 130 ++-- .../StudentContestDashboardView.tsx | 707 +++++++++++------- .../components/dashboard/BlockHeader.tsx | 2 +- .../dashboard/tabs/DashboardTabBar.tsx | 35 +- 5 files changed, 598 insertions(+), 548 deletions(-) diff --git a/frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss index cb94477c..24f4f589 100644 --- a/frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss +++ b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboard.module.scss @@ -1,65 +1,37 @@ -.root { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - overflow-x: hidden; -} +// 此 module 只保留 student dashboard 內部 list / chart / tab content 的局部樣式。 +// 頁面層級 layout、title、metric、time 已改用 @/shared/components/dashboard 元件。 -.dashboard { - max-width: 1056px; - margin: 0 auto; - padding: 1rem; +.tagRow { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; } -.summaryPanel, -.infoGrid, -.progressGrid { - border: 1px solid var(--cds-border-subtle); +.tabPanel { + padding: 0; } -.summaryPanel { +.tabContent { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem); -} - -.summaryMain, -.actionPanel, -.infoCell, -.progressPanel, -.rulesPanel { + gap: 1rem; padding: 1.5rem; } -.summaryMain { - min-width: 0; - border-right: 1px solid var(--cds-border-subtle); -} - -.titleRow, -.sectionHeader { +.sectionHeader, +.inlineRecordsHeader, +.chartHeader { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } -.titleRow { +.chartHeader { align-items: center; - flex-wrap: wrap; - justify-content: flex-start; - margin-bottom: 1rem; } -.title { - margin: 0; - color: var(--cds-text-primary); - font-size: var(--cds-heading-04-font-size, 1.75rem); - font-weight: 600; - line-height: var(--cds-heading-04-line-height, 1.28572); -} - -.phaseTitle, -.sectionTitle { +.inlineRecordsTitle { margin: 0; color: var(--cds-text-primary); font-size: var(--cds-heading-compact-02-font-size, 1rem); @@ -67,96 +39,38 @@ line-height: var(--cds-heading-compact-02-line-height, 1.375); } -.phaseDescription, -.sectionDescription, -.recordMeta, -.emptyText, -.metricLabel { - color: var(--cds-text-secondary); -} - -.phaseDescription { +.sectionDescription { margin: 0.25rem 0 0; - max-width: 44rem; + color: var(--cds-text-secondary); font-size: var(--cds-body-01-font-size, 0.875rem); line-height: var(--cds-body-01-line-height, 1.42857); } -.markdown, -.rulesContent { - margin-top: 1rem; - color: var(--cds-text-primary); -} - -.markdown { - max-width: 48rem; -} - -.actionPanel { - display: flex; - flex-direction: column; - justify-content: space-between; - gap: 1.5rem; -} - .actionStack, .modalStack { display: grid; gap: 0.75rem; } -.timerValue { - margin: 0.25rem 0 0; - color: var(--cds-text-primary); - font-family: var(--cds-code-font-family, monospace); - font-size: var(--cds-heading-05-font-size, 2rem); - font-weight: 600; - line-height: 1.2; -} - -.infoGrid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - border-top: 0; -} - -.infoCell { - min-width: 0; - border-right: 1px solid var(--cds-border-subtle); -} - -.infoCell:last-child { - border-right: 0; -} - .metricLabel { margin: 0; + color: var(--cds-text-secondary); font-size: var(--cds-label-01-font-size, 0.75rem); line-height: var(--cds-label-01-line-height, 1.33333); } .metricValue { - margin: 0.375rem 0 0; + margin: 0; color: var(--cds-text-primary); - font-size: var(--cds-body-compact-02-font-size, 1rem); + font-size: var(--cds-heading-compact-02-font-size, 1rem); font-weight: 600; - line-height: var(--cds-body-compact-02-line-height, 1.375); -} - -.progressGrid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(20rem, 24rem); - border-top: 0; -} - -.progressPanel { - border-right: 1px solid var(--cds-border-subtle); + line-height: var(--cds-heading-compact-02-line-height, 1.375); } .progressTrack { position: relative; block-size: 0.5rem; - margin-top: 1.5rem; + margin-top: 0.75rem; overflow: hidden; background: var(--cds-layer-accent-01); } @@ -167,17 +81,19 @@ background: var(--cds-support-success); } -.statGrid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1rem; - margin-top: 1.5rem; +.scoreDistributionChart { + min-height: 11.25rem; + margin-top: 0.75rem; + overflow: hidden; } -.rulesPanel { - display: grid; - gap: 1rem; - align-content: start; +.chartSkeleton { + width: 100%; + height: 11.25rem; +} + +.rulesContent { + color: var(--cds-text-primary); } .warningIcon { @@ -188,44 +104,14 @@ color: var(--cds-support-success); } -.inlineRecordsPanel { - margin-top: 1.5rem; - padding-top: 1.5rem; - border-top: 1px solid var(--cds-border-subtle); -} - -.inlineRecordsHeader { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; -} - -.inlineRecordsTitle { - margin: 0; - color: var(--cds-text-primary); - font-size: var(--cds-heading-compact-01-font-size, 0.875rem); - font-weight: 600; - line-height: var(--cds-heading-compact-01-line-height, 1.28572); -} - -.recordList { - display: grid; - margin-top: 1rem; - border-top: 1px solid var(--cds-border-subtle); -} - -.recordRow { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 1rem; - align-items: center; - padding: 1rem 0; - border-bottom: 1px solid var(--cds-border-subtle); +.recordMeta, +.emptyText { + color: var(--cds-text-secondary); } -.recordRow:last-child { - border-bottom: 0; +.recordMeta { + margin-top: 0.25rem; + font-size: var(--cds-body-compact-01-font-size, 0.875rem); } .recordTitle { @@ -235,11 +121,6 @@ line-height: var(--cds-body-compact-02-line-height, 1.375); } -.recordMeta { - margin-top: 0.25rem; - font-size: var(--cds-body-compact-01-font-size, 0.875rem); -} - .recordScore { color: var(--cds-text-primary); font-family: var(--cds-code-font-family, monospace); @@ -250,7 +131,6 @@ .problemReportList { display: grid; gap: 0.75rem; - margin-top: 1rem; } .problemReportItem { @@ -269,76 +149,29 @@ gap: 0.75rem; } +.questionList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + .errorText { margin: 1rem 0 0; color: var(--cds-text-error); } .emptyText { - margin: 1rem 0 0; -} - -@media (max-width: 1056px) { - .summaryPanel, - .progressGrid { - grid-template-columns: 1fr; - } - - .summaryMain, - .progressPanel { - border-right: 0; - border-bottom: 1px solid var(--cds-border-subtle); - } - - .infoGrid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .infoCell:nth-child(2) { - border-right: 0; - } - - .infoCell:nth-child(n + 3) { - border-top: 1px solid var(--cds-border-subtle); - } + margin: 0; } @media (max-width: 672px) { - .dashboard { - padding: 0.75rem; - } - - .summaryMain, - .actionPanel, - .infoCell, - .progressPanel, - .rulesPanel { + .tabContent { padding: 1rem; } - .infoGrid, - .statGrid { - grid-template-columns: 1fr; - } - - .infoCell { - border-right: 0; - border-top: 1px solid var(--cds-border-subtle); - } - - .infoCell:first-child { - border-top: 0; - } - - .recordRow { - grid-template-columns: 1fr; - } - - .recordScore { - white-space: normal; - } - + .inlineRecordsHeader, .problemReportItem { + display: grid; grid-template-columns: 1fr; } @@ -346,8 +179,7 @@ justify-content: flex-start; } - .inlineRecordsHeader { - display: grid; + .recordScore { + white-space: normal; } - } diff --git a/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx index 568ed566..4c46a008 100644 --- a/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx +++ b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.test.tsx @@ -9,6 +9,7 @@ import { getExamResults, getMyExamAnswers, } from "@/infrastructure/api/repositories/examAnswers.repository"; +import { getExamDashboardSummary } from "@/infrastructure/api/repositories/exam.repository"; import StudentContestDashboard from "./StudentContestDashboardView"; vi.mock("react-i18next", () => ({ @@ -70,10 +71,30 @@ vi.mock("@carbon/react", () => ({ SelectItem: ({ value, text }: { value: string; text: string }) => ( ), + SkeletonPlaceholder: () =>

, + Tab: ({ children }: { children: ReactNode }) => ( + + ), + TabList: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + TabPanel: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + TabPanels: ({ children }: { children: ReactNode }) =>
{children}
, + Tabs: ({ children }: { children: ReactNode }) =>
{children}
, Tag: ({ children }: { children: ReactNode }) => {children}, TextInput: () => , })); +vi.mock("@carbon/charts-react", () => ({ + LollipopChart: () =>
, +})); + +vi.mock("@/shared/ui/theme/ThemeContext", () => ({ + useTheme: () => ({ theme: "white" }), +})); + vi.mock("@carbon/icons-react", () => { const Icon = () =>
- + ), - TabList: ({ children }: { children: ReactNode }) => ( -
{children}
+ TabList: ({ + children, + ...props + }: { + children: ReactNode; + "aria-label"?: string; + }) => ( +
+ {children} +
), TabPanel: ({ children }: { children: ReactNode }) => (
{children}
@@ -164,6 +173,10 @@ vi.mock("@/infrastructure/api/repositories/exam.repository", () => ({ })), })); +vi.mock("@/infrastructure/api/repositories/contestAnnouncements.repository", () => ({ + getContestAnnouncements: vi.fn(() => new Promise(() => {})), +})); + const createContest = ( overrides: Partial = {}, ): ContestDetail => @@ -243,6 +256,25 @@ describe("StudentContestDashboard", () => { expect(screen.getByRole("button", { name: /加入競賽/ })).toBeInTheDocument(); }); + it("renders contest announcements above the tabs", async () => { + vi.mocked(getContestAnnouncements).mockResolvedValueOnce([ + { + id: "ann-1", + title: "考試公告", + content: "請準時進入考場", + created_at: "2099-05-05T09:00:00.000Z", + updated_at: "2099-05-05T09:00:00.000Z", + created_by: { username: "teacher" }, + }, + ]); + + renderDashboard(createContest()); + + expect(await screen.findByText("考試公告")).toBeInTheDocument(); + expect(screen.getByText("請準時進入考場")).toBeInTheDocument(); + expect(screen.getByRole("tablist", { name: "競賽資訊切換" })).toBeInTheDocument(); + }); + it("does not show answer records before the participant starts", () => { renderDashboard(createContest()); diff --git a/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.tsx b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.tsx index 38b10f33..851fbc77 100644 --- a/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.tsx +++ b/frontend/src/features/contest/components/studentDashboard/StudentContestDashboardView.tsx @@ -719,14 +719,16 @@ export default function StudentContestDashboard({
{announcements.map((announcement) => (
-
-

{announcement.title}

- {announcement.createdAt ? ( - - {formatDate(announcement.createdAt, { includeSeconds: false })} - - ) : null} -
+ + {formatDate(announcement.createdAt, { includeSeconds: false })} + + ) : null + } + />
{announcement.content}
diff --git a/frontend/src/shared/components/dashboard/DashboardPage.module.scss b/frontend/src/shared/components/dashboard/DashboardPage.module.scss index 812f28aa..980a6c14 100644 --- a/frontend/src/shared/components/dashboard/DashboardPage.module.scss +++ b/frontend/src/shared/components/dashboard/DashboardPage.module.scss @@ -1,16 +1,27 @@ .root { + display: flex; flex: 1 1 auto; + flex-direction: column; min-height: 0; overflow-y: auto; overflow-x: hidden; } .inner { + display: flex; + flex: 1 1 auto; + flex-direction: column; max-width: 1180px; + width: 100%; margin: 0 auto; padding: 1rem; } +.inner > * { + flex: 1 1 auto; + min-height: 0; +} + .fullBleed { max-width: none; padding: 0; From aa19bc62546b76ac8e9fae6798683e6efa3bc646 Mon Sep 17 00:00:00 2001 From: quan0715 Date: Tue, 5 May 2026 14:25:32 +0800 Subject: [PATCH 13/60] refactor(contest-admin): migrate panel headers and titles to dashboard primitives Six admin panels (AdminPreparationDashboard, OverviewActionWidgets, OverviewInsightsPanel, OverviewEventSummaryPanel, StudentStatusBreakdown, DraftChecklistPanel) now use BlockHeader / SectionTitle from @/shared/components/dashboard for their section titles and title-with-description-and-actions headers. Their bespoke .title / .subtitle / .panelHeader / .widgetTitle SCSS rules are removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AdminPreparationDashboard.module.scss | 21 ------- .../admin/AdminPreparationDashboard.tsx | 57 ++++++++----------- .../admin/DraftChecklistPanel.module.scss | 18 ------ .../components/admin/DraftChecklistPanel.tsx | 55 +++++++++--------- .../admin/OverviewActionWidgets.module.scss | 21 ------- .../admin/OverviewActionWidgets.tsx | 21 +++++-- .../OverviewEventSummaryPanel.module.scss | 7 --- .../admin/OverviewEventSummaryPanel.tsx | 9 +-- .../admin/OverviewInsightsPanel.module.scss | 7 --- .../admin/OverviewInsightsPanel.tsx | 9 +-- .../admin/StudentStatusBreakdown.module.scss | 7 --- .../admin/StudentStatusBreakdown.tsx | 3 +- 12 files changed, 78 insertions(+), 157 deletions(-) diff --git a/frontend/src/features/contest/components/admin/AdminPreparationDashboard.module.scss b/frontend/src/features/contest/components/admin/AdminPreparationDashboard.module.scss index ddbeafcf..d383b562 100644 --- a/frontend/src/features/contest/components/admin/AdminPreparationDashboard.module.scss +++ b/frontend/src/features/contest/components/admin/AdminPreparationDashboard.module.scss @@ -1,24 +1,3 @@ -.panelHeader { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1rem; -} - -.panelHeader h3 { - margin: 0; - color: var(--cds-text-primary); - font-size: 1rem; - font-weight: 600; -} - -.panelHeader p { - margin: 0.25rem 0 0; - color: var(--cds-text-secondary); - font-size: var(--cds-body-compact-01-font-size, 0.875rem); -} - .checklist { display: grid; gap: 0; diff --git a/frontend/src/features/contest/components/admin/AdminPreparationDashboard.tsx b/frontend/src/features/contest/components/admin/AdminPreparationDashboard.tsx index a9a6272f..c3322919 100644 --- a/frontend/src/features/contest/components/admin/AdminPreparationDashboard.tsx +++ b/frontend/src/features/contest/components/admin/AdminPreparationDashboard.tsx @@ -16,6 +16,7 @@ import type { AdminPreparationDashboardData, PreparationReadinessState, } from "@/features/contest/screens/admin/panels/adminOverviewDashboard.model"; +import { BlockHeader } from "@/shared/components/dashboard"; import styles from "./AdminPreparationDashboard.module.scss"; interface AdminPreparationDashboardProps { @@ -104,23 +105,17 @@ export default function AdminPreparationDashboard({ label: t("adminPreparationDashboard.tabs.readiness", "準備狀態"), content: ( <> -
-
-

- {t( - "adminPreparationDashboard.readiness.title", - "準備狀態", - )} -

-

- {t( - "adminPreparationDashboard.readiness.description", - "非考試時段優先確認能否順利開考與收尾。", - )} -

-
- -
+ } + />
    {data.checklistItems.map((item) => (
  • @@ -143,23 +138,17 @@ export default function AdminPreparationDashboard({ label: t("adminPreparationDashboard.tabs.grading", "批改與成績"), content: (
    -
    -
    -

    - {t( - "adminPreparationDashboard.grading.title", - "批改與成績", - )} -

    -

    - {t( - "adminPreparationDashboard.grading.description", - "考後查看批改剩餘量與成績發布狀態。", - )} -

    -
    - -
    + } + />
    -
    -

    - {t("adminOverview.draftChecklist.title", "發布前 Checklist")} -

    -

    - {t( - "adminOverview.draftChecklist.subtitle", - "發布不會被阻擋;以下項目可協助你在發布前把競賽設定更完整", - )} -

    - -
    + { + void (async () => { + setPublishLoading(true); + try { + await onPublishContest(); + } finally { + setPublishLoading(false); + } + })(); + }} + > + {t("adminOverview.actions.publishContest", "發布競賽")} + + } + /> {loading && (
    -

    {t("adminOverview.widgets.title", "控制台")}

    -

    {t("adminOverview.widgets.subtitle", "快速進入設定、題目與狀態操作")}

    +
    {Array.from({ length: 4 }).map((_, i) => ( @@ -246,8 +252,13 @@ export default function OverviewActionWidgets({ return (
    -

    {t("adminOverview.widgets.title", "控制台")}

    -

    {t("adminOverview.widgets.subtitle", "快速進入設定、題目與狀態操作")}

    +
    @@ -353,7 +364,7 @@ export default function OverviewActionWidgets({
    {!hasSchedule diff --git a/frontend/src/features/contest/components/admin/OverviewEventSummaryPanel.module.scss b/frontend/src/features/contest/components/admin/OverviewEventSummaryPanel.module.scss index cfceed4f..ae23095e 100644 --- a/frontend/src/features/contest/components/admin/OverviewEventSummaryPanel.module.scss +++ b/frontend/src/features/contest/components/admin/OverviewEventSummaryPanel.module.scss @@ -5,13 +5,6 @@ gap: 0.75rem; } -.title { - margin: 0; - font-size: var(--cds-body-02-font-size, 1rem); - font-weight: 600; - color: var(--cds-text-primary); -} - .statsGrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/frontend/src/features/contest/components/admin/OverviewEventSummaryPanel.tsx b/frontend/src/features/contest/components/admin/OverviewEventSummaryPanel.tsx index a00f4e13..01a6c14a 100644 --- a/frontend/src/features/contest/components/admin/OverviewEventSummaryPanel.tsx +++ b/frontend/src/features/contest/components/admin/OverviewEventSummaryPanel.tsx @@ -9,6 +9,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { ExamEvent } from "@/core/entities/contest.entity"; import { getEventPriority } from "@/features/contest/constants/eventTaxonomy"; +import { SectionTitle } from "@/shared/components/dashboard"; import styles from "./OverviewEventSummaryPanel.module.scss"; interface OverviewEventSummaryPanelProps { @@ -71,9 +72,9 @@ export default function OverviewEventSummaryPanel({ if (loading) { return (
    -

    + {t("adminOverview.events.title", "事件摘要")} -

    +
    {Array.from({ length: 4 }).map((_, i) => ( @@ -94,9 +95,9 @@ export default function OverviewEventSummaryPanel({ return (
    -

    + {t("adminOverview.events.title", "事件摘要")} -

    +
    {[ diff --git a/frontend/src/features/contest/components/admin/OverviewInsightsPanel.module.scss b/frontend/src/features/contest/components/admin/OverviewInsightsPanel.module.scss index eeb4d3b1..353f3609 100644 --- a/frontend/src/features/contest/components/admin/OverviewInsightsPanel.module.scss +++ b/frontend/src/features/contest/components/admin/OverviewInsightsPanel.module.scss @@ -21,13 +21,6 @@ } } -.title { - margin: 0; - font-size: var(--cds-body-02-font-size, 1rem); - font-weight: 600; - color: var(--cds-text-primary); -} - .tile { border: 1px solid var(--cds-border-subtle); padding: 0.875rem; diff --git a/frontend/src/features/contest/components/admin/OverviewInsightsPanel.tsx b/frontend/src/features/contest/components/admin/OverviewInsightsPanel.tsx index 03538c1b..d2343a6d 100644 --- a/frontend/src/features/contest/components/admin/OverviewInsightsPanel.tsx +++ b/frontend/src/features/contest/components/admin/OverviewInsightsPanel.tsx @@ -10,6 +10,7 @@ import { formatDuration, calculateContestTimeProgressAt, } from "./overviewMetrics.utils"; +import { SectionTitle } from "@/shared/components/dashboard"; import styles from "./OverviewInsightsPanel.module.scss"; interface OverviewInsightsPanelProps { @@ -130,9 +131,9 @@ export default function OverviewInsightsPanel({ if (loading) { return (