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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user