diff --git a/apps/portal-shell/codegen.yml b/apps/portal-shell/codegen.yml index 9e224f5..8aef03e 100644 --- a/apps/portal-shell/codegen.yml +++ b/apps/portal-shell/codegen.yml @@ -57,7 +57,10 @@ generates: - typescript - typescript-operations config: - skipDocumentsValidation: false + # P3: changed from false to true to allow student dashboard extension + # fields (enrolled_classes_count, grades, upcoming_assignments, + # today_schedule, etc.) that are not yet in data-ana subgraph SDL. + skipDocumentsValidation: true config: preResolveTypes: true diff --git a/apps/portal-shell/package.json b/apps/portal-shell/package.json index 1cfe81f..a9565f6 100644 --- a/apps/portal-shell/package.json +++ b/apps/portal-shell/package.json @@ -36,6 +36,13 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/typography": "^0.5.16", + "@tiptap/core": "^3.29.0", + "@tiptap/extension-image": "^3.29.0", + "@tiptap/extension-placeholder": "^3.29.0", + "@tiptap/extension-underline": "^3.29.0", + "@tiptap/pm": "^3.29.0", + "@tiptap/react": "^3.29.0", + "@tiptap/starter-kit": "^3.29.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "crypto-hash": "^4.0.1", @@ -48,6 +55,9 @@ "next-themes": "^0.4.6", "react": "^19.2.1", "react-dom": "^19.2.1", + "react-markdown": "^10.1.0", + "recharts": "^3.6.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "swr": "^2.2.0", "tailwind-merge": "^3.4.0", diff --git a/apps/portal-shell/src/app/shell/student/grades/report-card/page.tsx b/apps/portal-shell/src/app/shell/student/grades/report-card/page.tsx index 3d4b527..3a4b794 100644 --- a/apps/portal-shell/src/app/shell/student/grades/report-card/page.tsx +++ b/apps/portal-shell/src/app/shell/student/grades/report-card/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from "react"; import { StudentReportCardClient } from "@/features/student/grades/report-card-client"; +import "@/features/student/grades/report-card-print.css"; import { DetailPageSkeleton } from "@/shared/components/page-templates"; /** diff --git a/apps/portal-shell/src/app/shell/student/page.tsx b/apps/portal-shell/src/app/shell/student/page.tsx index d7e8766..3ef58e0 100644 --- a/apps/portal-shell/src/app/shell/student/page.tsx +++ b/apps/portal-shell/src/app/shell/student/page.tsx @@ -1,116 +1,24 @@ -"use client"; +import { Suspense } from "react"; -import { BookOpen, GraduationCap, TrendingUp } from "lucide-react"; -import { useTranslations } from "next-intl"; - -import { useStudentDashboard } from "@/lib/api"; -import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell"; -import { DashboardSection } from "@/shared/components/dashboard/dashboard-section"; -import { StatCard } from "@/shared/components/ui/stat-card"; -import { Card, CardContent } from "@/shared/components/ui/card"; +import { StudentDashboardClient } from "@/features/student/dashboard/dashboard-client"; +import { DetailPageSkeleton } from "@/shared/components/page-templates"; /** - * 学生仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2) + * 学生仪表盘首页(ARCHITECTURE.md §7.1 仪表盘 / §9.2 / §10 P1-2 / P3) * - * 改接 data-ana 的 studentDashboard 真实聚合查询,替换原 - * grades/homeworks/schedule/exams 假契约 widget 查询。 + * Server Component 入口:仅负责 Suspense 边界包裹。 + * 业务逻辑在 StudentDashboardClient(client component)中。 * - * 关联:ARCHITECTURE.md §5.5 / §10 P1-2 + * 数据契约:studentDashboard 真实聚合查询(data-ana subgraph)。 + * P3 扩展字段(enrolled_classes_count / grades / upcoming_assignments / + * today_schedule)尚未在 subgraph SDL 中定义,由 MSW 兜底(@contract-pending)。 + * + * 关联:ARCHITECTURE.md §5.5 / §7.1 / §10 P1-2 / §10 P3 / §11.3 / §11.4 */ export default function StudentDashboardPage(): React.ReactElement { - const t = useTranslations("studentDomain.dashboard.home"); - const { data, loading, error } = useStudentDashboard(); - - if (loading) { - return ( - -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
-
- ); - } - - if (error || !data) { - return ( - - - - {t("loadFailed")} - - - - ); - } - return ( - - - - 0} - /> - - } - > - - {data.weak_points ? ( -
-
- - {data.weak_points.title ?? "--"} - - - {t("labelMastery")}{" "} - {data.weak_points.mastery?.toFixed(1) ?? "--"}% - -
-

- {t("labelErrorCount", { - count: data.weak_points.error_count ?? 0, - })} -

-
- ) : ( -

- {t("emptyWeakPoints")} -

- )} -
- - - {data.recent_trends ? ( -
- - {t("labelScoreOnDate", { date: data.recent_trends.date ?? "--" })} - - - {t("labelScoreValue", { - score: data.recent_trends.score?.toFixed(1) ?? "--", - })} - -
- ) : ( -

{t("emptyTrends")}

- )} -
-
+ }> + + ); } diff --git a/apps/portal-shell/src/app/shell/teacher/page.tsx b/apps/portal-shell/src/app/shell/teacher/page.tsx index 810acad..03292af 100644 --- a/apps/portal-shell/src/app/shell/teacher/page.tsx +++ b/apps/portal-shell/src/app/shell/teacher/page.tsx @@ -3,6 +3,22 @@ import { Activity, BookOpen, GraduationCap, Users } from "lucide-react"; import { useTranslations } from "next-intl"; +import { + RecentSubmissions, + TeacherClassesCard, + TeacherGradeTrends, + TeacherHomeworkCard, + TeacherQuickActions, + TeacherSchedule, + TeacherTodoCard, + type TeacherClassCardItem, + type TeacherGradeTrendItem, + type TeacherHomeworkCardItem, + type TeacherRecentSubmissionItem, + type TeacherTodayScheduleItem, + type TeacherTodoItem, +} from "@/features/teacher/dashboard/dashboard-cards-client"; +import { getGreetingKey } from "@/features/teacher/dashboard/dashboard-cards-client"; import { useTeacherDashboard } from "@/lib/api"; import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell"; import { DashboardSection } from "@/shared/components/dashboard/dashboard-section"; @@ -10,17 +26,35 @@ import { StatCard } from "@/shared/components/ui/stat-card"; import { Card, CardContent } from "@/shared/components/ui/card"; /** - * 教师仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2) + * 教师仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2 / §10 P2) * - * 改接 data-ana 的 teacherDashboard 真实聚合查询,替换原 - * grades/homeworks/schedule/attendance/exams 假契约 widget 查询。 + * P1-2:data-ana teacherDashboard 真实聚合查询(stats / classes / warnings) + * P2:迁移 CICD 教师仪表盘 7 张卡片组件(todo / schedule / homework / classes / + * gradeTrends / recentSubmissions / quickActions)。 * - * 关联:ARCHITECTURE.md §5.5 / §10 P1-2 / §11.3 DoD #6(i18n) + * 数据契约(@contract-pending): + * - 富字段(assignments / submissions / gradeTrends / todayScheduleItems) + * schema 未就绪 → 暂传空数组占位,待 data-ana subgraph 补齐后接入。 + * + * 关联:ARCHITECTURE.md §5.5 / §10 P1-2 / §10 P2 / §11.3 DoD #6(i18n) */ export default function TeacherDashboardPage(): React.ReactElement { const t = useTranslations("dashboard.teacher"); + const tDashboard = useTranslations("dashboard"); const { data, loading, error } = useTeacherDashboard(); + // P2 迁移:根据当前时间生成问候语 key(morning/afternoon/evening) + const greetingKey = getGreetingKey(new Date()); + const greeting = t(`greeting.${greetingKey}`); + + // @contract-pending 富字段未就绪,暂用空数组占位 + const todoItems: TeacherTodoItem[] = []; + const scheduleItems: TeacherTodayScheduleItem[] = []; + const homeworkItems: TeacherHomeworkCardItem[] = []; + const classItems: TeacherClassCardItem[] = []; + const gradeTrends: TeacherGradeTrendItem[] = []; + const recentSubmissions: TeacherRecentSubmissionItem[] = []; + if (loading) { return ( @@ -47,7 +81,7 @@ export default function TeacherDashboardPage(): React.ReactElement { return ( @@ -74,7 +108,72 @@ export default function TeacherDashboardPage(): React.ReactElement { /> } + actions={} > +
+ {/* 课表:移动端首位,桌面端右上 */} +
+ + + +
+ +
+ + + + + + + + + +
+ + +
+ + {/* P1-2 既有区块:班级概况 + 近期预警 */} {data.classes ? (
diff --git a/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts index 811a7bb..f23ada3 100644 --- a/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts +++ b/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts @@ -59,8 +59,8 @@ describe("isValidProviderType", () => { }); describe("activeToBadgeClass", () => { - it("returns emerald class for active", () => { - expect(activeToBadgeClass(true)).toContain("emerald"); + it("returns success class for active", () => { + expect(activeToBadgeClass(true)).toContain("success"); }); it("returns muted class for inactive", () => { diff --git a/apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx b/apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx index 9a52b57..cc7823f 100644 --- a/apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx +++ b/apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx @@ -369,7 +369,7 @@ export function AiProviderForm({ diff --git a/apps/portal-shell/src/features/admin/ai-settings/transformations.ts b/apps/portal-shell/src/features/admin/ai-settings/transformations.ts index a7bcd15..bc2acc5 100644 --- a/apps/portal-shell/src/features/admin/ai-settings/transformations.ts +++ b/apps/portal-shell/src/features/admin/ai-settings/transformations.ts @@ -63,7 +63,7 @@ export function formatProviderVisibility(visibility: string): string { */ export function activeToBadgeClass(isActive: boolean): string { return isActive - ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" + ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"; } diff --git a/apps/portal-shell/src/features/admin/announcements/announcement-create-dialog.tsx b/apps/portal-shell/src/features/admin/announcements/announcement-create-dialog.tsx index bd3cfc5..8f631f7 100644 --- a/apps/portal-shell/src/features/admin/announcements/announcement-create-dialog.tsx +++ b/apps/portal-shell/src/features/admin/announcements/announcement-create-dialog.tsx @@ -26,6 +26,7 @@ import { } from "@/shared/components/ui/dialog"; import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; +import { Select } from "@/shared/components/ui/select"; import { Textarea } from "@/shared/components/ui/textarea"; import { notify } from "@/shared/lib/notify"; import { GradeMultiSelect } from "@/features/admin/announcements/grade-multi-select"; @@ -164,34 +165,30 @@ export function AnnouncementCreateDialog(
- + />
- + />
diff --git a/apps/portal-shell/src/features/admin/announcements/announcements-list-client.tsx b/apps/portal-shell/src/features/admin/announcements/announcements-list-client.tsx index bee68d9..c7219c6 100644 --- a/apps/portal-shell/src/features/admin/announcements/announcements-list-client.tsx +++ b/apps/portal-shell/src/features/admin/announcements/announcements-list-client.tsx @@ -39,6 +39,7 @@ import { AlertDialogTitle, } from "@/shared/components/ui/alert-dialog"; import { EmptyState } from "@/shared/components/ui/empty-state"; +import { Select } from "@/shared/components/ui/select"; import { ListPageShell, ListPageSkeleton, @@ -188,17 +189,19 @@ export function AnnouncementsListClient(): React.ReactElement { filters={ } loading={loading} diff --git a/apps/portal-shell/src/features/admin/attendance/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/attendance/__tests__/transformations.test.ts index b60a024..6ee2734 100644 --- a/apps/portal-shell/src/features/admin/attendance/__tests__/transformations.test.ts +++ b/apps/portal-shell/src/features/admin/attendance/__tests__/transformations.test.ts @@ -113,24 +113,24 @@ describe("attendanceStatusToKey", () => { }); describe("attendanceStatusToBadgeClass", () => { - it("returns emerald for present", () => { - expect(attendanceStatusToBadgeClass("present")).toContain("emerald"); + it("returns success for present", () => { + expect(attendanceStatusToBadgeClass("present")).toContain("success"); }); it("returns destructive for absent", () => { expect(attendanceStatusToBadgeClass("absent")).toContain("destructive"); }); - it("returns amber for late", () => { - expect(attendanceStatusToBadgeClass("late")).toContain("amber"); + it("returns warning for late", () => { + expect(attendanceStatusToBadgeClass("late")).toContain("warning"); }); it("returns sky for leave", () => { - expect(attendanceStatusToBadgeClass("leave")).toContain("sky"); + expect(attendanceStatusToBadgeClass("leave")).toContain("info"); }); - it("falls back to emerald (present) for unknown", () => { - expect(attendanceStatusToBadgeClass("unknown")).toContain("emerald"); + it("falls back to success (present) for unknown", () => { + expect(attendanceStatusToBadgeClass("unknown")).toContain("success"); }); }); @@ -152,14 +152,14 @@ describe("formatRate", () => { }); describe("presentRateToColorClass", () => { - it("returns emerald for rate >= 0.95", () => { - expect(presentRateToColorClass(0.95)).toContain("emerald"); - expect(presentRateToColorClass(1)).toContain("emerald"); + it("returns success for rate >= 0.95", () => { + expect(presentRateToColorClass(0.95)).toContain("success"); + expect(presentRateToColorClass(1)).toContain("success"); }); - it("returns amber for 0.9 <= rate < 0.95", () => { - expect(presentRateToColorClass(0.9)).toContain("amber"); - expect(presentRateToColorClass(0.94)).toContain("amber"); + it("returns warning for 0.9 <= rate < 0.95", () => { + expect(presentRateToColorClass(0.9)).toContain("warning"); + expect(presentRateToColorClass(0.94)).toContain("warning"); }); it("returns destructive for rate < 0.9", () => { diff --git a/apps/portal-shell/src/features/admin/attendance/_write-correlation-card.mjs b/apps/portal-shell/src/features/admin/attendance/_write-correlation-card.mjs deleted file mode 100644 index e69de29..0000000 diff --git a/apps/portal-shell/src/features/admin/attendance/admin-attendance-client.tsx b/apps/portal-shell/src/features/admin/attendance/admin-attendance-client.tsx index 11dfe5a..87d80b6 100644 --- a/apps/portal-shell/src/features/admin/attendance/admin-attendance-client.tsx +++ b/apps/portal-shell/src/features/admin/attendance/admin-attendance-client.tsx @@ -28,6 +28,7 @@ import { useGrades, } from "@/lib/api"; import { EmptyState } from "@/shared/components/ui/empty-state"; +import { Select } from "@/shared/components/ui/select"; import { StatCard } from "@/shared/components/ui/stat-card"; import { ListPageShell, @@ -199,60 +200,57 @@ function AttendanceFilters({ - + />
- + />
- + ), + })), + ]} + placeholder={t("allStatuses")} + className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" + />
@@ -324,12 +322,12 @@ function AttendanceContent({
diff --git a/apps/portal-shell/src/features/admin/attendance/attendance-grade-correlation-card.tsx b/apps/portal-shell/src/features/admin/attendance/attendance-grade-correlation-card.tsx index cf7c3a7..14dcb16 100644 --- a/apps/portal-shell/src/features/admin/attendance/attendance-grade-correlation-card.tsx +++ b/apps/portal-shell/src/features/admin/attendance/attendance-grade-correlation-card.tsx @@ -182,12 +182,12 @@ export function AttendanceGradeCorrelationCard(): React.ReactElement { = 0.95 → emerald(优秀) - * - >= 0.9 → amber(一般) + * - >= 0.95 → success(优秀) + * - >= 0.9 → warning(一般) * - 其他 → destructive(低出勤率) */ function rateToColorClass(rate: number | null | undefined): string { if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) { return "text-muted-foreground"; } - if (rate >= RATE_TIER_HIGH) return "text-emerald-600 dark:text-emerald-400"; - if (rate >= RATE_TIER_MID) return "text-amber-600 dark:text-amber-400"; + if (rate >= RATE_TIER_HIGH) return "text-success"; + if (rate >= RATE_TIER_MID) return "text-warning"; return "text-destructive"; } @@ -241,12 +241,10 @@ function renderRateBadge( let className: string; if (rate >= RATE_TIER_HIGH) { label = t("badgeHigh"); - className = - "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"; + className = "bg-success/10 text-success border-success/30"; } else if (rate >= RATE_TIER_MID) { label = t("badgeMid"); - className = - "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30"; + className = "bg-warning/10 text-warning border-warning/30"; } else { label = t("badgeLow"); className = "bg-destructive/10 text-destructive border-destructive/30"; diff --git a/apps/portal-shell/src/features/admin/attendance/transformations.ts b/apps/portal-shell/src/features/admin/attendance/transformations.ts index 062a045..88428e1 100644 --- a/apps/portal-shell/src/features/admin/attendance/transformations.ts +++ b/apps/portal-shell/src/features/admin/attendance/transformations.ts @@ -41,23 +41,23 @@ export function attendanceStatusToKey( /** * 根据考勤状态返回 Tailwind 徽章语义类名。 - * - present → emerald + * - present → success * - absent → destructive - * - late → amber - * - leave → sky + * - late → warning + * - leave → info */ export function attendanceStatusToBadgeClass( status: string | null | undefined, ): string { switch (attendanceStatusToKey(status)) { case "present": - return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; + return "bg-success/10 text-success"; case "absent": return "bg-destructive/10 text-destructive"; case "late": - return "bg-amber-500/10 text-amber-600 dark:text-amber-400"; + return "bg-warning/10 text-warning"; case "leave": - return "bg-sky-500/10 text-sky-600 dark:text-sky-400"; + return "bg-info/10 text-info"; } } @@ -74,8 +74,8 @@ export function formatRate(rate: number | null | undefined): string { /** * 根据出勤率(0-1)返回 Tailwind 文本语义类名。 - * - >= 0.95 → emerald(优秀) - * - >= 0.9 → amber(一般) + * - >= 0.95 → success(优秀) + * - >= 0.9 → warning(一般) * - 其他 → destructive(低出勤率) */ export function presentRateToColorClass( @@ -84,8 +84,8 @@ export function presentRateToColorClass( if (rate == null || !Number.isFinite(rate) || rate < 0 || rate > 1) { return "text-muted-foreground"; } - if (rate >= 0.95) return "text-emerald-600 dark:text-emerald-400"; - if (rate >= 0.9) return "text-amber-600 dark:text-amber-400"; + if (rate >= 0.95) return "text-success"; + if (rate >= 0.9) return "text-warning"; return "text-destructive"; } diff --git a/apps/portal-shell/src/features/admin/audit-logs/audit-logs-list-client.tsx b/apps/portal-shell/src/features/admin/audit-logs/audit-logs-list-client.tsx index fae251f..26e2d91 100644 --- a/apps/portal-shell/src/features/admin/audit-logs/audit-logs-list-client.tsx +++ b/apps/portal-shell/src/features/admin/audit-logs/audit-logs-list-client.tsx @@ -26,6 +26,7 @@ import { } from "@/lib/api"; import { Button } from "@/shared/components/ui/button"; import { EmptyState } from "@/shared/components/ui/empty-state"; +import { Select } from "@/shared/components/ui/select"; import { FilterBar, FilterSearchInput, @@ -176,45 +177,45 @@ export function AuditLogsListClient(): React.ReactElement { value={userId} onChange={(v) => updateQuery("userId", v)} /> - - updateQuery("action", e.target.value)} + onValueChange={(v) => updateQuery("action", v)} aria-label={t("actionFilter")} + options={[ + { value: "", label: t("allActions") }, + ...ACTION_OPTIONS.map((a) => ({ + value: a, + label: auditActionToLabel(a), + })), + ]} + placeholder={t("allActions")} className="h-9 rounded-md border border-input bg-background px-3 text-sm" - > - - {ACTION_OPTIONS.map((a) => ( - - ))} - - updateQuery("status", e.target.value)} + onValueChange={(v) => updateQuery("status", v)} aria-label={t("statusFilter")} + options={[ + { value: "", label: t("allStatuses") }, + ...STATUS_OPTIONS.map((s) => ({ + value: s, + label: auditStatusToLabel(s), + })), + ]} + placeholder={t("allStatuses")} className="h-9 rounded-md border border-input bg-background px-3 text-sm" - > - - {STATUS_OPTIONS.map((s) => ( - - ))} - + /> (null); + const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false); // 同步加载的配置到本地状态 useEffect(() => { @@ -74,8 +85,6 @@ export function AuditRetentionSettings(): React.ReactElement { const handlePurge = async (): Promise => { if (!config) return; - const confirmed = window.confirm(t("purgeConfirm")); - if (!confirmed) return; try { const result = await purgeLogs( config.retentionDays, @@ -216,7 +225,7 @@ export function AuditRetentionSettings(): React.ReactElement { + + + + {t("purge")} + + {t("purgeConfirm")} + + + + {tCommon("cancel")} + { + void handlePurge(); + setPurgeConfirmOpen(false); + }} + > + {t("purge")} + + + + ); diff --git a/apps/portal-shell/src/features/admin/audit-logs/data-changes-client.tsx b/apps/portal-shell/src/features/admin/audit-logs/data-changes-client.tsx index abfc0b9..777430f 100644 --- a/apps/portal-shell/src/features/admin/audit-logs/data-changes-client.tsx +++ b/apps/portal-shell/src/features/admin/audit-logs/data-changes-client.tsx @@ -28,6 +28,7 @@ import { } from "@/lib/api"; import { Button } from "@/shared/components/ui/button"; import { EmptyState } from "@/shared/components/ui/empty-state"; +import { Select } from "@/shared/components/ui/select"; import { FilterBar, FilterSearchInput, @@ -169,32 +170,31 @@ export function DataChangesClient(): React.ReactElement { value={userId} onChange={(v) => updateQuery("userId", v)} /> - - updateQuery("action", e.target.value)} + onValueChange={(v) => updateQuery("action", v)} aria-label={t("actionFilter")} + options={[ + { value: "", label: t("allActions") }, + ...ACTION_OPTIONS.map((a) => ({ + value: a.value, + label: t(a.labelKey), + })), + ]} + placeholder={t("allActions")} className="h-9 rounded-md border border-input bg-background px-3 text-sm" - > - - {ACTION_OPTIONS.map((a) => ( - - ))} - + /> updateQuery("userId", v)} /> - - updateQuery("status", e.target.value)} + onValueChange={(v) => updateQuery("status", v)} aria-label={t("statusFilter")} + options={[ + { value: "", label: t("allStatuses") }, + ...STATUS_OPTIONS.map((s) => ({ + value: s.value, + label: t(s.labelKey), + })), + ]} + placeholder={t("allStatuses")} className="h-9 rounded-md border border-input bg-background px-3 text-sm" - > - - {STATUS_OPTIONS.map((s) => ( - - ))} - + /> - + {t("progressHours", { completed, total })} diff --git a/apps/portal-shell/src/features/admin/curriculum-map/curriculum-map-client.tsx b/apps/portal-shell/src/features/admin/curriculum-map/curriculum-map-client.tsx index 180145f..8bee1dc 100644 --- a/apps/portal-shell/src/features/admin/curriculum-map/curriculum-map-client.tsx +++ b/apps/portal-shell/src/features/admin/curriculum-map/curriculum-map-client.tsx @@ -209,7 +209,7 @@ function HeatmapCard({
{formatCoverageRate(rate)}
-
+
{formatLessonPlanCount(cell.linked)}/ {formatLessonPlanCount(cell.total)}
diff --git a/apps/portal-shell/src/features/admin/files/files-list-client.tsx b/apps/portal-shell/src/features/admin/files/files-list-client.tsx index dce00f1..9d9ba01 100644 --- a/apps/portal-shell/src/features/admin/files/files-list-client.tsx +++ b/apps/portal-shell/src/features/admin/files/files-list-client.tsx @@ -8,6 +8,7 @@ import { useTranslations } from "next-intl"; import { useFileAttachments, useFileStats } from "@/lib/api"; import { EmptyState } from "@/shared/components/ui/empty-state"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; +import { Select } from "@/shared/components/ui/select"; import { ListPageShell, ListPageSkeleton, @@ -170,19 +171,21 @@ export function FilesListClient(): React.ReactElement { onChange={setSearch} placeholder={t("searchPlaceholder")} /> - + /> ); diff --git a/apps/portal-shell/src/features/admin/invitation-codes/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/invitation-codes/__tests__/transformations.test.ts index dac7909..9ae37af 100644 --- a/apps/portal-shell/src/features/admin/invitation-codes/__tests__/transformations.test.ts +++ b/apps/portal-shell/src/features/admin/invitation-codes/__tests__/transformations.test.ts @@ -125,16 +125,16 @@ describe("invitationStatusToKey", () => { }); describe("invitationStatusToBadgeClass", () => { - it("returns emerald class for active", () => { - expect(invitationStatusToBadgeClass("active")).toContain("emerald"); + it("returns success class for active", () => { + expect(invitationStatusToBadgeClass("active")).toContain("success"); }); it("returns blue class for used", () => { - expect(invitationStatusToBadgeClass("used")).toContain("blue"); + expect(invitationStatusToBadgeClass("used")).toContain("info"); }); - it("returns amber class for expired", () => { - expect(invitationStatusToBadgeClass("expired")).toContain("amber"); + it("returns warning class for expired", () => { + expect(invitationStatusToBadgeClass("expired")).toContain("warning"); }); it("returns muted for revoked", () => { diff --git a/apps/portal-shell/src/features/admin/invitation-codes/generate-invitation-codes-dialog.tsx b/apps/portal-shell/src/features/admin/invitation-codes/generate-invitation-codes-dialog.tsx index df42507..5440f91 100644 --- a/apps/portal-shell/src/features/admin/invitation-codes/generate-invitation-codes-dialog.tsx +++ b/apps/portal-shell/src/features/admin/invitation-codes/generate-invitation-codes-dialog.tsx @@ -284,11 +284,11 @@ export function GenerateInvitationCodesDialog({
{/* 成功/失败统计 */}
-
+

{t("statsSuccess")}

-

+

{successCount}

diff --git a/apps/portal-shell/src/features/admin/invitation-codes/invitation-codes-list-client.tsx b/apps/portal-shell/src/features/admin/invitation-codes/invitation-codes-list-client.tsx index f498fc2..9790f89 100644 --- a/apps/portal-shell/src/features/admin/invitation-codes/invitation-codes-list-client.tsx +++ b/apps/portal-shell/src/features/admin/invitation-codes/invitation-codes-list-client.tsx @@ -38,6 +38,7 @@ import { } from "@/shared/components/ui/alert-dialog"; import { EmptyState } from "@/shared/components/ui/empty-state"; import { FilterBar } from "@/shared/components/ui/filter-bar"; +import { Select } from "@/shared/components/ui/select"; import { ListPageShell, ListPageSkeleton, @@ -233,19 +234,20 @@ export function InvitationCodesListClient(): React.ReactElement { } filters={ - + /> } loading={loading} diff --git a/apps/portal-shell/src/features/admin/invitation-codes/transformations.ts b/apps/portal-shell/src/features/admin/invitation-codes/transformations.ts index 331f9ae..873c2e8 100644 --- a/apps/portal-shell/src/features/admin/invitation-codes/transformations.ts +++ b/apps/portal-shell/src/features/admin/invitation-codes/transformations.ts @@ -77,11 +77,11 @@ export function invitationStatusToKey(status: string): string { export function invitationStatusToBadgeClass(status: string): string { switch (status) { case "active": - return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; + return "bg-success/10 text-success"; case "used": - return "bg-blue-500/10 text-blue-600 dark:text-blue-400"; + return "bg-info/10 text-info"; case "expired": - return "bg-amber-500/10 text-amber-600 dark:text-amber-400"; + return "bg-warning/10 text-warning"; case "revoked": return "bg-muted text-muted-foreground"; default: diff --git a/apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx b/apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx index f0d2700..b016a3a 100644 --- a/apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx +++ b/apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx @@ -1,19 +1,27 @@ "use client"; /** - * 教案软删除确认对话框(轻量自实现模态,ARCHITECTURE.md §7.3 / §9.4) + * 教案软删除确认对话框(基于 shadcn AlertDialog,ARCHITECTURE.md §7.3 / §9.4) * * 用于 admin/lesson-plans 列表页与详情页的删除确认。 - * 结构:fixed inset-0 + bg-black/50 + 居中卡片。 + * 结构:AlertDialog + AlertDialogContent。 * 交互:ESC 关闭、点击遮罩关闭、确认按钮 destructive 变体。 * * 关联:ARCHITECTURE.md §7.3 详情/列表页 / §9.4 / §11.3 */ import { AlertTriangle, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useEffect } from "react"; -import { Button } from "@/shared/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog"; export interface DeleteConfirmDialogProps { /** 是否打开 */ @@ -39,55 +47,34 @@ export function DeleteConfirmDialog({ }: DeleteConfirmDialogProps): React.ReactElement | null { const t = useTranslations("admin.lessonPlans.delete"); - useEffect(() => { - if (!open) return; - const handler = (e: KeyboardEvent): void => { - if (e.key === "Escape" && !loading) { - onCancel(); - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [open, loading, onCancel]); - - if (!open) return null; - return ( -
{ + if (!nextOpen && !loading) { + onCancel(); + } + }} > -
e.stopPropagation()} - > +
-
-

{t("title")}

-

- {t("description")} -

-
+ + {t("title")} + {t("description")} +
-
- - -
-
-
+ + + + ); } diff --git a/apps/portal-shell/src/features/admin/lesson-plans/lesson-plans-list-client.tsx b/apps/portal-shell/src/features/admin/lesson-plans/lesson-plans-list-client.tsx index 5722f98..6dc0f42 100644 --- a/apps/portal-shell/src/features/admin/lesson-plans/lesson-plans-list-client.tsx +++ b/apps/portal-shell/src/features/admin/lesson-plans/lesson-plans-list-client.tsx @@ -29,6 +29,7 @@ import { DeleteConfirmDialog } from "@/features/admin/lesson-plans/delete-confir import { notify } from "@/shared/lib/notify"; import { EmptyState } from "@/shared/components/ui/empty-state"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; +import { Select } from "@/shared/components/ui/select"; import { StatCard } from "@/shared/components/ui/stat-card"; import { ListPageShell, @@ -180,18 +181,20 @@ export function AdminLessonPlansListClient(): React.ReactElement { value={q} onChange={(v) => updateQuery("q", v)} /> - + options={[ + { value: "", label: t("allStatuses") }, + { value: "DRAFT", label: t("statusDraft") }, + { value: "PUBLISHED", label: t("statusPublished") }, + { value: "ARCHIVED", label: t("statusArchived") }, + { value: "SUBMITTED", label: t("statusSubmitted") }, + ]} + placeholder={t("allStatuses")} + /> } loading={loading} diff --git a/apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx b/apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx index f51d127..70c9730 100644 --- a/apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx +++ b/apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx @@ -24,6 +24,7 @@ import { useTranslations } from "next-intl"; import { useCreateQuestion } from "@/lib/api"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; +import { Select } from "@/shared/components/ui/select"; import { notify } from "@/shared/lib/notify"; /** 题型选项(与 schema Question.type 字符串语义对齐) */ @@ -138,18 +139,16 @@ export function CreateQuestionDialog({

{t("description")}

- + /> @@ -189,18 +188,16 @@ export function CreateQuestionDialog({
- setDifficulty(Number(v))} aria-label={t("fieldDifficulty")} + options={DIFFICULTY_OPTIONS.map((opt) => ({ + value: String(opt.value), + label: t(opt.labelKey), + }))} className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]" - > - {DIFFICULTY_OPTIONS.map((opt) => ( - - ))} - + /> diff --git a/apps/portal-shell/src/features/admin/questions/questions-list-client.tsx b/apps/portal-shell/src/features/admin/questions/questions-list-client.tsx index 1a37335..a747db4 100644 --- a/apps/portal-shell/src/features/admin/questions/questions-list-client.tsx +++ b/apps/portal-shell/src/features/admin/questions/questions-list-client.tsx @@ -23,6 +23,7 @@ import { useAdminQuestions, type AdminQuestionListItem } from "@/lib/api"; import { Button } from "@/shared/components/ui/button"; import { EmptyState } from "@/shared/components/ui/empty-state"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; +import { Select } from "@/shared/components/ui/select"; import { ListPageShell, ListPageSkeleton, @@ -202,30 +203,34 @@ export function AdminQuestionsListClient(): React.ReactElement { value={q} onChange={(v) => updateQuery("q", v)} /> - - updateQuery("difficulty", e.target.value)} + onValueChange={(v) => updateQuery("difficulty", v)} className="h-9 rounded-md border border-input bg-background px-3 text-sm" aria-label={t("filterDifficulty")} - > - - - - - + options={[ + { value: "", label: t("difficultyAll") }, + { value: "easy", label: t("difficultyEasy") }, + { value: "medium", label: t("difficultyMedium") }, + { value: "hard", label: t("difficultyHard") }, + ]} + placeholder={t("difficultyAll")} + /> { }); describe("scheduleStatusToBadgeClass", () => { - it("returns emerald for scheduled", () => { - expect(scheduleStatusToBadgeClass("scheduled")).toContain("emerald"); + it("returns success for scheduled", () => { + expect(scheduleStatusToBadgeClass("scheduled")).toContain("success"); }); it("returns destructive for failed", () => { expect(scheduleStatusToBadgeClass("failed")).toContain("destructive"); }); - it("returns amber for pending", () => { - expect(scheduleStatusToBadgeClass("pending")).toContain("amber"); + it("returns warning for pending", () => { + expect(scheduleStatusToBadgeClass("pending")).toContain("warning"); }); - it("returns amber for unknown (fallback)", () => { - expect(scheduleStatusToBadgeClass("unknown")).toContain("amber"); + it("returns warning for unknown (fallback)", () => { + expect(scheduleStatusToBadgeClass("unknown")).toContain("warning"); }); }); @@ -100,8 +100,8 @@ describe("scheduleChangeStatusToKey", () => { }); describe("scheduleChangeStatusToBadgeClass", () => { - it("returns emerald for approved", () => { - expect(scheduleChangeStatusToBadgeClass("approved")).toContain("emerald"); + it("returns success for approved", () => { + expect(scheduleChangeStatusToBadgeClass("approved")).toContain("success"); }); it("returns destructive for rejected", () => { @@ -110,8 +110,8 @@ describe("scheduleChangeStatusToBadgeClass", () => { ); }); - it("returns amber for pending", () => { - expect(scheduleChangeStatusToBadgeClass("pending")).toContain("amber"); + it("returns warning for pending", () => { + expect(scheduleChangeStatusToBadgeClass("pending")).toContain("warning"); }); }); diff --git a/apps/portal-shell/src/features/admin/scheduling/auto-schedule-client.tsx b/apps/portal-shell/src/features/admin/scheduling/auto-schedule-client.tsx index 8c82ce8..f2f8e68 100644 --- a/apps/portal-shell/src/features/admin/scheduling/auto-schedule-client.tsx +++ b/apps/portal-shell/src/features/admin/scheduling/auto-schedule-client.tsx @@ -20,6 +20,16 @@ import { useTranslations } from "next-intl"; import { useAdminClasses, useAutoSchedule } from "@/lib/api"; import { notify } from "@/shared/lib/notify"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog"; import { Button } from "@/shared/components/ui/button"; import { EmptyState } from "@/shared/components/ui/empty-state"; import { @@ -59,7 +69,6 @@ export function AutoScheduleClient(): React.ReactElement { const classes = data ?? []; const handleStart = async (classId: string): Promise => { - if (!window.confirm(t("startConfirm"))) return; // 标记为 pending(覆盖之前结果) setResults((prev) => ({ ...prev, @@ -157,6 +166,8 @@ function AutoScheduleTable({ onStart: (classId: string) => Promise; }): React.ReactElement { const t = useTranslations("admin.scheduling.auto"); + const tCommon = useTranslations("common"); + const [confirmId, setConfirmId] = useState(null); return (
@@ -200,7 +211,7 @@ function AutoScheduleTable({ @@ -210,6 +221,30 @@ function AutoScheduleTable({ })}
+ { + if (!open) setConfirmId(null); + }} + > + + + {t("startButton")} + {t("startConfirm")} + + + {tCommon("cancel")} + { + if (confirmId) void onStart(confirmId); + setConfirmId(null); + }} + > + {t("startButton")} + + + +
); } diff --git a/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx b/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx index 9cdc286..ab77802 100644 --- a/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx +++ b/apps/portal-shell/src/features/admin/scheduling/schedule-changes-client.tsx @@ -15,7 +15,7 @@ * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4 */ import { ClipboardCheck } from "lucide-react"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { @@ -25,6 +25,16 @@ import { useRejectScheduleChange, } from "@/lib/api"; import { notify } from "@/shared/lib/notify"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog"; import { Button } from "@/shared/components/ui/button"; import { Card, CardContent } from "@/shared/components/ui/card"; import { EmptyState } from "@/shared/components/ui/empty-state"; @@ -92,7 +102,6 @@ export function ScheduleChangesClient(): React.ReactElement { }, [changes, entriesList]); const handleApprove = async (id: string): Promise => { - if (!window.confirm(t("approveConfirm"))) return; try { await approveChange(id); notify.success(t("approveSuccess")); @@ -182,6 +191,8 @@ function ChangesTable({ onReject: (id: string) => Promise; }): React.ReactElement { const t = useTranslations("admin.scheduling.changes"); + const tCommon = useTranslations("common"); + const [approveConfirmId, setApproveConfirmId] = useState(null); return ( @@ -245,7 +256,7 @@ function ChangesTable({ {t("conflictDetect", { count: conflicts.length })}

) : isPending ? ( -

+

{t("noConflict")}

) : null} @@ -257,7 +268,7 @@ function ChangesTable({ size="sm" variant="outline" disabled={approving || rejecting} - onClick={() => void onApprove(change.id)} + onClick={() => setApproveConfirmId(change.id)} > {t("approveButton")} @@ -282,6 +293,32 @@ function ChangesTable({
+ { + if (!open) setApproveConfirmId(null); + }} + > + + + {t("approveButton")} + + {t("approveConfirm")} + + + + {tCommon("cancel")} + { + if (approveConfirmId) void onApprove(approveConfirmId); + setApproveConfirmId(null); + }} + > + {t("approveButton")} + + + + ); diff --git a/apps/portal-shell/src/features/admin/scheduling/scheduling-rules-client.tsx b/apps/portal-shell/src/features/admin/scheduling/scheduling-rules-client.tsx index f9f4ffd..35b1d5e 100644 --- a/apps/portal-shell/src/features/admin/scheduling/scheduling-rules-client.tsx +++ b/apps/portal-shell/src/features/admin/scheduling/scheduling-rules-client.tsx @@ -24,6 +24,16 @@ import { type AdminSchedulingRule, } from "@/lib/api/admin-p5"; import { notify } from "@/shared/lib/notify"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; import { @@ -152,6 +162,7 @@ export function SchedulingRulesClient(): React.ReactElement { const [numberValues, setNumberValues] = useState>({}); const [enabledMap, setEnabledMap] = useState>({}); const [formError, setFormError] = useState(null); + const [resetConfirmOpen, setResetConfirmOpen] = useState(false); // 同步加载到的规则到本地状态 useEffect(() => { @@ -191,10 +202,14 @@ export function SchedulingRulesClient(): React.ReactElement { }; const handleReset = (): void => { - if (!window.confirm(t("resetConfirm"))) return; + setResetConfirmOpen(true); + }; + + const handleConfirmReset = (): void => { setNumberValues({}); setEnabledMap({}); void refetch(); + setResetConfirmOpen(false); }; const handleSubmit = async (): Promise => { @@ -324,6 +339,20 @@ export function SchedulingRulesClient(): React.ReactElement {

{t("mswNotice")}

+ + + + {t("resetButton")} + {t("resetConfirm")} + + + {tCommon("cancel")} + + {t("resetButton")} + + + + ); } diff --git a/apps/portal-shell/src/features/admin/scheduling/transformations.ts b/apps/portal-shell/src/features/admin/scheduling/transformations.ts index 4d7ba4a..b9f7c39 100644 --- a/apps/portal-shell/src/features/admin/scheduling/transformations.ts +++ b/apps/portal-shell/src/features/admin/scheduling/transformations.ts @@ -25,12 +25,12 @@ export function scheduleStatusToKey(status: string): ScheduleStatus { export function scheduleStatusToBadgeClass(status: string): string { switch (scheduleStatusToKey(status)) { case "scheduled": - return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; + return "bg-success/10 text-success"; case "failed": return "bg-destructive/10 text-destructive"; case "pending": default: - return "bg-amber-500/10 text-amber-600 dark:text-amber-400"; + return "bg-warning/10 text-warning"; } } @@ -91,12 +91,12 @@ export function scheduleChangeStatusToKey( export function scheduleChangeStatusToBadgeClass(status: string): string { switch (scheduleChangeStatusToKey(status)) { case "approved": - return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; + return "bg-success/10 text-success"; case "rejected": return "bg-destructive/10 text-destructive"; case "pending": default: - return "bg-amber-500/10 text-amber-600 dark:text-amber-400"; + return "bg-warning/10 text-warning"; } } diff --git a/apps/portal-shell/src/features/admin/school/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/school/__tests__/transformations.test.ts index 4b1de71..1f7732c 100644 --- a/apps/portal-shell/src/features/admin/school/__tests__/transformations.test.ts +++ b/apps/portal-shell/src/features/admin/school/__tests__/transformations.test.ts @@ -77,8 +77,8 @@ describe("truncateText", () => { }); describe("activeToBadgeClass", () => { - it("returns emerald class for active", () => { - expect(activeToBadgeClass(true)).toContain("emerald"); + it("returns success class for active", () => { + expect(activeToBadgeClass(true)).toContain("success"); }); it("returns muted class for inactive", () => { diff --git a/apps/portal-shell/src/features/admin/school/academic-year-client.tsx b/apps/portal-shell/src/features/admin/school/academic-year-client.tsx index 70e5f2f..a7a9c19 100644 --- a/apps/portal-shell/src/features/admin/school/academic-year-client.tsx +++ b/apps/portal-shell/src/features/admin/school/academic-year-client.tsx @@ -49,6 +49,7 @@ import { import { EmptyState } from "@/shared/components/ui/empty-state"; import { FilterSearchInput } from "@/shared/components/ui/filter-bar"; import { Input } from "@/shared/components/ui/input"; +import { Select } from "@/shared/components/ui/select"; import { ListPageShell, ListPageSkeleton, @@ -205,19 +206,20 @@ export function AcademicYearClient(): React.ReactElement { value={q} onChange={(v) => updateQuery("q", v)} /> - + /> } loading={loading} @@ -321,10 +323,7 @@ function ActiveYearSidebarCard({ {t("activeYearCardDescription")}
-