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

@@ -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 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
* ① 从 middleware 注入的请求头获取 userId / rolemiddleware 已校验 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-closedP0-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} />
);

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>
);
}