From 6d2dac0801dc5a02f25214c65ffd9f1081dd59f9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BB=96=E5=AE=8F=E7=A5=90?=
Date: Sun, 6 Sep 2026 23:51:28 +0800
Subject: [PATCH 01/14] feat: implement admin experiment detail panel
---
pnpm-lock.yaml | 16 -
.../admin/components/AdminSidebar.tsx | 9 +-
.../admin/components/ExperimentAdminShell.tsx | 109 +++
src/features/admin/formatters.ts | 10 +
.../admin/pages/AdminDashboardPage.tsx | 1 +
.../admin/pages/CreateExperimentPage.tsx | 677 ++++++++++++++++++
.../admin/pages/ExperimentAdmin.module.css | 484 +++++++++++++
.../admin/pages/ExperimentDetailPage.tsx | 633 ++++++++++++++++
.../admin/pages/ExperimentListPage.tsx | 264 +++++++
.../admin/services/adminRepository.ts | 12 +
src/routes.tsx | 63 ++
11 files changed, 2260 insertions(+), 18 deletions(-)
create mode 100644 src/features/admin/components/ExperimentAdminShell.tsx
create mode 100644 src/features/admin/pages/CreateExperimentPage.tsx
create mode 100644 src/features/admin/pages/ExperimentAdmin.module.css
create mode 100644 src/features/admin/pages/ExperimentDetailPage.tsx
create mode 100644 src/features/admin/pages/ExperimentListPage.tsx
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8ee0213..fbc246f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1311,42 +1311,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.2':
resolution: {integrity: sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.2':
resolution: {integrity: sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.2':
resolution: {integrity: sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.2':
resolution: {integrity: sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.2':
resolution: {integrity: sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.2':
resolution: {integrity: sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==}
@@ -1400,42 +1394,36 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.43':
resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@swc/core-linux-ppc64-gnu@1.15.43':
resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-s390x-gnu@1.15.43':
resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@swc/core-linux-x64-gnu@1.15.43':
resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-x64-musl@1.15.43':
resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.43':
resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==}
@@ -2142,28 +2130,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
diff --git a/src/features/admin/components/AdminSidebar.tsx b/src/features/admin/components/AdminSidebar.tsx
index 4d07092..f9cb120 100644
--- a/src/features/admin/components/AdminSidebar.tsx
+++ b/src/features/admin/components/AdminSidebar.tsx
@@ -7,6 +7,7 @@ import {
Search,
Users,
} from "lucide-react";
+import { useNavigate } from "react-router";
import { roleLabels } from "../formatters";
import type { User } from "../types";
@@ -25,6 +26,8 @@ export default function AdminSidebar({
activeSection,
onNavigate,
}: Props) {
+ const navigate = useNavigate();
+
return (
-
+
+
評分方式 *
+
本階段僅支援自動評分。
-
-
- setField("maxAttempts", value)
- }
- >
-
-
-
-
-
-
-
- setField(
- "result",
- value as Draft["result"]
- )
- }
- >
-
-
-
-
-
-
- setField(
- "release",
- value as Draft["release"]
- )
- }
- >
-
-
-
-
-
+
+
+
重新作答 *
+
+ 設定學生完成後是否能重新挑戰教材。
+
+
+ setField("maxAttempts", value)
+ }
+ >
+
+
+
+
+
+
+
+
+
結果顯示 *
+
+ 設定學生完成整份教材後能查看的內容。
+
+
+ setField(
+ "result",
+ value as Draft["result"]
+ )
+ }
+ >
+
+
+
+
+
+
+
+
正確答案公開 *
+
避免作答期間提前看到答案。
+
+ setField(
+ "release",
+ value as Draft["release"]
+ )
+ }
+ >
+
+
+
+
+
+
設定說明
-
- - 評分方式目前固定為自動評分
- - 結果顯示與答案公開可分開設定
- - 實驗開始後設定會鎖定
-
+
+ 這些設定會套用到本實驗中的所有教材。排程後仍可檢查內容,實驗開始後部分欄位會鎖定。
+
+
+ ✓ 評分方式目前固定為自動評分
+
+
+ ✓ 結果顯示與答案公開可分開設定
+
+
+ ✓ ACTIVE 後不可修改評分與公開規則
+
+
+ 進行中的實驗
+
+ 為保持學生作答條件一致,實驗開始後將鎖定本頁設定。
+
+
)}
@@ -455,11 +533,21 @@ export default function CreateExperimentPage() {
尚未提供教材目錄端點,正式環境暫時無法選擇教材。
)}
+
+ setCourseSearch(event.currentTarget.value)
+ }
+ />
{coursesQuery.isPending && (
載入教材中⋯
)}
- {courseOptions.map((course) => {
+ {visibleCourseOptions.map((course) => {
const available =
course.status === "PUBLISHED";
return (
@@ -498,7 +586,7 @@ export default function CreateExperimentPage() {
);
})}
- {courseOptions.length === 0 && (
+ {visibleCourseOptions.length === 0 && (
目前沒有可選教材
diff --git a/src/features/admin/pages/ExperimentAdmin.module.css b/src/features/admin/pages/ExperimentAdmin.module.css
index 40fe814..08515d5 100644
--- a/src/features/admin/pages/ExperimentAdmin.module.css
+++ b/src/features/admin/pages/ExperimentAdmin.module.css
@@ -333,43 +333,68 @@
display: flex;
height: 7rem;
align-items: center;
+ justify-content: space-between;
+ margin: 0;
padding: 0 2rem;
+ list-style: none;
}
.step {
+ display: flex;
min-width: 0;
+ height: 4rem;
flex: 0 1 15.625rem;
+ align-items: center;
+ gap: 0.75rem;
padding: 0;
}
.stepBody {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
gap: 0.125rem;
+ white-space: nowrap;
}
-.stepIcon {
- width: 2.25rem;
- min-width: 2.25rem;
- height: 2.25rem;
- border-color: #e5e5e5;
- background: #f5f5f5;
- font-size: 0.875rem;
-}
-.stepLabel {
+.stepBody strong {
color: #525252;
font-size: 1rem;
font-weight: 500;
line-height: 1.375rem;
}
-.step[data-progress] .stepLabel,
-.step[data-completed] .stepLabel {
+.stepActive .stepBody strong {
color: var(--admin-ink);
font-weight: 700;
}
-.stepDescription {
+.stepBody small {
color: #a1a1a1;
font-size: 0.75rem;
line-height: 1.0625rem;
}
+.stepIcon {
+ display: grid;
+ width: 2.25rem;
+ min-width: 2.25rem;
+ height: 2.25rem;
+ place-items: center;
+ border: 1px solid #e5e5e5;
+ border-radius: 999px;
+ background: #f5f5f5;
+ color: #525252;
+ font-size: 0.875rem;
+}
+.stepIconCurrent {
+ border-color: #005f55;
+ background: #005f55;
+ color: #fff;
+}
+.stepIconComplete {
+ border-color: #62cab8;
+ background: #62cab8;
+ color: #005f55;
+}
.stepSeparator {
- margin-inline: 0.75rem;
- background: #e5e5e5;
+ margin-left: auto;
+ color: #e5e5e5;
+ font-size: 1.125rem;
}
.formGrid {
display: grid;
@@ -381,13 +406,35 @@
background: #fff;
}
.formCard {
- min-height: 43.125rem;
padding: 1.75rem 2rem;
}
+.formGrid > .formCard {
+ min-height: 43.125rem;
+}
+.formCard .sectionTitle {
+ font-size: 1.5rem;
+ line-height: 2.125rem;
+}
.helpCard {
min-height: 22.75rem;
padding: 1.75rem;
}
+.guidanceItem {
+ width: 100%;
+ margin-top: 1rem;
+ color: var(--admin-ink);
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+.timezoneNotice {
+ width: fit-content;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ background: #62cab8;
+ color: var(--admin-ink);
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
.formCard :global(.mantine-Input-input) {
min-height: 3.25rem;
border-color: #e5e5e5;
@@ -414,12 +461,13 @@
gap: 1rem;
}
.choiceGrid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
+ display: flex;
+ flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.choice {
+ min-width: 12.125rem;
padding: 0.9rem;
border: 1px solid #d4d4d4;
border-radius: 0.5rem;
@@ -428,6 +476,41 @@
border-color: var(--admin-accent);
background: #f3fbfa;
}
+.settingBlock {
+ display: flex;
+ flex-direction: column;
+}
+.settingBlock > strong {
+ color: var(--admin-ink);
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.25rem;
+}
+.settingBlock > small {
+ margin-top: 0.25rem;
+ color: #737373;
+ font-size: 0.75rem;
+ line-height: 1.0625rem;
+}
+.teachingHelp {
+ min-height: 27.625rem;
+}
+.activeNotice {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ gap: 0.375rem;
+ margin-top: 1.25rem;
+ padding: 1rem;
+ border-radius: 0.5rem;
+ background: #edf5f3;
+ color: #525252;
+ font-size: 0.75rem;
+}
+.activeNotice strong {
+ color: var(--admin-ink);
+ font-size: 0.875rem;
+}
.formFooter {
display: flex;
height: 4rem;
@@ -438,6 +521,12 @@
padding: 0;
background: #fff;
}
+.formFooter button {
+ height: 2.75rem;
+ padding-inline: 1.125rem;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+}
.coursePicker {
display: flex;
flex-direction: column;
@@ -495,6 +584,10 @@
.reviewGrid {
grid-template-columns: 1fr;
}
+ .formGrid {
+ min-height: auto;
+ overflow: visible;
+ }
}
@media (max-width: 48rem) {
@@ -529,6 +622,9 @@
align-items: flex-start;
flex-direction: column;
}
+ .pageHeader h1 {
+ font-size: 2rem;
+ }
.filters,
.fieldGrid,
.choiceGrid {
From 2844c5de7a6461fbe079ffe7964233b1959e1b35 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BB=96=E5=AE=8F=E7=A5=90?=
Date: Mon, 7 Sep 2026 00:21:10 +0800
Subject: [PATCH 04/14] fix: align experiment form spacing
---
.../admin/pages/CreateExperimentPage.tsx | 6 ++---
.../admin/pages/ExperimentAdmin.module.css | 23 ++++++++++++++++++-
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/src/features/admin/pages/CreateExperimentPage.tsx b/src/features/admin/pages/CreateExperimentPage.tsx
index d8f58ca..16a6497 100644
--- a/src/features/admin/pages/CreateExperimentPage.tsx
+++ b/src/features/admin/pages/CreateExperimentPage.tsx
@@ -170,7 +170,7 @@ export default function CreateExperimentPage() {
navigate("/admin/experiments")}
>
@@ -255,7 +255,7 @@ export default function CreateExperimentPage() {
建立實驗草稿後,可以繼續設定教材與學生。
-
+
設定學生的作答方式,以及完成教材後可以看到的資訊。
-
+
評分方式 *
本階段僅支援自動評分。
diff --git a/src/features/admin/pages/ExperimentAdmin.module.css b/src/features/admin/pages/ExperimentAdmin.module.css
index 08515d5..beb8800 100644
--- a/src/features/admin/pages/ExperimentAdmin.module.css
+++ b/src/features/admin/pages/ExperimentAdmin.module.css
@@ -118,7 +118,7 @@
display: flex;
min-width: 0;
align-items: center;
- gap: 0.875rem;
+ gap: 1rem;
}
.backButton {
color: var(--admin-deep);
@@ -415,10 +415,25 @@
font-size: 1.5rem;
line-height: 2.125rem;
}
+.formCard > .muted {
+ margin-top: 1.5rem;
+}
+.formFields {
+ gap: 1.5rem;
+ margin-top: 1.5rem;
+}
.helpCard {
+ display: flex;
min-height: 22.75rem;
+ flex-direction: column;
+ gap: 1.125rem;
padding: 1.75rem;
}
+.helpCard p,
+.helpCard .guidanceItem,
+.helpCard .activeNotice {
+ margin: 0;
+}
.guidanceItem {
width: 100%;
margin-top: 1rem;
@@ -527,6 +542,12 @@
border-radius: 0.5rem;
font-size: 1rem;
}
+.formFooter button:disabled {
+ border-color: #e5e5e5;
+ background: #fff;
+ color: #004038;
+ opacity: 1;
+}
.coursePicker {
display: flex;
flex-direction: column;
From 0dcadb809918e5fa1f38efb3926220a5493494b8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BB=96=E5=AE=8F=E7=A5=90?=
Date: Mon, 7 Sep 2026 00:27:37 +0800
Subject: [PATCH 05/14] fix: correct experiment form layout
---
.../admin/pages/ExperimentAdmin.module.css | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/src/features/admin/pages/ExperimentAdmin.module.css b/src/features/admin/pages/ExperimentAdmin.module.css
index beb8800..2113c20 100644
--- a/src/features/admin/pages/ExperimentAdmin.module.css
+++ b/src/features/admin/pages/ExperimentAdmin.module.css
@@ -331,6 +331,7 @@
}
.stepper {
display: flex;
+ flex-direction: row;
height: 7rem;
align-items: center;
justify-content: space-between;
@@ -470,6 +471,22 @@
color: #a1a1a1;
font-size: 0.75rem;
}
+.formCard :global(.mantine-InputWrapper-root) {
+ display: flex;
+ flex-direction: column;
+}
+.formCard :global(.mantine-InputWrapper-label) {
+ order: 1;
+}
+.formCard :global(.mantine-Input-wrapper) {
+ order: 2;
+}
+.formCard :global(.mantine-InputWrapper-description) {
+ order: 3;
+}
+.formCard :global(.mantine-InputWrapper-error) {
+ order: 4;
+}
.fieldGrid {
display: grid;
grid-template-columns: 1fr 1fr;
From a7610663fa7ade1fa8466cb173e2f9cccec0598d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BB=96=E5=AE=8F=E7=A5=90?=
Date: Mon, 7 Sep 2026 23:48:31 +0800
Subject: [PATCH 06/14] feat: connect experiment management APIs
---
example.env | 4 +-
pnpm-lock.yaml | 16 +
.../admin/components/AdminSidebar.tsx | 4 +-
.../admin/pages/AdminDashboardPage.tsx | 1 -
.../admin/pages/CreateExperimentPage.tsx | 311 +++++++++++++++---
.../admin/pages/ExperimentDetailPage.tsx | 95 ++++--
.../admin/pages/ExperimentListPage.tsx | 96 +++++-
.../admin/services/adminRepository.ts | 87 ++++-
src/features/admin/types.ts | 8 +
9 files changed, 523 insertions(+), 99 deletions(-)
diff --git a/example.env b/example.env
index ead8b1f..1112b3b 100644
--- a/example.env
+++ b/example.env
@@ -1,11 +1,11 @@
# The backend of SciEdu
-VITE_BACKEND_BASE_URL="https://api.sciedu.sdc.nycu.club"
+VITE_BACKEND_BASE_URL="https://dev.sciedu.sdc.nycu.club"
# This frontend should only serve as either an educational
# content provider (edu mode), as a standalone llm (llm
# mode) or enable both during development (dev mode)
# The following feature should be one of edu/llm/dev
-VITE_APP_MODE=
+VITE_APP_MODE=dev
# Local-only Experimenter Admin preview. This bypass is ignored by production
# builds even when set to true.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fbc246f..8ee0213 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1311,36 +1311,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.2':
resolution: {integrity: sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.2':
resolution: {integrity: sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.2':
resolution: {integrity: sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.2':
resolution: {integrity: sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.2':
resolution: {integrity: sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.2':
resolution: {integrity: sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==}
@@ -1394,36 +1400,42 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.43':
resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@swc/core-linux-ppc64-gnu@1.15.43':
resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-s390x-gnu@1.15.43':
resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-x64-gnu@1.15.43':
resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-x64-musl@1.15.43':
resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.43':
resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==}
@@ -2130,24 +2142,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
diff --git a/src/features/admin/components/AdminSidebar.tsx b/src/features/admin/components/AdminSidebar.tsx
index f9cb120..d3b7377 100644
--- a/src/features/admin/components/AdminSidebar.tsx
+++ b/src/features/admin/components/AdminSidebar.tsx
@@ -44,9 +44,7 @@ export default function AdminSidebar({
總覽
- navigate("/admin/experiments")}
- >
+ navigate("/admin/experiments")}>
實驗管理
diff --git a/src/features/admin/pages/AdminDashboardPage.tsx b/src/features/admin/pages/AdminDashboardPage.tsx
index 5539378..a042f1a 100644
--- a/src/features/admin/pages/AdminDashboardPage.tsx
+++ b/src/features/admin/pages/AdminDashboardPage.tsx
@@ -18,7 +18,6 @@ import {
Search,
} from "lucide-react";
import { toast } from "sonner";
-import { useNavigate } from "react-router";
import { useDocumentTitle } from "../../../shared/hooks";
import AddParticipantModal from "../components/AddParticipantModal";
diff --git a/src/features/admin/pages/CreateExperimentPage.tsx b/src/features/admin/pages/CreateExperimentPage.tsx
index 16a6497..0e4b4c8 100644
--- a/src/features/admin/pages/CreateExperimentPage.tsx
+++ b/src/features/admin/pages/CreateExperimentPage.tsx
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
- Alert,
Badge,
Button,
Card,
@@ -13,18 +12,27 @@ import {
TextInput,
Title,
} from "@mantine/core";
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check } from "lucide-react";
import { useNavigate, useParams } from "react-router";
+import { toast } from "sonner";
import { useDocumentTitle } from "../../../shared/hooks";
+import { ApiError } from "../../../shared/utils/api";
+import AddParticipantModal from "../components/AddParticipantModal";
import ExperimentAdminShell from "../components/ExperimentAdminShell";
import {
+ addExperimentCourses,
+ createExperiment,
fetchExperiment,
- isAdminDemoMode,
listExperimentCourseCandidates,
listExperimentCourses,
+ listExperimentParticipants,
+ removeExperimentCourse,
+ updateExperiment,
+ updateExperimentStatus,
} from "../services/adminRepository";
+import type { EditableExperimentPayload } from "../types";
import styles from "./ExperimentAdmin.module.css";
type Draft = {
@@ -38,21 +46,20 @@ type Draft = {
courseIds: string[];
};
+const TAIPEI_TIME_ZONE = "Asia/Taipei";
+const defaultExperimentDate = toTaipeiInputValue(
+ new Date(Date.now() + 86_400_000).toISOString()
+).slice(0, 10);
+
const initialDraft: Draft = {
name: "",
description: "",
- startsAt: "2026-08-03T09:00",
- endsAt: "2026-08-03T16:00",
+ startsAt: `${defaultExperimentDate}T09:00`,
+ endsAt: `${defaultExperimentDate}T16:00`,
maxAttempts: "1",
result: "explanations",
release: "course",
- courseIds: isAdminDemoMode
- ? [
- "10000000-0000-4000-8000-000000000001",
- "10000000-0000-4000-8000-000000000002",
- "10000000-0000-4000-8000-000000000003",
- ]
- : [],
+ courseIds: [],
};
const steps = [
@@ -62,8 +69,34 @@ const steps = [
{ label: "學生與確認", description: "學生可稍後加入" },
];
+function toTaipeiInputValue(value: string) {
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone: TAIPEI_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hourCycle: "h23",
+ }).formatToParts(new Date(value));
+ const part = (type: Intl.DateTimeFormatPartTypes) =>
+ parts.find((item) => item.type === type)?.value ?? "";
+ return `${part("year")}-${part("month")}-${part("day")}T${part("hour")}:${part("minute")}`;
+}
+
+function taipeiInputToISOString(value: string) {
+ return new Date(`${value}:00+08:00`).toISOString();
+}
+
+function errorMessage(error: unknown) {
+ return error instanceof ApiError && error.message
+ ? error.message
+ : "操作失敗,請稍後再試";
+}
+
export default function CreateExperimentPage() {
const navigate = useNavigate();
+ const queryClient = useQueryClient();
const { experimentId } = useParams();
const isEditing = Boolean(experimentId);
useDocumentTitle(isEditing ? "編輯實驗" : "新增實驗");
@@ -72,6 +105,13 @@ export default function CreateExperimentPage() {
const [draft, setDraft] = useState(initialDraft);
const [showBasicErrors, setShowBasicErrors] = useState(false);
const [courseSearch, setCourseSearch] = useState("");
+ const [persistedExperimentId, setPersistedExperimentId] = useState(
+ experimentId ?? ""
+ );
+ const [persistedCourseIds, setPersistedCourseIds] = useState([]);
+ const [coursesTouched, setCoursesTouched] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isAddingStudents, setAddingStudents] = useState(false);
const coursesQuery = useQuery({
queryKey: ["admin", "experiments", "course-candidates"],
queryFn: listExperimentCourseCandidates,
@@ -86,6 +126,16 @@ export default function CreateExperimentPage() {
queryFn: () => listExperimentCourses(experimentId ?? ""),
enabled: isEditing,
});
+ const participantsQuery = useQuery({
+ queryKey: [
+ "admin",
+ "experiments",
+ persistedExperimentId,
+ "participants",
+ ],
+ queryFn: () => listExperimentParticipants(persistedExperimentId),
+ enabled: Boolean(persistedExperimentId) && step === 3,
+ });
useEffect(() => {
if (
@@ -98,8 +148,8 @@ export default function CreateExperimentPage() {
setDraft({
name: experiment.name,
description: experiment.description ?? "",
- startsAt: experiment.scheduledStartAt.slice(0, 16),
- endsAt: experiment.scheduledEndAt.slice(0, 16),
+ startsAt: toTaipeiInputValue(experiment.scheduledStartAt),
+ endsAt: toTaipeiInputValue(experiment.scheduledEndAt),
maxAttempts: String(
Math.max(1, experiment.configuration.maxAttempts)
),
@@ -115,16 +165,21 @@ export default function CreateExperimentPage() {
({ course }) => course.id
),
});
+ setPersistedCourseIds(
+ (assignedCoursesQuery.data ?? []).map(({ course }) => course.id)
+ );
initialized.current = true;
}, [assignedCoursesQuery.data, experimentQuery.data, isEditing]);
- const courseOptions = useMemo(
- () =>
- isEditing
- ? (assignedCoursesQuery.data ?? []).map(({ course }) => course)
- : (coursesQuery.data ?? []),
- [assignedCoursesQuery.data, coursesQuery.data, isEditing]
- );
+ const courseOptions = useMemo(() => {
+ const courses = new Map(
+ (coursesQuery.data ?? []).map((course) => [course.id, course])
+ );
+ (assignedCoursesQuery.data ?? []).forEach(({ course }) =>
+ courses.set(course.id, course)
+ );
+ return [...courses.values()];
+ }, [assignedCoursesQuery.data, coursesQuery.data]);
const visibleCourseOptions = useMemo(() => {
const normalized = courseSearch.trim().toLocaleLowerCase("zh-Hant");
return courseOptions.filter(
@@ -151,10 +206,17 @@ export default function CreateExperimentPage() {
draft.startsAt < draft.endsAt
);
const canContinueCourses = draft.courseIds.length > 0;
+ const currentStatus = experimentQuery.data?.status;
+ const isReadOnly =
+ currentStatus === "COMPLETED" || currentStatus === "ARCHIVED";
+ const isActive = currentStatus === "ACTIVE";
+ const canEditCourses = !currentStatus || currentStatus === "DRAFT";
const setField = (field: K, value: Draft[K]) =>
setDraft((current) => ({ ...current, [field]: value }));
const toggleCourse = (courseId: string) => {
+ if (!canEditCourses) return;
+ setCoursesTouched(true);
setDraft((current) => ({
...current,
courseIds: current.courseIds.includes(courseId)
@@ -163,6 +225,115 @@ export default function CreateExperimentPage() {
}));
};
+ const payloadFromDraft = (): EditableExperimentPayload => ({
+ name: draft.name.trim(),
+ ...(draft.description.trim()
+ ? { description: draft.description.trim() }
+ : {}),
+ scheduledStartAt: taipeiInputToISOString(draft.startsAt),
+ scheduledEndAt: taipeiInputToISOString(draft.endsAt),
+ configuration: {
+ maxAttempts: Number(draft.maxAttempts),
+ allowRetry: Number(draft.maxAttempts) > 1,
+ showScore: true,
+ showExplanations: draft.result === "explanations",
+ gradingMode: "AUTOMATIC",
+ correctAnswerReleaseMode:
+ draft.release === "page"
+ ? "AFTER_PAGE_SUBMISSION"
+ : "AFTER_COURSE_COMPLETION",
+ },
+ });
+
+ const persistExperiment = async () => {
+ if (persistedExperimentId) {
+ return updateExperiment(persistedExperimentId, payloadFromDraft());
+ }
+ const created = await createExperiment(payloadFromDraft());
+ setPersistedExperimentId(created.id);
+ return created;
+ };
+
+ const syncCourses = async (id: string) => {
+ if (!canEditCourses || (!coursesTouched && step < 2)) return;
+ const selected = new Set(draft.courseIds);
+ const persisted = new Set(persistedCourseIds);
+ const additions = draft.courseIds.filter((id) => !persisted.has(id));
+ const removals = persistedCourseIds.filter((id) => !selected.has(id));
+
+ await Promise.all([
+ additions.length ? addExperimentCourses(id, additions) : null,
+ ...removals.map((courseId) => removeExperimentCourse(id, courseId)),
+ ]);
+ setPersistedCourseIds([...draft.courseIds]);
+ setCoursesTouched(false);
+ };
+
+ const validateBasic = () => {
+ if (canContinueBasic) return true;
+ setShowBasicErrors(true);
+ return false;
+ };
+
+ const save = async (
+ options: { schedule?: boolean; exit?: boolean } = {}
+ ) => {
+ if (!validateBasic() || isReadOnly) return;
+ if (options.schedule && !canContinueCourses) {
+ setStep(2);
+ toast.error("排程前至少需要選擇一份已發布教材");
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ const experiment = await persistExperiment();
+ await syncCourses(experiment.id);
+ if (options.schedule && experiment.status !== "SCHEDULED") {
+ await updateExperimentStatus(experiment.id, "SCHEDULED");
+ }
+ await queryClient.invalidateQueries({
+ queryKey: ["admin", "experiments"],
+ });
+ toast.success(options.schedule ? "實驗已排程" : "實驗已儲存");
+ if (options.exit) {
+ navigate(`/admin/experiments/${experiment.id}`);
+ }
+ } catch (error) {
+ toast.error(errorMessage(error));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const goNext = async () => {
+ if (step === 0 && !validateBasic()) return;
+ if (step === 2 && !canContinueCourses) return;
+ setIsSaving(true);
+ try {
+ const experiment = await persistExperiment();
+ if (step === 2) await syncCourses(experiment.id);
+ setStep((current) => current + 1);
+ } catch (error) {
+ toast.error(errorMessage(error));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (
+ isEditing &&
+ (experimentQuery.isPending || assignedCoursesQuery.isPending)
+ ) {
+ return 載入實驗資料中⋯
;
+ }
+ if (
+ isEditing &&
+ (experimentQuery.isError || assignedCoursesQuery.isError)
+ ) {
+ return 實驗資料載入失敗
;
+ }
+
return (
@@ -258,6 +429,7 @@ export default function CreateExperimentPage() {