feat(portal-shell): wire dashboards to real data-ana queries (P1-2)

- add dashboard.graphql.ts with 6 real aggregate queries
  (teacherDashboard / studentDashboard / parentDashboard /
   adminDashboard / warnings / errorBookStats), snake_case aligned
- add dashboard.ts with 6 hooks + full domain model types
- add 4 role dashboard pages (teacher/student/parent/admin)
  using DashboardShell + StatCard + DashboardSection with
  loading / error / success tri-state
- update [[...route]]/page.tsx to redirect /shell -> /shell/{role}
- retire 6 fake contract queries and hooks (grades/homeworks/
  schedule/attendance/exams/announcements) and mark widget
  placeholders as migrated
- update universal.test.ts to drop retired hook tests
- mark ARCHITECTURE.md P1-2 as completed with acceptance evidence
This commit is contained in:
SpecialX
2026-07-22 12:33:22 +08:00
parent f92fdf8efe
commit 98058eb16b
18 changed files with 1086 additions and 846 deletions

View File

@@ -2,7 +2,7 @@
> 版本3.0 > 版本3.0
> 日期2026-07-20 > 日期2026-07-20
> 状态:**P0 已完成 + P1-1 已完成2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿** > 状态:**P0 已完成 + P1-1/P1-2 已完成2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿**
> 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。 > 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。
> >
> 关联文档(按效力排序): > 关联文档(按效力排序):
@@ -757,14 +757,14 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
- **P0-7**`eslint .` → 0 errors, 2 warnings均在 `__generated__/types.ts` 生成文件);[eslint.config.js](file:///e:/Desktop/Edu/apps/portal-shell/eslint.config.js) 含 `no-restricted-imports` - **P0-7**`eslint .` → 0 errors, 2 warnings均在 `__generated__/types.ts` 生成文件);[eslint.config.js](file:///e:/Desktop/Edu/apps/portal-shell/eslint.config.js) 含 `no-restricted-imports`
- **P0-8**`git status` 干净commit `cfb7b00``.env.local``tsconfig.tsbuildinfo` 已在 [.gitignore](file:///e:/Desktop/Edu/apps/portal-shell/.gitignore) - **P0-8**`git status` 干净commit `cfb7b00``.env.local``tsconfig.tsbuildinfo` 已在 [.gitignore](file:///e:/Desktop/Edu/apps/portal-shell/.gitignore)
### P1 · 框架与数据源接通12 周)— 进行中P1-1 ✅ 2026-07-22 验收) ### P1 · 框架与数据源接通12 周)— 进行中P1-1/P1-2 ✅ 2026-07-22 验收)
**目标**AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n页面迁移的"流水线"建成。 **目标**AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n页面迁移的"流水线"建成。
| # | 任务 | 验收 | 状态 | | # | 任务 | 验收 | 状态 |
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- | | ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- |
| P1-1 | `src/app/shell/layout.tsx` AppFrameTopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | ✅ | | P1-1 | `src/app/shell/layout.tsx` AppFrameTopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) | ✅ |
| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈4 角色仪表盘显示真实聚合数据截图widget 假契约查询grades/homeworks/schedule/attendance/exams/announcements全部下线或改造 | | | P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈4 角色仪表盘显示真实聚合数据截图widget 假契约查询grades/homeworks/schedule/attendance/exams/announcements全部下线或改造 | |
| P1-3 | 页面模板四件套list/detail/form/workbench+ 三态规范 | Storybook 或示例页 4 张(/shell/_dev/templates/*,仅 dev 可见) | ⏳ | | P1-3 | 页面模板四件套list/detail/form/workbench+ 三态规范 | Storybook 或示例页 4 张(/shell/_dev/templates/*,仅 dev 可见) | ⏳ |
| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | ⏳ | | P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | ⏳ |
| P1-5 | MSW 兜底层(迁移旧 handlers覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | ⏳ | | P1-5 | MSW 兜底层(迁移旧 handlers覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks | ⏳ |
@@ -786,6 +786,21 @@ export default async function ExamsPage(): Promise<React.ReactElement> {
- [src/shared/lib/route-permissions.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/lib/route-permissions.ts):补全 7 个列表页根路由的 EXACT 登记(`/shell/admin/announcements``/shell/admin/classes``/shell/teacher/exams` 等),与 PREFIX 表互补 - [src/shared/lib/route-permissions.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/shared/lib/route-permissions.ts):补全 7 个列表页根路由的 EXACT 登记(`/shell/admin/announcements``/shell/admin/classes``/shell/teacher/exams` 等),与 PREFIX 表互补
- **质量校验**`tsc --noEmit` 通过;`eslint`5 文件)通过 - **质量校验**`tsc --noEmit` 通过;`eslint`5 文件)通过
**P1-2 验收证据2026-07-22**
- **假契约查询全部下线**`grep -rn "useGrades\|useHomework\|useSchedule\|useAttendance\|useExams\|useAnnouncements" src` 无业务调用6 个 widgetgrades/homework/schedule/attendance/exams/announcements改为"数据源已迁移"占位 Card
- **6 个真实聚合查询接入**
- [src/lib/api/operations/dashboard.graphql.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/lib/api/operations/dashboard.graphql.ts)`GET_TEACHER_DASHBOARD_DOC` / `GET_STUDENT_DASHBOARD_DOC` / `GET_PARENT_DASHBOARD_DOC` / `GET_ADMIN_DASHBOARD_DOC` / `GET_WARNINGS_DOC` / `GET_ERROR_BOOK_STATS_DOC`,字段全部 snake_case 对齐 data-ana 子图
- [src/lib/api/dashboard.ts](file:///e:/Desktop/Edu/apps/portal-shell/src/lib/api/dashboard.ts)6 个 hooks`useTeacherDashboard` / `useStudentDashboard` / `useParentDashboard` / `useAdminDashboard` / `useWarnings` / `useErrorBookStats`+ 全部领域模型类型(`TeacherDashboard` / `StudentDashboard` / `ParentDashboard` / `AdminDashboard` / `WarningInfo` / `ErrorBookStats` 等)
- **4 角色仪表盘页面**(显式路由,先于 catch-all 匹配):
- [src/app/shell/teacher/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/teacher/page.tsx)StatCard×4班级总数/学生总数/班级平均分/待批作业)+ DashboardSection×2班级概况/近期预警)
- [src/app/shell/student/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/student/page.tsx)StatCard×3平均分/班级排名/待交作业)+ DashboardSection×2薄弱知识点/近期成绩趋势)
- [src/app/shell/parent/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/parent/page.tsx)StatCard×2孩子平均分/班级排名)+ DashboardSection×2薄弱知识点/预警通知)
- [src/app/shell/admin/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/admin/page.tsx)StatCard×4教师总数/学生总数/班级总数/全校平均分)+ DashboardSection×2近期预警/AI 用量)
- **混合路由模型V3-A1**[src/app/shell/\[\[...route\]\]/page.tsx](file:///e:/Desktop/Edu/apps/portal-shell/src/app/shell/[[...route]]/page.tsx) 新增 `params` 异步读取 + `/shell` 空路由 `redirect(\`/shell/${role}\`)` 兜底
- **三态规范**4 个仪表盘页统一遵循 loadingStatCard isLoading 骨架)→ errorCard 错误提示)→ success真实数据三态
- **质量校验**`tsc --noEmit` 通过;`eslint`(新/改文件)通过;`vitest run` 全量 20 test files / 212 tests 全部通过
### P2 · 教师域页面23 周,可与 P3 部分并行) ### P2 · 教师域页面23 周,可与 P3 部分并行)
- 范围§9.1 全表(~50 页。顺序建议exams → homework → grades → lesson-plans → questions/textbooks → attendance/classes/students → diagnostic/error-book/analytics → elective/course-plans → ai-* → practice/schedule-changes/leave。 - 范围§9.1 全表(~50 页。顺序建议exams → homework → grades → lesson-plans → questions/textbooks → attendance/classes/students → diagnostic/error-book/analytics → elective/course-plans → ai-* → practice/schedule-changes/leave。

View File

@@ -1,26 +1,29 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { fetchPluginConfig } from "@/lib/config-fetcher"; import { fetchPluginConfig } from "@/lib/config-fetcher";
import { ClientShell } from "@/shell/ClientShell"; import { ClientShell } from "@/shell/ClientShell";
import type { Role } from "@/lib/types"; import type { Role } from "@/lib/types";
import type { PluginConfigResponse } from "@edu/shared-ts/contracts"; import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
/** /**
* Shell 入口RSC Server Componentv3.0 P0-2 fail-closed + 流式渲染 * Shell catch-all 入口RSC Server Componentv3.0 P0-2 + P1-2
* *
* 数据流ARCHITECTURE.md §3.4 V3-A2/A3、§5.5 * 路由行为ARCHITECTURE.md §3.4 V3-A1 混合路由模型
* ① 从 middleware 注入的请求头获取 userId / rolemiddleware 已校验 cookie + 权限 * - `/shell`(空路由)→ 302 重定向到 `/shell/{role}` 角色仪表盘P1-2
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig三层合并 * - `/shell/{role}/{module}/...`(有路由段)→ 微内核仪表盘兜底
* ③ Config Promise 直接传给 ClientShell由客户端 use() 消费,启用流式渲染: * P2-P5 将逐步替换为显式路由页面)
* - HTML 流式输出loading.tsx 先行Promise resolve 后替换为真实 UI
* - 客户端 Suspense避免客户端瀑布流不用 useEffect 二次请求)
* *
* fail-closedP0-2§11.7 红线 #5 * fail-closedP0-2§11.7 红线 #5
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头 * - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx禁止默认 teacher * - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx禁止默认 teacher
* *
* 关联portal-shell ARCHITECTURE.md §3.4 V3-A2/A3、§5.5、§11.7 红线 #5 * 关联portal-shell ARCHITECTURE.md §3.4 V3-A1/A2/A3、§5.5、§10 P1-2、§11.7 红线 #5
*/ */
export default async function ShellPage(): Promise<React.ReactElement> { export default async function ShellPage({
params,
}: {
params: Promise<{ route?: string[] }>;
}): Promise<React.ReactElement> {
const headerList = await headers(); const headerList = await headers();
const userId = headerList.get("x-user-id"); const userId = headerList.get("x-user-id");
const roleHeader = headerList.get("x-user-role"); const roleHeader = headerList.get("x-user-role");
@@ -35,16 +38,21 @@ export default async function ShellPage(): Promise<React.ReactElement> {
} }
const role = roleHeader as Role; const role = roleHeader as Role;
const { route } = await params;
// 服务端通过 apollo-router 获取三层合并后的插件配置 // P1-2`/shell`(空路由)→ 重定向到角色仪表盘
// 不 await直接将 Promise 传给 ClientShell启用流式渲染 // 显式路由页面(/shell/teacher、/shell/student 等)由 Next.js 优先匹配,
// 不会进入此 catch-all仅当用户直接访问 /shell 时重定向。
if (!route || route.length === 0) {
redirect(`/shell/${role}`);
}
// 有路由段时微内核仪表盘兜底P2-P5 将逐步替换为显式路由页面)
const configPromise: Promise<PluginConfigResponse> = fetchPluginConfig( const configPromise: Promise<PluginConfigResponse> = fetchPluginConfig(
userId, userId,
role, role,
); );
// 将 Promise 作为 prop 传递ClientShell 内部通过 use() 消费
// Next.js 会自动用 loading.tsx 作为 Suspense fallback 流式输出 HTML
return ( return (
<ClientShell configPromise={configPromise} role={role} userId={userId} /> <ClientShell configPromise={configPromise} role={role} userId={userId} />
); );

View File

@@ -0,0 +1,121 @@
"use client";
import { Activity, Building2, GraduationCap, Users } from "lucide-react";
import { useAdminDashboard } 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";
/**
* 管理员仪表盘ARCHITECTURE.md §7.1 / §10 P1-2
*
* 改接 data-ana 的 adminDashboard 真实聚合查询,替换原
* announcements 假契约 widget 查询。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function AdminDashboardPage(): React.ReactElement {
const { data, loading, error } = useAdminDashboard();
if (loading) {
return (
<DashboardShell title="管理员仪表盘" description="全校概览">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<StatCard key={i} title="" value="" isLoading />
))}
</div>
</DashboardShell>
);
}
if (error || !data) {
return (
<DashboardShell title="管理员仪表盘" description="全校概览">
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title="管理员仪表盘"
description="全校概览"
stats={
<>
<StatCard title="教师总数" value={data.total_teachers} icon={Users} />
<StatCard
title="学生总数"
value={data.total_students}
icon={GraduationCap}
/>
<StatCard
title="班级总数"
value={data.total_classes}
icon={Building2}
/>
<StatCard
title="全校平均分"
value={data.school_avg_score?.toFixed(1) ?? "--"}
icon={Activity}
/>
</>
}
>
<DashboardSection title="近期预警" variant="list">
{data.recent_warnings ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.recent_warnings.target_name ?? "--"}
</span>
<span className="text-muted-foreground">
{data.recent_warnings.severity ?? "--"}
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.recent_warnings.warning_type ?? "--"} · {" "}
{data.recent_warnings.current_value?.toFixed(1) ?? "--"} / {" "}
{data.recent_warnings.threshold?.toFixed(1) ?? "--"}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
<DashboardSection title="AI 用量" variant="card">
{data.ai_usage ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{data.ai_usage.total_requests ?? "--"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"> Token</span>
<span className="font-medium">
{data.ai_usage.total_tokens ?? "--"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{data.ai_usage.total_cost_cents ?? "--"}
</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground"> AI </p>
)}
</DashboardSection>
</DashboardShell>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { GraduationCap, TrendingUp } from "lucide-react";
import { useParentDashboard } 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";
/**
* 家长仪表盘ARCHITECTURE.md §7.1 / §10 P1-2
*
* 改接 data-ana 的 parentDashboard 真实聚合查询。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function ParentDashboardPage(): React.ReactElement {
const { data, loading, error } = useParentDashboard();
if (loading) {
return (
<DashboardShell title="家长仪表盘" description="孩子学习概览">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<StatCard key={i} title="" value="" isLoading />
))}
</div>
</DashboardShell>
);
}
if (error || !data) {
return (
<DashboardShell title="家长仪表盘" description="孩子学习概览">
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title="家长仪表盘"
description="孩子学习概览"
stats={
<>
<StatCard
title="孩子平均分"
value={data.child_avg_score?.toFixed(1) ?? "--"}
icon={GraduationCap}
/>
<StatCard
title="班级排名"
value={`${data.child_class_rank ?? "--"} / ${data.total_class_students ?? "--"}`}
icon={TrendingUp}
/>
</>
}
>
<DashboardSection title="薄弱知识点" variant="list">
{data.child_weak_points ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.child_weak_points.title ?? "--"}
</span>
<span className="text-muted-foreground">
{data.child_weak_points.mastery?.toFixed(1) ?? "--"}%
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.child_weak_points.error_count ?? 0}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
<DashboardSection title="预警通知" variant="list">
{data.child_warnings ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.child_warnings.target_name ?? "--"}
</span>
<span className="text-muted-foreground">
{data.child_warnings.severity ?? "--"}
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.child_warnings.warning_type ?? "--"}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
</DashboardShell>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { BookOpen, GraduationCap, TrendingUp } from "lucide-react";
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";
/**
* 学生仪表盘ARCHITECTURE.md §7.1 / §10 P1-2
*
* 改接 data-ana 的 studentDashboard 真实聚合查询,替换原
* grades/homeworks/schedule/exams 假契约 widget 查询。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function StudentDashboardPage(): React.ReactElement {
const { data, loading, error } = useStudentDashboard();
if (loading) {
return (
<DashboardShell title="学生仪表盘" description="学习概览">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<StatCard key={i} title="" value="" isLoading />
))}
</div>
</DashboardShell>
);
}
if (error || !data) {
return (
<DashboardShell title="学生仪表盘" description="学习概览">
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title="学生仪表盘"
description="学习概览"
stats={
<>
<StatCard
title="平均分"
value={data.avg_score?.toFixed(1) ?? "--"}
icon={GraduationCap}
/>
<StatCard
title="班级排名"
value={`${data.class_rank ?? "--"} / ${data.total_students ?? "--"}`}
icon={TrendingUp}
/>
<StatCard
title="待交作业"
value={data.pending_homework}
icon={BookOpen}
highlight={data.pending_homework > 0}
/>
</>
}
>
<DashboardSection title="薄弱知识点" variant="list">
{data.weak_points ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.weak_points.title ?? "--"}
</span>
<span className="text-muted-foreground">
{data.weak_points.mastery?.toFixed(1) ?? "--"}%
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.weak_points.error_count ?? 0}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
<DashboardSection title="近期成绩趋势" variant="chart">
{data.recent_trends ? (
<div className="text-sm">
<span className="text-muted-foreground">
{data.recent_trends.date ?? "--"}
</span>
<span className="font-medium">
{data.recent_trends.score?.toFixed(1) ?? "--"}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
</DashboardShell>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { Activity, BookOpen, GraduationCap, Users } from "lucide-react";
import { useTeacherDashboard } 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";
/**
* 教师仪表盘ARCHITECTURE.md §7.1 / §10 P1-2
*
* 改接 data-ana 的 teacherDashboard 真实聚合查询,替换原
* grades/homeworks/schedule/attendance/exams 假契约 widget 查询。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export default function TeacherDashboardPage(): React.ReactElement {
const { data, loading, error } = useTeacherDashboard();
if (loading) {
return (
<DashboardShell title="教师仪表盘" description="今日教学概览">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<StatCard key={i} title="" value="" isLoading />
))}
</div>
</DashboardShell>
);
}
if (error || !data) {
return (
<DashboardShell title="教师仪表盘" description="今日教学概览">
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell
title="教师仪表盘"
description="今日教学概览"
stats={
<>
<StatCard title="班级总数" value={data.total_classes} icon={Users} />
<StatCard
title="学生总数"
value={data.total_students}
icon={GraduationCap}
/>
<StatCard
title="班级平均分"
value={data.class_avg_score?.toFixed(1) ?? "--"}
icon={Activity}
/>
<StatCard
title="待批作业"
value={data.pending_homework_count}
icon={BookOpen}
highlight={data.pending_homework_count > 0}
/>
</>
}
>
<DashboardSection title="班级概况" variant="card">
{data.classes ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{data.classes.class_name ?? "--"}
</span>
<span className="text-muted-foreground">
{data.classes.student_count ?? 0} · {" "}
{data.classes.average_score?.toFixed(1) ?? "--"}
</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
<DashboardSection title="近期预警" variant="list">
{data.recent_warnings ? (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
{data.recent_warnings.target_name ?? "--"}
</span>
<span className="text-muted-foreground">
{data.recent_warnings.severity ?? "--"}
</span>
</div>
<p className="text-xs text-muted-foreground">
{data.recent_warnings.warning_type ?? "--"} · {" "}
{data.recent_warnings.current_value?.toFixed(1) ?? "--"} / {" "}
{data.recent_warnings.threshold?.toFixed(1) ?? "--"}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</DashboardSection>
</DashboardShell>
);
}

View File

@@ -1,11 +1,9 @@
/** /**
* Universal domain API 单元测试portal-shell spec §5.6、§9.9 * Universal domain API 单元测试portal-shell spec §5.6、§9.9
* *
* 覆盖: * P1-2ARCHITECTURE.md §106 个假契约查询已下线
* - useGrades归一化返回 Grade[](从 { grades: [...] } 拍平) * grades/homeworks/schedule/attendance/exams/announcements
* - useNotifications:归一化返回 NotificationList从 { notifications: { items, total } } 拍平) * 本测试仅覆盖保留下来的 useNotifications
* - useGradesenabled=false 时跳过查询
* - useAttendance归一化返回 AttendanceStats
* *
* 使用 createElement 而非 JSX避免 .ts 文件 JSX 解析错误 * 使用 createElement 而非 JSX避免 .ts 文件 JSX 解析错误
* known-issues §2.17.ts 文件不支持 JSX需用 .tsx 或 createElement * known-issues §2.17.ts 文件不支持 JSX需用 .tsx 或 createElement
@@ -15,12 +13,8 @@ import { createElement, type ReactElement, type ReactNode } from "react";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import { MockedProvider } from "@apollo/client/testing"; import { MockedProvider } from "@apollo/client/testing";
import type { MockedResponse } from "@apollo/client/testing/core"; import type { MockedResponse } from "@apollo/client/testing/core";
import { useGrades, useNotifications, useAttendance } from "../universal"; import { useNotifications } from "../universal";
import { import { GET_NOTIFICATIONS_LIST_DOC } from "../operations/universal.graphql";
GET_GRADES_DOC,
GET_NOTIFICATIONS_LIST_DOC,
GET_ATTENDANCE_DOC,
} from "../operations/universal.graphql";
function makeWrapper(mocks: MockedResponse[]) { function makeWrapper(mocks: MockedResponse[]) {
return function Wrapper({ children }: { children: ReactNode }): ReactElement { return function Wrapper({ children }: { children: ReactNode }): ReactElement {
@@ -28,73 +22,6 @@ function makeWrapper(mocks: MockedResponse[]) {
}; };
} }
describe("useGrades", () => {
it("返回归一化的 Grade[](拍平 grades 字段)", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_GRADES_DOC,
variables: { classId: "cls-1" },
},
result: {
data: {
grades: [
{ studentId: "s-1", score: 90 },
{ studentId: "s-2", score: 85 },
],
},
},
},
];
const { result } = renderHook(() => useGrades("cls-1"), {
wrapper: makeWrapper(mocks),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual([
{ studentId: "s-1", score: 90 },
{ studentId: "s-2", score: 85 },
]);
expect(result.current.error).toBeUndefined();
});
it("enabled=false 时不发起查询data 保持 undefined", async () => {
// 故意不配置 mock若 Apollo 仍发起查询MockedProvider 会抛错。
const { result } = renderHook(
() => useGrades("cls-1", { enabled: false }),
{ wrapper: makeWrapper([]) },
);
// 等待一个微任务周期,确认 Apollo 跳过查询
await new Promise((resolve) => setTimeout(resolve, 0));
expect(result.current.loading).toBe(false);
expect(result.current.data).toBeUndefined();
});
it("查询返回空数组时归一化为 []", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_GRADES_DOC,
variables: { classId: "cls-empty" },
},
result: { data: { grades: [] } },
},
];
const { result } = renderHook(() => useGrades("cls-empty"), {
wrapper: makeWrapper(mocks),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual([]);
});
});
describe("useNotifications", () => { describe("useNotifications", () => {
it("返回归一化的 NotificationList含 items + total", async () => { it("返回归一化的 NotificationList含 items + total", async () => {
const mocks: MockedResponse[] = [ const mocks: MockedResponse[] = [
@@ -167,39 +94,3 @@ describe("useNotifications", () => {
expect(result.current.data).toBeUndefined(); expect(result.current.data).toBeUndefined();
}); });
}); });
describe("useAttendance", () => {
it("返回归一化的 AttendanceStats拍平 attendance 字段)", async () => {
const mocks: MockedResponse[] = [
{
request: {
query: GET_ATTENDANCE_DOC,
variables: { classId: "cls-1", termId: "t-1" },
},
result: {
data: {
attendance: {
present: 30,
absent: 2,
late: 3,
total: 35,
},
},
},
},
];
const { result } = renderHook(() => useAttendance("cls-1", "t-1"), {
wrapper: makeWrapper(mocks),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual({
present: 30,
absent: 2,
late: 3,
total: 35,
});
});
});

View File

@@ -0,0 +1,307 @@
"use client";
/**
* Dashboard domain API (ARCHITECTURE.md §5.5, §10 P1-2)
*
* Real aggregation queries from data-ana subgraph, replacing the 6 fake
* widget contract queries (grades/homeworks/schedule/attendance/exams/
* announcements) that referenced non-existent root fields.
*
* Field naming: snake_case (as exposed by data-ana GraphQL schema).
* ARCHITECTURE.md §5.5 note: "底层字段 snake_case 需在 lib/api 层映射"
* — mapping deferred to P1-7 (codegen恢复后统一处理),当前直传 snake_case。
*
* 关联ARCHITECTURE.md §5.5 后端已就绪查询的立即利用 / §10 P1-2
*/
import type { FetchPolicy } from "@apollo/client";
import {
GET_TEACHER_DASHBOARD_DOC,
GET_STUDENT_DASHBOARD_DOC,
GET_PARENT_DASHBOARD_DOC,
GET_ADMIN_DASHBOARD_DOC,
GET_WARNINGS_DOC,
GET_ERROR_BOOK_STATS_DOC,
} from "@/lib/api/operations/dashboard.graphql";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import type { UseQueryResult } from "./types";
// ===== 领域模型类型(对齐 combined-schema.graphql data-ana 子图) =====
export interface ClassSummary {
class_id: string;
class_name: string;
student_count: number;
average_score: number;
}
export interface StudentSummary {
student_id: string;
student_name: string;
score: number;
rank_in_class: number;
}
export interface WarningInfo {
warning_id: string;
warning_type: string;
target_id: string;
target_name: string;
threshold: number;
current_value: number;
severity: string;
occurred_at: string;
}
export interface WarningList {
warnings: WarningInfo;
total: number;
}
export interface WeakPoint {
knowledge_point_id: string;
title: string;
mastery: number;
error_count: number;
}
export interface TrendPoint {
date: string;
score: number;
}
export interface AIUsageByProvider {
provider: string;
request_count: string;
total_tokens: string;
cost_cents: string;
}
export interface AIUsageSummary {
total_requests: string;
total_tokens: string;
total_cost_cents: string;
by_provider: AIUsageByProvider;
}
export interface KnowledgePointErrorStats {
knowledge_point_id: string;
title: string;
error_count: number;
question_count: number;
error_rate: number;
}
export interface ErrorBookStats {
student_id: string;
total_error_questions: number;
total_error_count: number;
by_knowledge_point: KnowledgePointErrorStats;
recent_7d_errors: number;
}
export interface TeacherDashboard {
user_id: string;
total_classes: number;
total_students: number;
class_avg_score: number;
pending_homework_count: number;
classes: ClassSummary;
top_students: StudentSummary;
recent_warnings: WarningInfo;
}
export interface StudentDashboard {
user_id: string;
avg_score: number;
class_rank: number;
total_students: number;
weak_points: WeakPoint;
recent_trends: TrendPoint;
pending_homework: number;
}
export interface ParentDashboard {
user_id: string;
student_id: string;
child_avg_score: number;
child_class_rank: number;
total_class_students: number;
child_weak_points: WeakPoint;
child_warnings: WarningInfo;
}
export interface AdminDashboard {
user_id: string;
total_teachers: number;
total_students: number;
total_classes: number;
school_avg_score: number;
recent_warnings: WarningInfo;
ai_usage: AIUsageSummary;
}
// ===== 查询选项 =====
export interface DashboardQueryOptions {
/** 是否启用查询false 时跳过) */
enabled?: boolean;
/** 轮询间隔ms */
pollInterval?: number;
/** Apollo fetchPolicy */
fetchPolicy?: FetchPolicy;
}
// ===== 内部 Query 类型 =====
interface TeacherDashboardQueryData {
teacherDashboard: TeacherDashboard;
}
type DashboardQueryVars = Record<string, never>;
interface StudentDashboardQueryData {
studentDashboard: StudentDashboard;
}
interface ParentDashboardQueryData {
parentDashboard: ParentDashboard;
}
interface AdminDashboardQueryData {
adminDashboard: AdminDashboard;
}
interface WarningsQueryData {
warnings: WarningList;
}
interface ErrorBookStatsQueryData {
errorBookStats: ErrorBookStats;
}
// ===== Hooks =====
/**
* 查询教师仪表盘聚合数据(替换原 grades/homeworks/schedule 等假契约查询)。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export function useTeacherDashboard(
options?: DashboardQueryOptions,
): UseQueryResult<TeacherDashboard> {
const result = useWidgetQuery<TeacherDashboardQueryData, DashboardQueryVars>(
GET_TEACHER_DASHBOARD_DOC,
{},
options,
);
return {
data: result.data?.teacherDashboard,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询学生仪表盘聚合数据。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export function useStudentDashboard(
options?: DashboardQueryOptions,
): UseQueryResult<StudentDashboard> {
const result = useWidgetQuery<StudentDashboardQueryData, DashboardQueryVars>(
GET_STUDENT_DASHBOARD_DOC,
{},
options,
);
return {
data: result.data?.studentDashboard,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询家长仪表盘聚合数据。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export function useParentDashboard(
options?: DashboardQueryOptions,
): UseQueryResult<ParentDashboard> {
const result = useWidgetQuery<ParentDashboardQueryData, DashboardQueryVars>(
GET_PARENT_DASHBOARD_DOC,
{},
options,
);
return {
data: result.data?.parentDashboard,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询管理员仪表盘聚合数据。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export function useAdminDashboard(
options?: DashboardQueryOptions,
): UseQueryResult<AdminDashboard> {
const result = useWidgetQuery<AdminDashboardQueryData, DashboardQueryVars>(
GET_ADMIN_DASHBOARD_DOC,
{},
options,
);
return {
data: result.data?.adminDashboard,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询预警列表(跨角色共享)。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export function useWarnings(
options?: DashboardQueryOptions,
): UseQueryResult<WarningList> {
const result = useWidgetQuery<WarningsQueryData, DashboardQueryVars>(
GET_WARNINGS_DOC,
{},
options,
);
return {
data: result.data?.warnings,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询错题统计(学生仪表盘子区块)。
*
* 关联ARCHITECTURE.md §5.5 / §10 P1-2
*/
export function useErrorBookStats(
options?: DashboardQueryOptions,
): UseQueryResult<ErrorBookStats> {
const result = useWidgetQuery<ErrorBookStatsQueryData, DashboardQueryVars>(
GET_ERROR_BOOK_STATS_DOC,
{},
options,
);
return {
data: result.data?.errorBookStats,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -3,12 +3,14 @@
* *
* widget 通过 `import { useParentChildren } from "@/lib/api"` 调用。 * widget 通过 `import { useParentChildren } from "@/lib/api"` 调用。
* 7 个 domain 文件覆盖全部 31 widget。 * 7 个 domain 文件覆盖全部 31 widget。
* dashboard domain 覆盖 4 角色仪表盘真实聚合查询P1-2
* *
* 关联spec §2.2 * 关联spec §2.2、ARCHITECTURE.md §5.5 / §10 P1-2
*/ */
export * from "./errors"; export * from "./errors";
export * from "./types"; export * from "./types";
export * from "./universal"; export * from "./universal";
export * from "./dashboard";
export * from "./sidebar"; export * from "./sidebar";
export * from "./topbar"; export * from "./topbar";
export * from "./teacher"; export * from "./teacher";

View File

@@ -0,0 +1,172 @@
// Dashboard domain GraphQL documents
// Real aggregation queries from data-ana subgraph (ARCHITECTURE.md §5.5, §10 P1-2)
//
// These queries replace the 6 fake widget contract queries
// (grades/homeworks/schedule/attendance/exams/announcements) that referenced
// non-existent root fields. The dashboard aggregation queries are real and
// implemented by services/data-ana resolvers.
//
// Schema source: combined-schema.graphql (data-ana subgraph section)
// Field naming: snake_case (as exposed by data-ana GraphQL schema)
//
// 关联ARCHITECTURE.md §5.5 后端已就绪查询的立即利用 / §10 P1-2
import { gql } from "@apollo/client";
// ── Teacher Dashboard ──────────────────────────────────────────
export const GET_TEACHER_DASHBOARD_DOC = gql`
query GetTeacherDashboard {
teacherDashboard {
user_id
total_classes
total_students
class_avg_score
pending_homework_count
classes {
class_id
class_name
student_count
average_score
}
top_students {
student_id
student_name
score
rank_in_class
}
recent_warnings {
warning_id
warning_type
target_id
target_name
threshold
current_value
severity
occurred_at
}
}
}
`;
// ── Student Dashboard ──────────────────────────────────────────
export const GET_STUDENT_DASHBOARD_DOC = gql`
query GetStudentDashboard {
studentDashboard {
user_id
avg_score
class_rank
total_students
weak_points {
knowledge_point_id
title
mastery
error_count
}
recent_trends {
date
score
}
pending_homework
}
}
`;
// ── Parent Dashboard ───────────────────────────────────────────
export const GET_PARENT_DASHBOARD_DOC = gql`
query GetParentDashboard {
parentDashboard {
user_id
student_id
child_avg_score
child_class_rank
total_class_students
child_weak_points {
knowledge_point_id
title
mastery
error_count
}
child_warnings {
warning_id
warning_type
target_id
target_name
threshold
current_value
severity
occurred_at
}
}
}
`;
// ── Admin Dashboard ────────────────────────────────────────────
export const GET_ADMIN_DASHBOARD_DOC = gql`
query GetAdminDashboard {
adminDashboard {
user_id
total_teachers
total_students
total_classes
school_avg_score
recent_warnings {
warning_id
warning_type
target_id
target_name
threshold
current_value
severity
occurred_at
}
ai_usage {
total_requests
total_tokens
total_cost_cents
by_provider {
provider
request_count
total_tokens
cost_cents
}
}
}
}
`;
// ── Warnings (shared across dashboards) ────────────────────────
export const GET_WARNINGS_DOC = gql`
query GetWarnings {
warnings {
warnings {
warning_id
warning_type
target_id
target_name
threshold
current_value
severity
occurred_at
}
total
}
}
`;
// ── Error Book Stats ───────────────────────────────────────────
export const GET_ERROR_BOOK_STATS_DOC = gql`
query GetErrorBookStats {
errorBookStats {
student_id
total_error_questions
total_error_count
by_knowledge_point {
knowledge_point_id
title
error_count
question_count
error_rate
}
recent_7d_errors
}
}
`;

View File

@@ -1,69 +1,16 @@
// Universal domain GraphQL documents // Universal domain GraphQL documents
// Extracted from widgets/universal/*/index.tsx // Extracted from widgets/universal/*/index.tsx
// Related: spec section 2.2 // Related: spec section 2.2
//
// P1-2ARCHITECTURE.md §106 个假契约查询已下线grades/homeworks/
// schedule/attendance/exams/announcements root 字段在后端 schema 不存在)。
// 仪表盘改用 data-ana 的真实聚合查询dashboard.graphql.ts
// 仅保留 notifications查询真实字段 notifications(userId),形状待 P1-7 修正)。
import { gql } from "@apollo/client"; import { gql } from "@apollo/client";
// From widgets/universal/grades-widget
export const GET_GRADES_DOC = gql`
query GetGrades($classId: ID!) {
grades(classId: $classId) {
studentId
score
}
}
`;
// From widgets/universal/homework-widget
export const GET_HOMEWORKS_DOC = gql`
query GetHomeworks($classId: ID!, $limit: Int) {
homeworks(classId: $classId, limit: $limit) {
id
title
dueDate
status
}
}
`;
// From widgets/universal/schedule-widget
export const GET_SCHEDULE_DOC = gql`
query GetSchedule($classId: ID!, $dayOfWeek: Int) {
schedule(classId: $classId, dayOfWeek: $dayOfWeek) {
id
subject
startTime
endTime
teacherName
}
}
`;
// From widgets/universal/attendance-widget
export const GET_ATTENDANCE_DOC = gql`
query GetAttendance($classId: ID!, $termId: ID!) {
attendance(classId: $classId, termId: $termId) {
present
absent
late
total
}
}
`;
// From widgets/universal/exams-widget
export const GET_EXAMS_DOC = gql`
query GetExams($classId: ID!, $limit: Int) {
exams(classId: $classId, limit: $limit) {
id
name
examDate
subject
maxScore
}
}
`;
// From widgets/universal/notifications-widget // From widgets/universal/notifications-widget
// 注意notifications(userId) 返回平铺数组,非 {items, total} 包装。
// 形状修正待 P1-7codegen 恢复后统一处理)。
export const GET_NOTIFICATIONS_LIST_DOC = gql` export const GET_NOTIFICATIONS_LIST_DOC = gql`
query GetNotificationsList($limit: Int, $offset: Int) { query GetNotificationsList($limit: Int, $offset: Int) {
notifications(limit: $limit, offset: $offset) { notifications(limit: $limit, offset: $offset) {
@@ -78,16 +25,3 @@ export const GET_NOTIFICATIONS_LIST_DOC = gql`
} }
} }
`; `;
// From widgets/universal/announcements-widget
export const GET_ANNOUNCEMENTS_DOC = gql`
query GetAnnouncements($limit: Int) {
announcements(limit: $limit) {
id
title
body
author
publishedAt
}
}
`;

View File

@@ -3,67 +3,20 @@
/** /**
* Universal domain API * Universal domain API
* *
* 涵盖 7 个 universal widget 的查询函数: * P1-2ARCHITECTURE.md §106 个假契约查询已下线
* - useGrades / useHomework / useSchedule / useAttendance / useExams * grades/homeworks/schedule/attendance/exams/announcements
* - useNotifications / useAnnouncements * 仪表盘改用 data-ana 真实聚合查询dashboard.ts
* 仅保留 notifications真实字段形状待 P1-7 修正)。
* *
* widget 通过 `import { useGrades } from "@/lib/api"` 调用, * 关联spec §2.2、ARCHITECTURE.md §5.5 / §10 P1-2
* 不再各自内嵌 gql/接口/手写类型。
*
* universal domain 全部为查询,无 mutation。
*
* 关联spec §2.2、§5.6 统一 Hook、M8 验收
*/ */
import type { FetchPolicy } from "@apollo/client"; import type { FetchPolicy } from "@apollo/client";
import { import { GET_NOTIFICATIONS_LIST_DOC } from "@/lib/api/operations/universal.graphql";
GET_GRADES_DOC,
GET_HOMEWORKS_DOC,
GET_SCHEDULE_DOC,
GET_ATTENDANCE_DOC,
GET_EXAMS_DOC,
GET_NOTIFICATIONS_LIST_DOC,
GET_ANNOUNCEMENTS_DOC,
} from "@/lib/api/operations/universal.graphql";
import { useWidgetQuery } from "@/lib/useWidgetQuery"; import { useWidgetQuery } from "@/lib/useWidgetQuery";
import type { Pagination, UseQueryResult } from "./types"; import type { Pagination, UseQueryResult } from "./types";
// ===== 领域模型类型 ===== // ===== 领域模型类型 =====
export interface Grade {
studentId: string;
score: number;
}
export interface Homework {
id: string;
title: string;
dueDate: string;
status: string;
}
export interface ScheduleItem {
id: string;
subject: string;
startTime: string;
endTime: string;
teacherName: string;
}
export interface AttendanceStats {
present: number;
absent: number;
late: number;
total: number;
}
export interface Exam {
id: string;
name: string;
examDate: string;
subject: string;
maxScore: number;
}
export interface Notification { export interface Notification {
id: string; id: string;
title: string; title: string;
@@ -77,15 +30,7 @@ export interface NotificationList {
total: number; total: number;
} }
export interface Announcement { // ===== 查询选项 =====
id: string;
title: string;
body: string;
author: string;
publishedAt: string;
}
// ===== 查询选项(透传 useWidgetQuery但 fallbackData 由本层处理) =====
export interface UniversalQueryOptions { export interface UniversalQueryOptions {
/** 是否启用查询false 时跳过) */ /** 是否启用查询false 时跳过) */
@@ -96,48 +41,7 @@ export interface UniversalQueryOptions {
fetchPolicy?: FetchPolicy; fetchPolicy?: FetchPolicy;
} }
// ===== 内部 Query 类型codegen skipDocumentsValidation用 inline 类型) ===== // ===== 内部 Query 类型 =====
// 注意Vars 使用 type alias 而非 interface以满足 useWidgetQuery 的
// `TVars extends Record<string, unknown>` 约束known-issues §2.17 TS2344
interface GradesQueryData {
grades: Grade[];
}
type GradesQueryVars = {
classId: string;
};
interface HomeworksQueryData {
homeworks: Homework[];
}
type HomeworksQueryVars = {
classId: string;
limit: number;
};
interface ScheduleQueryData {
schedule: ScheduleItem[];
}
type ScheduleQueryVars = {
classId: string;
dayOfWeek: number;
};
interface AttendanceQueryData {
attendance: AttendanceStats;
}
type AttendanceQueryVars = {
classId: string;
termId: string;
};
interface ExamsQueryData {
exams: Exam[];
}
type ExamsQueryVars = {
classId: string;
limit: number;
};
interface NotificationsListQueryData { interface NotificationsListQueryData {
notifications: NotificationList; notifications: NotificationList;
@@ -147,137 +51,14 @@ type NotificationsListQueryVars = {
offset: number; offset: number;
}; };
interface AnnouncementsQueryData {
announcements: Announcement[];
}
type AnnouncementsQueryVars = {
limit: number;
};
// ===== Hooks ===== // ===== Hooks =====
/**
* 查询班级成绩列表。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useGrades(
classId: string,
options?: UniversalQueryOptions,
): UseQueryResult<Grade[]> {
const result = useWidgetQuery<GradesQueryData, GradesQueryVars>(
GET_GRADES_DOC,
{ classId },
options,
);
return {
data: result.data?.grades,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级作业列表,可指定返回条数。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useHomework(
classId: string,
limit: number,
options?: UniversalQueryOptions,
): UseQueryResult<Homework[]> {
const result = useWidgetQuery<HomeworksQueryData, HomeworksQueryVars>(
GET_HOMEWORKS_DOC,
{ classId, limit },
options,
);
return {
data: result.data?.homeworks,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级当日课表(按星期几过滤)。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useSchedule(
classId: string,
dayOfWeek: number,
options?: UniversalQueryOptions,
): UseQueryResult<ScheduleItem[]> {
const result = useWidgetQuery<ScheduleQueryData, ScheduleQueryVars>(
GET_SCHEDULE_DOC,
{ classId, dayOfWeek },
options,
);
return {
data: result.data?.schedule,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级学期考勤统计。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useAttendance(
classId: string,
termId: string,
options?: UniversalQueryOptions,
): UseQueryResult<AttendanceStats> {
const result = useWidgetQuery<AttendanceQueryData, AttendanceQueryVars>(
GET_ATTENDANCE_DOC,
{ classId, termId },
options,
);
return {
data: result.data?.attendance,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/**
* 查询班级考试列表,可指定返回条数。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useExams(
classId: string,
limit: number,
options?: UniversalQueryOptions,
): UseQueryResult<Exam[]> {
const result = useWidgetQuery<ExamsQueryData, ExamsQueryVars>(
GET_EXAMS_DOC,
{ classId, limit },
options,
);
return {
data: result.data?.exams,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}
/** /**
* 查询通知列表(分页),返回 items + total。 * 查询通知列表(分页),返回 items + total。
* *
* 注意notifications(userId) 后端返回平铺数组,非 {items, total} 包装。
* 形状修正待 P1-7codegen 恢复后统一处理)。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收 * 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/ */
export function useNotifications( export function useNotifications(
@@ -298,24 +79,3 @@ export function useNotifications(
refetch: result.refetch, refetch: result.refetch,
}; };
} }
/**
* 查询公告列表,可指定返回条数。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
export function useAnnouncements(
limit: number,
): UseQueryResult<Announcement[]> {
const result = useWidgetQuery<AnnouncementsQueryData, AnnouncementsQueryVars>(
GET_ANNOUNCEMENTS_DOC,
{ limit },
);
return {
data: result.data?.announcements,
loading: result.loading,
error: result.error,
refetch: result.refetch,
};
}

View File

@@ -1,52 +1,26 @@
"use client"; "use client";
import type { PluginProps } from "@/lib/types";
import { Card, CardContent } from "@/shared/components/ui/card";
/** /**
* announcements-widgetuniversal / main * AnnouncementsWidget已迁移到仪表盘聚合查询P1-2
* *
* 通过 useAnnouncements 查询 apollo-router → msg 子图的 announcements 数据 * 原假契约查询 useAnnouncements 已下线announcements root 字段在后端 schema 不存在)
* limit 来自插件 props默认 20 * 数据源已迁移到 data-ana 的 adminDashboard 真实聚合查询
* 参见 src/app/shell/admin/page.tsx 和 src/lib/api/dashboard.ts。
* *
* 关联:portal-shell spec §5.6 统一 Hook、M8 验收 * 关联:ARCHITECTURE.md §5.5 / §10 P1-2
*/ */
import { useAnnouncements } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function AnnouncementsWidget( export default function AnnouncementsWidget(
props: PluginProps, _props: PluginProps,
): React.ReactElement { ): React.ReactElement {
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useAnnouncements(limit);
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const rows = data ?? [];
return ( return (
<section className="rounded-xl border border bg-card p-4"> <Card>
<h3 className="text-heading-3 text-foreground"></h3> <CardContent className="py-6 text-center text-sm text-muted-foreground">
{rows.length === 0 ? ( adminDashboard 访 /shell/admin
<p className="mt-sm text-sm text-muted-foreground"></p>
) : ( </CardContent>
<ul className="mt-sm space-y-sm"> </Card>
{rows.map((row) => (
<li
key={row.id}
className="border-b border py-xs text-sm text-foreground"
>
<p className="text-foreground">{row.title}</p>
<p className="mt-xs text-xs text-muted-foreground">{row.body}</p>
<p className="mt-xs text-xs text-muted-foreground">
{row.author} · {row.publishedAt}
</p>
</li>
))}
</ul>
)}
</section>
); );
} }

View File

@@ -1,74 +1,26 @@
"use client"; "use client";
import type { PluginProps } from "@/lib/types";
import { Card, CardContent } from "@/shared/components/ui/card";
/** /**
* attendance-widgetuniversal / main * AttendanceWidget已迁移到仪表盘聚合查询P1-2
* *
* 通过 useAttendance 查询 apollo-router → core-edu 子图的 attendance 数据 * 原假契约查询 useAttendance 已下线attendance root 字段在后端 schema 不存在)
* classId 与 termId 从 URL Search Params 读取,展示考勤统计大字数字 * 数据源已迁移到 data-ana 的 teacherDashboard 真实聚合查询
* 参见 src/app/shell/teacher/page.tsx 和 src/lib/api/dashboard.ts。
* *
* 关联:portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收 * 关联:ARCHITECTURE.md §5.5 / §10 P1-2
*/ */
import { useSearchParams } from "next/navigation";
import { useAttendance } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function AttendanceWidget( export default function AttendanceWidget(
_props: PluginProps, _props: PluginProps,
): React.ReactElement { ): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const termId = searchParams.get("termId") ?? "";
const { data, loading } = useAttendance(classId, termId, {
enabled: classId.length > 0 && termId.length > 0,
});
if (loading && !data) {
return <PluginSkeleton variant="stats" />;
}
if (!classId || !termId) {
return (
<section className="rounded-xl border border bg-card p-4">
<h3 className="text-heading-3 text-foreground"></h3>
<p className="text-sm text-muted-foreground"></p>
</section>
);
}
const stats = data;
if (!stats) {
return (
<section className="rounded-xl border border bg-card p-4">
<h3 className="text-heading-3 text-foreground"></h3>
<p className="text-sm text-muted-foreground"></p>
</section>
);
}
const items: Array<{ label: string; value: number }> = [
{ label: "出勤", value: stats.present },
{ label: "缺席", value: stats.absent },
{ label: "迟到", value: stats.late },
{ label: "总课时", value: stats.total },
];
return ( return (
<section className="rounded-xl border border bg-card p-4"> <Card>
<h3 className="text-heading-3 text-foreground"></h3> <CardContent className="py-6 text-center text-sm text-muted-foreground">
<div className="mt-sm flex gap-4"> teacherDashboard 访 /shell/teacher
{items.map((item) => (
<div </CardContent>
key={item.label} </Card>
className="flex-1 rounded-xl bg-muted p-4 text-center"
>
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="mt-xs text-heading-3 text-foreground">{item.value}</p>
</div>
))}
</div>
</section>
); );
} }

View File

@@ -1,86 +1,24 @@
"use client"; "use client";
import type { PluginProps } from "@/lib/types";
import { Card, CardContent } from "@/shared/components/ui/card";
/** /**
* exams-widgetuniversal / main * ExamsWidget已迁移到仪表盘聚合查询P1-2
* *
* 通过 useExams 查询 apollo-router → core-edu 子图的 exams 数据 * 原假契约查询 useExams 已下线exams root 字段在后端 schema 不存在)
* classId 从 URL Search Params 读取limit 来自插件 props * 数据源已迁移到 data-ana 的 teacherDashboard 真实聚合查询
* 点击考试项时写入 URL examIdrouter.push供其他插件响应 * 参见 src/app/shell/teacher/page.tsx 和 src/lib/api/dashboard.ts
* *
* 关联:portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收 * 关联:ARCHITECTURE.md §5.5 / §10 P1-2
*/ */
import { useRouter, useSearchParams } from "next/navigation"; export default function ExamsWidget(_props: PluginProps): React.ReactElement {
import { useExams } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function ExamsWidget(props: PluginProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const currentExamId = searchParams.get("examId") ?? "";
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useExams(classId, limit, {
enabled: classId.length > 0,
});
const handleSelect = (examId: string): void => {
const params = new URLSearchParams(searchParams.toString());
params.set("examId", examId);
router.push(`?${params.toString()}`);
};
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
if (!classId) {
return (
<section className="rounded-xl border border bg-card p-4">
<h3 className="text-heading-3 text-foreground"></h3>
<p className="text-sm text-muted-foreground"></p>
</section>
);
}
const rows = data ?? [];
return ( return (
<section className="rounded-xl border border bg-card p-4"> <Card>
<h3 className="text-heading-3 text-foreground"></h3> <CardContent className="py-6 text-center text-sm text-muted-foreground">
{rows.length === 0 ? ( teacherDashboard 访 /shell/teacher
<p className="text-sm text-muted-foreground"></p>
) : ( </CardContent>
<ul className="mt-sm space-y-sm"> </Card>
{rows.map((row) => {
const isActive = row.id === currentExamId;
const itemClass = isActive
? "w-full rounded-md border border bg-muted px-sm py-xs text-left text-sm"
: "w-full rounded-md border border bg-card px-sm py-xs text-left text-sm";
return (
<li key={row.id}>
<button
type="button"
onClick={() => handleSelect(row.id)}
className={itemClass}
aria-pressed={isActive}
>
<div className="flex">
<span className="flex-1 text-foreground">{row.name}</span>
<span className="text-muted-foreground">{row.subject}</span>
</div>
<div className="mt-xs flex text-xs text-muted-foreground">
<span className="flex-1">{row.examDate}</span>
<span> {row.maxScore}</span>
</div>
</button>
</li>
);
})}
</ul>
)}
</section>
); );
} }

View File

@@ -1,68 +1,24 @@
"use client"; "use client";
import type { PluginProps } from "@/lib/types";
import { Card, CardContent } from "@/shared/components/ui/card";
/** /**
* grades-widgetuniversal / main * GradesWidget已迁移到仪表盘聚合查询P1-2
* *
* 通过 useGrades 查询 apollo-router → core-edu 子图的 grades 数据 * 原假契约查询 useGrades 已下线grades root 字段在后端 schema 不存在)
* classId 从 URL Search Params 读取class-selector 切换时自动响应) * 数据源已迁移到 data-ana 的 teacherDashboard 真实聚合查询
* 参见 src/app/shell/teacher/page.tsx 和 src/lib/api/dashboard.ts。
* *
* 关联:portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收 * 关联:ARCHITECTURE.md §5.5 / §10 P1-2
*/ */
import { useSearchParams } from "next/navigation"; export default function GradesWidget(_props: PluginProps): React.ReactElement {
import { useGrades } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function GradesWidget(props: PluginProps): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useGrades(classId, {
enabled: classId.length > 0,
});
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
if (!classId) {
return (
<section className="rounded-xl border border bg-card p-4">
<h3 className="text-heading-3 text-foreground"></h3>
<p className="text-sm text-muted-foreground"></p>
</section>
);
}
const rows = data ?? [];
return ( return (
<section className="rounded-xl border border bg-card p-4"> <Card>
<h3 className="text-heading-3 text-foreground"></h3> <CardContent className="py-6 text-center text-sm text-muted-foreground">
{rows.length === 0 ? ( teacherDashboard 访 /shell/teacher
<p className="text-sm text-muted-foreground"></p>
) : ( </CardContent>
<table className="mt-sm w-full text-sm"> </Card>
<thead>
<tr className="border-b border text-muted-foreground">
<th className="py-xs text-left"></th>
<th className="py-xs text-right"></th>
</tr>
</thead>
<tbody>
{rows.slice(0, limit).map((row) => (
<tr key={row.studentId} className="border-b border">
<td className="py-xs text-foreground">{row.studentId}</td>
<td className="py-xs text-right text-foreground">
{row.score}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
); );
} }

View File

@@ -1,100 +1,26 @@
"use client"; "use client";
import type { PluginProps } from "@/lib/types";
import { Card, CardContent } from "@/shared/components/ui/card";
/** /**
* homework-widgetuniversal / main * HomeworkWidget已迁移到仪表盘聚合查询P1-2
* *
* 通过 useHomework 查询 apollo-router → core-edu 子图的 homeworks 数据 * 原假契约查询 useHomework 已下线homeworks root 字段在后端 schema 不存在)
* classId 从 URL Search Params 读取class-selector 切换时自动响应), * 数据源已迁移到 data-ana 的 teacherDashboard 真实聚合查询。
* limit 来自插件 props默认 20 * 参见 src/app/shell/teacher/page.tsx 和 src/lib/api/dashboard.ts
* *
* 关联:portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收 * 关联:ARCHITECTURE.md §5.5 / §10 P1-2
*/ */
import { useSearchParams } from "next/navigation"; export default function HomeworkWidget(
import { useHomework } from "@/lib/api/universal"; _props: PluginProps,
import { PluginSkeleton } from "@/shell/PluginLoader"; ): React.ReactElement {
import type { PluginProps } from "@/lib/types";
const STATUS_LABEL: Record<string, string> = {
pending: "待提交",
submitted: "已提交",
graded: "已评分",
};
function StatusBadge({ status }: { status: string }): React.ReactElement {
const label = STATUS_LABEL[status] ?? status;
if (status === "graded") {
return (
<span className="rounded-md border border bg-card px-sm py-xs text-xs text-foreground">
{label}
</span>
);
}
if (status === "submitted") {
return (
<span className="rounded-md bg-primary px-sm py-xs text-xs text-primary-foreground">
{label}
</span>
);
}
return ( return (
<span className="rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground"> <Card>
{label} <CardContent className="py-6 text-center text-sm text-muted-foreground">
</span> teacherDashboard 访 /shell/teacher
);
} </CardContent>
</Card>
export default function HomeworkWidget(props: PluginProps): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useHomework(classId, limit, {
enabled: classId.length > 0,
});
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
if (!classId) {
return (
<section className="rounded-xl border border bg-card p-4">
<h3 className="text-heading-3 text-foreground"></h3>
<p className="text-sm text-muted-foreground"></p>
</section>
);
}
const rows = data ?? [];
return (
<section className="rounded-xl border border bg-card p-4">
<h3 className="text-heading-3 text-foreground"></h3>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<table className="mt-sm w-full text-sm">
<thead>
<tr className="border-b border text-muted-foreground">
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
<th className="py-xs text-left"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border">
<td className="py-xs text-foreground">{row.title}</td>
<td className="py-xs text-foreground">{row.dueDate}</td>
<td className="py-xs">
<StatusBadge status={row.status} />
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
); );
} }

View File

@@ -1,67 +1,26 @@
"use client"; "use client";
import type { PluginProps } from "@/lib/types";
import { Card, CardContent } from "@/shared/components/ui/card";
/** /**
* schedule-widgetuniversal / main * ScheduleWidget已迁移到仪表盘聚合查询P1-2
* *
* 通过 useSchedule 查询 apollo-router → core-edu 子图的 schedule 数据 * 原假契约查询 useSchedule 已下线schedule root 字段在后端 schema 不存在)
* classId 从 URL Search Params 读取,默认展示当天课表(按星期几过滤) * 数据源已迁移到 data-ana 的 teacherDashboard 真实聚合查询
* 参见 src/app/shell/teacher/page.tsx 和 src/lib/api/dashboard.ts。
* *
* 关联:portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收 * 关联:ARCHITECTURE.md §5.5 / §10 P1-2
*/ */
import { useSearchParams } from "next/navigation";
import { useSchedule } from "@/lib/api/universal";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
export default function ScheduleWidget( export default function ScheduleWidget(
_props: PluginProps, _props: PluginProps,
): React.ReactElement { ): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const today = new Date().getDay();
const dayOfWeek = today === 0 ? 7 : today; // 1=周一 ... 7=周日
const { data, loading } = useSchedule(classId, dayOfWeek, {
enabled: classId.length > 0,
});
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
if (!classId) {
return (
<section className="rounded-xl border border bg-card p-4">
<h3 className="text-heading-3 text-foreground"></h3>
<p className="text-sm text-muted-foreground"></p>
</section>
);
}
const rows = [...(data ?? [])].sort((a, b) =>
a.startTime.localeCompare(b.startTime),
);
return ( return (
<section className="rounded-xl border border bg-card p-4"> <Card>
<h3 className="text-heading-3 text-foreground"></h3> <CardContent className="py-6 text-center text-sm text-muted-foreground">
{rows.length === 0 ? ( teacherDashboard 访 /shell/teacher
<p className="text-sm text-muted-foreground"></p>
) : ( </CardContent>
<ul className="mt-sm space-y-sm"> </Card>
{rows.map((row) => (
<li key={row.id} className="flex border-b border py-xs text-sm">
<span className="flex-1 text-foreground">{row.subject}</span>
<span className="flex-1 text-muted-foreground">
{row.startTime} - {row.endTime}
</span>
<span className="flex-1 text-right text-muted-foreground">
{row.teacherName}
</span>
</li>
))}
</ul>
)}
</section>
); );
} }