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:
@@ -2,7 +2,7 @@
|
||||
|
||||
> 版本:3.0
|
||||
> 日期:2026-07-20
|
||||
> 状态:**P0 已完成 + P1-1 已完成(2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿**
|
||||
> 状态:**P0 已完成 + P1-1/P1-2 已完成(2026-07-22 验收)+ P1 进行中;架构审计完成 + 重设计方案定稿**
|
||||
> 本文档地位:**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-8**:`git status` 干净(commit `cfb7b00`);`.env.local` 与 `tsconfig.tsbuildinfo` 已在 [.gitignore](file:///e:/Desktop/Edu/apps/portal-shell/.gitignore)
|
||||
|
||||
### P1 · 框架与数据源接通(1–2 周)— 进行中(P1-1 ✅ 2026-07-22 验收)
|
||||
### P1 · 框架与数据源接通(1–2 周)— 进行中(P1-1/P1-2 ✅ 2026-07-22 验收)
|
||||
|
||||
**目标**:AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n,页面迁移的"流水线"建成。
|
||||
|
||||
| # | 任务 | 验收 | 状态 |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| P1-1 | `src/app/shell/layout.tsx` AppFrame(TopBar 静态挂载 + 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-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 | ⏳ |
|
||||
| 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 表互补
|
||||
- **质量校验**:`tsc --noEmit` 通过;`eslint`(5 文件)通过
|
||||
|
||||
**P1-2 验收证据(2026-07-22)**:
|
||||
|
||||
- **假契约查询全部下线**:`grep -rn "useGrades\|useHomework\|useSchedule\|useAttendance\|useExams\|useAnnouncements" src` 无业务调用;6 个 widget(grades/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 个仪表盘页统一遵循 loading(StatCard isLoading 骨架)→ error(Card 错误提示)→ success(真实数据)三态
|
||||
- **质量校验**:`tsc --noEmit` 通过;`eslint`(新/改文件)通过;`vitest run` 全量 20 test files / 212 tests 全部通过
|
||||
|
||||
### P2 · 教师域页面(2–3 周,可与 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。
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { fetchPluginConfig } from "@/lib/config-fetcher";
|
||||
import { ClientShell } from "@/shell/ClientShell";
|
||||
import type { Role } from "@/lib/types";
|
||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* Shell 入口(RSC Server Component,v3.0 P0-2 fail-closed + 流式渲染)
|
||||
* Shell catch-all 入口(RSC Server Component,v3.0 P0-2 + P1-2)
|
||||
*
|
||||
* 数据流(ARCHITECTURE.md §3.4 V3-A2/A3、§5.5):
|
||||
* ① 从 middleware 注入的请求头获取 userId / role(middleware 已校验 cookie + 权限)
|
||||
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
||||
* ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染:
|
||||
* - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI
|
||||
* - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求)
|
||||
* 路由行为(ARCHITECTURE.md §3.4 V3-A1 混合路由模型):
|
||||
* - `/shell`(空路由)→ 302 重定向到 `/shell/{role}` 角色仪表盘(P1-2)
|
||||
* - `/shell/{role}/{module}/...`(有路由段)→ 微内核仪表盘兜底
|
||||
* (P2-P5 将逐步替换为显式路由页面)
|
||||
*
|
||||
* fail-closed(P0-2,§11.7 红线 #5):
|
||||
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头
|
||||
* - 头缺失 = 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 userId = headerList.get("x-user-id");
|
||||
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 { route } = await params;
|
||||
|
||||
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
||||
// 不 await:直接将 Promise 传给 ClientShell,启用流式渲染
|
||||
// P1-2:`/shell`(空路由)→ 重定向到角色仪表盘
|
||||
// 显式路由页面(/shell/teacher、/shell/student 等)由 Next.js 优先匹配,
|
||||
// 不会进入此 catch-all;仅当用户直接访问 /shell 时重定向。
|
||||
if (!route || route.length === 0) {
|
||||
redirect(`/shell/${role}`);
|
||||
}
|
||||
|
||||
// 有路由段时:微内核仪表盘兜底(P2-P5 将逐步替换为显式路由页面)
|
||||
const configPromise: Promise<PluginConfigResponse> = fetchPluginConfig(
|
||||
userId,
|
||||
role,
|
||||
);
|
||||
|
||||
// 将 Promise 作为 prop 传递,ClientShell 内部通过 use() 消费
|
||||
// Next.js 会自动用 loading.tsx 作为 Suspense fallback 流式输出 HTML
|
||||
return (
|
||||
<ClientShell configPromise={configPromise} role={role} userId={userId} />
|
||||
);
|
||||
|
||||
121
apps/portal-shell/src/app/shell/admin/page.tsx
Normal file
121
apps/portal-shell/src/app/shell/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/portal-shell/src/app/shell/parent/page.tsx
Normal file
105
apps/portal-shell/src/app/shell/parent/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
apps/portal-shell/src/app/shell/student/page.tsx
Normal file
107
apps/portal-shell/src/app/shell/student/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
apps/portal-shell/src/app/shell/teacher/page.tsx
Normal file
113
apps/portal-shell/src/app/shell/teacher/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Universal domain API 单元测试(portal-shell spec §5.6、§9.9)
|
||||
*
|
||||
* 覆盖:
|
||||
* - useGrades:归一化返回 Grade[](从 { grades: [...] } 拍平)
|
||||
* - useNotifications:归一化返回 NotificationList(从 { notifications: { items, total } } 拍平)
|
||||
* - useGrades:enabled=false 时跳过查询
|
||||
* - useAttendance:归一化返回 AttendanceStats
|
||||
* P1-2(ARCHITECTURE.md §10):6 个假契约查询已下线
|
||||
* (grades/homeworks/schedule/attendance/exams/announcements)。
|
||||
* 本测试仅覆盖保留下来的 useNotifications。
|
||||
*
|
||||
* 使用 createElement 而非 JSX,避免 .ts 文件 JSX 解析错误
|
||||
* (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 { MockedProvider } from "@apollo/client/testing";
|
||||
import type { MockedResponse } from "@apollo/client/testing/core";
|
||||
import { useGrades, useNotifications, useAttendance } from "../universal";
|
||||
import {
|
||||
GET_GRADES_DOC,
|
||||
GET_NOTIFICATIONS_LIST_DOC,
|
||||
GET_ATTENDANCE_DOC,
|
||||
} from "../operations/universal.graphql";
|
||||
import { useNotifications } from "../universal";
|
||||
import { GET_NOTIFICATIONS_LIST_DOC } from "../operations/universal.graphql";
|
||||
|
||||
function makeWrapper(mocks: MockedResponse[]) {
|
||||
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", () => {
|
||||
it("返回归一化的 NotificationList(含 items + total)", async () => {
|
||||
const mocks: MockedResponse[] = [
|
||||
@@ -167,39 +94,3 @@ describe("useNotifications", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
307
apps/portal-shell/src/lib/api/dashboard.ts
Normal file
307
apps/portal-shell/src/lib/api/dashboard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
*
|
||||
* widget 通过 `import { useParentChildren } from "@/lib/api"` 调用。
|
||||
* 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 "./types";
|
||||
export * from "./universal";
|
||||
export * from "./dashboard";
|
||||
export * from "./sidebar";
|
||||
export * from "./topbar";
|
||||
export * from "./teacher";
|
||||
|
||||
172
apps/portal-shell/src/lib/api/operations/dashboard.graphql.ts
Normal file
172
apps/portal-shell/src/lib/api/operations/dashboard.graphql.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,69 +1,16 @@
|
||||
// Universal domain GraphQL documents
|
||||
// Extracted from widgets/universal/*/index.tsx
|
||||
// Related: spec section 2.2
|
||||
//
|
||||
// P1-2(ARCHITECTURE.md §10):6 个假契约查询已下线(grades/homeworks/
|
||||
// schedule/attendance/exams/announcements root 字段在后端 schema 不存在)。
|
||||
// 仪表盘改用 data-ana 的真实聚合查询(dashboard.graphql.ts)。
|
||||
// 仅保留 notifications(查询真实字段 notifications(userId),形状待 P1-7 修正)。
|
||||
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
|
||||
// 注意:notifications(userId) 返回平铺数组,非 {items, total} 包装。
|
||||
// 形状修正待 P1-7(codegen 恢复后统一处理)。
|
||||
export const GET_NOTIFICATIONS_LIST_DOC = gql`
|
||||
query GetNotificationsList($limit: Int, $offset: Int) {
|
||||
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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -3,67 +3,20 @@
|
||||
/**
|
||||
* Universal domain API
|
||||
*
|
||||
* 涵盖 7 个 universal widget 的查询函数:
|
||||
* - useGrades / useHomework / useSchedule / useAttendance / useExams
|
||||
* - useNotifications / useAnnouncements
|
||||
* P1-2(ARCHITECTURE.md §10):6 个假契约查询已下线
|
||||
* (grades/homeworks/schedule/attendance/exams/announcements)。
|
||||
* 仪表盘改用 data-ana 真实聚合查询(dashboard.ts)。
|
||||
* 仅保留 notifications(真实字段,形状待 P1-7 修正)。
|
||||
*
|
||||
* widget 通过 `import { useGrades } from "@/lib/api"` 调用,
|
||||
* 不再各自内嵌 gql/接口/手写类型。
|
||||
*
|
||||
* universal domain 全部为查询,无 mutation。
|
||||
*
|
||||
* 关联:spec §2.2、§5.6 统一 Hook、M8 验收
|
||||
* 关联:spec §2.2、ARCHITECTURE.md §5.5 / §10 P1-2
|
||||
*/
|
||||
import type { FetchPolicy } from "@apollo/client";
|
||||
import {
|
||||
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 { GET_NOTIFICATIONS_LIST_DOC } from "@/lib/api/operations/universal.graphql";
|
||||
import { useWidgetQuery } from "@/lib/useWidgetQuery";
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -77,15 +30,7 @@ export interface NotificationList {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
author: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
// ===== 查询选项(透传 useWidgetQuery,但 fallbackData 由本层处理) =====
|
||||
// ===== 查询选项 =====
|
||||
|
||||
export interface UniversalQueryOptions {
|
||||
/** 是否启用查询(false 时跳过) */
|
||||
@@ -96,48 +41,7 @@ export interface UniversalQueryOptions {
|
||||
fetchPolicy?: FetchPolicy;
|
||||
}
|
||||
|
||||
// ===== 内部 Query 类型(codegen skipDocumentsValidation,用 inline 类型) =====
|
||||
// 注意: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;
|
||||
};
|
||||
// ===== 内部 Query 类型 =====
|
||||
|
||||
interface NotificationsListQueryData {
|
||||
notifications: NotificationList;
|
||||
@@ -147,137 +51,14 @@ type NotificationsListQueryVars = {
|
||||
offset: number;
|
||||
};
|
||||
|
||||
interface AnnouncementsQueryData {
|
||||
announcements: Announcement[];
|
||||
}
|
||||
type AnnouncementsQueryVars = {
|
||||
limit: number;
|
||||
};
|
||||
|
||||
// ===== 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。
|
||||
*
|
||||
* 注意:notifications(userId) 后端返回平铺数组,非 {items, total} 包装。
|
||||
* 形状修正待 P1-7(codegen 恢复后统一处理)。
|
||||
*
|
||||
* 关联:portal-shell spec §5.6 统一 Hook、M8 验收
|
||||
*/
|
||||
export function useNotifications(
|
||||
@@ -298,24 +79,3 @@ export function useNotifications(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,52 +1,26 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* announcements-widget(universal / main)
|
||||
* AnnouncementsWidget(已迁移到仪表盘聚合查询,P1-2)
|
||||
*
|
||||
* 通过 useAnnouncements 查询 apollo-router → msg 子图的 announcements 数据。
|
||||
* limit 来自插件 props(默认 20)。
|
||||
* 原假契约查询 useAnnouncements 已下线(announcements root 字段在后端 schema 不存在)。
|
||||
* 数据源已迁移到 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(
|
||||
props: PluginProps,
|
||||
_props: PluginProps,
|
||||
): 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 (
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">公告</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="mt-sm text-sm text-muted-foreground">暂无公告</p>
|
||||
) : (
|
||||
<ul className="mt-sm space-y-sm">
|
||||
{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>
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-sm text-muted-foreground">
|
||||
数据源已迁移到仪表盘聚合查询(adminDashboard)。 请访问 /shell/admin
|
||||
查看真实数据。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,26 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* attendance-widget(universal / main)
|
||||
* AttendanceWidget(已迁移到仪表盘聚合查询,P1-2)
|
||||
*
|
||||
* 通过 useAttendance 查询 apollo-router → core-edu 子图的 attendance 数据。
|
||||
* classId 与 termId 从 URL Search Params 读取,展示考勤统计大字数字。
|
||||
* 原假契约查询 useAttendance 已下线(attendance root 字段在后端 schema 不存在)。
|
||||
* 数据源已迁移到 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(
|
||||
_props: PluginProps,
|
||||
): 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 (
|
||||
<section className="rounded-xl border border bg-card p-4">
|
||||
<h3 className="text-heading-3 text-foreground">考勤</h3>
|
||||
<div className="mt-sm flex gap-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
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>
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-sm text-muted-foreground">
|
||||
数据源已迁移到仪表盘聚合查询(teacherDashboard)。 请访问 /shell/teacher
|
||||
查看真实数据。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +1,24 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* exams-widget(universal / main)
|
||||
* ExamsWidget(已迁移到仪表盘聚合查询,P1-2)
|
||||
*
|
||||
* 通过 useExams 查询 apollo-router → core-edu 子图的 exams 数据。
|
||||
* classId 从 URL Search Params 读取,limit 来自插件 props。
|
||||
* 点击考试项时写入 URL examId(router.push),供其他插件响应。
|
||||
* 原假契约查询 useExams 已下线(exams root 字段在后端 schema 不存在)。
|
||||
* 数据源已迁移到 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 { useRouter, useSearchParams } from "next/navigation";
|
||||
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 ?? [];
|
||||
|
||||
export default function ExamsWidget(_props: PluginProps): React.ReactElement {
|
||||
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>
|
||||
) : (
|
||||
<ul className="mt-sm space-y-sm">
|
||||
{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>
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-sm text-muted-foreground">
|
||||
数据源已迁移到仪表盘聚合查询(teacherDashboard)。 请访问 /shell/teacher
|
||||
查看真实数据。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,24 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* grades-widget(universal / main)
|
||||
* GradesWidget(已迁移到仪表盘聚合查询,P1-2)
|
||||
*
|
||||
* 通过 useGrades 查询 apollo-router → core-edu 子图的 grades 数据。
|
||||
* classId 从 URL Search Params 读取(class-selector 切换时自动响应)。
|
||||
* 原假契约查询 useGrades 已下线(grades root 字段在后端 schema 不存在)。
|
||||
* 数据源已迁移到 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 { 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 ?? [];
|
||||
|
||||
export default function GradesWidget(_props: PluginProps): React.ReactElement {
|
||||
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-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>
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-sm text-muted-foreground">
|
||||
数据源已迁移到仪表盘聚合查询(teacherDashboard)。 请访问 /shell/teacher
|
||||
查看真实数据。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +1,26 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* homework-widget(universal / main)
|
||||
* HomeworkWidget(已迁移到仪表盘聚合查询,P1-2)
|
||||
*
|
||||
* 通过 useHomework 查询 apollo-router → core-edu 子图的 homeworks 数据。
|
||||
* classId 从 URL Search Params 读取(class-selector 切换时自动响应),
|
||||
* limit 来自插件 props(默认 20)。
|
||||
* 原假契约查询 useHomework 已下线(homeworks root 字段在后端 schema 不存在)。
|
||||
* 数据源已迁移到 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 { useHomework } from "@/lib/api/universal";
|
||||
import { PluginSkeleton } from "@/shell/PluginLoader";
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default function HomeworkWidget(
|
||||
_props: PluginProps,
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<span className="rounded-md bg-muted px-sm py-xs text-xs text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-sm text-muted-foreground">
|
||||
数据源已迁移到仪表盘聚合查询(teacherDashboard)。 请访问 /shell/teacher
|
||||
查看真实数据。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,26 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import type { PluginProps } from "@/lib/types";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* schedule-widget(universal / main)
|
||||
* ScheduleWidget(已迁移到仪表盘聚合查询,P1-2)
|
||||
*
|
||||
* 通过 useSchedule 查询 apollo-router → core-edu 子图的 schedule 数据。
|
||||
* classId 从 URL Search Params 读取,默认展示当天课表(按星期几过滤)。
|
||||
* 原假契约查询 useSchedule 已下线(schedule root 字段在后端 schema 不存在)。
|
||||
* 数据源已迁移到 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(
|
||||
_props: PluginProps,
|
||||
): 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 (
|
||||
<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>
|
||||
) : (
|
||||
<ul className="mt-sm space-y-sm">
|
||||
{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>
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-sm text-muted-foreground">
|
||||
数据源已迁移到仪表盘聚合查询(teacherDashboard)。 请访问 /shell/teacher
|
||||
查看真实数据。
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user