feat(student-portal): 完整实现 student-portal 微前端

包含 src 全部实现、Dockerfile、配置文件、contracts 包等
This commit is contained in:
SpecialX
2026-07-10 19:10:36 +08:00
parent 24c2860b41
commit 74474a2d04
80 changed files with 17226 additions and 70 deletions

View File

@@ -0,0 +1,35 @@
# ============================================================
# student-portal 环境变量ai14端口 4001
# 对齐 ARB-002 MF 配置 + ARB-001 GraphQL + MSW mock 策略
# ============================================================
# --- 运行模式 ---
# MF 启用开关P2=false 独立壳P3=true 接入 Shell
NEXT_PUBLIC_MF_ENABLED=false
# MSW mock 启用开关(上游就绪前用 enabled就绪后 disabled
NEXT_PUBLIC_API_MOCKING=enabled
# --- API 网关 ---
NEXT_PUBLIC_API_GATEWAY_URL=http://localhost:8080
NEXT_PUBLIC_PUSH_GATEWAY_URL=ws://localhost:8081
# --- GraphQL endpoint经 api-gateway 代理到 student-bff :3009---
NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/student/graphql
# --- Shellteacher-portal地址MF Remote 加载入口)---
NEXT_PUBLIC_SHELL_URL=http://localhost:4000
# --- 考试作答 ---
# 自动保存间隔(秒)
NEXT_PUBLIC_EXAM_AUTOSAVE_INTERVAL=30
# 服务器时间同步间隔(秒)
NEXT_PUBLIC_EXAM_TIME_SYNC_INTERVAL=300
# --- 可观测性P6---
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_OTEL_EXPORTER_URL=
# --- 应用元信息 ---
NEXT_PUBLIC_APP_NAME=student-portal
NEXT_PUBLIC_APP_VERSION=0.1.0

32
apps/student-portal/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# dependencies
node_modules/
# next.js
.next/
out/
build/
dist/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# typescript
*.tsbuildinfo
next-env.d.ts
# msw
public/mockServiceWorker.js

View File

@@ -0,0 +1,58 @@
# 多阶段构建student-portal 生产镜像ai14P6 硬化)
# 用法docker build -t edu/student-portal:latest -f apps/student-portal/Dockerfile .
# 端口4001004 §1.2 强制 4 端 4000-4003
# ============ Stage 1: 装依赖 ============
FROM node:22-alpine AS deps
WORKDIR /app
# 启用 pnpm
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# 先拷依赖清单,利用缓存
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
COPY apps/student-portal/package.json ./apps/student-portal/
# 安装依赖(含 devDependencies构建需要
RUN pnpm install --filter @edu/student-portal... --frozen-lockfile || pnpm install --filter @edu/student-portal...
# ============ Stage 2: 构建 ============
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/student-portal/node_modules ./apps/student-portal/node_modules
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
COPY apps/student-portal ./apps/student-portal
# 构建生产产物(禁用 telemetry
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @edu/student-portal run build
# ============ Stage 3: 运行时 ============
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=4001
# 非 root 用户运行
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# 拷构建产物与必要清单
COPY --from=builder /app/apps/student-portal/package.json ./package.json
COPY --from=builder /app/apps/student-portal/.next ./.next
COPY --from=builder /app/apps/student-portal/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/student-portal/next.config.js ./next.config.js
USER nextjs
EXPOSE 4001
# 健康检查liveness
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --quiet --spider http://localhost:4001/api/health || exit 1
CMD ["node_modules/.bin/next", "start", "-p", "4001"]

View File

@@ -0,0 +1,70 @@
/**
* student-portal ESLint 配置ai14
*
* 强制约束project_rules §3.10
* - no-restricted-syntax禁止 #hex 颜色字面量
* - design-tokens/no-hardcoded-fonts禁止 'Inter'/'Fraunces' 字面量
* - 白名单primitive.css原始令牌定义
*/
const js = require('@eslint/js');
const tseslint = require('typescript-eslint');
const jsxAlly = require('eslint-plugin-jsx-a11y');
module.exports = tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: { jsx: true },
project: true,
},
},
plugins: {
'jsx-a11y': jsxAlly,
},
rules: {
// 禁止 anyproject_rules §3.4
'@typescript-eslint/no-explicit-any': 'error',
// 禁止 as 断言(除非从 unknown 转换)
'@typescript-eslint/no-unsafe-assignment': 'warn',
// 函数返回值必须显式标注
'@typescript-eslint/explicit-function-return-type': [
'warn',
{ allowExpressions: true, allowTypedFunctionExpressions: true },
],
// import type 强制
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
// A11y 基础规则
'jsx-a11y/alt-text': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/tabindex-no-positive': 'error',
// 禁止硬编码颜色(#hex— 白名单primitive.css
'no-restricted-syntax': [
'error',
{
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
message:
'禁止硬编码 #hex 颜色,使用语义令牌 var(--color-*) 或 Tailwind bg-*/text-*',
},
],
},
},
{
// 白名单primitive.css 允许定义原始令牌
files: ['src/styles/primitive.css'],
rules: {
'no-restricted-syntax': 'off',
},
},
{
ignores: ['node_modules/', '.next/', 'build/', 'dist/', 'src/**/*.css'],
},
);

View File

@@ -0,0 +1,99 @@
/**
* student-portal Next.js 配置ai14
*
* MF 角色Remotename: 'student_app'ARB-002
* - 暴露:./StudentApp学生端完整应用入口
* - 复用 Shell 暴露的 singletonreact/react-dom/urql/graphql/@tanstack/react-query 等
* - 独立壳模式NEXT_PUBLIC_MF_ENABLED=false不依赖 Shell独立渲染
*
* 端口4001004 §1.2 强制 4 端 4000-4003
* 路由:无 /student 前缀student-portal_contract.md §1.2
*/
const NextFederationPlugin =
require('@module-federation/nextjs-mf')?.default;
const isMfEnabled = process.env.NEXT_PUBLIC_MF_ENABLED === 'true';
const shellUrl =
process.env.NEXT_PUBLIC_SHELL_URL || 'http://localhost:4000';
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// ESM 模式transpilePackages 对齐 NestJS ESM 规范
transpilePackages: [
'@edu/ui-components',
'@edu/ui-tokens',
'@edu/hooks',
'@edu/contracts',
'@edu/shared-ts',
],
async rewrites() {
const gatewayUrl =
process.env.NEXT_PUBLIC_API_GATEWAY_URL || 'http://localhost:8080';
return [
{
source: '/api/v1/:path*',
destination: `${gatewayUrl}/api/v1/:path*`,
},
{
source: '/api/auth/:path*',
destination: `${gatewayUrl}/api/auth/:path*`,
},
];
},
// 远程类型安全MF 加载失败不影响独立壳渲染
webpack(config, { isServer }) {
// ESM 包使用 .js 后缀导入源码TS 文件),需映射 .js → .ts
// 适用于 @edu/hooks 等 ESM 包type: module + .js 后缀导入)
// 注意:仅映射 .js不映射 .mjs避免破坏 urql 等库的内部解析)
config.resolve = config.resolve || {};
config.resolve.extensionAlias = {
...config.resolve.extensionAlias,
'.js': ['.ts', '.tsx', '.js'],
};
if (isMfEnabled && NextFederationPlugin) {
config.plugins.push(
new NextFederationPlugin({
name: 'student_app',
filename: 'static/chunks/remoteEntry.js',
exposes: {
'./StudentApp': './src/app/student-app.tsx',
},
remotes: {
teacher: `teacher_app@${shellUrl}/_next/static/chunks/remoteEntry.js`,
},
shared: {
// ARB-002 §2.2Shell 暴露的 singleton 列表
react: { singleton: true, requiredVersion: false },
'react-dom': { singleton: true, requiredVersion: false },
urql: { singleton: true, requiredVersion: false },
graphql: { singleton: true, requiredVersion: false },
'@tanstack/react-query': {
singleton: true,
requiredVersion: false,
},
zustand: { singleton: true, requiredVersion: false },
nuqs: { singleton: true, requiredVersion: false },
'@edu/ui-tokens': { singleton: true, requiredVersion: false },
'@edu/ui-components': {
singleton: true,
requiredVersion: false,
},
'@edu/hooks': { singleton: true, requiredVersion: false },
'@edu/contracts': { singleton: true, requiredVersion: false },
'@edu/shared-ts': { singleton: true, requiredVersion: false },
},
extraOptions: {
exposeFiles: true,
enableImageLoaderFix: true,
},
}),
);
}
return config;
},
};
module.exports = nextConfig;

View File

@@ -0,0 +1,52 @@
{
"name": "@edu/student-portal",
"version": "0.1.0",
"private": true,
"description": "学生端微前端MF Remote端口 4001- ai14",
"scripts": {
"dev": "next dev -p 4001",
"build": "next build",
"start": "next start -p 4001",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@edu/contracts": "workspace:*",
"@edu/hooks": "workspace:*",
"@edu/shared-ts": "workspace:*",
"@edu/ui-components": "workspace:*",
"@edu/ui-tokens": "workspace:*",
"@module-federation/nextjs-mf": "^8.3.0",
"@sentry/nextjs": "^8.30.0",
"@tanstack/react-query": "^5.59.0",
"graphql": "^16.9.0",
"idb-keyval": "^6.2.1",
"next": "^14.2.0",
"next-intl": "^3.20.0",
"nuqs": "^1.19.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"recharts": "^2.12.0",
"urql": "^4.2.0",
"zod": "^3.23.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.0.0",
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/client-preset": "^4.5.0",
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"eslint": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"mock-socket": "^9.3.1",
"msw": "^2.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,228 @@
"use client";
/**
* AI 辅学页面ai14P5 可选)
*
* - 聊天界面SSE 流式回答
* - 消息列表(用户 + AI
* - 输入框 + 发送按钮
* - Mock 模式NEXT_PUBLIC_API_MOCKING=enabled本地模拟流式回答
* - 未启用时通过 FeatureFlag 展示"AI 辅学功能开发中"占位
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { FeatureFlag } from "@/components/feature-flag";
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
}
const MOCK_REPLY =
"这是一个很好的问题。根据你提到的知识点,建议你先回顾相关定义,再通过例题巩固。我可以帮你梳理思路:首先明确题干要求,其次列出已知条件,最后逐步推导结论。";
function useAiTutorEnabled(): boolean {
return (
process.env.NEXT_PUBLIC_FEATURE_AI_TUTOR === "true" ||
process.env.NEXT_PUBLIC_API_MOCKING === "enabled"
);
}
function isMockMode(): boolean {
return process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
}
/** 流式追加文本到指定消息 */
function streamInto(
messages: ChatMessage[],
id: string,
append: (full: string) => void,
): (chunk: string) => void {
let acc = messages.find((m) => m.id === id)?.content ?? "";
return (chunk: string) => {
acc += chunk;
append(acc);
};
}
function ChatInterface(): React.ReactNode {
const mocking = isMockMode();
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: "welcome",
role: "assistant",
content: "你好!我是 AI 学习助手,有什么可以帮你的吗?",
},
]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
listRef.current?.scrollTo({
top: listRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages]);
const send = useCallback(async (): Promise<void> => {
const text = input.trim();
if (!text || streaming) return;
const userMsg: ChatMessage = {
id: `u-${Date.now()}`,
role: "user",
content: text,
};
const aiId = `a-${Date.now()}`;
const aiMsg: ChatMessage = { id: aiId, role: "assistant", content: "" };
setMessages((prev) => [...prev, userMsg, aiMsg]);
setInput("");
setStreaming(true);
const updateAi = (full: string): void => {
setMessages((prev) =>
prev.map((m) => (m.id === aiId ? { ...m, content: full } : m)),
);
};
const sink = streamInto(messages, aiId, updateAi);
try {
if (mocking) {
// Mock按字符流式输出
const chars = Array.from(MOCK_REPLY);
for (let i = 0; i < chars.length; i += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 20));
sink(chars[i] ?? "");
}
} else {
// 真实 SSEPOST 请求并以 reader 解析 data: 行
const res = await fetch("/api/v1/student/ai-tutor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
if (!res.ok || !res.body) throw new Error("请求失败");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("data:")) {
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") break;
sink(payload);
}
}
}
}
} catch {
updateAi("回答生成失败,请重试");
} finally {
setStreaming(false);
}
}, [input, streaming, mocking, messages]);
const handleClear = useCallback((): void => {
setMessages([
{
id: "welcome",
role: "assistant",
content: "你好!我是 AI 学习助手,有什么可以帮你的吗?",
},
]);
}, []);
return (
<main
className="px-md py-lg max-w-3xl mx-auto flex flex-col"
style={{ minHeight: "70vh" }}
>
<header className="mb-md">
<div className="flex items-center justify-between">
<div>
<h1 className="font-serif text-2xl text-ink">AI </h1>
<p className="mt-xs text-sm text-ink-muted"></p>
</div>
<button type="button" className="btn-secondary" onClick={handleClear}>
</button>
</div>
</header>
<div className="rule-thin mb-md" />
<div
ref={listRef}
className="flex-1 overflow-y-auto space-y-md py-md"
role="log"
aria-live="polite"
aria-label="对话记录"
>
{messages.map((m) => (
<article
key={m.id}
className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`card-paper px-md py-sm max-w-[85%] ${
m.role === "user" ? "bg-accent-muted" : ""
}`}
>
<p className="text-xs text-ink-subtle mb-xs">
{m.role === "user" ? "我" : "AI 助手"}
</p>
<p className="text-sm text-ink whitespace-pre-wrap break-words">
{m.content || (streaming ? "正在思考…" : "")}
</p>
</div>
</article>
))}
</div>
<form
className="flex gap-sm pt-md"
onSubmit={(e) => {
e.preventDefault();
void send();
}}
>
<label htmlFor="ai-input" className="sr-only">
</label>
<input
id="ai-input"
className="input-paper flex-1"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="请输入你的问题…"
disabled={streaming}
aria-label="问题输入框"
/>
<button
type="submit"
className="btn-primary"
disabled={streaming || !input.trim()}
>
{streaming ? "正在思考…" : "发送"}
</button>
</form>
</main>
);
}
export default function AiTutorPage(): React.ReactNode {
const enabled = useAiTutorEnabled();
return (
<FeatureFlag enabled={enabled}>
<ChatInterface />
</FeatureFlag>
);
}

View File

@@ -0,0 +1,17 @@
/**
* 健康检查端点ai14P6 硬化)
*
* GET /api/health — liveness 探针
* 仅返回进程存活状态,不检查外部依赖。
* 用于 Dockerfile HEALTHCHECK。
*/
import { NextResponse } from "next/server";
export function GET(): NextResponse {
return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
service: "student-portal",
});
}

View File

@@ -0,0 +1,49 @@
/**
* 就绪检查端点ai14P6 硬化)
*
* GET /api/ready — readiness 探针
* 检查 GraphQL endpoint经 api-gateway 代理到 student-bff是否可达。
* - 就绪200 + { ready: true, checks: { graphql: true }, timestamp }
* - 未就绪503 + { ready: false, checks: { graphql: false }, timestamp }
*/
import { NextResponse } from "next/server";
interface ReadinessResult {
ready: boolean;
checks: { graphql: boolean };
timestamp: string;
}
async function checkGraphql(): Promise<boolean> {
const gateway =
process.env.NEXT_PUBLIC_API_GATEWAY_URL ?? "http://localhost:8080";
const endpoint =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
const url = `${gateway}${endpoint}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "{ __typename }" }),
signal: controller.signal,
});
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
export async function GET(): Promise<NextResponse> {
const graphqlOk = await checkGraphql();
const result: ReadinessResult = {
ready: graphqlOk,
checks: { graphql: graphqlOk },
timestamp: new Date().toISOString(),
};
return NextResponse.json(result, { status: graphqlOk ? 200 : 503 });
}

View File

@@ -0,0 +1,448 @@
"use client";
/**
* Dashboard — 学生仪表盘ai14
*
* 数据源useStudentDashboard GraphQL hookstudent-bff 聚合)
* 视图模型avgScore / classRank / attendanceRate / learningStreakDays /
* upcomingHomework(3) / upcomingExams(2) / recentGrades(3)
*
* 设计依据01-understanding.md §7dashboard 视口)
* 02-architecture-design.md §14.4dashboard SSR + CSR
*/
import Link from "next/link";
import { useStudentDashboard, useCurrentUser } from "@/lib/graphql/hooks";
import type {
StudentDashboard,
DashboardHomework,
DashboardExam,
DashboardGrade,
} from "@/lib/graphql/types";
// 日期格式化(中文短格式)
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
function formatDueDate(iso: string): string {
return dateFormatter.format(new Date(iso));
}
function formatPercent(rate: number): string {
return `${(rate * 100).toFixed(1)}%`;
}
export default function DashboardPage(): JSX.Element {
const { data, fetching, error } = useStudentDashboard();
const { data: currentUser } = useCurrentUser();
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<HeaderSection studentName={currentUser?.name} />
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} />
) : !data ? (
<EmptyState />
) : (
<>
<StatGrid data={data} />
<div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
<UpcomingHomeworkCard items={data.upcomingHomework ?? []} />
<UpcomingExamsCard items={data.upcomingExams ?? []} />
</div>
<div className="mt-6">
<RecentGradesCard items={data.recentGrades ?? []} />
</div>
</>
)}
</div>
);
}
// === 顶部欢迎区 ===
function HeaderSection({
studentName,
}: {
studentName: string | undefined;
}): JSX.Element {
const today = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
}).format(new Date());
return (
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{today}
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
{studentName ?? "同学"}
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
);
}
// === 统计卡片网格 ===
function StatGrid({ data }: { data: StudentDashboard }): JSX.Element {
return (
<section
aria-label="学习概览"
className="grid grid-cols-2 lg:grid-cols-4 gap-4"
>
<StatCard
label="平均分"
value={data.avgScore.toFixed(1)}
suffix="/ 100"
accent
/>
<StatCard label="班级排名" value={String(data.classRank)} suffix="名" />
<StatCard label="出勤率" value={formatPercent(data.attendanceRate)} />
<StatCard
label="连续学习"
value={String(data.learningStreakDays)}
suffix="天"
/>
</section>
);
}
function StatCard({
label,
value,
suffix,
accent,
}: {
label: string;
value: string;
suffix?: string;
accent?: boolean;
}): JSX.Element {
return (
<div className="card-paper p-5 lg:p-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{label}
</p>
<p
className="mt-3 text-3xl lg:text-4xl font-serif"
style={{
color: accent ? "var(--color-accent)" : "var(--color-ink)",
}}
>
{value}
{suffix && (
<span
className="ml-1 text-sm font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{suffix}
</span>
)}
</p>
</div>
);
}
// === 即将到期的作业 ===
function UpcomingHomeworkCard({
items,
}: {
items: DashboardHomework[];
}): JSX.Element {
return (
<section aria-label="即将到期的作业" className="card-paper p-6">
<div className="flex items-baseline justify-between mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
</h2>
<Link
href="/my-homework"
className="text-xs hover:underline"
style={{ color: "var(--color-accent)" }}
>
</Link>
</div>
<div className="rule-thin mb-4" />
{items.length === 0 ? (
<p
className="text-sm py-6 text-center"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-3">
{items.map((hw) => (
<li key={hw.id} className="mark-left">
<Link
href={`/my-homework/${hw.id}/submit`}
className="block group"
>
<p
className="text-sm font-medium group-hover:underline"
style={{ color: "var(--color-ink)" }}
>
{hw.title}
</p>
<p
className="mt-1 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{hw.subject.name} · {formatDueDate(hw.dueAt)}
</p>
</Link>
</li>
))}
</ul>
)}
</section>
);
}
// === 即将到来的考试 ===
function UpcomingExamsCard({ items }: { items: DashboardExam[] }): JSX.Element {
return (
<section aria-label="即将到来的考试" className="card-paper p-6">
<div className="flex items-baseline justify-between mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
</h2>
<Link
href="/my-exams"
className="text-xs hover:underline"
style={{ color: "var(--color-accent)" }}
>
</Link>
</div>
<div className="rule-thin mb-4" />
{items.length === 0 ? (
<p
className="text-sm py-6 text-center"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-3">
{items.map((exam) => (
<li key={exam.id} className="mark-left">
<p
className="text-sm font-medium"
style={{ color: "var(--color-ink)" }}
>
{exam.name}
</p>
<p
className="mt-1 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{exam.subject.name} · {formatDueDate(exam.startsAt)} ·{" "}
{Math.round(exam.durationSeconds / 60)}
</p>
</li>
))}
</ul>
)}
</section>
);
}
// === 最近成绩 ===
function RecentGradesCard({ items }: { items: DashboardGrade[] }): JSX.Element {
return (
<section aria-label="最近成绩" className="card-paper p-6">
<div className="flex items-baseline justify-between mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
</h2>
<Link
href="/my-grades"
className="text-xs hover:underline"
style={{ color: "var(--color-accent)" }}
>
</Link>
</div>
<div className="rule-thin mb-4" />
{items.length === 0 ? (
<p
className="text-sm py-6 text-center"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="divide-y" style={{ borderColor: "var(--color-rule)" }}>
{items.map((grade) => (
<li
key={grade.id}
className="py-3 flex items-center justify-between gap-4"
>
<div className="min-w-0 flex-1">
<p
className="text-sm font-medium text-truncate"
style={{ color: "var(--color-ink)" }}
>
{grade.examName}
</p>
<p
className="mt-1 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{formatDueDate(grade.submittedAt)}
</p>
</div>
<div className="text-right">
<p
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
{grade.score}
<span
className="text-xs font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{" "}
/ {grade.maxScore}
</span>
</p>
<span
className="badge badge-info mt-1"
aria-label={`等级 ${grade.grade}`}
>
{grade.grade}
</span>
</div>
</li>
))}
</ul>
)}
</section>
);
}
// === 加载骨架 ===
function LoadingSkeleton(): JSX.Element {
return (
<div aria-busy="true" aria-live="polite">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4" aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="card-paper p-6">
<div
className="h-3 w-16 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-4 h-8 w-24 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
</div>
))}
</div>
<div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
{[0, 1].map((i) => (
<div key={i} className="card-paper p-6">
<div
className="h-4 w-32 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div className="mt-6 space-y-3">
{[0, 1, 2].map((j) => (
<div
key={j}
className="h-12 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
))}
</div>
</div>
))}
</div>
<span className="sr-only">...</span>
</div>
);
}
// === 错误态 ===
function ErrorState({ message }: { message: string }): JSX.Element {
return (
<div
role="alert"
className="card-paper p-8 text-center"
style={{ borderColor: "var(--color-danger)" }}
>
<p
className="text-base font-serif"
style={{ color: "var(--color-danger)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 btn-secondary text-xs"
>
</button>
</div>
);
}
// === 空态 ===
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p
className="text-4xl font-serif"
style={{ color: "var(--color-ink-subtle)" }}
aria-hidden="true"
>
</p>
<p
className="mt-4 text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,203 @@
"use client";
/**
* 学习趋势页ai14P4路由 /dashboard/trend
*
* - 消费 useMyTrend 查询
* - 折线图recharts展示历次考试得分趋势
* - 汇总统计:平均分、最高分、最低分、趋势方向
*/
import { useMemo } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useMyTrend } from "@/lib/graphql/hooks";
import type { TrendDataPoint } from "@/lib/graphql/types";
interface ChartDatum {
label: string;
score: number;
}
type TrendDirection = "up" | "down" | "stable";
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("zh-CN", {
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function computeStats(points: TrendDataPoint[]): {
average: number;
highest: number;
lowest: number;
direction: TrendDirection;
} {
if (points.length === 0) {
return { average: 0, highest: 0, lowest: 0, direction: "stable" };
}
const scores = points.map((p) => p.score);
const sum = scores.reduce((acc, s) => acc + s, 0);
const average = Math.round((sum / scores.length) * 10) / 10;
const highest = Math.max(...scores);
const lowest = Math.min(...scores);
let direction: TrendDirection = "stable";
if (points.length >= 2) {
const last = points[points.length - 1]?.score ?? 0;
const prev = points[points.length - 2]?.score ?? 0;
if (last - prev > 0.5) direction = "up";
else if (prev - last > 0.5) direction = "down";
}
return { average, highest, lowest, direction };
}
const DIRECTION_LABEL: Record<TrendDirection, string> = {
up: "上升",
down: "下降",
stable: "平稳",
};
function directionColor(dir: TrendDirection): string {
if (dir === "up") return "var(--color-success)";
if (dir === "down") return "var(--color-danger)";
return "var(--color-ink-muted)";
}
export default function TrendPage(): React.ReactNode {
const { data, fetching, error, reexecute } = useMyTrend();
const points = data ?? [];
const stats = useMemo(() => computeStats(points), [points]);
const chartData = useMemo<ChartDatum[]>(
() => points.map((p) => ({ label: formatDate(p.date), score: p.score })),
[points],
);
return (
<main
className="px-md py-lg max-w-4xl mx-auto"
aria-labelledby="trend-title"
>
<header className="mb-lg">
<h1 id="trend-title" className="font-serif text-2xl text-ink">
</h1>
<p className="mt-xs text-sm text-ink-muted"></p>
</header>
<div className="rule-thin mb-lg" />
{fetching ? (
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : points.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<>
{/* 汇总统计 */}
<section
className="grid grid-cols-2 sm:grid-cols-4 gap-md mb-lg"
aria-label="趋势汇总"
>
<div className="card-paper p-md">
<p className="text-xs text-ink-subtle"></p>
<p className="font-serif text-2xl text-ink mt-xs">
{stats.average}
</p>
</div>
<div className="card-paper p-md">
<p className="text-xs text-ink-subtle"></p>
<p className="font-serif text-2xl text-ink mt-xs">
{stats.highest}
</p>
</div>
<div className="card-paper p-md">
<p className="text-xs text-ink-subtle"></p>
<p className="font-serif text-2xl text-ink mt-xs">
{stats.lowest}
</p>
</div>
<div className="card-paper p-md">
<p className="text-xs text-ink-subtle"></p>
<p
className="font-serif text-2xl mt-xs"
style={{ color: directionColor(stats.direction) }}
>
{DIRECTION_LABEL[stats.direction]}
</p>
</div>
</section>
{/* 趋势折线图 */}
<section className="card-paper p-lg" aria-label="得分趋势折线图">
<div style={{ width: "100%", height: 320 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 8, right: 16, bottom: 8, left: 0 }}
>
<CartesianGrid
stroke="var(--color-rule)"
strokeDasharray="3 3"
/>
<XAxis
dataKey="label"
tick={{ fill: "var(--color-ink-muted)", fontSize: 12 }}
/>
<YAxis
tick={{ fill: "var(--color-ink-muted)", fontSize: 12 }}
domain={["auto", "auto"]}
/>
<Tooltip
contentStyle={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
borderRadius: "var(--radius-base)",
fontSize: 12,
}}
labelStyle={{ color: "var(--color-ink)" }}
/>
<Line
type="monotone"
dataKey="score"
stroke="var(--color-accent)"
strokeWidth={2}
dot={{ r: 3, fill: "var(--color-accent)" }}
activeDot={{ r: 5 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</section>
</>
)}
</main>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
/**
* 学情诊断页ai14P4路由 /dashboard/weakness
*
* - 消费 useMyWeakness 查询
* - 雷达图recharts按科目展示掌握度
* - 薄弱知识点卡片:科目、知识点、掌握度、建议
*/
import { useMemo } from "react";
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
} from "recharts";
import { useMyWeakness } from "@/lib/graphql/hooks";
import type { RecommendedAction, WeaknessPoint } from "@/lib/graphql/types";
interface RadarDatum {
subject: string;
mastery: number;
fullMark: number;
}
const RECOMMENDED_ACTION_LABEL: Record<RecommendedAction, string> = {
review: "建议复习",
practice: "建议练习",
mastered: "已掌握",
};
function masteryPercent(mastery: number): number {
return Math.round(Math.max(0, Math.min(1, mastery)) * 100);
}
/** 按科目聚合平均掌握度 */
function aggregateBySubject(items: WeaknessPoint[]): RadarDatum[] {
const map = new Map<string, { sum: number; count: number }>();
for (const item of items) {
const name = item.subject.name;
const prev = map.get(name) ?? { sum: 0, count: 0 };
prev.sum += masteryPercent(item.mastery);
prev.count += 1;
map.set(name, prev);
}
return Array.from(map.entries()).map(([subject, { sum, count }]) => ({
subject,
mastery: count > 0 ? Math.round(sum / count) : 0,
fullMark: 100,
}));
}
export default function WeaknessPage(): React.ReactNode {
const { data, fetching, error, reexecute } = useMyWeakness();
const items = data ?? [];
const radarData = useMemo(() => aggregateBySubject(items), [items]);
return (
<main
className="px-md py-lg max-w-4xl mx-auto"
aria-labelledby="weakness-title"
>
<header className="mb-lg">
<h1 id="weakness-title" className="font-serif text-2xl text-ink">
</h1>
<p className="mt-xs text-sm text-ink-muted"></p>
</header>
<div className="rule-thin mb-lg" />
{fetching ? (
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : items.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<>
{/* 雷达图 */}
<section
className="card-paper p-lg mb-lg"
aria-label="科目掌握度雷达图"
>
<h2 className="font-serif text-base text-ink mb-md"></h2>
<div style={{ width: "100%", height: 320 }}>
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={radarData} outerRadius="70%">
<PolarGrid stroke="var(--color-rule)" />
<PolarAngleAxis
dataKey="subject"
tick={{ fill: "var(--color-ink-muted)", fontSize: 12 }}
/>
<PolarRadiusAxis
angle={90}
domain={[0, 100]}
tick={{ fill: "var(--color-ink-subtle)", fontSize: 10 }}
/>
<Radar
name="掌握度"
dataKey="mastery"
stroke="var(--color-accent)"
fill="var(--color-accent)"
fillOpacity={0.35}
/>
<Tooltip
contentStyle={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
borderRadius: "var(--radius-base)",
fontSize: 12,
}}
labelStyle={{ color: "var(--color-ink)" }}
/>
</RadarChart>
</ResponsiveContainer>
</div>
</section>
{/* 薄弱知识点卡片 */}
<section aria-label="薄弱知识点列表">
<h2 className="font-serif text-base text-ink mb-md"></h2>
<ul className="space-y-md">
{items.map((item: WeaknessPoint) => {
const pct = masteryPercent(item.mastery);
const subjectName = item.subject.name;
return (
<li key={item.id}>
<article className="card-paper p-md">
<div className="flex items-center justify-between gap-md mb-xs">
<span className="badge badge-info">{subjectName}</span>
<span
className="text-sm"
style={{
color:
pct < 50
? "var(--color-danger)"
: "var(--color-warning)",
}}
aria-label={`掌握度 ${pct}%`}
>
{pct}%
</span>
</div>
<p className="font-serif text-base text-ink">
{item.knowledgePointName}
</p>
<p className="mt-xs text-xs text-ink-subtle">
{item.wrongCount} · {item.lastWrongAt}
</p>
<p className="mt-xs text-sm text-ink-muted">
<span className="text-ink-subtle"></span>
{RECOMMENDED_ACTION_LABEL[item.recommendedAction]}
</p>
</article>
</li>
);
})}
</ul>
</section>
</>
)}
</main>
);
}

View File

@@ -0,0 +1,57 @@
/**
* RootLayout — 学生端根布局ai14
*
* 职责:
* - next/font/google 加载三套字体Inter / Fraunces / JetBrains_Mono
* 并暴露 CSS 变量 --font-sans / --font-serif / --font-mono
* - 注入全局样式 globals.css含三层设计令牌
* - 通过 Providers 客户端组件挂载 QueryClient + GraphQL + MSW
*
* 设计依据02-architecture-design.md §1.1 / §3.4
* 字体规范project_rules §3.10(仅 primitive.css 可定义字面量,此处用 next/font 提供变量)
*/
import type { Metadata } from "next";
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
import "../styles/globals.css";
import Providers from "./providers";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--font-serif",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "Edu Student Portal",
description: "K12 智慧学习平台 - 学生端",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<html
lang="zh-CN"
className={`${inter.variable} ${fraunces.variable} ${jetbrainsMono.variable}`}
>
<body className="bg-paper text-ink font-sans antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
/**
* 学习路径页ai14P4
*
* - 消费 useLearningPath 查询
* - 知识点卡片标题、推荐顺序、掌握度进度条0-100%)、推荐动作
* - 可视化进度指示
*/
import { useLearningPath } from "@/lib/graphql/hooks";
import type { LearningPathNode } from "@/lib/graphql/types";
const STATUS_LABEL: Record<LearningPathNode["status"], string> = {
locked: "未解锁",
available: "可学习",
"in-progress": "学习中",
completed: "已完成",
};
function masteryPercent(node: LearningPathNode): number {
return Math.round(Math.max(0, Math.min(1, node.mastery)) * 100);
}
function statusTone(status: LearningPathNode["status"]): string {
switch (status) {
case "completed":
return "var(--color-success)";
case "in-progress":
return "var(--color-accent)";
case "available":
return "var(--color-info)";
default:
return "var(--color-ink-subtle)";
}
}
function recommendedText(node: LearningPathNode): string {
if (node.mastery >= 0.8) return "已掌握";
if (node.mastery >= 0.5) return "建议练习";
return "建议复习";
}
export default function LearningPathPage(): React.ReactNode {
const { data, fetching, error, reexecute } = useLearningPath();
const nodes = data ?? [];
return (
<main
className="px-md py-lg max-w-4xl mx-auto"
aria-labelledby="learning-path-title"
>
<header className="mb-lg">
<h1 id="learning-path-title" className="font-serif text-2xl text-ink">
</h1>
<p className="mt-xs text-sm text-ink-muted">
</p>
</header>
<div className="rule-thin mb-lg" />
{fetching ? (
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : nodes.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<ol className="space-y-md" aria-label="学习路径节点列表">
{nodes.map((node, index) => {
const pct = masteryPercent(node);
return (
<li key={node.id}>
<article className="card-paper p-md">
<div className="flex items-start justify-between gap-md">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-sm mb-xs">
<span className="text-xs text-ink-subtle">
{node.recommendedOrder || index + 1}
</span>
<span
className="badge"
style={{ color: statusTone(node.status) }}
aria-label={`状态:${STATUS_LABEL[node.status]}`}
>
{STATUS_LABEL[node.status]}
</span>
</div>
<h2 className="font-serif text-base text-ink text-truncate">
{node.name}
</h2>
</div>
<div className="text-right shrink-0">
<p className="text-xs text-ink-subtle"></p>
<p
className="font-serif text-xl"
style={{ color: statusTone(node.status) }}
aria-label={`掌握度 ${pct}%`}
>
{pct}%
</p>
</div>
</div>
{/* 进度条 */}
<div
className="mt-md h-2 w-full rounded"
style={{ background: "var(--color-rule)" }}
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${node.name} 掌握度进度条`}
>
<div
className="h-2 rounded"
style={{
width: `${pct}%`,
background: statusTone(node.status),
}}
/>
</div>
<p className="mt-md text-sm text-ink-muted">
<span className="text-ink-subtle"></span>
{recommendedText(node)}
</p>
</article>
</li>
);
})}
</ol>
)}
</main>
);
}

View File

@@ -0,0 +1,601 @@
"use client";
/**
* MyAttendance — 我的考勤ai14
*
* 数据源useMyAttendance GraphQL hook
* 视图:
* - 顶部摘要:总天数 / 出勤 / 缺勤 / 迟到 / 出勤率
* - 日历网格按月分组每格显示当日状态颜色present/absent/late
* - 最近异常列表:缺勤与迟到记录明细
*
* 设计依据01-understanding.md §7my-attendance 视口)
* 02-architecture-design.md §14.4CSR
*/
import { useMemo } from "react";
import { useMyAttendance } from "@/lib/graphql/hooks";
import type { AttendanceRecord, AttendanceStatus } from "@/lib/graphql/types";
const WEEKDAY_LABELS = ["日", "一", "二", "三", "四", "五", "六"];
const MONTH_NAMES = [
"一月",
"二月",
"三月",
"四月",
"五月",
"六月",
"七月",
"八月",
"九月",
"十月",
"十一月",
"十二月",
];
export default function MyAttendancePage(): JSX.Element {
const { data, fetching, error } = useMyAttendance();
const attendance = data ?? [];
// 摘要统计
const summary = useMemo(() => computeSummary(attendance), [attendance]);
// 按月分组用于日历视图
const monthlyGroups = useMemo(() => groupByMonth(attendance), [attendance]);
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} />
) : attendance.length === 0 ? (
<EmptyState />
) : (
<>
<SummaryStats summary={summary} />
<div className="mt-8">
<CalendarView monthlyGroups={monthlyGroups} />
</div>
<div className="mt-8">
<AbnormalList attendance={attendance} />
</div>
</>
)}
</div>
);
}
// === 顶部统计 ===
interface AttendanceSummary {
total: number;
present: number;
absent: number;
late: number;
rate: number;
}
function computeSummary(items: AttendanceRecord[]): AttendanceSummary {
let present = 0;
let absent = 0;
let late = 0;
for (const it of items) {
if (it.status === "present") present++;
else if (it.status === "absent") absent++;
else if (it.status === "late") late++;
}
const total = items.length;
const rate = total === 0 ? 0 : present / total;
return { total, present, absent, late, rate };
}
function SummaryStats({
summary,
}: {
summary: AttendanceSummary;
}): JSX.Element {
return (
<section
aria-label="考勤摘要"
className="grid grid-cols-2 lg:grid-cols-5 gap-4"
>
<StatTile label="总记录" value={String(summary.total)} suffix="天" />
<StatTile
label="出勤"
value={String(summary.present)}
suffix="天"
tone="success"
/>
<StatTile
label="缺勤"
value={String(summary.absent)}
suffix="天"
tone="danger"
/>
<StatTile
label="迟到"
value={String(summary.late)}
suffix="天"
tone="warning"
/>
<StatTile
label="出勤率"
value={`${(summary.rate * 100).toFixed(1)}%`}
tone="accent"
/>
</section>
);
}
function StatTile({
label,
value,
suffix,
tone = "default",
}: {
label: string;
value: string;
suffix?: string;
tone?: "default" | "success" | "danger" | "warning" | "accent";
}): JSX.Element {
const toneColor = (() => {
switch (tone) {
case "success":
return "var(--color-success)";
case "danger":
return "var(--color-danger)";
case "warning":
return "var(--color-warning)";
case "accent":
return "var(--color-accent)";
default:
return "var(--color-ink)";
}
})();
return (
<div className="card-paper p-5">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{label}
</p>
<p
className="mt-3 text-2xl lg:text-3xl font-serif"
style={{ color: toneColor }}
>
{value}
{suffix && (
<span
className="ml-1 text-xs font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{suffix}
</span>
)}
</p>
</div>
);
}
// === 日历网格视图 ===
interface MonthlyGroup {
year: number;
month: number; // 0-11
items: AttendanceRecord[];
}
function groupByMonth(items: AttendanceRecord[]): MonthlyGroup[] {
const map = new Map<string, MonthlyGroup>();
for (const it of items) {
const d = new Date(it.date);
const key = `${d.getFullYear()}-${d.getMonth()}`;
if (!map.has(key)) {
map.set(key, {
year: d.getFullYear(),
month: d.getMonth(),
items: [],
});
}
map.get(key)!.items.push(it);
}
return Array.from(map.values()).sort(
(a, b) => b.year - a.year || b.month - a.month,
);
}
function CalendarView({
monthlyGroups,
}: {
monthlyGroups: MonthlyGroup[];
}): JSX.Element {
return (
<section aria-label="日历视图">
<div className="space-y-8">
{monthlyGroups.map((group) => (
<MonthGrid key={`${group.year}-${group.month}`} group={group} />
))}
</div>
</section>
);
}
function MonthGrid({ group }: { group: MonthlyGroup }): JSX.Element {
// 构建当月日历:按 date 索引
const byDate = new Map<string, AttendanceRecord>();
for (const it of group.items) {
byDate.set(it.date, it);
}
// 当月天数
const daysInMonth = new Date(group.year, group.month + 1, 0).getDate();
// 当月 1 号是周几
const firstWeekday = new Date(group.year, group.month, 1).getDay();
const cells: Array<{
day: number | null;
dateStr: string | null;
item?: AttendanceRecord;
}> = [];
// 前置空格
for (let i = 0; i < firstWeekday; i++) {
cells.push({ day: null, dateStr: null });
}
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${group.year}-${String(group.month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
cells.push({
day,
dateStr,
item: byDate.get(dateStr),
});
}
return (
<div className="card-paper p-5 lg:p-6">
<div className="flex items-baseline justify-between mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
{group.year} {MONTH_NAMES[group.month]}
</h2>
<Legend />
</div>
<div className="rule-thin mb-4" />
<div
className="grid grid-cols-7 gap-1"
role="grid"
aria-label={`${group.year}${group.month + 1}月考勤日历`}
>
{WEEKDAY_LABELS.map((wd) => (
<div
key={wd}
role="columnheader"
className="text-center text-xs py-1"
style={{ color: "var(--color-ink-muted)" }}
>
{wd}
</div>
))}
{cells.map((cell, idx) => (
<DayCell key={idx} cell={cell} />
))}
</div>
</div>
);
}
function DayCell({
cell,
}: {
cell: {
day: number | null;
dateStr: string | null;
item?: AttendanceRecord;
};
}): JSX.Element {
if (cell.day === null) {
return <div role="gridcell" className="aspect-square" aria-hidden="true" />;
}
const status = cell.item?.status;
const bg = getBackgroundColorForStatus(status);
const fg = getForegroundColorForStatus(status);
const label = getStatusLabel(status);
return (
<div
role="gridcell"
className="aspect-square rounded-sm flex flex-col items-center justify-center text-xs"
style={{ background: bg, color: fg }}
aria-label={
cell.item
? `${cell.dateStr} ${label}${cell.item.subject ? ` · ${cell.item.subject.name}` : ""}`
: `${cell.dateStr} 无记录`
}
title={
cell.item
? `${cell.dateStr} · ${label}${cell.item.subject ? ` · ${cell.item.subject.name}` : ""}${cell.item.reason ? ` · ${cell.item.reason}` : ""}${cell.item.lateMinutes ? ` · 迟到 ${cell.item.lateMinutes} 分钟` : ""}`
: (cell.dateStr ?? "")
}
>
<span className="font-mono">{cell.day}</span>
{status && (
<span className="text-[10px] mt-0.5 leading-none">
{label.slice(0, 1)}
</span>
)}
</div>
);
}
function Legend(): JSX.Element {
return (
<ul className="flex flex-wrap gap-3 text-xs" aria-label="图例">
<LegendItem color="var(--color-success)" label="出勤" />
<LegendItem color="var(--color-warning)" label="迟到" />
<LegendItem color="var(--color-danger)" label="缺勤" />
<LegendItem color="var(--color-rule)" label="无记录" />
</ul>
);
}
function LegendItem({
color,
label,
}: {
color: string;
label: string;
}): JSX.Element {
return (
<li className="flex items-center gap-1">
<span
className="inline-block w-3 h-3 rounded-sm"
style={{ background: color }}
aria-hidden="true"
/>
<span style={{ color: "var(--color-ink-muted)" }}>{label}</span>
</li>
);
}
// === 异常列表(缺勤 + 迟到)===
function AbnormalList({
attendance,
}: {
attendance: AttendanceRecord[];
}): JSX.Element {
const abnormal = attendance
.filter((a) => a.status === "absent" || a.status === "late")
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (abnormal.length === 0) {
return (
<section aria-label="异常记录" className="card-paper p-6">
<h2
className="text-lg font-serif mb-2"
style={{ color: "var(--color-ink)" }}
>
</h2>
<div className="rule-thin mb-4" />
<p
className="text-sm text-center py-6"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</section>
);
}
return (
<section aria-label="异常记录" className="card-paper p-6">
<div className="flex items-baseline justify-between mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
</h2>
<span className="text-xs" style={{ color: "var(--color-ink-muted)" }}>
{abnormal.length}
</span>
</div>
<div className="rule-thin mb-4" />
<ul className="divide-y" style={{ borderColor: "var(--color-rule)" }}>
{abnormal.map((it) => (
<li
key={it.id}
className="py-3 flex items-start justify-between gap-4"
>
<div className="min-w-0 flex-1">
<p
className="text-sm font-medium"
style={{ color: "var(--color-ink)" }}
>
{it.subject.name}
</p>
<p
className="mt-1 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(it.date))}
{it.reason ? ` · ${it.reason}` : ""}
{it.lateMinutes ? ` · 迟到 ${it.lateMinutes} 分钟` : ""}
</p>
</div>
<span
className={`badge ${
it.status === "absent" ? "badge-danger" : "badge-warning"
} flex-shrink-0`}
>
{getStatusLabel(it.status)}
</span>
</li>
))}
</ul>
</section>
);
}
// === 状态工具 ===
function getBackgroundColorForStatus(
status: AttendanceStatus | undefined,
): string {
if (!status) return "var(--color-paper)";
switch (status) {
case "present":
return "hsl(var(--hue-success), var(--sat-success), 92%)";
case "late":
return "hsl(var(--hue-warning), var(--sat-warning), 92%)";
case "absent":
return "hsl(var(--hue-danger), var(--sat-danger), 92%)";
default:
return "var(--color-paper)";
}
}
function getForegroundColorForStatus(
status: AttendanceStatus | undefined,
): string {
if (!status) return "var(--color-ink-subtle)";
switch (status) {
case "present":
return "var(--color-success)";
case "late":
return "var(--color-warning)";
case "absent":
return "var(--color-danger)";
default:
return "var(--color-ink-muted)";
}
}
function getStatusLabel(status: AttendanceStatus | undefined): string {
if (!status) return "无记录";
const map: Record<AttendanceStatus, string> = {
present: "出勤",
late: "迟到",
absent: "缺勤",
};
return map[status] ?? "无记录";
}
function LoadingSkeleton(): JSX.Element {
return (
<div aria-busy="true" aria-live="polite">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4" aria-hidden="true">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="card-paper p-5">
<div
className="h-3 w-12 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-3 h-7 w-20 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
</div>
))}
</div>
<div className="mt-8 card-paper p-6" aria-hidden="true">
<div
className="h-4 w-32 rounded-sm animate-pulse mb-4"
style={{ background: "var(--color-rule)" }}
/>
<div className="rule-thin mb-4" />
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: 35 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-sm animate-pulse"
style={{
background: "var(--color-rule)",
opacity: 0.4,
}}
/>
))}
</div>
</div>
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message }: { message: string }): JSX.Element {
return (
<div
role="alert"
className="card-paper p-8 text-center"
style={{ borderColor: "var(--color-danger)" }}
>
<p
className="text-base font-serif"
style={{ color: "var(--color-danger)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 btn-secondary text-xs"
>
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p
className="text-4xl font-serif"
style={{ color: "var(--color-ink-subtle)" }}
aria-hidden="true"
>
</p>
<p
className="mt-4 text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
/**
* MyClasses — 我的班级ai14
*
* 数据源useMyClasses GraphQL hook
* 视图:班级卡片网格(班级名 + 班主任 + 学生数 + 年级 + 学年)
*
* 设计依据01-understanding.md §7 L1 视口my-classes
* 02-architecture-design.md §14.4CSR
*/
import { useMyClasses } from "@/lib/graphql/hooks";
import type { ClassInfo } from "@/lib/graphql/types";
export default function MyClassesPage(): JSX.Element {
const { data, fetching, error } = useMyClasses();
const classes = data ?? [];
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} />
) : classes.length === 0 ? (
<EmptyState />
) : (
<section
aria-label="班级列表"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
>
{classes.map((cls) => (
<ClassCard key={cls.id} cls={cls} />
))}
</section>
)}
</div>
);
}
function ClassCard({ cls }: { cls: ClassInfo }): JSX.Element {
return (
<article className="card-paper p-6 flex flex-col">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.grade} · {cls.year}
</p>
<h2
className="mt-2 text-2xl font-serif text-truncate"
style={{ color: "var(--color-ink)" }}
>
{cls.name}
</h2>
</div>
<span
className="badge badge-info flex-shrink-0"
aria-label={`${cls.studentCount} 名学生`}
>
{cls.studentCount}
</span>
</div>
<div className="rule-thin my-4" />
<dl className="space-y-2 text-sm">
<div className="flex items-baseline justify-between">
<dt
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd style={{ color: "var(--color-ink)" }}>{cls.homeroomTeacher}</dd>
</div>
</dl>
</article>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div
aria-busy="true"
aria-live="polite"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
>
{[0, 1, 2].map((i) => (
<div key={i} className="card-paper p-6" aria-hidden="true">
<div
className="h-3 w-20 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-3 h-7 w-32 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-6 h-px w-full"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-4 h-4 w-24 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
</div>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message }: { message: string }): JSX.Element {
return (
<div
role="alert"
className="card-paper p-8 text-center"
style={{ borderColor: "var(--color-danger)" }}
>
<p
className="text-base font-serif"
style={{ color: "var(--color-danger)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 btn-secondary text-xs"
>
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p
className="text-4xl font-serif"
style={{ color: "var(--color-ink-subtle)" }}
aria-hidden="true"
>
</p>
<p
className="mt-4 text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,408 @@
"use client";
/**
* 考试结果页面ai14
*
* 路由:/my-exams/[id]/result
* 展示考试得分、评级、逐题回顾、答题摘要
*/
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { cn } from "@/lib/exam-types";
import type {
AnswerInput,
ExamResult,
ExamResultSummary,
QuestionResult,
} from "@/lib/exam-types";
const GRAPHQL_ENDPOINT =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
const IS_MOCKING = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
/** 创建模拟考试结果数据 */
function createMockExamResult(examId: string): ExamResult {
return {
examId,
examName: "期末考试 · 数学综合测试",
score: 78,
maxScore: 100,
grade: "B+",
submittedAt: new Date().toISOString(),
durationSeconds: 3245,
questionResults: [
{
questionId: "q1",
questionContent: "已知函数 f(x) = x² + 2x + 1求 f(2) 的值。",
questionType: "single-choice",
studentAnswer: { type: "single-choice", optionId: "c" },
correctAnswer: { type: "single-choice", optionId: "c" },
isCorrect: true,
isAnswered: true,
explanation: "f(2) = 2² + 2×2 + 1 = 4 + 4 + 1 = 9",
maxScore: 10,
earnedScore: 10,
},
{
questionId: "q2",
questionContent: "下列哪些是质数?(多选)",
questionType: "multiple-choice",
studentAnswer: { type: "multiple-choice", optionIds: ["a", "c", "d"] },
correctAnswer: { type: "multiple-choice", optionIds: ["a", "c"] },
isCorrect: false,
isAnswered: true,
explanation:
"9 = 3 × 3不是质数。质数是只能被 1 和自身整除的大于 1 的自然数。",
maxScore: 15,
earnedScore: 8,
},
{
questionId: "q3",
questionContent: "sin(30°) = ______",
questionType: "fill-blank",
studentAnswer: { type: "fill-blank", values: ["0.5"] },
correctAnswer: { type: "fill-blank", values: ["1/2"] },
isCorrect: true,
isAnswered: true,
explanation: "sin(30°) = 1/2 = 0.5",
maxScore: 10,
earnedScore: 10,
},
{
questionId: "q4",
questionContent: "简述导数的定义。",
questionType: "short-answer",
studentAnswer: {
type: "short-answer",
text: "导数是函数在某点的瞬时变化率。",
},
correctAnswer: null,
isCorrect: true,
isAnswered: true,
explanation:
"导数是函数在某一点的瞬时变化率,即当自变量增量趋于零时函数增量与自变量增量之比的极限。",
maxScore: 20,
earnedScore: 18,
},
{
questionId: "q5",
questionContent: "论述微积分的基本思想及其在现代科学中的应用。",
questionType: "essay",
studentAnswer: null,
correctAnswer: null,
isCorrect: false,
isAnswered: false,
explanation:
"本题未作答。微积分的基本思想包括极限、微分和积分,广泛应用于物理学、工程学、经济学等领域。",
maxScore: 45,
earnedScore: 0,
},
],
};
}
/** 获取考试结果API 或 Mock */
async function fetchExamResult(examId: string): Promise<ExamResult> {
if (IS_MOCKING) {
await new Promise((r) => setTimeout(r, 300));
return createMockExamResult(examId);
}
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `query ExamResult($examId: ID!) {
examResult(examId: $examId) {
examId examName score maxScore grade submittedAt durationSeconds
questionResults {
questionId questionContent questionType
studentAnswer correctAnswer
isCorrect isAnswered explanation maxScore earnedScore
}
}
}`,
variables: { examId },
}),
});
if (!response.ok) throw new Error(`结果加载失败: ${response.status}`);
const result = (await response.json()) as {
data?: { examResult?: ExamResult };
errors?: Array<{ message: string }>;
};
if (result.errors?.length)
throw new Error(result.errors[0]?.message ?? "查询失败");
if (!result.data?.examResult) throw new Error("结果数据为空");
return result.data.examResult;
}
/** 计算结果摘要 */
function computeSummary(results: QuestionResult[]): ExamResultSummary {
const totalQuestions = results.length;
const correctCount = results.filter((r) => r.isCorrect).length;
const unansweredCount = results.filter((r) => !r.isAnswered).length;
const wrongCount = totalQuestions - correctCount - unansweredCount;
return { totalQuestions, correctCount, wrongCount, unansweredCount };
}
/** 格式化答案为可读文本 */
function formatAnswer(answer: AnswerInput | null): string {
if (!answer) return "未作答";
switch (answer.type) {
case "single-choice":
return `选项 ${answer.optionId.toUpperCase()}`;
case "multiple-choice":
return answer.optionIds.map((id) => id.toUpperCase()).join("、");
case "fill-blank":
return answer.values.join("、");
case "short-answer":
case "essay":
return answer.text || "(空)";
default:
return "未知格式";
}
}
/** 格式化时长 */
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
}
export default function ExamResultPage(): JSX.Element {
const params = useParams();
const router = useRouter();
const examId =
typeof params?.id === "string"
? params.id
: Array.isArray(params?.id)
? params.id[0]
: "";
const [result, setResult] = useState<ExamResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!examId) {
setError("考试 ID 缺失");
setLoading(false);
return;
}
fetchExamResult(examId)
.then((data) => {
setResult(data);
setLoading(false);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "加载失败");
setLoading(false);
});
}, [examId]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-paper">
<div className="text-center">
<p className="font-serif text-xl text-ink"></p>
</div>
</div>
);
}
if (error || !result) {
return (
<div className="flex h-screen items-center justify-center bg-paper">
<div className="text-center">
<p className="font-serif text-xl text-danger"></p>
<p className="mt-sm text-sm text-ink-muted">{error ?? "未知错误"}</p>
<button
type="button"
onClick={() => router.push("/my-exams")}
className="mt-lg rounded bg-accent px-lg py-sm text-sm text-paper hover:bg-accent-hover"
>
</button>
</div>
</div>
);
}
const summary = computeSummary(result.questionResults);
const scorePercentage = Math.round((result.score / result.maxScore) * 100);
return (
<div className="min-h-screen bg-paper">
<div className="mx-auto max-w-4xl px-xl py-xl">
{/* === 结果概要 === */}
<div className="rounded-lg border border-rule bg-paper p-xl shadow-card">
<div className="flex items-center justify-between">
<div>
<h1 className="font-serif text-2xl text-ink">
{result.examName}
</h1>
<p className="mt-xs text-sm text-ink-muted">
{new Date(result.submittedAt).toLocaleString("zh-CN")}
</p>
<p className="mt-xs text-sm text-ink-muted">
{formatDuration(result.durationSeconds)}
</p>
</div>
<div className="text-right">
<div className="font-mono text-4xl text-accent">
{result.score}
<span className="text-lg text-ink-muted">
/{result.maxScore}
</span>
</div>
<div
className={cn(
"mt-xs inline-block rounded px-sm py-xs text-sm font-medium",
scorePercentage >= 80 && "bg-success/10 text-success",
scorePercentage >= 60 &&
scorePercentage < 80 &&
"bg-warning/10 text-warning",
scorePercentage < 60 && "bg-danger/10 text-danger",
)}
>
{result.grade} · {scorePercentage}%
</div>
</div>
</div>
</div>
{/* === 答题摘要 === */}
<div className="mt-lg grid grid-cols-4 gap-md">
<div className="rounded-lg border border-rule bg-paper p-md text-center shadow-paper">
<div className="font-mono text-2xl text-ink">
{summary.totalQuestions}
</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div className="rounded-lg border border-rule bg-paper p-md text-center shadow-paper">
<div className="font-mono text-2xl text-success">
{summary.correctCount}
</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div className="rounded-lg border border-rule bg-paper p-md text-center shadow-paper">
<div className="font-mono text-2xl text-danger">
{summary.wrongCount}
</div>
<div className="text-xs text-ink-muted"></div>
</div>
<div className="rounded-lg border border-rule bg-paper p-md text-center shadow-paper">
<div className="font-mono text-2xl text-ink-muted">
{summary.unansweredCount}
</div>
<div className="text-xs text-ink-muted"></div>
</div>
</div>
{/* === 逐题回顾 === */}
<div className="mt-xl">
<h2 className="font-serif text-xl text-ink"></h2>
<div className="mt-md space-y-md">
{result.questionResults.map((qr, idx) => (
<div
key={qr.questionId}
className="rounded-lg border border-rule bg-paper p-lg shadow-paper"
>
{/* 题头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-sm">
<span className="font-mono text-sm text-ink-muted">
{idx + 1}
</span>
<span className="text-xs text-ink-subtle">
{qr.questionType === "single-choice" && "单选题"}
{qr.questionType === "multiple-choice" && "多选题"}
{qr.questionType === "fill-blank" && "填空题"}
{qr.questionType === "short-answer" && "简答题"}
{qr.questionType === "essay" && "论述题"}
</span>
</div>
<div className="flex items-center gap-md">
<span className="text-xs text-ink-muted">
{qr.earnedScore}/{qr.maxScore}
</span>
<span
className={cn(
"rounded px-sm py-xs text-xs font-medium",
qr.isCorrect && "bg-success/10 text-success",
!qr.isCorrect &&
qr.isAnswered &&
"bg-danger/10 text-danger",
!qr.isAnswered && "bg-ink-muted/10 text-ink-muted",
)}
>
{qr.isCorrect ? "正确" : qr.isAnswered ? "错误" : "未答"}
</span>
</div>
</div>
{/* 题目内容 */}
<p className="mt-md text-sm text-ink">{qr.questionContent}</p>
{/* 答案对比 */}
<div className="mt-md space-y-sm">
<div className="flex gap-sm">
<span className="min-w-fit text-xs text-ink-muted">
</span>
<span
className={cn(
"text-sm",
qr.isCorrect ? "text-success" : "text-danger",
)}
>
{formatAnswer(qr.studentAnswer)}
</span>
</div>
{qr.correctAnswer && (
<div className="flex gap-sm">
<span className="min-w-fit text-xs text-ink-muted">
</span>
<span className="text-sm text-success">
{formatAnswer(qr.correctAnswer)}
</span>
</div>
)}
</div>
{/* 解析 */}
{qr.explanation && (
<div className="mt-md rounded border border-rule bg-accent-muted/30 p-md">
<p className="text-xs text-ink-muted"></p>
<p className="mt-xs text-sm text-ink">{qr.explanation}</p>
</div>
)}
</div>
))}
</div>
</div>
{/* === 操作按钮 === */}
<div className="mt-xl flex justify-center gap-md">
<button
type="button"
onClick={() => router.push("/my-exams")}
className="rounded border border-rule px-xl py-sm text-sm text-ink hover:bg-accent-muted"
>
</button>
<button
type="button"
onClick={() => router.push("/dashboard")}
className="rounded bg-accent px-xl py-sm text-sm text-paper hover:bg-accent-hover"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
/**
* 考试作答页面ai14
*
* 路由:/my-exams/[id]/take
* 获取 URL 中的 examId渲染 ExamTaking 组件
*/
import { useParams } from "next/navigation";
import { ExamTaking } from "@/components/exam-taking/exam-taking";
export default function TakeExamPage(): JSX.Element {
const params = useParams();
const id =
typeof params?.id === "string"
? params.id
: Array.isArray(params?.id)
? params.id[0]
: "";
if (!id) {
return (
<div className="flex h-screen items-center justify-center bg-paper">
<div className="text-center">
<p className="font-serif text-xl text-danger"> ID </p>
<p className="mt-sm text-sm text-ink-muted">
URL ID
</p>
</div>
</div>
);
}
return <ExamTaking examId={id} />;
}

View File

@@ -0,0 +1,364 @@
"use client";
/**
* MyExams — 我的考试列表ai14
*
* 数据源useMyExams GraphQL hook
* 视图按状态分组not_started / in_progress / submitted / graded
* 每张卡片:标题 + 学科 + 起止时间 + 时长 + 状态徽标
* 链接activein_progress→ /my-exams/[id]/take
* submitted/graded → /my-exams/[id]/result
*
* 设计依据01-understanding.md §8L2 路由表)
* 02-architecture-design.md §11考试作答边界场景
*/
import Link from "next/link";
import { useMyExams } from "@/lib/graphql/hooks";
import type { ExamListItem, ExamStatus } from "@/lib/graphql/types";
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
// 状态分组配置(顺序即展示顺序)
const STATUS_GROUPS: Array<{
key: ExamStatus;
label: string;
description: string;
}> = [
{
key: "in_progress",
label: "进行中",
description: "点击进入作答",
},
{
key: "not_started",
label: "未开始",
description: "等待开考",
},
{
key: "submitted",
label: "已提交",
description: "等待批改",
},
{
key: "graded",
label: "已批改",
description: "查看成绩",
},
{
key: "expired",
label: "已结束",
description: "考试已结束",
},
];
export default function MyExamsPage(): JSX.Element {
const { data, fetching, error } = useMyExams();
const exams = data ?? [];
// 按状态分组
const grouped = STATUS_GROUPS.map((g) => ({
...g,
items: exams.filter((e) => e.status === g.key),
}));
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} />
) : exams.length === 0 ? (
<EmptyState />
) : (
<div className="space-y-10">
{grouped.map(
(group) =>
group.items.length > 0 && (
<section key={group.key} aria-label={group.label}>
<div className="flex items-baseline gap-3 mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
{group.label}
</h2>
<span
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{group.items.length} · {group.description}
</span>
</div>
<div className="rule-thin mb-4" />
<ul className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{group.items.map((exam) => (
<li key={exam.id}>
<ExamCard exam={exam} />
</li>
))}
</ul>
</section>
),
)}
</div>
)}
</div>
);
}
function ExamCard({ exam }: { exam: ExamListItem }): JSX.Element {
const startsAt = new Date(exam.startsAt);
const isTakeable = exam.status === "in_progress";
const isResult = exam.status === "submitted" || exam.status === "graded";
const linkHref = isTakeable
? `/my-exams/${exam.id}/take`
: isResult
? `/my-exams/${exam.id}/result`
: null;
const badgeClass = getBadgeClassForStatus(exam.status);
const inner = (
<article
className={`card-paper p-5 h-full transition-shadow ${
linkHref ? "hover:shadow-card motion-safe" : ""
}`}
aria-label={`${exam.name} - ${exam.status}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{exam.subject.name}
</p>
<h3
className="mt-1 text-lg font-serif text-truncate"
style={{ color: "var(--color-ink)" }}
>
{exam.name}
</h3>
</div>
<span className={`badge ${badgeClass} flex-shrink-0`}>
{getStatusLabel(exam.status)}
</span>
</div>
<div className="rule-thin my-4" />
<dl className="grid grid-cols-2 gap-y-2 text-xs">
<div>
<dt
className="uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>
{dateFormatter.format(startsAt)}
</dd>
</div>
<div>
<dt
className="uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>
{Math.round(exam.durationSeconds / 60)}
</dd>
</div>
<div>
<dt
className="uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>
{exam.questionCount}
</dd>
</div>
<div>
<dt
className="uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd className="mt-1" style={{ color: "var(--color-ink)" }}>
{exam.totalScore}
</dd>
</div>
</dl>
{linkHref && (
<p className="mt-4 text-xs" style={{ color: "var(--color-accent)" }}>
{isTakeable ? "进入作答 →" : "查看结果 →"}
</p>
)}
</article>
);
if (!linkHref) return inner;
return (
<Link
href={linkHref}
className="block h-full focus-visible:outline-2"
aria-label={`${exam.name} - ${isTakeable ? "进入作答" : "查看结果"}`}
>
{inner}
</Link>
);
}
function getBadgeClassForStatus(status: ExamStatus): string {
switch (status) {
case "in_progress":
return "badge-danger";
case "not_started":
return "badge-info";
case "submitted":
return "badge-warning";
case "graded":
return "badge-success";
case "expired":
return "badge-info";
default:
return "badge-info";
}
}
function getStatusLabel(status: ExamStatus): string {
const map: Record<ExamStatus, string> = {
not_started: "未开始",
in_progress: "进行中",
submitted: "已提交",
graded: "已批改",
expired: "已结束",
};
return map[status] ?? status;
}
function LoadingSkeleton(): JSX.Element {
return (
<div aria-busy="true" aria-live="polite" className="space-y-10">
{[0, 1].map((i) => (
<div key={i} aria-hidden="true">
<div
className="h-4 w-24 rounded-sm animate-pulse mb-4"
style={{ background: "var(--color-rule)" }}
/>
<div className="rule-thin mb-4" />
<ul className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{[0, 1].map((j) => (
<li key={j} className="card-paper p-5">
<div
className="h-3 w-16 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-2 h-5 w-32 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-6 h-px w-full"
style={{ background: "var(--color-rule)" }}
/>
<div className="mt-4 grid grid-cols-2 gap-y-3">
{[0, 1, 2, 3].map((k) => (
<div
key={k}
className="h-3 w-20 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
))}
</div>
</li>
))}
</ul>
</div>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message }: { message: string }): JSX.Element {
return (
<div
role="alert"
className="card-paper p-8 text-center"
style={{ borderColor: "var(--color-danger)" }}
>
<p
className="text-base font-serif"
style={{ color: "var(--color-danger)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 btn-secondary text-xs"
>
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p
className="text-4xl font-serif"
style={{ color: "var(--color-ink-subtle)" }}
aria-hidden="true"
>
</p>
<p
className="mt-4 text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,477 @@
"use client";
/**
* MyGrades — 我的成绩ai14
*
* 数据源useMyGrades GraphQL hook
* 视图:成绩表(学科 / 考试名 / 得分 / 等级 / 日期)+ 趋势指示
*
* 隐私合规01-understanding.md §12.2):成绩属高敏感数据,
* P3 阶段默认展示无遮罩P6 可加点击查看。本页仅做展示层。
*
* 设计依据01-understanding.md §7my-grades 视口)
* 02-architecture-design.md §14.4CSR + Suspense
*/
import { useMyGrades } from "@/lib/graphql/hooks";
import type { GradeRecord } from "@/lib/graphql/types";
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
export default function MyGradesPage(): JSX.Element {
const { data, fetching, error } = useMyGrades();
const grades = data ?? [];
// 计算趋势:相邻同科目的成绩对比
const trendMap = computeTrendMap(grades);
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} />
) : grades.length === 0 ? (
<EmptyState />
) : (
<GradeTable grades={grades} trendMap={trendMap} />
)}
</div>
);
}
function GradeTable({
grades,
trendMap,
}: {
grades: GradeRecord[];
trendMap: Map<string, TrendDirection>;
}): JSX.Element {
return (
<section aria-label="成绩列表" className="card-paper overflow-hidden">
{/* 桌面端表格 */}
<div className="hidden md:block">
<table className="w-full">
<caption className="sr-only"></caption>
<thead>
<tr
className="border-b"
style={{ borderColor: "var(--color-rule)" }}
>
<Th></Th>
<Th></Th>
<Th align="right"></Th>
<Th align="center"></Th>
<Th align="center"></Th>
<Th align="right"></Th>
<Th align="right"></Th>
</tr>
</thead>
<tbody>
{grades.map((grade) => (
<tr
key={grade.id}
className="border-b last:border-b-0"
style={{ borderColor: "var(--color-rule)" }}
>
<Td>{grade.subject.name}</Td>
<Td>
<span
className="text-sm font-medium"
style={{ color: "var(--color-ink)" }}
>
{grade.examName}
</span>
</Td>
<Td align="right">
<span
className="text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
{grade.score}
</span>
<span
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{" "}
/ {grade.maxScore}
</span>
</Td>
<Td align="center">
<span
className={`badge ${getBadgeClassForGrade(grade.grade)}`}
>
{grade.grade}
</span>
</Td>
<Td align="center">
<TrendIndicator
direction={trendMap.get(grade.id) ?? "stable"}
/>
</Td>
<Td align="right">
<span
className="text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{grade.rank}
</span>
</Td>
<Td align="right">
<span
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{dateFormatter.format(new Date(grade.submittedAt))}
</span>
</Td>
</tr>
))}
</tbody>
</table>
</div>
{/* 移动端卡片列表 */}
<ul
className="md:hidden divide-y"
style={{ borderColor: "var(--color-rule)" }}
>
{grades.map((grade) => (
<li key={grade.id} className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{grade.subject.name}
</p>
<p
className="mt-1 text-sm font-medium text-truncate"
style={{ color: "var(--color-ink)" }}
>
{grade.examName}
</p>
</div>
<span
className={`badge ${getBadgeClassForGrade(grade.grade)} flex-shrink-0`}
>
{grade.grade}
</span>
</div>
<div className="mt-3 flex items-baseline justify-between">
<span
className="text-2xl font-serif"
style={{ color: "var(--color-ink)" }}
>
{grade.score}
<span
className="text-xs font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{" "}
/ {grade.maxScore}
</span>
</span>
<div className="flex items-center gap-3">
<TrendIndicator
direction={trendMap.get(grade.id) ?? "stable"}
/>
<span
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{grade.rank}
</span>
</div>
</div>
<p
className="mt-2 text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{dateFormatter.format(new Date(grade.submittedAt))}
</p>
</li>
))}
</ul>
</section>
);
}
type TrendDirection = "up" | "down" | "stable";
// 计算趋势:按 subject 分组,相邻比较(按 submittedAt 倒序)
function computeTrendMap(grades: GradeRecord[]): Map<string, TrendDirection> {
const result = new Map<string, TrendDirection>();
// 按学科分组
const bySubject = new Map<string, GradeRecord[]>();
for (const g of grades) {
const list = bySubject.get(g.subject.id) ?? [];
list.push(g);
bySubject.set(g.subject.id, list);
}
// 每个学科内按时间正序,相邻比较得到每条记录的趋势
for (const list of bySubject.values()) {
const sorted = [...list].sort(
(a, b) =>
new Date(a.submittedAt).getTime() - new Date(b.submittedAt).getTime(),
);
sorted.forEach((g, idx) => {
if (idx === 0) {
result.set(g.id, "stable");
return;
}
const prev = sorted[idx - 1];
if (!prev) {
result.set(g.id, "stable");
return;
}
const diff = g.score - prev.score;
if (Math.abs(diff) < 1) {
result.set(g.id, "stable");
} else if (diff > 0) {
result.set(g.id, "up");
} else {
result.set(g.id, "down");
}
});
}
return result;
}
function TrendIndicator({
direction,
}: {
direction: TrendDirection;
}): JSX.Element {
if (direction === "stable") {
return (
<span
aria-label="持平"
className="inline-flex items-center"
style={{ color: "var(--color-ink-muted)" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path d="M5 12h14" />
</svg>
</span>
);
}
if (direction === "up") {
return (
<span
aria-label="上升"
className="inline-flex items-center"
style={{ color: "var(--color-success)" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path d="m18 15-6-6-6 6" />
</svg>
</span>
);
}
return (
<span
aria-label="下降"
className="inline-flex items-center"
style={{ color: "var(--color-danger)" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
);
}
function getBadgeClassForGrade(grade: string): string {
if (grade === "优秀") return "badge-success";
if (grade === "良好") return "badge-info";
if (grade === "及格") return "badge-warning";
if (grade === "不及格") return "badge-danger";
return "badge-info";
}
// === 表格小工具 ===
function Th({
children,
align = "left",
}: {
children: React.ReactNode;
align?: "left" | "right" | "center";
}): JSX.Element {
return (
<th
scope="col"
className="px-4 py-3 text-xs uppercase tracking-wider font-sans"
style={{
color: "var(--color-ink-muted)",
textAlign: align,
}}
>
{children}
</th>
);
}
function Td({
children,
align = "left",
}: {
children: React.ReactNode;
align?: "left" | "right" | "center";
}): JSX.Element {
return (
<td
className="px-4 py-3 text-sm"
style={{
color: "var(--color-ink)",
textAlign: align,
}}
>
{children}
</td>
);
}
function LoadingSkeleton(): JSX.Element {
return (
<div
aria-busy="true"
aria-live="polite"
className="card-paper p-0 overflow-hidden"
>
<div className="hidden md:block" aria-hidden="true">
<div
className="h-10 border-b"
style={{
background: "var(--color-paper)",
borderColor: "var(--color-rule)",
}}
/>
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-12 border-b last:border-b-0 animate-pulse"
style={{
background: "var(--color-rule)",
borderColor: "var(--color-rule)",
opacity: 0.3,
}}
/>
))}
</div>
<div className="md:hidden space-y-3 p-4" aria-hidden="true">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-20 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)", opacity: 0.3 }}
/>
))}
</div>
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message }: { message: string }): JSX.Element {
return (
<div
role="alert"
className="card-paper p-8 text-center"
style={{ borderColor: "var(--color-danger)" }}
>
<p
className="text-base font-serif"
style={{ color: "var(--color-danger)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 btn-secondary text-xs"
>
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p
className="text-4xl font-serif"
style={{ color: "var(--color-ink-subtle)" }}
aria-hidden="true"
>
</p>
<p
className="mt-4 text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,386 @@
"use client";
/**
* 作业提交页ai14P3
*
* - useParams 获取作业 id
* - useHomeworkDetail 加载作业详情
* - react-hook-form + zod 校验:每题按类型渲染(简答 textarea / 选择 radio
* - 提交按钮调用 useSubmitHomework mutationexecute + useEffect 监听结果)
* - 成功toast + 跳转 /my-homework失败错误提示
* - 自动保存草稿到 localStoragekey: homework-draft:${id}),挂载时恢复
* - 附件上传占位(待 ISSUE-014-05 仲裁)
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useHomeworkDetail, useSubmitHomework } from "@/lib/graphql/hooks";
import type { AnswerInput, HomeworkQuestion } from "@/lib/graphql/types";
interface FormValues {
answers: Record<string, string>;
note: string;
}
const schema = z.object({
answers: z.record(z.string(), z.string().min(1, "请填写答案")),
note: z.string().max(500).optional().or(z.literal("")),
});
const DRAFT_PREFIX = "homework-draft:";
const SAVE_DEBOUNCE_MS = 800;
function draftKey(id: string): string {
return `${DRAFT_PREFIX}${id}`;
}
function loadDraft(id: string): Partial<FormValues> | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(draftKey(id));
return raw ? (JSON.parse(raw) as Partial<FormValues>) : null;
} catch {
return null;
}
}
function saveDraft(id: string, values: FormValues): void {
try {
window.localStorage.setItem(draftKey(id), JSON.stringify(values));
} catch {
// 存储失败静默忽略
}
}
function clearDraft(id: string): void {
try {
window.localStorage.removeItem(draftKey(id));
} catch {
// 忽略
}
}
function formatDueAt(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { hour12: false });
} catch {
return iso;
}
}
function isChoice(q: HomeworkQuestion): boolean {
return q.type === "single-choice" || q.type === "multiple-choice";
}
/** 将表单字符串值转换为 AnswerInput 联合类型 */
function toAnswerInput(question: HomeworkQuestion, value: string): AnswerInput {
switch (question.type) {
case "single-choice":
return { type: "single-choice", optionId: value };
case "multiple-choice":
return { type: "multiple-choice", optionIds: [value] };
case "fill-blank":
return { type: "fill-blank", values: [value] };
case "short-answer":
return { type: "short-answer", text: value };
case "essay":
return { type: "essay", text: value };
}
}
export default function HomeworkSubmitPage(): React.ReactNode {
const params = useParams();
const router = useRouter();
const homeworkId =
typeof params.id === "string"
? params.id
: Array.isArray(params.id)
? (params.id[0] ?? "")
: "";
const { data: homework, fetching, error } = useHomeworkDetail(homeworkId);
const submitMutation = useSubmitHomework();
const form = useForm<FormValues>({
defaultValues: { answers: {}, note: "" },
});
const { register, handleSubmit, formState, reset, watch, getValues } = form;
const [toast, setToast] = useState<{
message: string;
tone: "success" | "error";
} | null>(null);
const [draftRestored, setDraftRestored] = useState(false);
const restoredRef = useRef(false);
const submittingRef = useRef(false);
// 作业详情加载后初始化表单 + 恢复草稿
useEffect(() => {
if (!homework || restoredRef.current) return;
restoredRef.current = true;
const draft = loadDraft(homeworkId);
const defaults: FormValues = { answers: {}, note: "" };
if (draft?.answers) defaults.answers = draft.answers;
if (draft?.note) defaults.note = draft.note;
reset(defaults);
setDraftRestored(
Boolean(draft?.answers && Object.keys(draft.answers).length > 0),
);
}, [homework, homeworkId, reset]);
// 自动保存草稿(防抖):每次表单变更后延迟写入 localStorage
useEffect(() => {
if (!homework) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const subscription = watch(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
saveDraft(homeworkId, getValues());
}, SAVE_DEBOUNCE_MS);
});
return () => {
subscription.unsubscribe();
if (timer) clearTimeout(timer);
};
}, [homework, homeworkId, watch, getValues]);
const showToast = (message: string, tone: "success" | "error"): void => {
setToast({ message, tone });
window.setTimeout(() => setToast(null), 2500);
};
// 监听 mutation 结果urql 无 onSuccess/onError 回调,用 useEffect 监听状态变化
useEffect(() => {
if (!submittingRef.current) return;
if (submitMutation.fetching) return; // 仍在请求中
submittingRef.current = false;
if (submitMutation.success && submitMutation.data) {
clearDraft(homeworkId);
showToast("作业已提交", "success");
window.setTimeout(() => router.push("/my-homework"), 600);
} else {
const message =
submitMutation.errors[0]?.message ??
submitMutation.error?.message ??
"提交失败,请重试";
showToast(message, "error");
}
}, [
submitMutation.fetching,
submitMutation.success,
submitMutation.error,
submitMutation.errors,
submitMutation.data,
homeworkId,
router,
]);
const questions = useMemo(() => homework?.questions ?? [], [homework]);
const onSubmit = handleSubmit((values) => {
// zod 二次校验(与字段 required 双保险)
const parsed = schema.safeParse(values);
if (!parsed.success) {
const first = parsed.error.issues[0];
showToast(first?.message ?? "请检查答案", "error");
return;
}
const answers = questions.map((q) => {
const value = values.answers[q.id] ?? "";
return { questionId: q.id, answer: toAnswerInput(q, value) };
});
const payload = {
homeworkId,
answers,
note: values.note || undefined,
};
submittingRef.current = true;
submitMutation.execute(payload);
});
if (fetching) {
return (
<main className="px-md py-lg max-w-3xl mx-auto">
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
</main>
);
}
if (error) {
return (
<main className="px-md py-lg max-w-3xl mx-auto">
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => router.push("/my-homework")}
>
</button>
</div>
</main>
);
}
if (!homework) {
return (
<main className="px-md py-lg max-w-3xl mx-auto">
<p className="text-sm text-ink-muted"></p>
</main>
);
}
return (
<main
className="px-md py-lg max-w-3xl mx-auto"
aria-labelledby="homework-submit-title"
>
<header className="mb-lg">
<h1 id="homework-submit-title" className="font-serif text-2xl text-ink">
{homework.title}
</h1>
<p className="mt-xs text-sm text-ink-muted">
{homework.subject.name}
{homework.dueAt ? ` · 截止时间:${formatDueAt(homework.dueAt)}` : ""}
</p>
{draftRestored ? (
<p className="mt-xs text-xs text-ink-subtle" role="status">
稿
</p>
) : null}
</header>
<div className="rule-thin mb-lg" />
<form onSubmit={onSubmit} className="space-y-lg" noValidate>
{questions.map((q, index) => {
const fieldName = `answers.${q.id}` as const;
const fieldError = formState.errors.answers?.[q.id];
return (
<fieldset key={q.id} className="card-paper p-md">
<legend className="font-serif text-base text-ink mb-sm">
{index + 1}
<span
className="ml-xs text-xs text-ink-subtle"
aria-hidden="true"
>
{isChoice(q) ? "选择题" : "简答题"}
</span>
</legend>
<p className="text-sm text-ink mb-md whitespace-pre-wrap">
{q.content}
</p>
{isChoice(q) && q.options && q.options.length > 0 ? (
<div
role="radiogroup"
aria-label={`${index + 1} 题选项`}
className="space-y-sm"
>
{q.options.map((opt) => (
<label
key={opt.id}
className="flex items-center gap-sm cursor-pointer"
>
<input
type="radio"
value={opt.id}
{...register(fieldName, { required: "请填写答案" })}
aria-label={opt.text}
/>
<span className="text-sm text-ink">{opt.text}</span>
</label>
))}
</div>
) : (
<textarea
className="input-paper w-full min-h-24"
placeholder="请输入你的答案"
aria-label={`${index + 1} 题答案`}
{...register(fieldName, { required: "请填写答案" })}
/>
)}
{fieldError ? (
<p className="mt-xs text-xs text-danger" role="alert">
{fieldError.message ?? "请填写答案"}
</p>
) : null}
</fieldset>
);
})}
{/* 备注 */}
<fieldset className="card-paper p-md">
<legend className="font-serif text-base text-ink mb-sm">
</legend>
<textarea
className="input-paper w-full min-h-16"
placeholder="对老师说的话…"
maxLength={500}
aria-label="备注"
{...register("note")}
/>
</fieldset>
{/* 附件上传占位(待 ISSUE-014-05 仲裁) */}
<section className="card-paper p-md" aria-label="附件上传">
<p className="font-serif text-base text-ink mb-sm"></p>
<div
className="border border-dashed p-lg text-center"
style={{ borderColor: "var(--color-rule)" }}
role="note"
>
<p className="text-sm text-ink-muted">
ISSUE-014-05
</p>
<p className="mt-xs text-xs text-ink-subtle">
/ PDF 5 10MB
</p>
</div>
</section>
<div
className="sticky bottom-0 -mx-md px-md py-md"
style={{ background: "var(--color-paper)" }}
>
<button
type="submit"
className="btn-primary w-full"
disabled={submitMutation.fetching}
aria-busy={submitMutation.fetching}
>
{submitMutation.fetching ? "提交中…" : "提交作业"}
</button>
</div>
</form>
{toast ? (
<div
className="fixed bottom-md left-1/2 -translate-x-1/2 card-paper px-lg py-md text-sm"
role="alert"
aria-live="assertive"
style={{
color:
toast.tone === "success"
? "var(--color-success)"
: "var(--color-danger)",
}}
>
{toast.message}
</div>
) : null}
</main>
);
}

View File

@@ -0,0 +1,372 @@
"use client";
/**
* MyHomework — 我的作业列表ai14
*
* 数据源useMyHomework GraphQL hook
* 视图按状态分组pending / submitted / graded
* 每张卡片:标题 + 学科 + 截止时间 + 状态徽标
* 链接pending → /my-homework/[id]/submit
*
* 设计依据01-understanding.md §8L2 路由表)
* 02-architecture-design.md §15详细组件设计
*/
import Link from "next/link";
import { useMyHomework } from "@/lib/graphql/hooks";
import type { HomeworkListItem, HomeworkStatus } from "@/lib/graphql/types";
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
// 状态分组配置(顺序即展示顺序)
const STATUS_GROUPS: Array<{
key: HomeworkStatus;
label: string;
description: string;
}> = [
{
key: "pending",
label: "待提交",
description: "点击进入提交",
},
{
key: "overdue",
label: "已逾期",
description: "请尽快提交",
},
{
key: "submitted",
label: "已提交",
description: "等待批改",
},
{
key: "graded",
label: "已批改",
description: "查看得分",
},
];
export default function MyHomeworkPage(): JSX.Element {
const { data, fetching, error } = useMyHomework();
const homework = data ?? [];
// 按状态分组
const grouped = STATUS_GROUPS.map((g) => ({
...g,
items: homework.filter((h) => h.status === g.key),
}));
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<LoadingSkeleton />
) : error ? (
<ErrorState message={error.message} />
) : homework.length === 0 ? (
<EmptyState />
) : (
<div className="space-y-10">
{grouped.map(
(group) =>
group.items.length > 0 && (
<section key={group.key} aria-label={group.label}>
<div className="flex items-baseline gap-3 mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
{group.label}
</h2>
<span
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{group.items.length} · {group.description}
</span>
</div>
<div className="rule-thin mb-4" />
<ul className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{group.items.map((hw) => (
<li key={hw.id}>
<HomeworkCard homework={hw} />
</li>
))}
</ul>
</section>
),
)}
</div>
)}
</div>
);
}
function HomeworkCard({
homework,
}: {
homework: HomeworkListItem;
}): JSX.Element {
const dueAt = new Date(homework.dueAt);
const isPending = homework.status === "pending";
const linkHref = isPending ? `/my-homework/${homework.id}/submit` : null;
const badgeClass = getBadgeClassForStatus(homework.status);
// 截止时间紧迫度(仅 pending 显示)
const now = new Date();
const isOverdue = isPending && dueAt < now;
const isUrgent =
isPending &&
!isOverdue &&
dueAt.getTime() - now.getTime() < 24 * 60 * 60 * 1000;
const inner = (
<article
className={`card-paper p-5 h-full transition-shadow ${
linkHref ? "hover:shadow-card motion-safe" : ""
}`}
aria-label={`${homework.title} - ${homework.status}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
{homework.subject.name}
</p>
<h3
className="mt-1 text-lg font-serif text-truncate"
style={{ color: "var(--color-ink)" }}
>
{homework.title}
</h3>
</div>
<span className={`badge ${badgeClass} flex-shrink-0`}>
{getStatusLabel(homework.status)}
</span>
</div>
<div className="rule-thin my-4" />
<dl className="space-y-2 text-xs">
<div className="flex items-baseline justify-between gap-2">
<dt
className="uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd
style={{
color: isOverdue
? "var(--color-danger)"
: isUrgent
? "var(--color-warning)"
: "var(--color-ink)",
}}
>
{dateFormatter.format(dueAt)}
{isOverdue && " · 已逾期"}
{isUrgent && " · 24h 内"}
</dd>
</div>
<div className="flex items-baseline justify-between gap-2">
<dt
className="uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd style={{ color: "var(--color-ink)" }}>
{homework.questionCount}
</dd>
</div>
{homework.status === "graded" && homework.score !== undefined && (
<div className="flex items-baseline justify-between gap-2">
<dt
className="uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</dt>
<dd
className="text-base font-serif"
style={{ color: "var(--color-accent)" }}
>
{homework.score}
<span
className="text-xs font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{" "}
/ {homework.totalScore}
</span>
</dd>
</div>
)}
</dl>
{linkHref && (
<p className="mt-4 text-xs" style={{ color: "var(--color-accent)" }}>
</p>
)}
</article>
);
if (!linkHref) return inner;
return (
<Link
href={linkHref}
className="block h-full focus-visible:outline-2"
aria-label={`${homework.title} - 进入提交`}
>
{inner}
</Link>
);
}
function getBadgeClassForStatus(status: HomeworkStatus): string {
switch (status) {
case "pending":
return "badge-warning";
case "submitted":
return "badge-info";
case "graded":
return "badge-success";
case "overdue":
return "badge-danger";
default:
return "badge-info";
}
}
function getStatusLabel(status: HomeworkStatus): string {
const map: Record<HomeworkStatus, string> = {
pending: "待提交",
submitted: "已提交",
graded: "已批改",
overdue: "已逾期",
};
return map[status] ?? status;
}
function LoadingSkeleton(): JSX.Element {
return (
<div aria-busy="true" aria-live="polite" className="space-y-10">
{[0, 1].map((i) => (
<div key={i} aria-hidden="true">
<div
className="h-4 w-24 rounded-sm animate-pulse mb-4"
style={{ background: "var(--color-rule)" }}
/>
<div className="rule-thin mb-4" />
<ul className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{[0, 1].map((j) => (
<li key={j} className="card-paper p-5">
<div
className="h-3 w-16 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-2 h-5 w-32 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
<div
className="mt-6 h-px w-full"
style={{ background: "var(--color-rule)" }}
/>
<div className="mt-4 space-y-2">
{[0, 1].map((k) => (
<div
key={k}
className="h-3 w-32 rounded-sm animate-pulse"
style={{ background: "var(--color-rule)" }}
/>
))}
</div>
</li>
))}
</ul>
</div>
))}
<span className="sr-only">...</span>
</div>
);
}
function ErrorState({ message }: { message: string }): JSX.Element {
return (
<div
role="alert"
className="card-paper p-8 text-center"
style={{ borderColor: "var(--color-danger)" }}
>
<p
className="text-base font-serif"
style={{ color: "var(--color-danger)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
{message}
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 btn-secondary text-xs"
>
</button>
</div>
);
}
function EmptyState(): JSX.Element {
return (
<div className="card-paper p-8 text-center">
<p
className="text-4xl font-serif"
style={{ color: "var(--color-ink-subtle)" }}
aria-hidden="true"
>
</p>
<p
className="mt-4 text-base font-serif"
style={{ color: "var(--color-ink)" }}
>
</p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</p>
</div>
);
}

View File

@@ -0,0 +1,315 @@
"use client";
/**
* 通知中心页面ai14P5
*
* - 消费 useMyNotifications 查询first 控制分页大小)
* - 通知类型图标(作业/考试/成绩/系统)
* - 已读/未读状态、"全部标记已读"按钮useMarkAllAsRead
* - 单条"标记已读"useMarkAsRead
* - WebSocket 实时更新useNotificationsWebSocket 派发 CustomEvent
* - 加载更多:增大 first 参数urql 自动重新查询)
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
useMarkAllAsRead,
useMarkAsRead,
useMyNotifications,
} from "@/lib/graphql/hooks";
import type { NotificationItem, NotificationType } from "@/lib/graphql/types";
import {
NOTIFICATION_UPDATED_EVENT,
useNotificationsWebSocket,
} from "@/hooks/use-notifications-websocket";
const PAGE_SIZE = 20;
interface ToastState {
message: string;
tone: "success" | "error";
}
const TYPE_LABEL: Record<NotificationType, string> = {
homework: "作业",
exam: "考试",
grade: "成绩",
system: "系统",
};
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { hour12: false });
} catch {
return iso;
}
}
export default function NotificationsPage(): React.ReactNode {
const { isConnected } = useNotificationsWebSocket();
const [pageSize, setPageSize] = useState<number>(PAGE_SIZE);
const [toast, setToast] = useState<ToastState | null>(null);
const { data, fetching, error, reexecute } = useMyNotifications(pageSize);
const items: NotificationItem[] = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const unreadCount = data?.unreadCount ?? 0;
const markReadMutation = useMarkAsRead();
const markAllMutation = useMarkAllAsRead();
const markingReadRef = useRef<string | null>(null);
const markingAllRef = useRef(false);
const showToast = useCallback(
(message: string, tone: ToastState["tone"]): void => {
setToast({ message, tone });
window.setTimeout(() => setToast(null), 2500);
},
[],
);
// WebSocket 更新事件:重新查询通知列表
useEffect(() => {
const handler = (): void => {
reexecute();
};
window.addEventListener(NOTIFICATION_UPDATED_EVENT, handler);
return () => {
window.removeEventListener(NOTIFICATION_UPDATED_EVENT, handler);
};
}, [reexecute]);
// 监听标记已读 mutation 结果urql 无 onSuccess/onError 回调)
useEffect(() => {
if (!markingReadRef.current) return;
if (markReadMutation.fetching) return;
markingReadRef.current = null;
if (markReadMutation.success) {
showToast("已标记为已读", "success");
reexecute();
} else {
const message =
markReadMutation.errors[0]?.message ??
markReadMutation.error?.message ??
"标记失败,请重试";
showToast(message, "error");
}
}, [
markReadMutation.fetching,
markReadMutation.success,
markReadMutation.error,
markReadMutation.errors,
reexecute,
showToast,
]);
// 监听批量标记已读 mutation 结果
useEffect(() => {
if (!markingAllRef.current) return;
if (markAllMutation.fetching) return;
markingAllRef.current = false;
if (markAllMutation.success) {
showToast("已全部标记为已读", "success");
reexecute();
} else {
const message =
markAllMutation.errors[0]?.message ??
markAllMutation.error?.message ??
"操作失败,请重试";
showToast(message, "error");
}
}, [
markAllMutation.fetching,
markAllMutation.success,
markAllMutation.error,
markAllMutation.errors,
reexecute,
showToast,
]);
const handleMarkRead = useCallback(
(id: string): void => {
markingReadRef.current = id;
markReadMutation.execute({ notificationId: id });
},
[markReadMutation],
);
const handleMarkAllRead = useCallback((): void => {
markingAllRef.current = true;
markAllMutation.execute({});
}, [markAllMutation]);
const handleLoadMore = useCallback((): void => {
setPageSize((prev) => prev + PAGE_SIZE);
}, []);
const hasMore = items.length < totalCount;
return (
<main
className="px-md py-lg max-w-4xl mx-auto"
aria-labelledby="notifications-title"
>
<header className="mb-lg">
<div className="flex items-center justify-between gap-md">
<div>
<h1
id="notifications-title"
className="font-serif text-2xl text-ink"
>
</h1>
<p className="mt-xs text-sm text-ink-muted">
</p>
</div>
<button
type="button"
className="btn-secondary"
onClick={handleMarkAllRead}
disabled={unreadCount === 0 || markAllMutation.fetching}
aria-label="全部标记已读"
>
{unreadCount > 0 ? `${unreadCount}` : ""}
</button>
</div>
<p
className="mt-sm text-xs"
style={{
color: isConnected
? "var(--color-success)"
: "var(--color-warning)",
}}
role="status"
aria-live="polite"
>
{isConnected ? "● 实时连接已建立" : "○ 实时连接已断开,正在重连…"}
</p>
</header>
<div className="rule-thin mb-lg" />
{fetching && items.length === 0 ? (
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : items.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<ul className="space-y-md" aria-label="通知列表">
{items.map((item) => (
<li key={item.id}>
<article
className="card-paper p-md"
aria-current={item.read ? "false" : "true"}
aria-label={`${TYPE_LABEL[item.type]}通知:${item.title}`}
>
<div className="flex items-start justify-between gap-md">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-sm mb-xs">
<span
className={`badge ${
item.type === "grade"
? "badge-success"
: item.type === "exam"
? "badge-warning"
: item.type === "homework"
? "badge-info"
: "badge-danger"
}`}
aria-label={`类型:${TYPE_LABEL[item.type]}`}
>
{TYPE_LABEL[item.type]}
</span>
{!item.read ? (
<span className="badge badge-info" aria-label="未读">
</span>
) : null}
</div>
<h2 className="font-serif text-base text-ink text-truncate">
{item.title}
</h2>
{item.content ? (
<p className="mt-xs text-sm text-ink-muted text-clamp-2">
{item.content}
</p>
) : null}
<p className="mt-xs text-xs text-ink-subtle">
{formatTime(item.createdAt)}
</p>
</div>
{!item.read ? (
<button
type="button"
className="btn-secondary shrink-0"
onClick={() => handleMarkRead(item.id)}
disabled={markReadMutation.fetching}
aria-label={`标记已读:${item.title}`}
>
</button>
) : null}
</div>
</article>
</li>
))}
</ul>
)}
{hasMore ? (
<div className="mt-lg text-center">
<button
type="button"
className="btn-secondary"
onClick={handleLoadMore}
disabled={fetching}
>
{fetching ? "加载中…" : "加载更多"}
</button>
</div>
) : items.length > 0 ? (
<p className="mt-lg text-center text-xs text-ink-subtle">
{totalCount}
</p>
) : null}
{toast ? (
<div
className="fixed bottom-md left-1/2 -translate-x-1/2 card-paper px-lg py-md text-sm"
role="alert"
aria-live="assertive"
style={{
color:
toast.tone === "success"
? "var(--color-success)"
: "var(--color-danger)",
}}
>
{toast.message}
</div>
) : null}
</main>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Home — 根路径重定向到 /dashboardai14
*
* 设计依据student-portal_contract.md §1.2(路由无 /student 前缀)
* 优先用 Next.js 服务端 redirect避免客户端闪烁
*/
import { redirect } from "next/navigation";
export default function Home(): never {
redirect("/dashboard");
}

View File

@@ -0,0 +1,54 @@
"use client";
/**
* Providers — 客户端 Provider 聚合ai14
*
* 职责:
* - TanStack Query QueryClientProviderL2 服务端状态)
* - GraphQLProviderurql client 单例,复用 Shell 暴露的 ARB-002 配置)
* - MSW mock 初始化(仅 dev + NEXT_PUBLIC_API_MOCKING=enabled
*
* 设计依据02-architecture-design.md §1.1 / §1.2
*/
import { useState, useEffect, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { GraphQLProvider } from "@/lib/graphql/provider";
import { initMocks } from "@/mocks/browser";
export default function Providers({
children,
}: {
children: ReactNode;
}): JSX.Element {
// QueryClient 单例useState 保证组件 re-render 时实例稳定
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 学生端数据频变5min staleTime 平衡性能与新鲜度
staleTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
}),
);
// MSW 初始化(仅 dev + mock 启用,且仅在浏览器环境)
useEffect(() => {
if (typeof window === "undefined") return;
if (process.env.NEXT_PUBLIC_API_MOCKING !== "enabled") return;
void initMocks();
}, []);
return (
<QueryClientProvider client={queryClient}>
<GraphQLProvider>{children}</GraphQLProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
/**
* StudentApp — student-portal MF Remote 入口ai14
*
* 职责:
* - 作为 Module Federation 暴露的 ./StudentApp 入口
* - 用 AppShell 包裹 children提供左侧导航 + 顶栏
* - 由 teacher-portal Shell 动态加载NEXT_PUBLIC_MF_ENABLED=true
* - 独立壳模式NEXT_PUBLIC_MF_ENABLED=false下也可作为根容器使用
*
* 设计依据02-architecture-design.md §1.1 / §1.2
* next.config.js exposes['./StudentApp']
*/
import type { ReactNode } from "react";
import AppShell from "@/components/app-shell";
export default function StudentApp({
children,
}: {
children: ReactNode;
}): JSX.Element {
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,130 @@
"use client";
/**
* 章节目录页ai14P4
*
* - 消费 useChapters 查询textbookId 取自动态路由参数)
* - 树形导航(可展开/收起mark-left 竖线样式
* - 每个章节:标题、层级、顺序号
*/
import { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useChapters } from "@/lib/graphql/hooks";
import type { ChapterNode as ChapterNodeType } from "@/lib/graphql/types";
interface ChapterRowProps {
chapter: ChapterNodeType;
level: number;
}
function ChapterRow({ chapter, level }: ChapterRowProps): React.ReactNode {
const hasChildren = chapter.children && chapter.children.length > 0;
const [expanded, setExpanded] = useState(level === 0);
return (
<li
className="mark-left"
style={{ marginLeft: level > 0 ? "var(--space-md)" : 0 }}
>
<div className="py-sm">
<div className="flex items-center justify-between gap-md">
<div className="flex items-center gap-sm min-w-0">
{hasChildren ? (
<button
type="button"
className="text-xs text-accent"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={expanded ? "收起" : "展开"}
>
{expanded ? "▼" : "▶"}
</button>
) : (
<span className="text-xs text-ink-subtle" aria-hidden="true">
</span>
)}
<span className="text-sm text-ink text-truncate">
{chapter.name}
</span>
</div>
<div className="flex items-center gap-sm shrink-0">
<span className="text-xs text-ink-subtle">L{chapter.level}</span>
<span className="text-xs text-ink-subtle">#{chapter.order}</span>
</div>
</div>
</div>
{hasChildren && expanded ? (
<ul className="mt-xs space-y-xs" role="group">
{(chapter.children ?? []).map((child: ChapterNodeType) => (
<ChapterRow key={child.id} chapter={child} level={level + 1} />
))}
</ul>
) : null}
</li>
);
}
export default function ChaptersPage(): React.ReactNode {
const params = useParams();
const textbookId =
typeof params.id === "string"
? params.id
: Array.isArray(params.id)
? (params.id[0] ?? "")
: "";
const { data, fetching, error, reexecute } = useChapters(textbookId);
const chapters = data ?? [];
return (
<main
className="px-md py-lg max-w-4xl mx-auto"
aria-labelledby="chapters-title"
>
<header className="mb-lg">
<div className="flex items-center gap-sm">
<Link href="/textbooks" className="text-sm text-accent">
</Link>
</div>
<h1 id="chapters-title" className="font-serif text-2xl text-ink mt-sm">
</h1>
<p className="mt-xs text-sm text-ink-muted"></p>
</header>
<div className="rule-thin mb-lg" />
{fetching ? (
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : chapters.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<ul className="space-y-xs" role="tree" aria-label="章节树">
{chapters.map((ch: ChapterNodeType) => (
<ChapterRow key={ch.id} chapter={ch} level={0} />
))}
</ul>
)}
</main>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
/**
* 教材列表页ai14P4
*
* - 消费 useTextbooks 查询
* - 教材卡片:科目、书名、版本、作者
* - 链接到 /textbooks/[id]/chapters
*/
import Link from "next/link";
import { useTextbooks } from "@/lib/graphql/hooks";
import type { Textbook } from "@/lib/graphql/types";
function Cover({ textbook }: { textbook: Textbook }): React.ReactNode {
return (
<div
className="w-full h-32 flex items-center justify-center rounded"
style={{
background: "var(--color-accent-muted)",
color: "var(--color-accent)",
}}
role="img"
aria-label={`${textbook.name} 封面占位`}
>
<span className="font-serif text-lg">{textbook.subject.name}</span>
</div>
);
}
export default function TextbooksPage(): React.ReactNode {
const { data, fetching, error, reexecute } = useTextbooks();
const textbooks = data ?? [];
return (
<main
className="px-md py-lg max-w-5xl mx-auto"
aria-labelledby="textbooks-title"
>
<header className="mb-lg">
<h1 id="textbooks-title" className="font-serif text-2xl text-ink">
</h1>
<p className="mt-xs text-sm text-ink-muted"></p>
</header>
<div className="rule-thin mb-lg" />
{fetching ? (
<p className="text-sm text-ink-muted" aria-live="polite">
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p className="text-sm text-ink">{error.message || "加载失败"}</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : textbooks.length === 0 ? (
<p className="text-sm text-ink-muted" role="status">
</p>
) : (
<ul
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-lg"
aria-label="教材列表"
>
{textbooks.map((tb: Textbook) => (
<li key={tb.id}>
<Link
href={`/textbooks/${tb.id}/chapters`}
className="card-paper block overflow-hidden transition hover:shadow-card"
aria-label={`查看教材 ${tb.name} 的章节`}
>
<Cover textbook={tb} />
<div className="p-md">
<div className="flex items-center gap-sm mb-xs">
<span className="badge badge-success">
{tb.subject.name}
</span>
<span className="badge badge-info">{tb.version}</span>
</div>
<h2 className="font-serif text-base text-ink text-clamp-2">
{tb.name}
</h2>
<p className="mt-xs text-xs text-ink-subtle">{tb.author}</p>
<p className="mt-xs text-xs text-accent"> </p>
</div>
</Link>
</li>
))}
</ul>
)}
</main>
);
}

View File

@@ -0,0 +1,316 @@
"use client";
/**
* AppShell — 学生端应用壳ai14
*
* 职责:
* - 左侧导航栏:学生端 11 项菜单(含 P3-P5 阶段)
* - 顶栏:学生姓名 + 头像 + 退出登录
* - 主内容区:渲染 children
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
*
* 设计依据01-understanding.md §7L1 导航菜单)/ §18.1(响应式断点)
* 纸感设计:使用 --color-rule 分隔线 + .card-paper / mark-left 标记
*/
import { useState, useCallback, useEffect, type ReactNode } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
// 学生端导航菜单01-understanding.md §7 L1 视口)
interface NavItem {
label: string;
route: string;
}
const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", route: "/dashboard" },
{ label: "我的班级", route: "/my-classes" },
{ label: "我的考试", route: "/my-exams" },
{ label: "我的作业", route: "/my-homework" },
{ label: "我的成绩", route: "/my-grades" },
{ label: "我的考勤", route: "/my-attendance" },
{ label: "学习路径", route: "/learning-path" },
{ label: "学情诊断", route: "/diagnostic" },
{ label: "教材", route: "/textbooks" },
{ label: "通知中心", route: "/notifications" },
{ label: "AI辅学", route: "/ai-tutor" },
];
export default function AppShell({
children,
}: {
children: ReactNode;
}): JSX.Element {
const router = useRouter();
const pathname = usePathname();
const [drawerOpen, setDrawerOpen] = useState(false);
const [studentName, setStudentName] = useState<string | null>(null);
// 从 localStorage 拉取当前学生信息BFF currentUser 已注入)
const loadStudentInfo = useCallback(async (): Promise<void> => {
try {
const raw = window.localStorage.getItem("student_user");
if (!raw) return;
const parsed = JSON.parse(raw) as {
name?: string;
avatar?: string | null;
};
setStudentName(parsed.name ?? null);
} catch {
// 容错localStorage 异常时不阻断渲染
}
}, []);
useEffect(() => {
void loadStudentInfo();
}, [loadStudentInfo]);
// 路由切换后关闭抽屉
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
// ESC 关闭抽屉A11y 键盘支持)
useEffect(() => {
if (!drawerOpen) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") setDrawerOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawerOpen]);
const handleLogout = useCallback((): void => {
window.localStorage.removeItem("student_token");
window.localStorage.removeItem("student_user");
void router.push("/login");
}, [router]);
const isItemActive = (route: string): boolean =>
pathname === route || pathname.startsWith(`${route}/`);
// 渲染左侧导航(抽屉与桌面共用)
const renderNav = (): JSX.Element => (
<nav aria-label="主导航" className="mt-4 px-3">
<ul className="space-y-1">
{NAV_ITEMS.map((item) => {
const active = isItemActive(item.route);
return (
<li key={item.route}>
<Link
href={item.route}
aria-current={active ? "page" : undefined}
className="block px-3 py-2 text-sm rounded-sm transition-colors motion-safe"
style={{
color: active ? "var(--color-accent)" : "var(--color-ink)",
borderLeft: active
? "2px solid var(--color-accent)"
: "2px solid transparent",
fontFamily: active ? "var(--font-serif)" : "var(--font-sans)",
background: active
? "var(--color-accent-muted)"
: "transparent",
}}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
// 渲染顶部用户区(抽屉与桌面共用)
const renderUserBar = (): JSX.Element => (
<div
className="mt-auto px-6 py-4 border-t"
style={{ borderColor: "var(--color-rule)" }}
>
<div className="mb-3 flex items-center gap-3">
<span
className="inline-flex w-8 h-8 items-center justify-center rounded-full text-xs font-serif"
style={{
background: "var(--color-accent-muted)",
color: "var(--color-accent)",
}}
aria-hidden="true"
>
{studentName?.slice(0, 1) ?? "S"}
</span>
<div className="min-w-0 flex-1">
<p
className="text-sm text-truncate"
style={{ color: "var(--color-ink)" }}
>
{studentName ?? "同学"}
</p>
<p
className="text-xs text-truncate"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
</div>
<button
type="button"
onClick={handleLogout}
className="text-xs uppercase tracking-wide hover:opacity-70 transition-opacity"
style={{ color: "var(--color-ink-muted)" }}
>
退
</button>
</div>
);
return (
<div
className="min-h-screen flex bg-paper"
style={{ background: "var(--color-paper)" }}
>
{/* 移动端遮罩A11yrole=dialog + aria-modal*/}
{drawerOpen && (
<div
role="button"
tabIndex={0}
aria-label="关闭抽屉"
onClick={() => setDrawerOpen(false)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setDrawerOpen(false);
}}
className="lg:hidden fixed inset-0 z-30"
style={{ background: "rgba(0,0,0,0.4)" }}
/>
)}
{/* 移动端抽屉 */}
<aside
className={`lg:hidden fixed inset-y-0 left-0 z-40 w-64 flex flex-col transform transition-transform duration-200 motion-safe ${
drawerOpen ? "translate-x-0" : "-translate-x-full"
}`}
style={{
background: "var(--color-paper)",
borderRight: "1px solid var(--color-rule)",
}}
aria-label="侧边导航"
aria-hidden={!drawerOpen}
>
<div className="px-6 py-6">
<h1
className="text-xl font-serif"
style={{ color: "var(--color-ink)" }}
>
Edu
</h1>
<p
className="text-xs mt-1"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<div className="rule-thin mx-6" />
{renderNav()}
{renderUserBar()}
</aside>
{/* 桌面端固定侧栏 */}
<aside
className="hidden lg:flex w-56 flex-shrink-0 flex-col relative border-r"
style={{
borderColor: "var(--color-rule)",
background: "var(--color-paper)",
}}
aria-label="侧边导航"
>
<div className="px-6 py-6">
<h1
className="text-xl font-serif"
style={{ color: "var(--color-ink)" }}
>
Edu
</h1>
<p
className="text-xs mt-1"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<div className="rule-thin mx-6" />
{renderNav()}
{renderUserBar()}
</aside>
{/* 主区域:顶栏 + 内容 */}
<div className="flex-1 flex flex-col min-w-0">
{/* 顶栏:移动端 hamburger + 标题 */}
<header
className="flex items-center gap-3 px-4 lg:px-8 py-4 border-b sticky top-0 z-20"
style={{
borderColor: "var(--color-rule)",
background: "var(--color-paper)",
}}
>
<button
type="button"
onClick={() => setDrawerOpen((v) => !v)}
className="lg:hidden p-2 rounded-sm"
aria-label={drawerOpen ? "关闭菜单" : "打开菜单"}
aria-expanded={drawerOpen}
aria-controls="mobile-drawer"
style={{ color: "var(--color-ink)" }}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
{drawerOpen ? (
<path d="M18 6 6 18M6 6l12 12" />
) : (
<path d="M3 6h18M3 12h18M3 18h18" />
)}
</svg>
</button>
<h2
className="text-lg font-serif flex-1 text-truncate"
style={{ color: "var(--color-ink)" }}
>
{NAV_ITEMS.find((i) => isItemActive(i.route))?.label ??
"Edu 学生端"}
</h2>
{/* 桌面端:右侧显示当前学生 */}
<div className="hidden lg:flex items-center gap-3">
<span
className="inline-flex w-8 h-8 items-center justify-center rounded-full text-xs font-serif"
style={{
background: "var(--color-accent-muted)",
color: "var(--color-accent)",
}}
aria-hidden="true"
>
{studentName?.slice(0, 1) ?? "S"}
</span>
<span className="text-sm" style={{ color: "var(--color-ink)" }}>
{studentName ?? "同学"}
</span>
</div>
</header>
{/* 内容区 */}
<main className="flex-1 overflow-auto" role="main" id="main-content">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
/**
* 倒计时组件ai14
*
* 基于服务器时间偏移计算剩余时间,防止客户端篡改
* 最后 5 分钟变红色警告,最后 1 分钟闪烁
* 倒计时归零时触发 onTimeUp 回调
*/
import { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/exam-types";
export interface CountdownTimerProps {
/** 考试截止时间ISO 8601 */
endTime: string;
/** 倒计时归零回调 */
onTimeUp: () => void;
/** 服务器时间偏移毫秒serverTime - clientTime */
serverTimeOffset: number;
}
/** 格式化秒数为 HH:MM:SS */
function formatDuration(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
}
/** 计算剩余秒数 */
function calcRemaining(endTime: string, offset: number): number {
const expiry = new Date(endTime).getTime();
const correctedNow = Date.now() + offset;
return Math.max(0, Math.floor((expiry - correctedNow) / 1000));
}
export function CountdownTimer({
endTime,
onTimeUp,
serverTimeOffset,
}: CountdownTimerProps): JSX.Element {
const [remaining, setRemaining] = useState(() =>
calcRemaining(endTime, serverTimeOffset),
);
const onTimeUpRef = useRef(onTimeUp);
onTimeUpRef.current = onTimeUp;
useEffect(() => {
const tick = (): void => {
const r = calcRemaining(endTime, serverTimeOffset);
setRemaining(r);
if (r === 0) {
onTimeUpRef.current();
}
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [endTime, serverTimeOffset]);
const formatted = formatDuration(remaining);
const isUrgent = remaining <= 300 && remaining > 0; // 最后 5 分钟
const isCritical = remaining <= 60 && remaining > 0; // 最后 1 分钟
const isExpired = remaining === 0;
return (
<div
className={cn(
"font-mono text-2xl tabular-nums transition-colors",
isExpired && "text-danger",
isCritical && "text-danger animate-pulse",
isUrgent && !isCritical && "text-warning",
!isUrgent && !isCritical && !isExpired && "text-ink",
)}
role="timer"
aria-live="polite"
aria-label={`剩余时间 ${formatted}`}
>
{isExpired ? "时间到" : formatted}
</div>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
/**
* 草稿恢复弹窗组件ai14
*
* 进入考试页时检测到 IDB 中有未完成草稿时弹出
* 询问用户是否恢复上次作答
*/
import { useEffect, useRef } from "react";
import { cn } from "@/lib/exam-types";
export interface DraftRecoveryProps {
/** 是否检测到草稿 */
hasDraft: boolean;
/** 恢复草稿回调 */
onRestore: () => void;
/** 丢弃草稿回调 */
onDiscard: () => void;
}
export function DraftRecovery({
hasDraft,
onRestore,
onDiscard,
}: DraftRecoveryProps): JSX.Element | null {
const restoreRef = useRef<HTMLButtonElement>(null);
// 弹窗打开时聚焦恢复按钮
useEffect(() => {
if (hasDraft && restoreRef.current) {
restoreRef.current.focus();
}
}, [hasDraft]);
// ESC 键关闭(等同于丢弃)
useEffect(() => {
if (!hasDraft) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
onDiscard();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hasDraft, onDiscard]);
if (!hasDraft) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="draft-recovery-title"
>
<div
className={cn(
"mx-md w-full max-w-md rounded-lg border border-rule bg-paper p-xl shadow-card",
)}
>
<h2 id="draft-recovery-title" className="font-serif text-xl text-ink">
稿
</h2>
<p className="mt-sm text-sm text-ink-muted">
</p>
<div className="mt-xl flex justify-end gap-sm">
<button
type="button"
onClick={onDiscard}
className={cn(
"rounded border border-rule px-lg py-sm text-sm text-ink-muted",
"hover:bg-paper transition-colors",
)}
aria-label="丢弃草稿,重新开始"
>
</button>
<button
ref={restoreRef}
type="button"
onClick={onRestore}
className={cn(
"rounded bg-accent px-lg py-sm text-sm text-paper",
"hover:bg-accent-hover transition-colors",
)}
aria-label="恢复上次作答的草稿"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,797 @@
"use client";
/**
* 考试作答主组件ai14
*
* 编排所有考试作答能力:状态机 / 服务器时间同步 / IDB 草稿恢复 /
* 自动保存 / 防作弊 / 多标签检测 / 提交去重 / 倒计时
*
* 布局:顶部栏(标题+倒计时+服务器时间)| 左侧题目导航 | 主内容区 | 底部导航栏
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/exam-types";
import type { AnswerInput, ExamDetail, ExamQuestion } from "@/lib/exam-types";
import { CountdownTimer } from "./countdown-timer";
import { DraftRecovery } from "./draft-recovery";
import { useExamStateMachine } from "@/hooks/use-exam-state-machine";
import { useServerTimeSync } from "@/hooks/use-server-time-sync";
import { useExamDraft } from "@/hooks/use-exam-draft";
import { useAntiCheat } from "@/hooks/use-anti-cheat";
import { useMultiTabGuard } from "@/hooks/use-multi-tab-guard";
import { useSubmitDedup } from "@/hooks/use-submit-dedup";
export interface ExamTakingProps {
examId: string;
}
const GRAPHQL_ENDPOINT =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
const IS_MOCKING = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
/** 创建模拟考试数据(开发环境 / API 未就绪时使用) */
function createMockExamDetail(examId: string): ExamDetail {
return {
id: examId,
name: "期末考试 · 数学综合测试",
durationSeconds: 5400,
startsAt: new Date(Date.now() - 60_000).toISOString(),
expiresAt: new Date(Date.now() + 5400_000).toISOString(),
questionCount: 5,
questions: [
{
id: "q1",
type: "single-choice",
content: "已知函数 f(x) = x² + 2x + 1求 f(2) 的值。",
options: [
{ id: "a", text: "7" },
{ id: "b", text: "8" },
{ id: "c", text: "9" },
{ id: "d", text: "10" },
],
score: 0,
maxScore: 10,
},
{
id: "q2",
type: "multiple-choice",
content: "下列哪些是质数?(多选)",
options: [
{ id: "a", text: "2" },
{ id: "b", text: "4" },
{ id: "c", text: "7" },
{ id: "d", text: "9" },
],
score: 0,
maxScore: 15,
},
{
id: "q3",
type: "fill-blank",
content: "sin(30°) = ______",
score: 0,
maxScore: 10,
},
{
id: "q4",
type: "short-answer",
content: "简述导数的定义。",
score: 0,
maxScore: 20,
},
{
id: "q5",
type: "essay",
content: "论述微积分的基本思想及其在现代科学中的应用。",
score: 0,
maxScore: 45,
},
],
};
}
/** 获取考试详情API 或 Mock */
async function fetchExamDetail(examId: string): Promise<ExamDetail> {
if (IS_MOCKING) {
await new Promise((r) => setTimeout(r, 300));
return createMockExamDetail(examId);
}
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `query ExamDetail($examId: ID!) {
exam(id: $examId) {
id name durationSeconds startsAt expiresAt questionCount
questions { id type content options { id text } score maxScore }
}
}`,
variables: { examId },
}),
});
if (!response.ok) throw new Error(`考试详情加载失败: ${response.status}`);
const result = (await response.json()) as {
data?: { exam?: ExamDetail };
errors?: Array<{ message: string }>;
};
if (result.errors?.length)
throw new Error(result.errors[0]?.message ?? "查询失败");
if (!result.data?.exam) throw new Error("考试数据为空");
return result.data.exam;
}
/** 提交考试API 或 Mock */
async function submitExamApi(
examId: string,
idempotencyKey: string,
): Promise<boolean> {
if (IS_MOCKING) {
await new Promise((r) => setTimeout(r, 1000));
return true;
}
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `mutation SubmitExam($examId: ID!, $idempotencyKey: String!) {
submitExam(examId: $examId, idempotencyKey: $idempotencyKey) { submissionId submittedAt }
}`,
variables: { examId, idempotencyKey },
}),
});
return response.ok;
}
export function ExamTaking({ examId }: ExamTakingProps): JSX.Element {
const router = useRouter();
// === Hooks ===
const { state, actions, canSubmit } = useExamStateMachine();
const { offset, isSyncing, lastSyncAt, isDegraded } = useServerTimeSync();
const {
draft,
saveDraft,
setAnswers,
clearDraft,
isSaving,
hasDraft,
lastSavedAt,
} = useExamDraft(examId);
const {
isFullscreen,
requestFullscreen,
showWarning,
warningMessage,
dismissWarning,
} = useAntiCheat(examId);
const { isDuplicate, dismissWarning: dismissTabWarning } =
useMultiTabGuard(examId);
const {
idempotencyKey,
isSubmitting,
markSubmitting,
markComplete,
canSubmit: canDedupSubmit,
} = useSubmitDedup(examId);
// === State ===
const [exam, setExam] = useState<ExamDetail | null>(null);
const [answers, setAnswersState] = useState<Record<string, AnswerInput>>({});
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showDraftModal, setShowDraftModal] = useState(false);
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const submittedRef = useRef(false);
// === 加载考试详情 ===
useEffect(() => {
fetchExamDetail(examId)
.then((data) => {
setExam(data);
setLoading(false);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "加载失败");
setLoading(false);
});
}, [examId]);
// === 检测到草稿时弹恢复弹窗 ===
useEffect(() => {
if (hasDraft && draft && !submittedRef.current) {
setShowDraftModal(true);
}
}, [hasDraft, draft]);
// === 同步答案到 hook 供自动保存 ===
useEffect(() => {
setAnswers(answers);
}, [answers, setAnswers]);
// === 考试数据就绪后启动状态机 ===
useEffect(() => {
if (exam && state === "NotStarted") {
actions.start();
}
}, [exam, state, actions]);
// === 离开页面警告 ===
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent): void => {
if (state === "InProgress" || state === "AutoSaving") {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [state]);
// === 时间到自动提交 ===
const handleTimeUp = useCallback((): void => {
if (submittedRef.current) return;
setSubmitError(null);
markSubmitting();
void submitExamApi(examId, idempotencyKey)
.then(async (ok) => {
if (ok) {
submittedRef.current = true;
actions.beginSubmit();
actions.completeSubmit();
await clearDraft();
router.push(`/my-exams/${examId}/result`);
} else {
markComplete();
setSubmitError("自动提交失败,请手动重试");
}
})
.catch(() => {
markComplete();
setSubmitError("网络错误,自动提交失败");
});
}, [
examId,
idempotencyKey,
markSubmitting,
markComplete,
actions,
clearDraft,
router,
]);
// === 答案变更 ===
const handleAnswerChange = useCallback(
(questionId: string, answer: AnswerInput): void => {
setAnswersState((prev) => ({ ...prev, [questionId]: answer }));
},
[],
);
// === 题目失焦保存草稿 ===
const handleBlur = useCallback((): void => {
if (Object.keys(answers).length > 0) {
void saveDraft(answers);
}
}, [answers, saveDraft]);
// === 提交确认 ===
const handleConfirmSubmit = useCallback((): void => {
setShowSubmitConfirm(true);
}, []);
// === 执行提交 ===
const handleExecuteSubmit = useCallback(async (): Promise<void> => {
if (!canDedupSubmit || submittedRef.current) return;
setShowSubmitConfirm(false);
setSubmitError(null);
markSubmitting();
try {
const ok = await submitExamApi(examId, idempotencyKey);
if (ok) {
submittedRef.current = true;
actions.beginSubmit();
actions.completeSubmit();
await clearDraft();
router.push(`/my-exams/${examId}/result`);
} else {
markComplete();
setSubmitError("提交失败,请重试");
}
} catch {
markComplete();
setSubmitError("网络错误,提交失败,请重试");
}
}, [
canDedupSubmit,
examId,
idempotencyKey,
markSubmitting,
markComplete,
actions,
clearDraft,
router,
]);
// === 草稿恢复 ===
const handleRestoreDraft = useCallback((): void => {
if (draft) {
setAnswersState(draft.answers);
setAnswers(draft.answers);
}
setShowDraftModal(false);
}, [draft, setAnswers]);
const handleDiscardDraft = useCallback((): void => {
void clearDraft();
setAnswersState({});
setShowDraftModal(false);
}, [clearDraft]);
// === 加载态 ===
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-paper">
<div className="text-center">
<div className="font-serif text-xl text-ink"></div>
<div className="mt-sm text-sm text-ink-muted"></div>
</div>
</div>
);
}
// === 错误态 ===
if (error || !exam) {
return (
<div className="flex h-screen items-center justify-center bg-paper">
<div className="text-center">
<div className="font-serif text-xl text-danger"></div>
<div className="mt-sm text-sm text-ink-muted">
{error ?? "未知错误"}
</div>
<button
type="button"
onClick={() => router.back()}
className="mt-lg rounded bg-accent px-lg py-sm text-sm text-paper hover:bg-accent-hover"
>
</button>
</div>
</div>
);
}
const currentQuestion = exam.questions[currentIndex];
if (!currentQuestion) {
return (
<div className="flex h-screen items-center justify-center bg-paper">
<p className="text-sm text-ink-muted"></p>
</div>
);
}
const unansweredCount = exam.questions.filter((q) => !answers[q.id]).length;
const answeredCount = exam.questions.length - unansweredCount;
return (
<div className="flex h-screen flex-col bg-paper">
{/* === 顶部栏 === */}
<header className="flex items-center justify-between border-b border-rule px-xl py-md">
<div className="flex items-center gap-md">
<h1 className="font-serif text-lg text-ink">{exam.name}</h1>
{isSaving && (
<span className="text-xs text-ink-muted" aria-live="polite">
</span>
)}
{lastSavedAt && !isSaving && (
<span className="text-xs text-ink-subtle">
{new Date(lastSavedAt).toLocaleTimeString("zh-CN")}
</span>
)}
</div>
<div className="flex items-center gap-lg">
<CountdownTimer
endTime={exam.expiresAt}
onTimeUp={handleTimeUp}
serverTimeOffset={offset}
/>
<div className="flex items-center gap-sm text-xs text-ink-muted">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
isDegraded
? "bg-warning"
: isSyncing
? "bg-info"
: "bg-success",
)}
aria-hidden="true"
/>
<span aria-live="polite">
{isDegraded ? "时间同步降级" : isSyncing ? "同步中" : "已同步"}
</span>
{lastSyncAt && (
<span className="text-ink-subtle">
{new Date(lastSyncAt).toLocaleTimeString("zh-CN")}
</span>
)}
</div>
<button
type="button"
onClick={requestFullscreen}
className="rounded border border-rule px-sm py-xs text-xs text-ink-muted hover:bg-accent-muted"
aria-label={isFullscreen ? "退出全屏" : "进入全屏"}
>
{isFullscreen ? "退出全屏" : "全屏"}
</button>
</div>
</header>
{/* === 主内容区 === */}
<div className="flex flex-1 overflow-hidden">
{/* 左侧题目导航 */}
<aside className="w-48 border-r border-rule bg-paper p-md lg:w-64">
<div className="mb-md text-xs font-medium text-ink-muted">
{answeredCount}/{exam.questions.length}
</div>
<nav aria-label="题目导航">
<ul className="space-y-sm">
{exam.questions.map((q, idx) => {
const isAnswered = !!answers[q.id];
const isCurrent = idx === currentIndex;
return (
<li key={q.id}>
<button
type="button"
onClick={() => setCurrentIndex(idx)}
aria-current={isCurrent ? "true" : undefined}
aria-label={`${idx + 1}${isAnswered ? "(已作答)" : "(未作答)"}`}
className={cn(
"flex w-full items-center gap-sm rounded px-sm py-xs text-sm transition-colors",
isCurrent && "bg-accent text-paper",
!isCurrent &&
isAnswered &&
"text-success hover:bg-accent-muted",
!isCurrent &&
!isAnswered &&
"text-ink-muted hover:bg-accent-muted",
)}
>
<span className="font-mono">{idx + 1}</span>
<span className="flex-1 text-left">
{q.type === "single-choice" && "单选"}
{q.type === "multiple-choice" && "多选"}
{q.type === "fill-blank" && "填空"}
{q.type === "short-answer" && "简答"}
{q.type === "essay" && "论述"}
</span>
{isAnswered && (
<span aria-hidden="true" className="text-xs">
{isCurrent ? "✓" : "✓"}
</span>
)}
</button>
</li>
);
})}
</ul>
</nav>
</aside>
{/* 题目内容区 */}
<main className="flex-1 overflow-y-auto p-xl">
<div className="mx-auto max-w-3xl">
<div className="mb-lg flex items-center justify-between">
<span className="font-serif text-lg text-ink">
{currentIndex + 1} / {exam.questions.length}
</span>
<span className="text-sm text-ink-muted">
{currentQuestion.maxScore}
</span>
</div>
<div className="rounded-lg border border-rule bg-paper p-xl shadow-paper">
<p className="mb-xl text-base text-ink">
{currentQuestion.content}
</p>
<QuestionInput
question={currentQuestion}
answer={answers[currentQuestion.id] ?? null}
onChange={(ans) => handleAnswerChange(currentQuestion.id, ans)}
onBlur={handleBlur}
/>
</div>
</div>
</main>
</div>
{/* === 底部导航栏 === */}
<footer className="flex items-center justify-between border-t border-rule px-xl py-md">
<button
type="button"
onClick={() => setCurrentIndex((i) => Math.max(0, i - 1))}
disabled={currentIndex === 0}
className="rounded border border-rule px-lg py-sm text-sm text-ink hover:bg-accent-muted disabled:opacity-40"
aria-label="上一题"
>
</button>
<span className="text-sm text-ink-muted">
{currentIndex + 1} / {exam.questions.length}
</span>
<div className="flex items-center gap-sm">
<button
type="button"
onClick={() =>
setCurrentIndex((i) => Math.min(exam.questions.length - 1, i + 1))
}
disabled={currentIndex === exam.questions.length - 1}
className="rounded border border-rule px-lg py-sm text-sm text-ink hover:bg-accent-muted disabled:opacity-40"
aria-label="下一题"
>
</button>
<button
type="button"
onClick={handleConfirmSubmit}
disabled={
!canSubmit ||
!canDedupSubmit ||
isSubmitting ||
state === "Submitted"
}
className="rounded bg-accent px-xl py-sm text-sm text-paper hover:bg-accent-hover disabled:opacity-50"
aria-label="提交考试"
>
{isSubmitting ? "提交中…" : "提交考试"}
</button>
</div>
</footer>
{/* === 草稿恢复弹窗 === */}
<DraftRecovery
hasDraft={showDraftModal}
onRestore={handleRestoreDraft}
onDiscard={handleDiscardDraft}
/>
{/* === 提交确认弹窗 === */}
{showSubmitConfirm && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="submit-confirm-title"
>
<div className="mx-md w-full max-w-md rounded-lg border border-rule bg-paper p-xl shadow-card">
<h2
id="submit-confirm-title"
className="font-serif text-xl text-ink"
>
</h2>
{unansweredCount > 0 ? (
<p className="mt-sm text-sm text-warning">
{unansweredCount}
</p>
) : (
<p className="mt-sm text-sm text-ink-muted">
</p>
)}
{submitError && (
<p className="mt-sm text-sm text-danger" role="alert">
{submitError}
</p>
)}
<div className="mt-xl flex justify-end gap-sm">
<button
type="button"
onClick={() => setShowSubmitConfirm(false)}
className="rounded border border-rule px-lg py-sm text-sm text-ink-muted hover:bg-paper"
>
</button>
<button
type="button"
onClick={handleExecuteSubmit}
disabled={isSubmitting}
className="rounded bg-accent px-lg py-sm text-sm text-paper hover:bg-accent-hover disabled:opacity-50"
>
{isSubmitting ? "提交中…" : "确认提交"}
</button>
</div>
</div>
</div>
)}
{/* === 防作弊警告遮罩 === */}
{showWarning && warningMessage && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-ink/60"
role="alertdialog"
aria-modal="true"
aria-labelledby="warning-title"
>
<div className="mx-md w-full max-w-sm rounded-lg border border-danger bg-paper p-xl shadow-card">
<h2 id="warning-title" className="font-serif text-lg text-danger">
</h2>
<p className="mt-sm text-sm text-ink">{warningMessage}</p>
<button
type="button"
onClick={dismissWarning}
className="mt-lg w-full rounded bg-danger px-lg py-sm text-sm text-paper hover:opacity-90"
>
</button>
</div>
</div>
)}
{/* === 多标签检测遮罩 === */}
{isDuplicate && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/80"
role="alertdialog"
aria-modal="true"
aria-labelledby="multitab-title"
>
<div className="mx-md w-full max-w-sm rounded-lg border border-danger bg-paper p-xl shadow-card text-center">
<h2 id="multitab-title" className="font-serif text-lg text-danger">
</h2>
<p className="mt-sm text-sm text-ink-muted">
</p>
<button
type="button"
onClick={dismissTabWarning}
className="mt-lg rounded bg-accent px-lg py-sm text-sm text-paper hover:bg-accent-hover"
>
</button>
</div>
</div>
)}
</div>
);
}
// === 题目输入子组件 ===
interface QuestionInputProps {
question: ExamQuestion;
answer: AnswerInput | null;
onChange: (answer: AnswerInput) => void;
onBlur: () => void;
}
function QuestionInput({
question,
answer,
onChange,
onBlur,
}: QuestionInputProps): JSX.Element {
switch (question.type) {
case "single-choice":
return (
<div role="radiogroup" aria-label="单选题选项" className="space-y-sm">
{question.options?.map((opt) => {
const isChecked =
answer?.type === "single-choice" && answer.optionId === opt.id;
return (
<label
key={opt.id}
className={cn(
"flex cursor-pointer items-center gap-md rounded border px-lg py-sm transition-colors",
isChecked
? "border-accent bg-accent-muted"
: "border-rule hover:bg-accent-muted",
)}
>
<input
type="radio"
name={question.id}
checked={isChecked}
onChange={() =>
onChange({ type: "single-choice", optionId: opt.id })
}
onBlur={onBlur}
className="accent-accent"
/>
<span className="text-sm text-ink">{opt.text}</span>
</label>
);
})}
</div>
);
case "multiple-choice":
return (
<div role="group" aria-label="多选题选项" className="space-y-sm">
{question.options?.map((opt) => {
const isChecked =
answer?.type === "multiple-choice" &&
answer.optionIds.includes(opt.id);
return (
<label
key={opt.id}
className={cn(
"flex cursor-pointer items-center gap-md rounded border px-lg py-sm transition-colors",
isChecked
? "border-accent bg-accent-muted"
: "border-rule hover:bg-accent-muted",
)}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const current =
answer?.type === "multiple-choice"
? answer.optionIds
: [];
const next = e.target.checked
? [...current, opt.id]
: current.filter((id) => id !== opt.id);
onChange({ type: "multiple-choice", optionIds: next });
}}
onBlur={onBlur}
className="accent-accent"
/>
<span className="text-sm text-ink">{opt.text}</span>
</label>
);
})}
</div>
);
case "fill-blank":
return (
<input
type="text"
value={answer?.type === "fill-blank" ? (answer.values[0] ?? "") : ""}
onChange={(e) =>
onChange({ type: "fill-blank", values: [e.target.value] })
}
onBlur={onBlur}
placeholder="请输入答案"
className="w-full rounded border border-rule px-lg py-sm text-sm text-ink focus:border-accent focus:outline-none"
aria-label="填空题答案"
/>
);
case "short-answer":
return (
<textarea
value={answer?.type === "short-answer" ? answer.text : ""}
onChange={(e) =>
onChange({ type: "short-answer", text: e.target.value })
}
onBlur={onBlur}
rows={4}
placeholder="请输入简答内容"
className="w-full rounded border border-rule px-lg py-sm text-sm text-ink focus:border-accent focus:outline-none"
aria-label="简答题答案"
/>
);
case "essay":
return (
<textarea
value={answer?.type === "essay" ? answer.text : ""}
onChange={(e) => onChange({ type: "essay", text: e.target.value })}
onBlur={onBlur}
rows={8}
placeholder="请输入论述内容"
className="w-full rounded border border-rule px-lg py-sm text-sm text-ink focus:border-accent focus:outline-none"
aria-label="论述题答案"
/>
);
default:
return <div className="text-sm text-ink-muted"></div>;
}
}

View File

@@ -0,0 +1,52 @@
"use client";
/**
* FeatureFlag 组件ai14
*
* 按特性开关渲染子树或占位符。
* - enabled=true渲染 children
* - enabled=false渲染 fallback默认为"功能开发中"纸感占位)
*
* 用途P4/P5 页面在特性未启用时优雅降级展示占位。
*/
import type { ReactNode } from "react";
export interface FeatureFlagProps {
/** 特性是否启用 */
enabled: boolean;
/** 未启用时的兜底内容(可选,默认渲染纸感占位) */
fallback?: ReactNode;
/** 启用后渲染的子树 */
children: ReactNode;
}
/** 默认"功能开发中"占位(纸感设计) */
function DefaultPlaceholder(): ReactNode {
return (
<section
className="card-paper p-xl text-center mx-auto max-w-2xl"
role="status"
aria-live="polite"
aria-label="功能开发中"
>
<div className="mark-left py-md px-md inline-block text-left">
<p className="font-serif text-xl text-ink"></p>
<p className="mt-sm text-sm text-ink-muted">
</p>
</div>
</section>
);
}
export function FeatureFlag({
enabled,
fallback,
children,
}: FeatureFlagProps): ReactNode {
if (!enabled) {
return fallback ?? <DefaultPlaceholder />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,165 @@
/**
* 防作弊监控 Hookai14
*
* 监听浏览器事件visibilitychange / copy / paste / contextmenu / fullscreenchange
* 记录违规行为,超阈值显示警告遮罩
* 违规记录批量上报(防抖),上报失败静默保留本地
*/
import { useState, useEffect, useCallback, useRef } from "react";
import type { ViolationRecord, ViolationType } from "@/lib/exam-types";
const GRAPHQL_ENDPOINT =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
const FLUSH_THRESHOLD = 10; // 队列满 10 条后批量上报
/** 批量上报违规记录到服务端 */
async function flushViolations(
examId: string,
batch: ViolationRecord[],
): Promise<boolean> {
try {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `mutation RecordViolations($examId: ID!, $behaviors: [BehaviorInput!]!) {
recordExamViolation(examId: $examId, behaviors: $behaviors) { ok }
}`,
variables: { examId, behaviors: batch },
}),
});
return response.ok;
} catch {
return false;
}
}
export interface UseAntiCheatReturn {
violations: ViolationRecord[];
isFullscreen: boolean;
requestFullscreen: () => Promise<void>;
exitFullscreen: () => Promise<void>;
showWarning: boolean;
warningMessage: string | null;
dismissWarning: () => void;
}
export function useAntiCheat(examId: string): UseAntiCheatReturn {
const [violations, setViolations] = useState<ViolationRecord[]>([]);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showWarning, setShowWarning] = useState(false);
const [warningMessage, setWarningMessage] = useState<string | null>(null);
const queueRef = useRef<ViolationRecord[]>([]);
const tabSwitchCount = useRef(0);
const record = useCallback(
(type: ViolationType, details?: string): void => {
const entry: ViolationRecord = { type, timestamp: Date.now(), details };
setViolations((prev) => [...prev, entry]);
queueRef.current.push(entry);
if (queueRef.current.length >= FLUSH_THRESHOLD) {
const batch = queueRef.current.splice(0);
void flushViolations(examId, batch);
}
},
[examId],
);
// 阻止默认行为并记录ClipboardEvent / MouseEvent 均继承自 Event
useEffect(() => {
const onCopy = (e: ClipboardEvent): void => {
e.preventDefault();
record("copy", "copy attempted");
};
const onPaste = (e: ClipboardEvent): void => {
e.preventDefault();
record("paste", "paste attempted");
};
const onContext = (e: MouseEvent): void => {
e.preventDefault();
record("contextmenu", "contextmenu attempted");
};
document.addEventListener("copy", onCopy);
document.addEventListener("paste", onPaste);
document.addEventListener("contextmenu", onContext);
return () => {
document.removeEventListener("copy", onCopy);
document.removeEventListener("paste", onPaste);
document.removeEventListener("contextmenu", onContext);
};
}, [record]);
// 切屏检测
useEffect(() => {
const onVisibility = (): void => {
if (document.visibilityState === "hidden") {
tabSwitchCount.current += 1;
record("tab-switch", `${tabSwitchCount.current} 次切换`);
if (tabSwitchCount.current >= 3) {
setShowWarning(true);
setWarningMessage("检测到多次切屏,已记录违规行为");
}
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [record]);
// 全屏状态检测
useEffect(() => {
const onFullscreen = (): void => {
const isFs = document.fullscreenElement !== null;
setIsFullscreen(isFs);
if (!isFs) {
record("fullscreen-exit", "退出全屏");
}
};
document.addEventListener("fullscreenchange", onFullscreen);
return () => document.removeEventListener("fullscreenchange", onFullscreen);
}, [record]);
// 卸载时 flush 剩余记录
useEffect(() => {
return () => {
if (queueRef.current.length > 0) {
const batch = queueRef.current.splice(0);
void flushViolations(examId, batch);
}
};
}, [examId]);
const requestFullscreen = useCallback(async (): Promise<void> => {
try {
await document.documentElement.requestFullscreen();
} catch {
// 全屏请求失败,静默处理
}
}, []);
const exitFullscreen = useCallback(async (): Promise<void> => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
} catch {
// 退出全屏失败,静默处理
}
}, []);
const dismissWarning = useCallback((): void => {
setShowWarning(false);
setWarningMessage(null);
}, []);
return {
violations,
isFullscreen,
requestFullscreen,
exitFullscreen,
showWarning,
warningMessage,
dismissWarning,
};
}

View File

@@ -0,0 +1,160 @@
/**
* 考试草稿管理 Hookai14
*
* 使用 idb-keyval 持久化草稿到 IndexedDB
* 支持断网恢复:进入考试页时检测已有草稿
* 自动保存:每 NEXT_PUBLIC_EXAM_AUTOSAVE_INTERVAL 秒 + 组件卸载时
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { get, set, del } from "idb-keyval";
import type { AnswerInput, ExamTakingDraft } from "@/lib/exam-types";
import { getStudentId, buildDraftKey } from "@/lib/exam-types";
const DEFAULT_AUTOSAVE_INTERVAL = 30; // 默认 30 秒
/** 获取自动保存间隔(毫秒) */
function getAutosaveIntervalMs(): number {
const seconds = Number(process.env.NEXT_PUBLIC_EXAM_AUTOSAVE_INTERVAL);
return (
(Number.isFinite(seconds) && seconds > 0
? seconds
: DEFAULT_AUTOSAVE_INTERVAL) * 1000
);
}
export interface UseExamDraftReturn {
draft: ExamTakingDraft | null;
saveDraft: (answers: Record<string, AnswerInput>) => Promise<void>;
/** 更新内部答案引用(不触发保存,供自动保存定时器使用) */
setAnswers: (answers: Record<string, AnswerInput>) => void;
clearDraft: () => Promise<void>;
isSaving: boolean;
hasDraft: boolean;
lastSavedAt: number | null;
/** 手动加载草稿(用于恢复确认后) */
loadDraft: () => Promise<ExamTakingDraft | null>;
}
export function useExamDraft(
examId: string,
expiresAt?: string,
): UseExamDraftReturn {
const [draft, setDraft] = useState<ExamTakingDraft | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const answersRef = useRef<Record<string, AnswerInput>>({});
const expiresAtRef = useRef<string>(expiresAt ?? "");
// 当外部传入新的 expiresAt 时更新引用
useEffect(() => {
if (expiresAt) {
expiresAtRef.current = expiresAt;
}
}, [expiresAt]);
/** 保存草稿到 IDB */
const saveDraft = useCallback(
async (answers: Record<string, AnswerInput>): Promise<void> => {
const studentId = getStudentId();
const key = buildDraftKey(examId, studentId);
setIsSaving(true);
try {
const now = Date.now();
const updated: ExamTakingDraft = draft
? { ...draft, answers, lastSavedAt: now }
: {
examId,
studentId,
answers,
startedAt: now,
serverStartedAt: now,
lastSavedAt: now,
durationSeconds: 0,
expiresAt: expiresAtRef.current,
};
await set(key, updated);
setDraft(updated);
setLastSavedAt(now);
answersRef.current = answers;
} catch {
// IDB 写入失败,静默处理(内存中仍保留)
} finally {
setIsSaving(false);
}
},
[examId, draft],
);
/** 从 IDB 加载草稿 */
const loadDraft = useCallback(async (): Promise<ExamTakingDraft | null> => {
const studentId = getStudentId();
const key = buildDraftKey(examId, studentId);
try {
const saved = (await get(key)) as ExamTakingDraft | undefined;
return saved ?? null;
} catch {
return null;
}
}, [examId]);
/** 清除草稿 */
const clearDraft = useCallback(async (): Promise<void> => {
const studentId = getStudentId();
const key = buildDraftKey(examId, studentId);
try {
await del(key);
setDraft(null);
setLastSavedAt(null);
} catch {
// 清除失败静默处理
}
}, [examId]);
/** 更新内部答案引用(不触发保存,供自动保存定时器使用) */
const setAnswers = useCallback(
(answers: Record<string, AnswerInput>): void => {
answersRef.current = answers;
},
[],
);
/** 自动保存(定时器 + 卸载时) */
const autoSave = useCallback(async (): Promise<void> => {
if (Object.keys(answersRef.current).length === 0) return;
await saveDraft(answersRef.current);
}, [saveDraft]);
// 进入时加载已有草稿
useEffect(() => {
loadDraft().then((saved) => {
if (saved) {
setDraft(saved);
setLastSavedAt(saved.lastSavedAt);
answersRef.current = saved.answers;
expiresAtRef.current = saved.expiresAt;
}
});
}, [loadDraft]);
// 定时自动保存
useEffect(() => {
const interval = setInterval(autoSave, getAutosaveIntervalMs());
return () => {
clearInterval(interval);
// 卸载时保存草稿
autoSave();
};
}, [autoSave]);
return {
draft,
saveDraft,
setAnswers,
clearDraft,
isSaving,
hasDraft: draft !== null && Object.keys(draft.answers).length > 0,
lastSavedAt,
loadDraft,
};
}

View File

@@ -0,0 +1,70 @@
/**
* 考试作答状态机 Hookai14
*
* 状态流转NotStarted → InProgress → AutoSaving → Submitting → Submitted
* 防止非法状态转移,控制提交时机
*/
import { useReducer, useMemo } from "react";
export type ExamState =
"NotStarted" | "InProgress" | "AutoSaving" | "Submitting" | "Submitted";
type ExamAction =
| { type: "start" }
| { type: "beginAutoSave" }
| { type: "endAutoSave" }
| { type: "beginSubmit" }
| { type: "completeSubmit" };
/** 状态转移函数(纯函数,校验合法转移) */
function reducer(state: ExamState, action: ExamAction): ExamState {
switch (action.type) {
case "start":
return state === "NotStarted" ? "InProgress" : state;
case "beginAutoSave":
return state === "InProgress" ? "AutoSaving" : state;
case "endAutoSave":
return state === "AutoSaving" ? "InProgress" : state;
case "beginSubmit":
return state === "InProgress" || state === "AutoSaving"
? "Submitting"
: state;
case "completeSubmit":
return state === "Submitting" ? "Submitted" : state;
default:
return state;
}
}
export interface UseExamStateMachineReturn {
state: ExamState;
actions: {
start: () => void;
beginAutoSave: () => void;
endAutoSave: () => void;
beginSubmit: () => void;
completeSubmit: () => void;
};
canSubmit: boolean;
}
/** 考试作答状态机 */
export function useExamStateMachine(): UseExamStateMachineReturn {
const [state, dispatch] = useReducer(reducer, "NotStarted");
const actions = useMemo(
() => ({
start: () => dispatch({ type: "start" }),
beginAutoSave: () => dispatch({ type: "beginAutoSave" }),
endAutoSave: () => dispatch({ type: "endAutoSave" }),
beginSubmit: () => dispatch({ type: "beginSubmit" }),
completeSubmit: () => dispatch({ type: "completeSubmit" }),
}),
[],
);
const canSubmit = state === "InProgress" || state === "AutoSaving";
return { state, actions, canSubmit };
}

View File

@@ -0,0 +1,109 @@
/**
* 多标签页检测 Hookai14
*
* 使用 BroadcastChannel 检测同一考试是否已在其他标签页打开
* 降级方案Safari < 15.4 不支持时使用 localStorage 事件
*/
import { useState, useEffect, useCallback } from "react";
const CHANNEL_NAME = "student-exam-guard";
type GuardMessage =
| { type: "tab-opened"; examId: string; tabId: string; ts: number }
| { type: "tab-exists"; examId: string; tabId: string; ts: number }
| { type: "tab-closed"; examId: string; tabId: string; ts: number };
export interface UseMultiTabGuardReturn {
/** 是否检测到重复标签页 */
isDuplicate: boolean;
/** 关闭警告(用户确认后) */
dismissWarning: () => void;
}
function generateTabId(): string {
return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function useMultiTabGuard(examId: string): UseMultiTabGuardReturn {
const [isDuplicate, setIsDuplicate] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const tabId = generateTabId();
// 降级方案:不支持 BroadcastChannel 时使用 localStorage
if (typeof BroadcastChannel === "undefined") {
const storageKey = `exam-active-${examId}`;
window.localStorage.setItem(
storageKey,
JSON.stringify({ tabId, ts: Date.now() }),
);
const onStorage = (e: StorageEvent): void => {
if (e.key === storageKey && e.newValue) {
try {
const other = JSON.parse(e.newValue) as { tabId: string };
if (other.tabId !== tabId && !dismissed) {
setIsDuplicate(true);
}
} catch {
// 解析失败忽略
}
}
};
window.addEventListener("storage", onStorage);
return () => {
window.localStorage.removeItem(storageKey);
window.removeEventListener("storage", onStorage);
};
}
// 主方案BroadcastChannel
const channel = new BroadcastChannel(CHANNEL_NAME);
const msg: GuardMessage = {
type: "tab-opened",
examId,
tabId,
ts: Date.now(),
};
channel.postMessage(msg);
const onMessage = (event: MessageEvent): void => {
const data = event.data as GuardMessage;
if (!data || data.tabId === tabId || data.examId !== examId) return;
if (
(data.type === "tab-opened" || data.type === "tab-exists") &&
!dismissed
) {
setIsDuplicate(true);
// 回应:我也在作答
channel.postMessage({
type: "tab-exists",
examId,
tabId,
ts: Date.now(),
} satisfies GuardMessage);
}
};
channel.addEventListener("message", onMessage);
return () => {
channel.postMessage({
type: "tab-closed",
examId,
tabId,
ts: Date.now(),
} satisfies GuardMessage);
channel.removeEventListener("message", onMessage);
channel.close();
};
}, [examId, dismissed]);
const dismissWarning = useCallback(() => {
setDismissed(true);
setIsDuplicate(false);
}, []);
return { isDuplicate, dismissWarning };
}

View File

@@ -0,0 +1,141 @@
"use client";
/**
* 通知 WebSocket Hookai14P5
*
* - 连接 push-gatewayws://localhost:8081/wsNEXT_PUBLIC_PUSH_GATEWAY_URL
* - 指数退避重连1s → 2s → 4s → 8s → … 上限 30s
* - 收到消息:派发 CustomEvent 通知页面重新查询 + BroadcastChannel 跨标签广播
* - Mock 模式NEXT_PUBLIC_API_MOCKING=enabled使用 mock-socket 模拟推送
* - 返回:{ isConnected, lastMessage, reconnect }
*
* 注意urql 不支持 react-query 的 invalidateQueries改用 CustomEvent 解耦。
* 通知页面监听 'edu-notifications-updated' 事件后调用 reexecute()。
*/
import { useCallback, useEffect, useRef, useState } from "react";
/** 通知更新事件名(供页面监听) */
export const NOTIFICATION_UPDATED_EVENT = "edu-notifications-updated";
export interface NotificationMessage {
type: string;
payload: Record<string, unknown>;
}
export interface UseNotificationsWebSocketResult {
isConnected: boolean;
lastMessage: NotificationMessage | null;
reconnect: () => void;
}
const CHANNEL_NAME = "edu-notification";
const MAX_DELAY_MS = 30000;
export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<NotificationMessage | null>(
null,
);
const wsRef = useRef<WebSocket | null>(null);
const retryRef = useRef(0);
const reconnectRef = useRef<() => void>(() => {});
useEffect(() => {
const base =
process.env.NEXT_PUBLIC_PUSH_GATEWAY_URL ?? "ws://localhost:8081";
const url = `${base}/ws`;
const channel =
typeof BroadcastChannel !== "undefined"
? new BroadcastChannel(CHANNEL_NAME)
: null;
const notifyUpdate = (): void => {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(NOTIFICATION_UPDATED_EVENT));
};
const onMessage = (raw: string): void => {
let msg: NotificationMessage;
try {
msg = JSON.parse(raw) as NotificationMessage;
} catch {
return;
}
setLastMessage(msg);
notifyUpdate();
channel?.postMessage(msg);
};
const connect = (): void => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
retryRef.current = 0;
setIsConnected(true);
};
ws.onmessage = (e: MessageEvent) => {
const raw = typeof e.data === "string" ? e.data : "";
if (raw) onMessage(raw);
};
ws.onclose = () => {
setIsConnected(false);
const delay = Math.min(1000 * 2 ** retryRef.current, MAX_DELAY_MS);
retryRef.current += 1;
connectTimer = setTimeout(connect, delay);
};
ws.onerror = () => ws.close();
};
let connectTimer: ReturnType<typeof setTimeout>;
let mockServer: { stop: () => void } | null = null;
// 跨标签广播监听:其他标签的 WebSocket 消息也会触发本标签更新
const onChannelMessage = (): void => {
notifyUpdate();
};
channel?.addEventListener("message", onChannelMessage);
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
// Mock 模式mock-socket 拦截 WebSocket周期性推送通知
void import("mock-socket").then(({ Server }) => {
const server = new Server(url);
server.on("connection", (socket) => {
const timer = setInterval(() => {
socket.send(
JSON.stringify({
type: "NotificationRequested",
payload: { ts: Date.now() },
}),
);
}, 60000);
socket.on("close", () => clearInterval(timer));
});
mockServer = server;
connect();
});
} else {
connect();
}
reconnectRef.current = (): void => {
retryRef.current = 0;
clearTimeout(connectTimer);
wsRef.current?.close();
};
return () => {
clearTimeout(connectTimer);
mockServer?.stop();
wsRef.current?.close();
channel?.removeEventListener("message", onChannelMessage);
channel?.close();
};
}, []);
const reconnect = useCallback((): void => {
reconnectRef.current();
}, []);
return { isConnected, lastMessage, reconnect };
}

View File

@@ -0,0 +1,89 @@
/**
* 服务器时间同步 Hookai14
*
* 定期轮询服务器时间,计算客户端与服务器的时间偏移
* 用于考试倒计时校正,防止客户端篡改时间
* 同步失败时保持上次偏移,标记为降级模式
*/
import { useState, useEffect, useCallback, useRef } from "react";
import type { ServerTimeSyncState } from "@/lib/exam-types";
const DEFAULT_SYNC_INTERVAL = 300; // 默认 5 分钟(秒)
const GRAPHQL_ENDPOINT =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
/** 获取同步间隔(毫秒) */
function getSyncIntervalMs(): number {
const seconds = Number(process.env.NEXT_PUBLIC_EXAM_TIME_SYNC_INTERVAL);
return (
(Number.isFinite(seconds) && seconds > 0
? seconds
: DEFAULT_SYNC_INTERVAL) * 1000
);
}
/** 查询服务器时间 */
async function fetchServerTime(): Promise<number> {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "{ serverTime }" }),
});
if (!response.ok) {
throw new Error(`服务器时间查询失败: ${response.status}`);
}
const result = (await response.json()) as { data?: { serverTime?: string } };
const serverTimeStr = result.data?.serverTime;
if (!serverTimeStr) {
throw new Error("服务器时间响应格式错误");
}
return new Date(serverTimeStr).getTime();
}
export function useServerTimeSync(): ServerTimeSyncState {
const [state, setState] = useState<ServerTimeSyncState>({
serverTime: Date.now(),
offset: 0,
isSyncing: false,
lastSyncAt: null,
isDegraded: false,
});
const offsetRef = useRef(0);
const sync = useCallback(async () => {
setState((prev) => ({ ...prev, isSyncing: true }));
const requestStart = Date.now();
try {
const serverTime = await fetchServerTime();
const requestEnd = Date.now();
const roundTrip = requestEnd - requestStart;
// 估算服务器响应时的客户端时间(取往返中点)
const estimatedClientAtServer = requestStart + roundTrip / 2;
const newOffset = serverTime - estimatedClientAtServer;
offsetRef.current = newOffset;
setState({
serverTime,
offset: newOffset,
isSyncing: false,
lastSyncAt: Date.now(),
isDegraded: false,
});
} catch {
// 同步失败:保持上次偏移,标记降级
setState((prev) => ({
...prev,
isSyncing: false,
isDegraded: true,
}));
}
}, []);
useEffect(() => {
sync();
const interval = setInterval(sync, getSyncIntervalMs());
return () => clearInterval(interval);
}, [sync]);
return state;
}

View File

@@ -0,0 +1,63 @@
/**
* 提交去重 Hookai14
*
* 生成幂等 key防止快速点击导致的重复提交
* 支持基于 crypto.randomUUID 的 key 生成与降级方案
*/
import { useState, useCallback, useRef } from "react";
export interface UseSubmitDedupReturn {
/** 幂等 key每次新提交生成新的 */
idempotencyKey: string;
/** 是否正在提交中 */
isSubmitting: boolean;
/** 标记开始提交(生成新 key + 锁定) */
markSubmitting: () => void;
/** 标记提交完成(解锁,生成新 key 供下次使用) */
markComplete: () => void;
/** 是否允许提交(未在提交中) */
canSubmit: boolean;
}
/** 生成幂等 key优先 crypto.randomUUID降级 Date+Random */
function generateIdempotencyKey(examId: string): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return `exam-${examId}-${crypto.randomUUID()}`;
}
return `exam-${examId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function useSubmitDedup(examId: string): UseSubmitDedupReturn {
const [isSubmitting, setIsSubmitting] = useState(false);
const [idempotencyKey, setIdempotencyKey] = useState(() =>
generateIdempotencyKey(examId),
);
const lockRef = useRef(false);
const markSubmitting = useCallback(() => {
if (lockRef.current) return;
lockRef.current = true;
setIdempotencyKey(generateIdempotencyKey(examId));
setIsSubmitting(true);
}, [examId]);
const markComplete = useCallback(() => {
lockRef.current = false;
setIsSubmitting(false);
setIdempotencyKey(generateIdempotencyKey(examId));
}, [examId]);
const canSubmit = !lockRef.current;
return {
idempotencyKey,
isSubmitting,
markSubmitting,
markComplete,
canSubmit,
};
}

View File

@@ -0,0 +1,283 @@
/**
* zh-CN 国际化文案ai14
*
* 学生端全部 UI 文案集中在此,按域分区。
* 错误码 i18n 路由:依据 extensions.code 前缀映射到 errors.{prefix}.{{code}}
* 前缀IAM_ / CORE_EDU_ / BFF_STUDENT_ / GW_ / NETWORK_
*/
export const messages = {
common: {
loading: "加载中…",
error: "加载失败",
empty: "暂无数据",
retry: "重试",
cancel: "取消",
confirm: "确认",
submit: "提交",
submitting: "提交中…",
save: "保存",
saved: "已保存",
saving: "保存中…",
back: "返回",
more: "加载更多",
noMore: "没有更多了",
all: "全部",
markAllRead: "全部标记已读",
markRead: "标记已读",
unread: "未读",
read: "已读",
search: "搜索",
optional: "选填",
required: "必填",
expand: "展开",
collapse: "收起",
reload: "重新加载",
backHome: "返回首页",
degradedMode: "部分数据可能不是最新",
offlineMode: "离线模式:操作将在联网后同步",
networkError: "网络错误,请检查网络连接",
pageError: "页面出现错误",
pageErrorDesc: "抱歉,页面渲染时出现异常,请尝试重新加载。",
featureInDevelopment: "功能开发中",
featureInDevelopmentDesc: "此功能正在建设中,敬请期待。",
},
nav: {
dashboard: "仪表盘",
myHomework: "我的作业",
myExams: "我的考试",
myGrades: "我的成绩",
myAttendance: "我的考勤",
learningPath: "学习路径",
diagnostic: "学情诊断",
weakness: "薄弱知识点",
trend: "学习趋势",
textbooks: "教材",
notifications: "通知中心",
aiTutor: "AI 辅学",
},
dashboard: {
title: "学生仪表盘",
welcome: "欢迎,{name}",
upcomingHomework: "待完成作业",
upcomingExams: "即将开始的考试",
recentGrades: "最近成绩",
attendanceRate: "出勤率",
learningStreakDays: "连续学习天数",
days: "天",
noUpcomingHomework: "暂无待完成作业",
noUpcomingExams: "暂无即将开始的考试",
},
exams: {
title: "我的考试",
statusUpcoming: "未开始",
statusInProgress: "进行中",
statusSubmitted: "已提交",
statusExpired: "已结束",
start: "开始作答",
viewResult: "查看成绩",
takeExam: "进入考试",
submit: "交卷",
confirmSubmit: "确认提交试卷?提交后将无法修改。",
unansweredWarning: "还有 {count} 题未作答",
submitSuccess: "试卷已提交",
submitFailed: "提交失败,请重试",
submitFailedNetwork: "网络异常,答案已保存,稍后将自动重试提交",
timeRemaining: "剩余时间:{time}",
timeUp: "时间到,已自动提交",
alreadySubmitted: "该试卷已提交",
resultTitle: "考试结果",
score: "得分",
maxScore: "满分",
rank: "排名",
antiCheat: {
tooManyTabSwitches: "检测到多次切屏,已记录",
copyDisabled: "考试期间禁止复制",
pasteDisabled: "考试期间禁止粘贴",
fullscreenExit: "已退出全屏",
multiTabDetected: "检测到多个标签页打开同一考试",
},
draftRecovery: {
title: "检测到未完成的作答",
description:
"上次作答已自动保存(已答 {answeredCount} 题,保存于 {lastSavedAt}),是否恢复?",
restore: "恢复作答",
discard: "放弃并重新开始",
},
},
homework: {
title: "我的作业",
statusPending: "待提交",
statusSubmitted: "已提交",
statusGraded: "已批改",
statusExpired: "已截止",
statusOverdue: "已逾期",
subject: "科目",
dueAt: "截止时间",
submittedAt: "提交时间",
submit: "提交作业",
submitSuccess: "作业已提交",
submitFailed: "提交失败,请重试",
question: "第 {index} 题",
yourAnswer: "你的答案",
shortAnswerPlaceholder: "请输入你的答案",
noteLabel: "备注(选填)",
notePlaceholder: "对老师说的话…",
attachmentLabel: "附件",
attachmentPlaceholder: "附件上传功能待 ISSUE-014-05 仲裁",
attachmentHint: "支持图片 / PDF最多 5 个,单个不超过 10MB",
draftSaved: "草稿已自动保存",
answerRequired: "请填写答案",
restoreDraftTitle: "检测到未提交的草稿",
restoreDraftDesc: "是否恢复上次的作答草稿?",
restore: "恢复草稿",
discard: "不恢复",
},
grades: {
title: "我的成绩",
subject: "科目",
examName: "考试名称",
score: "得分",
maxScore: "满分",
grade: "等级",
submittedAt: "考试时间",
rank: "班级排名",
noGrades: "暂无成绩记录",
},
attendance: {
title: "我的考勤",
present: "出勤",
absent: "缺勤",
late: "迟到",
earlyLeave: "早退",
leave: "请假",
rate: "出勤率",
thisMonth: "本月",
noRecords: "暂无考勤记录",
},
learning: {
pathTitle: "学习路径",
pathSubtitle: "按知识点掌握度推荐的学习顺序",
mastery: "掌握度",
recommended: "推荐下一步",
recommendedReview: "建议复习",
recommendedPractice: "建议练习",
recommendedMastered: "已掌握",
statusLocked: "未解锁",
statusAvailable: "可学习",
statusInProgress: "学习中",
statusCompleted: "已完成",
nextAction: "推荐动作",
diagnosticTitle: "学情诊断",
diagnosticSubtitle: "各科目知识点掌握度雷达图",
weaknessTitle: "薄弱知识点",
weaknessSubtitle: "需要加强的知识点与建议",
topic: "知识点",
trendTitle: "学习趋势",
trendSubtitle: "历次考试得分趋势",
trendUp: "上升",
trendDown: "下降",
trendStable: "平稳",
average: "平均分",
highest: "最高分",
lowest: "最低分",
textbooksTitle: "教材列表",
textbooksSubtitle: "按科目浏览教材与章节",
chaptersTitle: "章节目录",
chaptersSubtitle: "教材章节与课时",
publisher: "出版社",
grade: "年级",
lessons: "{count} 课时",
progress: "进度",
noPath: "暂无学习路径数据",
noWeakness: "暂无薄弱知识点数据,继续保持!",
noTrend: "暂无学习趋势数据",
noTextbooks: "暂无教材",
noChapters: "暂无章节",
lessonCount: "{count} 节课",
completedLessons: "已完成 {done}/{total}",
},
notifications: {
title: "通知中心",
subtitle: "作业、考试、成绩与系统通知",
markAllRead: "全部标记已读",
markRead: "标记已读",
loadMore: "加载更多",
typeHomework: "作业",
typeExam: "考试",
typeGrade: "成绩",
typeSystem: "系统",
typeHomeworkDesc: "作业通知",
typeExamDesc: "考试通知",
typeGradeDesc: "成绩通知",
typeSystemDesc: "系统通知",
noNotifications: "暂无通知",
connected: "实时连接已建立",
disconnected: "实时连接已断开,正在重连…",
unreadCount: "{count} 条未读",
markAllReadSuccess: "已全部标记为已读",
markReadSuccess: "已标记为已读",
},
"ai-tutor": {
title: "AI 辅学",
subtitle: "智能答疑与学习辅导",
placeholder: "请输入你的问题…",
send: "发送",
thinking: "正在思考…",
you: "我",
ai: "AI 助手",
welcome: "你好!我是 AI 学习助手,有什么可以帮你的吗?",
errorStream: "回答生成失败,请重试",
clear: "清空对话",
inDevelopment: "AI 辅学功能开发中",
inDevelopmentDesc: "AI 辅学功能正在建设中,敬请期待。",
},
errors: {
unknown: "发生未知错误,请稍后重试",
networkError: "网络连接失败,请检查网络",
unauthorized: "登录已过期,请重新登录",
forbidden: "没有权限执行此操作",
notFound: "请求的资源不存在",
serverError: "服务器繁忙,请稍后重试",
iam: {
IAM_INVALID_CREDENTIALS: "账号或密码错误",
IAM_TOKEN_EXPIRED: "登录已过期,请重新登录",
IAM_ACCOUNT_LOCKED: "账号已被锁定,请联系管理员",
},
coreEdu: {
CORE_EDU_EXAM_NOT_FOUND: "考试不存在或已删除",
CORE_EDU_EXAM_NOT_STARTED: "考试尚未开始",
CORE_EDU_EXAM_EXPIRED: "考试已结束",
CORE_EDU_EXAM_ALREADY_SUBMITTED: "该考试已提交,无法重复提交",
CORE_EDU_HOMEWORK_NOT_FOUND: "作业不存在或已删除",
CORE_EDU_HOMEWORK_EXPIRED: "作业已截止",
CORE_EDU_HOMEWORK_ALREADY_SUBMITTED: "该作业已提交",
CORE_EDU_GRADE_NOT_FOUND: "成绩不存在",
},
bffStudent: {
BFF_STUDENT_UPSTREAM_UNAVAILABLE: "部分服务暂不可用,数据可能不完整",
BFF_STUDENT_QUERY_FAILED: "查询失败,请稍后重试",
},
gateway: {
GW_RATE_LIMITED: "请求过于频繁,请稍后再试",
GW_UNAUTHORIZED: "未登录或登录已过期",
GW_FORBIDDEN: "没有权限",
},
network: {
NETWORK_TIMEOUT: "请求超时,请检查网络后重试",
NETWORK_OFFLINE: "当前处于离线状态",
},
},
} as const;
export type Messages = typeof messages;

View File

@@ -0,0 +1,93 @@
"use client";
/**
* ErrorBoundary 组件ai14P6 硬化)
*
* 捕获子树 React 渲染异常,展示友好错误页并提供"重新加载"。
* 若 Sentry 已初始化(见 lib/observability/sentry.ts自动上报异常。
*/
import { Component, type ErrorInfo, type ReactNode } from "react";
export interface ErrorBoundaryProps {
children: ReactNode;
/** 自定义兜底 UI可选 */
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
override state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
// 上报到 Sentry若已初始化会在 window 上挂载捕获器)
if (typeof window !== "undefined") {
const capture = (
window as unknown as {
__eduCaptureException?: (e: Error, extra?: unknown) => void;
}
).__eduCaptureException;
capture?.(error, { componentStack: info.componentStack });
}
// 控制台兜底日志
if (typeof console !== "undefined") {
console.error("[student-portal] 渲染异常:", error, info);
}
}
private handleReload = (): void => {
if (typeof window !== "undefined") {
window.location.reload();
}
};
override render(): ReactNode {
if (!this.state.hasError) {
return this.props.children;
}
if (this.props.fallback) {
return this.props.fallback;
}
return (
<main
className="min-h-screen flex items-center justify-center p-xl"
role="alert"
aria-live="assertive"
>
<section className="card-paper p-xl max-w-lg w-full text-center">
<div className="mark-left py-md px-md text-left mb-lg">
<h1 className="font-serif text-2xl text-ink"></h1>
<p className="mt-sm text-sm text-ink-muted">
</p>
{this.state.error?.message ? (
<p className="mt-sm text-xs text-ink-subtle font-mono">
{this.state.error.message}
</p>
) : null}
</div>
<button
type="button"
className="btn-primary"
onClick={this.handleReload}
>
</button>
</section>
</main>
);
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,144 @@
/**
* 考试作答共享类型定义ai14
*
* 定义考试作答场景的所有视图模型与数据结构,
* 供 hooks / components / pages 共享引用。
*/
/** 题型枚举 */
export type QuestionType =
"single-choice" | "multiple-choice" | "fill-blank" | "short-answer" | "essay";
/** 答案输入(联合类型,按题型区分) */
export type AnswerInput =
| { type: "single-choice"; optionId: string }
| { type: "multiple-choice"; optionIds: string[] }
| { type: "fill-blank"; values: string[] }
| { type: "short-answer"; text: string }
| { type: "essay"; text: string };
/** 选项 */
export interface QuestionOption {
id: string;
text: string;
}
/** 题目 */
export interface ExamQuestion {
id: string;
type: QuestionType;
content: string;
options?: QuestionOption[];
score: number;
maxScore: number;
}
/** 考试详情 */
export interface ExamDetail {
id: string;
name: string;
durationSeconds: number;
startsAt: string;
expiresAt: string;
questions: ExamQuestion[];
questionCount: number;
}
/** 考试作答草稿IDB 持久化) */
export interface ExamTakingDraft {
examId: string;
studentId: string;
answers: Record<string, AnswerInput>;
startedAt: number;
serverStartedAt: number;
lastSavedAt: number | null;
durationSeconds: number;
expiresAt: string;
}
/** 可疑行为类型 */
export type ViolationType =
| "tab-switch"
| "copy"
| "paste"
| "contextmenu"
| "fullscreen-exit"
| "multi-tab"
| "window-blur";
/** 违规记录 */
export interface ViolationRecord {
type: ViolationType;
timestamp: number;
details?: string;
}
/** 题目作答结果 */
export interface QuestionResult {
questionId: string;
questionContent: string;
questionType: QuestionType;
studentAnswer: AnswerInput | null;
correctAnswer: AnswerInput | null;
isCorrect: boolean;
isAnswered: boolean;
explanation: string;
maxScore: number;
earnedScore: number;
}
/** 考试结果 */
export interface ExamResult {
examId: string;
examName: string;
score: number;
maxScore: number;
grade: string;
submittedAt: string;
durationSeconds: number;
questionResults: QuestionResult[];
}
/** 考试结果摘要 */
export interface ExamResultSummary {
totalQuestions: number;
correctCount: number;
wrongCount: number;
unansweredCount: number;
}
/** 服务器时间同步状态 */
export interface ServerTimeSyncState {
serverTime: number;
offset: number;
isSyncing: boolean;
lastSyncAt: number | null;
isDegraded: boolean;
}
/** 获取当前学生 ID从 localStorage 读取,模拟 auth */
export function getStudentId(): string {
if (typeof window === "undefined") return "ssr-placeholder";
try {
const stored = window.localStorage.getItem("edu-user");
if (stored) {
const user = JSON.parse(stored) as { id?: string };
if (user.id) return user.id;
}
} catch {
// 解析失败,使用默认值
}
return "current-student";
}
/** 构建草稿存储 key */
export function buildDraftKey(examId: string, studentId: string): string {
return `exam-draft:${examId}:${studentId}`;
}
/** 类名拼接工具(条件类名管理) */
export function cn(
...classes: Array<string | false | null | undefined>
): string {
return classes.filter(Boolean).join(" ");
}

View File

@@ -0,0 +1,173 @@
/**
* GraphQL urql client 创建ai14
*
* ARB-002 裁决Shell 暴露 GraphQL client 单例student-portal 复用。
* 当 NEXT_PUBLIC_MF_ENABLED=true 时provider.tsx 优先使用 Shell 的 useGraphQLClient。
* 当 NEXT_PUBLIC_MF_ENABLED=false独立壳模式使用本文件创建的独立 urql client。
*
* 独立 client 配置:
* - url: NEXT_PUBLIC_GRAPHQL_ENDPOINT默认 /api/v1/student/graphql
* - fetchOptions: credentials=include + Authorization 头(从 localStorage 读取 token
* - exchanges: cacheExchange + errorExchange401 → 跳转登录)+ fetchExchange
*/
import {
cacheExchange,
createClient,
errorExchange,
fetchExchange,
} from "urql";
import type { Client } from "urql";
import type { CombinedError } from "urql";
import { ERROR_CODES } from "./types";
// ---------------------------------------------------------------------------
// token 读取(与 teacher-portal auth.ts 对齐的 key
// ---------------------------------------------------------------------------
/** localStorage token 键名(与 @edu/hooks useAuth 对齐) */
const TOKEN_KEY = "edu_auth_token";
/**
* 从 localStorage 读取 access token
* @returns token 或 nullSSR 或未登录时)
*/
function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
// ---------------------------------------------------------------------------
// 401 重定向处理
// ---------------------------------------------------------------------------
/** 是否正在重定向(防止多个 401 同时触发多次跳转) */
let isRedirecting = false;
/**
* 401 错误处理:清除 token 并跳转登录页
*/
function handleUnauthorized(): void {
if (isRedirecting || typeof window === "undefined") return;
isRedirecting = true;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem("edu_auth_user");
// 跳转登录页student-portal 无独立登录页,复用 Shell 的 /login
window.location.href = "/login";
}
// ---------------------------------------------------------------------------
// 独立 urql client 创建
// ---------------------------------------------------------------------------
/**
* 创建独立的 urql client非 MF 模式使用)
*
* 配置说明:
* - cacheExchange默认文档缓存按 operationName + variables 缓存)
* - errorExchange拦截 401 错误,自动跳转登录页;拦截降级标记
* - fetchExchange实际发起 fetch 请求
*
* @returns urql Client 实例
*/
export function createStandaloneClient(): Client {
const url =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || "/api/v1/student/graphql";
const client = createClient({
url,
fetchOptions: (): Record<string, unknown> => {
const token = getStoredToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return {
credentials: "include",
headers,
};
},
exchanges: [
cacheExchange,
errorExchange({
onError: (error: CombinedError): void => {
// 检查 401 未授权
const isUnauthorized =
error.response?.status === 401 ||
error.graphQLErrors?.some(
(e) =>
e.extensions?.code === ERROR_CODES.IAM_UNAUTHORIZED ||
e.message.includes("Unauthorized"),
);
if (isUnauthorized) {
handleUnauthorized();
return;
}
// 检查降级模式标记ARB-001 §3.4 方案 B
const degradedErrors = error.graphQLErrors?.filter(
(e) => e.extensions?.degraded === true,
);
if (degradedErrors && degradedErrors.length > 0) {
// 降级模式:展示部分数据 + 降级标识(由 UI 层处理)
// 此处仅记录,不阻断流程
if (typeof console !== "undefined") {
console.warn(
"[student-portal] GraphQL 降级模式:部分数据可能不是最新",
degradedErrors,
);
}
}
},
}),
fetchExchange,
],
});
return client;
}
// ---------------------------------------------------------------------------
// client 单例管理
// ---------------------------------------------------------------------------
/** 独立模式 client 单例 */
let standaloneClient: Client | null = null;
/**
* 获取 GraphQL client独立壳模式单例
*
* ARB-002 说明:
* - MF 模式NEXT_PUBLIC_MF_ENABLED=trueShell 通过 React Context 注入 client
* 应使用 provider.tsx 的 GraphQLProvider + useGraphQLClient()
* - 独立模式NEXT_PUBLIC_MF_ENABLED=false返回本文件创建的单例 client
*
* 本函数返回独立模式的单例MF 模式请通过 provider.tsx 获取 Context 中的 client。
*
* @returns urql Client 实例(独立模式单例)
*/
export function getGraphQLClient(): Client {
if (!standaloneClient) {
standaloneClient = createStandaloneClient();
}
return standaloneClient;
}
/**
* 重置 GraphQL client 单例(测试用)
* 清除缓存的 client 实例,下次调用 getGraphQLClient() 时重新创建
*/
export function resetGraphQLClient(): void {
standaloneClient = null;
}
/**
* 判断当前是否为 MF 模式
* @returns NEXT_PUBLIC_MF_ENABLED === 'true'
*/
export function isMfMode(): boolean {
return process.env.NEXT_PUBLIC_MF_ENABLED === "true";
}

View File

@@ -0,0 +1,699 @@
/**
* GraphQL React Hooksai14
*
* 封装 urql useQuery / useMutation解包 ActionState 信封提取业务数据。
* 每个 Hook 返回统一的 UseQueryResult / UseMutationResult 结构。
*
* 16 个查询 Hook + 8 个变更 Hook对齐 types.ts 操作清单。
*
* 缓存策略02-architecture-design.md §3
* - 学生数据 30s 短缓存staleTime 30s
* - 考试题目数据 0s 禁缓存(防泄露)
* - 学习路径 5min 长缓存(低频变更)
*/
import { useMutation, useQuery } from "urql";
import type { CombinedError } from "urql";
import type {
ActionError,
ActionState,
AttendanceRecord,
ChapterNode,
ClassInfo,
CurrentUser,
ExamDetail,
ExamListItem,
GradeRecord,
HomeworkDetail,
HomeworkListItem,
LearningPathNode,
MarkAllAsReadInput,
MarkAllAsReadResult,
MarkAsReadInput,
MarkAsReadResult,
MutationResponses,
NotificationItem,
QueryResponses,
RecordExamViolationInput,
RecordExamViolationResult,
RecordPasteEventInput,
RecordPasteEventResult,
SaveExamDraftInput,
SaveExamDraftResult,
ServerTime,
StudentDashboard,
SubmitExamInput,
SubmitExamResult,
SubmitHomeworkInput,
SubmitHomeworkResult,
SubjectRef,
Textbook,
TrendDataPoint,
UpdateNotificationPreferenceInput,
UpdateNotificationPreferenceResult,
WeaknessPoint,
} from "./types";
import {
CHAPTERS_QUERY,
CURRENT_USER_QUERY,
EXAM_DETAIL_QUERY,
HOMEWORK_DETAIL_QUERY,
LEARNING_PATH_QUERY,
MARK_ALL_AS_READ_MUTATION,
MARK_AS_READ_MUTATION,
MY_ATTENDANCE_QUERY,
MY_CLASSES_QUERY,
MY_EXAMS_QUERY,
MY_GRADES_QUERY,
MY_HOMEWORK_QUERY,
MY_NOTIFICATIONS_QUERY,
MY_TREND_QUERY,
MY_WEAKNESS_QUERY,
RECORD_EXAM_VIOLATION_MUTATION,
RECORD_PASTE_EVENT_MUTATION,
SAVE_EXAM_DRAFT_MUTATION,
SERVER_TIME_QUERY,
STUDENT_DASHBOARD_QUERY,
SUBMIT_EXAM_MUTATION,
SUBMIT_HOMEWORK_MUTATION,
TEXTBOOKS_QUERY,
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
} from "./operations";
// ---------------------------------------------------------------------------
// 通用 Hook 返回类型
// ---------------------------------------------------------------------------
/** 查询 Hook 统一返回结构 */
export interface UseQueryResult<T> {
/** 解包后的业务数据ActionState.data */
data: T | null;
/** 是否正在请求 */
fetching: boolean;
/** urql 错误(网络/GraphQL 顶层错误) */
error: CombinedError | undefined;
/** 数据是否 stale */
stale: boolean;
/** ActionState.success */
success: boolean;
/** ActionState.errors */
errors: ActionError[];
/** 降级模式标记 */
degraded: boolean;
/** 降级原因 */
degradedReason: string | undefined;
/** 重新执行查询 */
reexecute: (opts?: Record<string, unknown>) => void;
}
/** 变更 Hook 统一返回结构 */
export interface UseMutationResult<T, V> {
/** 解包后的业务数据ActionState.data */
data: T | null;
/** 是否正在请求 */
fetching: boolean;
/** urql 错误 */
error: CombinedError | undefined;
/** ActionState.success */
success: boolean;
/** ActionState.errors */
errors: ActionError[];
/** 降级模式标记 */
degraded: boolean;
/** 降级原因 */
degradedReason: string | undefined;
/** 触发变更 */
execute: (variables: V) => void;
}
/** 通知列表查询响应数据 */
interface NotificationsData {
items: NotificationItem[];
totalCount: number;
unreadCount: number;
}
// ---------------------------------------------------------------------------
// ActionState 解包工具
// ---------------------------------------------------------------------------
/**
* 从 urql 响应数据中解包 ActionState
* @param state ActionState 或 null/undefined
* @returns 解包后的数据或 null
*/
function unwrapData<T>(state: ActionState<T> | null | undefined): T | null {
return state?.data ?? null;
}
/** 从 ActionState 提取 success */
function getSuccess<T>(state: ActionState<T> | null | undefined): boolean {
return state?.success ?? false;
}
/** 从 ActionState 提取 errors */
function getErrors<T>(state: ActionState<T> | null | undefined): ActionError[] {
return state?.errors ?? [];
}
/** 从 ActionState 提取 degraded 标记 */
function getDegraded<T>(state: ActionState<T> | null | undefined): boolean {
return state?.extensions?.degraded ?? false;
}
/** 从 ActionState 提取 degradedReason */
function getDegradedReason<T>(
state: ActionState<T> | null | undefined,
): string | undefined {
return state?.extensions?.degradedReason;
}
// ---------------------------------------------------------------------------
// 查询 Hooks16 个)
// ---------------------------------------------------------------------------
/** 当前学生信息 + 权限 + 数据范围 */
export function useCurrentUser(): UseQueryResult<CurrentUser> {
const [result, reexecute] = useQuery<{
currentUser: ActionState<CurrentUser>;
}>({ query: CURRENT_USER_QUERY });
const state = result.data?.currentUser;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的班级列表 */
export function useMyClasses(): UseQueryResult<ClassInfo[]> {
const [result, reexecute] = useQuery<{
myClasses: ActionState<ClassInfo[]>;
}>({ query: MY_CLASSES_QUERY });
const state = result.data?.myClasses;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的考试列表 */
export function useMyExams(): UseQueryResult<ExamListItem[]> {
const [result, reexecute] = useQuery<{
myExams: ActionState<ExamListItem[]>;
}>({ query: MY_EXAMS_QUERY });
const state = result.data?.myExams;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 考试详情(含题目,禁缓存防泄露) */
export function useExamDetail(examId: string): UseQueryResult<ExamDetail> {
const [result, reexecute] = useQuery<{
examDetail: ActionState<ExamDetail>;
}>({
query: EXAM_DETAIL_QUERY,
variables: { examId },
// 考试题目数据强制禁缓存02-architecture-design §3
context: { additionalTypenames: [], requestPolicy: "network-only" },
});
const state = result.data?.examDetail;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的作业列表 */
export function useMyHomework(): UseQueryResult<HomeworkListItem[]> {
const [result, reexecute] = useQuery<{
myHomework: ActionState<HomeworkListItem[]>;
}>({ query: MY_HOMEWORK_QUERY });
const state = result.data?.myHomework;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 作业详情(含题目) */
export function useHomeworkDetail(
homeworkId: string,
): UseQueryResult<HomeworkDetail> {
const [result, reexecute] = useQuery<{
homeworkDetail: ActionState<HomeworkDetail>;
}>({
query: HOMEWORK_DETAIL_QUERY,
variables: { homeworkId },
});
const state = result.data?.homeworkDetail;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的成绩列表 */
export function useMyGrades(): UseQueryResult<GradeRecord[]> {
const [result, reexecute] = useQuery<{
myGrades: ActionState<GradeRecord[]>;
}>({ query: MY_GRADES_QUERY });
const state = result.data?.myGrades;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的考勤记录 */
export function useMyAttendance(): UseQueryResult<AttendanceRecord[]> {
const [result, reexecute] = useQuery<{
myAttendance: ActionState<AttendanceRecord[]>;
}>({ query: MY_ATTENDANCE_QUERY });
const state = result.data?.myAttendance;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 教材列表 */
export function useTextbooks(): UseQueryResult<Textbook[]> {
const [result, reexecute] = useQuery<{
textbooks: ActionState<Textbook[]>;
}>({ query: TEXTBOOKS_QUERY });
const state = result.data?.textbooks;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 章节列表(树形结构,按教材 ID 查询) */
export function useChapters(textbookId: string): UseQueryResult<ChapterNode[]> {
const [result, reexecute] = useQuery<{
chapters: ActionState<ChapterNode[]>;
}>({
query: CHAPTERS_QUERY,
variables: { textbookId },
});
const state = result.data?.chapters;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 学习路径8 知识点 + 掌握度) */
export function useLearningPath(): UseQueryResult<LearningPathNode[]> {
const [result, reexecute] = useQuery<{
learningPath: ActionState<LearningPathNode[]>;
}>({ query: LEARNING_PATH_QUERY });
const state = result.data?.learningPath;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 学生仪表盘聚合数据 */
export function useStudentDashboard(): UseQueryResult<StudentDashboard> {
const [result, reexecute] = useQuery<{
studentDashboard: ActionState<StudentDashboard>;
}>({ query: STUDENT_DASHBOARD_QUERY });
const state = result.data?.studentDashboard;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 我的薄弱点(错题本) */
export function useMyWeakness(): UseQueryResult<WeaknessPoint[]> {
const [result, reexecute] = useQuery<{
myWeakness: ActionState<WeaknessPoint[]>;
}>({ query: MY_WEAKNESS_QUERY });
const state = result.data?.myWeakness;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 学习趋势7 数据点) */
export function useMyTrend(): UseQueryResult<TrendDataPoint[]> {
const [result, reexecute] = useQuery<{
myTrend: ActionState<TrendDataPoint[]>;
}>({ query: MY_TREND_QUERY });
const state = result.data?.myTrend;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 通知列表(分页查询) */
export function useMyNotifications(
first: number = 10,
after?: string,
): UseQueryResult<NotificationsData> {
const [result, reexecute] = useQuery<{
myNotifications: ActionState<NotificationsData>;
}>({
query: MY_NOTIFICATIONS_QUERY,
variables: { first, after },
});
const state = result.data?.myNotifications;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 服务器时间(倒计时校正) */
export function useServerTime(): UseQueryResult<ServerTime> {
const [result, reexecute] = useQuery<{
serverTime: ActionState<ServerTime>;
}>({ query: SERVER_TIME_QUERY });
const state = result.data?.serverTime;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
// ---------------------------------------------------------------------------
// 变更 Hooks8 个)
// ---------------------------------------------------------------------------
/** 提交作业 */
export function useSubmitHomework(): UseMutationResult<
SubmitHomeworkResult,
SubmitHomeworkInput
> {
const [result, execute] = useMutation<{
submitHomework: ActionState<SubmitHomeworkResult>;
}>(SUBMIT_HOMEWORK_MUTATION);
const state = result.data?.submitHomework;
const executeMutation = (input: SubmitHomeworkInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 提交考试 */
export function useSubmitExam(): UseMutationResult<
SubmitExamResult,
SubmitExamInput
> {
const [result, execute] = useMutation<{
submitExam: ActionState<SubmitExamResult>;
}>(SUBMIT_EXAM_MUTATION);
const state = result.data?.submitExam;
const executeMutation = (input: SubmitExamInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 保存考试草稿(自动保存) */
export function useSaveExamDraft(): UseMutationResult<
SaveExamDraftResult,
SaveExamDraftInput
> {
const [result, execute] = useMutation<{
saveExamDraft: ActionState<SaveExamDraftResult>;
}>(SAVE_EXAM_DRAFT_MUTATION);
const state = result.data?.saveExamDraft;
const executeMutation = (input: SaveExamDraftInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 标记通知已读 */
export function useMarkAsRead(): UseMutationResult<
MarkAsReadResult,
MarkAsReadInput
> {
const [result, execute] = useMutation<{
markAsRead: ActionState<MarkAsReadResult>;
}>(MARK_AS_READ_MUTATION);
const state = result.data?.markAsRead;
const executeMutation = (input: MarkAsReadInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 批量标记已读 */
export function useMarkAllAsRead(): UseMutationResult<
MarkAllAsReadResult,
MarkAllAsReadInput
> {
const [result, execute] = useMutation<{
markAllAsRead: ActionState<MarkAllAsReadResult>;
}>(MARK_ALL_AS_READ_MUTATION);
const state = result.data?.markAllAsRead;
const executeMutation = (input: MarkAllAsReadInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 记录考试违规(防作弊) */
export function useRecordExamViolation(): UseMutationResult<
RecordExamViolationResult,
RecordExamViolationInput
> {
const [result, execute] = useMutation<{
recordExamViolation: ActionState<RecordExamViolationResult>;
}>(RECORD_EXAM_VIOLATION_MUTATION);
const state = result.data?.recordExamViolation;
const executeMutation = (input: RecordExamViolationInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 记录粘贴事件(防作弊) */
export function useRecordPasteEvent(): UseMutationResult<
RecordPasteEventResult,
RecordPasteEventInput
> {
const [result, execute] = useMutation<{
recordPasteEvent: ActionState<RecordPasteEventResult>;
}>(RECORD_PASTE_EVENT_MUTATION);
const state = result.data?.recordPasteEvent;
const executeMutation = (input: RecordPasteEventInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 更新通知偏好 */
export function useUpdateNotificationPreference(): UseMutationResult<
UpdateNotificationPreferenceResult,
UpdateNotificationPreferenceInput
> {
const [result, execute] = useMutation<{
updateNotificationPreference: ActionState<UpdateNotificationPreferenceResult>;
}>(UPDATE_NOTIFICATION_PREFERENCE_MUTATION);
const state = result.data?.updateNotificationPreference;
const executeMutation = (input: UpdateNotificationPreferenceInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
// ---------------------------------------------------------------------------
// 类型导出(供响应映射使用)
// ---------------------------------------------------------------------------
export type { QueryResponses, MutationResponses };
export type { SubjectRef };

View File

@@ -0,0 +1,145 @@
/**
* student-portal GraphQL 层 barrel exportsai14
*
* 统一导出 GraphQL client、Provider、类型、操作字符串、Hooks。
* 业务组件通过 `@/lib/graphql` 统一引入。
*
* 导出分类:
* - clientcreateStandaloneClient, getGraphQLClient, resetGraphQLClient, isMfMode
* - providerGraphQLProvider, StandaloneGraphQLProvider
* - typesActionState, ActionError, 所有实体类型, 错误码常量
* - operations所有 gql tagged 操作字符串
* - hooks所有 24 个 React Hooks
*/
// client
export {
createStandaloneClient,
getGraphQLClient,
resetGraphQLClient,
isMfMode,
} from "./client";
// provider
export { GraphQLProvider, StandaloneGraphQLProvider } from "./provider";
export type { GraphQLProviderProps } from "./provider";
// types
export type {
ActionError,
ActionExtensions,
ActionState,
AnswerInput,
AttendanceRecord,
AttendanceStatus,
ChapterNode,
ClassInfo,
CurrentUser,
DashboardExam,
DashboardGrade,
DashboardHomework,
DataScope,
ExamDetail,
ExamListItem,
ExamQuestion,
ExamStatus,
GradeRecord,
HomeworkDetail,
HomeworkListItem,
HomeworkQuestion,
HomeworkStatus,
LearningPathNode,
LearningPathStatus,
MarkAllAsReadInput,
MarkAllAsReadResult,
MarkAsReadInput,
MarkAsReadResult,
MutationOperationName,
MutationResponses,
NotificationItem,
NotificationType,
OperationName,
QueryOperationName,
QueryResponses,
QuestionOption,
QuestionType,
RecommendedAction,
RecordExamViolationInput,
RecordExamViolationResult,
RecordPasteEventInput,
RecordPasteEventResult,
SaveExamDraftInput,
SaveExamDraftResult,
ServerTime,
StudentDashboard,
SubjectRef,
SubmitExamInput,
SubmitExamResult,
SubmitHomeworkInput,
SubmitHomeworkResult,
Textbook,
TrendDataPoint,
UpdateNotificationPreferenceInput,
UpdateNotificationPreferenceResult,
WeaknessPoint,
} from "./types";
export { ERROR_CODES, ERROR_CODE_PREFIX, getErrorI18nPrefix } from "./types";
// operations
export {
CHAPTERS_QUERY,
CURRENT_USER_QUERY,
EXAM_DETAIL_QUERY,
HOMEWORK_DETAIL_QUERY,
LEARNING_PATH_QUERY,
MARK_ALL_AS_READ_MUTATION,
MARK_AS_READ_MUTATION,
MY_ATTENDANCE_QUERY,
MY_CLASSES_QUERY,
MY_EXAMS_QUERY,
MY_GRADES_QUERY,
MY_HOMEWORK_QUERY,
MY_NOTIFICATIONS_QUERY,
MY_TREND_QUERY,
MY_WEAKNESS_QUERY,
RECORD_EXAM_VIOLATION_MUTATION,
RECORD_PASTE_EVENT_MUTATION,
SAVE_EXAM_DRAFT_MUTATION,
SERVER_TIME_QUERY,
STUDENT_DASHBOARD_QUERY,
SUBMIT_EXAM_MUTATION,
SUBMIT_HOMEWORK_MUTATION,
TEXTBOOKS_QUERY,
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
} from "./operations";
// hooks
export {
useChapters,
useCurrentUser,
useExamDetail,
useHomeworkDetail,
useLearningPath,
useMarkAllAsRead,
useMarkAsRead,
useMyAttendance,
useMyClasses,
useMyExams,
useMyGrades,
useMyHomework,
useMyNotifications,
useMyTrend,
useMyWeakness,
useRecordExamViolation,
useRecordPasteEvent,
useSaveExamDraft,
useServerTime,
useStudentDashboard,
useSubmitExam,
useSubmitHomework,
useTextbooks,
useUpdateNotificationPreference,
} from "./hooks";
export type { UseMutationResult, UseQueryResult } from "./hooks";

View File

@@ -0,0 +1,716 @@
/**
* GraphQL 操作字符串定义ai14
*
* 使用 urql 的 gql tag 定义所有 24 个 GraphQL 操作16 查询 + 8 变更)。
* 操作名与 MSW handlers.ts 的 operationName 分发对齐。
*
* 所有操作的响应均包裹在 ActionState 信封中:
* query/mutation → { data: { <op>: ActionState<T> } }
*
* 查询变量类型见 types.tsHook 层负责解包 ActionState 提取 data。
*/
import { gql } from "urql";
// ===========================================================================
// 查询操作16 个)
// ===========================================================================
/** 当前学生信息(含权限 + 视口 + 数据范围) */
export const CURRENT_USER_QUERY = gql`
query currentUser {
currentUser {
success
errors {
code
message
field
}
data {
id
name
studentNo
grade
avatar
roles
permissions
dataScope
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 我的班级列表 */
export const MY_CLASSES_QUERY = gql`
query myClasses {
myClasses {
success
errors {
code
message
field
}
data {
id
name
homeroomTeacher
studentCount
grade
year
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 我的考试列表 */
export const MY_EXAMS_QUERY = gql`
query myExams {
myExams {
success
errors {
code
message
}
data {
id
name
subject {
id
name
}
status
startsAt
expiresAt
durationSeconds
questionCount
totalScore
submittedAt
submissionId
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 考试详情(含题目) */
export const EXAM_DETAIL_QUERY = gql`
query examDetail($examId: ID!) {
examDetail(examId: $examId) {
success
errors {
code
message
}
data {
id
name
subject {
id
name
}
status
startsAt
expiresAt
durationSeconds
totalScore
questionCount
questions {
id
type
content
options {
id
text
}
score
maxScore
}
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 我的作业列表 */
export const MY_HOMEWORK_QUERY = gql`
query myHomework {
myHomework {
success
errors {
code
message
}
data {
id
title
subject {
id
name
}
status
dueAt
assignedAt
questionCount
totalScore
submittedAt
gradedAt
score
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 作业详情(含题目) */
export const HOMEWORK_DETAIL_QUERY = gql`
query homeworkDetail($homeworkId: ID!) {
homeworkDetail(homeworkId: $homeworkId) {
success
errors {
code
message
}
data {
id
title
subject {
id
name
}
status
dueAt
assignedAt
questionCount
totalScore
questions {
id
type
content
options {
id
text
}
maxScore
}
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 我的成绩列表 */
export const MY_GRADES_QUERY = gql`
query myGrades {
myGrades {
success
errors {
code
message
}
data {
id
examId
examName
subject {
id
name
}
score
maxScore
grade
rank
submittedAt
gradedAt
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 我的考勤记录 */
export const MY_ATTENDANCE_QUERY = gql`
query myAttendance {
myAttendance {
success
errors {
code
message
}
data {
id
date
status
subject {
id
name
}
lateMinutes
reason
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 教材列表 */
export const TEXTBOOKS_QUERY = gql`
query textbooks {
textbooks {
success
errors {
code
message
}
data {
id
name
subject {
id
name
}
version
author
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 章节列表(树形结构) */
export const CHAPTERS_QUERY = gql`
query chapters($textbookId: ID!) {
chapters(textbookId: $textbookId) {
success
errors {
code
message
}
data {
id
textbookId
name
level
parentId
order
children {
id
textbookId
name
level
parentId
order
children {
id
textbookId
name
level
parentId
order
children {
id
textbookId
name
level
parentId
order
}
}
}
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 学习路径8 知识点 + 掌握度) */
export const LEARNING_PATH_QUERY = gql`
query learningPath {
learningPath {
success
errors {
code
message
}
data {
id
name
knowledgePointId
status
mastery
dependencies
recommendedOrder
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 学生仪表盘聚合数据 */
export const STUDENT_DASHBOARD_QUERY = gql`
query studentDashboard {
studentDashboard {
success
errors {
code
message
}
data {
upcomingHomework {
id
title
dueAt
subject {
id
name
}
status
}
upcomingExams {
id
name
startsAt
expiresAt
durationSeconds
subject {
id
name
}
status
}
recentGrades {
id
examName
score
maxScore
grade
submittedAt
}
attendanceRate
learningStreakDays
avgScore
classRank
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 我的薄弱点(错题本) */
export const MY_WEAKNESS_QUERY = gql`
query myWeakness {
myWeakness {
success
errors {
code
message
}
data {
id
questionId
questionContent
subject {
id
name
}
knowledgePointId
knowledgePointName
mastery
wrongCount
lastWrongAt
recommendedAction
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 学习趋势7 数据点) */
export const MY_TREND_QUERY = gql`
query myTrend {
myTrend {
success
errors {
code
message
}
data {
date
score
examName
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 通知列表 */
export const MY_NOTIFICATIONS_QUERY = gql`
query myNotifications($first: Int, $after: String) {
myNotifications(first: $first, after: $after) {
success
errors {
code
message
}
data {
items {
id
type
title
content
read
createdAt
}
totalCount
unreadCount
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 服务器时间(倒计时校正用) */
export const SERVER_TIME_QUERY = gql`
query serverTime {
serverTime {
success
errors {
code
message
}
data {
timestamp
offset
}
extensions {
degraded
degradedReason
}
}
}
`;
// ===========================================================================
// 变更操作8 个)
// ===========================================================================
/** 提交作业 */
export const SUBMIT_HOMEWORK_MUTATION = gql`
mutation submitHomework($input: SubmitHomeworkInput!) {
submitHomework(input: $input) {
success
errors {
code
message
field
}
data {
submissionId
submittedAt
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 提交考试 */
export const SUBMIT_EXAM_MUTATION = gql`
mutation submitExam($input: SubmitExamInput!) {
submitExam(input: $input) {
success
errors {
code
message
field
}
data {
submissionId
submittedAt
score
maxScore
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 保存考试草稿(自动保存) */
export const SAVE_EXAM_DRAFT_MUTATION = gql`
mutation saveExamDraft($input: SaveExamDraftInput!) {
saveExamDraft(input: $input) {
success
errors {
code
message
}
data {
savedAt
ok
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 标记通知已读 */
export const MARK_AS_READ_MUTATION = gql`
mutation markAsRead($input: MarkAsReadInput!) {
markAsRead(input: $input) {
success
errors {
code
message
}
data {
notificationId
read
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 批量标记已读 */
export const MARK_ALL_AS_READ_MUTATION = gql`
mutation markAllAsRead($input: MarkAllAsReadInput) {
markAllAsRead(input: $input) {
success
errors {
code
message
}
data {
updatedCount
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 记录考试违规(防作弊) */
export const RECORD_EXAM_VIOLATION_MUTATION = gql`
mutation recordExamViolation($input: RecordExamViolationInput!) {
recordExamViolation(input: $input) {
success
errors {
code
message
}
data {
recorded
violationCount
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 记录粘贴事件(防作弊) */
export const RECORD_PASTE_EVENT_MUTATION = gql`
mutation recordPasteEvent($input: RecordPasteEventInput!) {
recordPasteEvent(input: $input) {
success
errors {
code
message
}
data {
recorded
}
extensions {
degraded
degradedReason
}
}
}
`;
/** 更新通知偏好 */
export const UPDATE_NOTIFICATION_PREFERENCE_MUTATION = gql`
mutation updateNotificationPreference(
$input: UpdateNotificationPreferenceInput!
) {
updateNotificationPreference(input: $input) {
success
errors {
code
message
}
data {
type
enabled
updatedAt
}
extensions {
degraded
degradedReason
}
}
}
`;

View File

@@ -0,0 +1,134 @@
/**
* GraphQL Provider 组件ai14
*
* ARB-002 裁裁Shell 暴露 GraphQL client 单例。
* - MF 模式NEXT_PUBLIC_MF_ENABLED=true优先使用 Shell 暴露的 useGraphQLClient
* 若 Shell 尚未暴露,降级为独立 client打印警告
* - 独立模式NEXT_PUBLIC_MF_ENABLED=false使用 createStandaloneClient() 单例
*
* 用法:
* <GraphQLProvider>
* <App />
* </GraphQLProvider>
*
* MF 模式下 Shell 也可通过 client prop 注入:
* <GraphQLProvider client={shellClient}>
* <App />
* </GraphQLProvider>
*/
"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Provider } from "urql";
import type { Client } from "urql";
import { createStandaloneClient, getGraphQLClient, isMfMode } from "./client";
/** GraphQLProvider 组件 Props */
export interface GraphQLProviderProps {
/** 子组件树 */
children: ReactNode;
/**
* 外部注入的 clientMF 模式下由 Shell 注入)
* 未提供时:
* - 独立模式:使用 getGraphQLClient() 单例
* - MF 模式:尝试从 Shell 动态加载,失败则降级为独立 client
*/
client?: Client;
}
/**
* 尝试动态加载 Shell 暴露的 useGraphQLClient hook
*
* ARB-002Shell 通过 @edu/hooks 暴露 useGraphQLClient。
* 当前 @edu/hooks 尚未实现该 hook此处做运行时探测
* - 若可用:返回 Shell 的 client
* - 若不可用:返回 null降级为独立 client
*
* @returns Promise<Client | null>
*/
async function tryLoadShellClient(): Promise<Client | null> {
try {
const mod = await import("@edu/hooks");
const hooks = mod as Record<string, unknown>;
const useGraphQLClient = hooks["useGraphQLClient"];
if (typeof useGraphQLClient === "function") {
// useGraphQLClient 是 React Hook无法在异步上下文调用
// 此处仅做存在性检测;实际使用由 MF Shell 在上层 Provider 注入
// 当 Shell 就绪后,应通过 client prop 传入或由 Shell Provider 包裹
return null;
}
return null;
} catch {
// @edu/hooks 加载失败(可能 MF 未启用或包未就绪)
return null;
}
}
/**
* GraphQLProvider — 提供 urql client 给组件树
*
* 根据运行模式自动选择 client
* 1. 若 client prop 提供Shell 注入):直接使用
* 2. 独立模式:使用 getGraphQLClient() 单例
* 3. MF 模式(无 client prop尝试动态加载 Shell client失败降级为独立 client
*/
export function GraphQLProvider({
children,
client: clientProp,
}: GraphQLProviderProps): ReactNode {
const [activeClient, setActiveClient] = useState<Client>(() => {
// 优先使用外部注入的 client
if (clientProp) {
return clientProp;
}
// 独立模式直接返回单例
if (!isMfMode()) {
return getGraphQLClient();
}
// MF 模式:先用独立 client 作为降级,后续异步尝试加载 Shell client
return getGraphQLClient();
});
// MF 模式下异步探测 Shell 是否暴露 useGraphQLClient
useEffect(() => {
if (!isMfMode() || clientProp) {
return;
}
let mounted = true;
tryLoadShellClient().then((shellClient) => {
if (!mounted || !shellClient) {
// Shell 未就绪,保持独立 client已在初始 state 中设置)
if (isMfMode()) {
console.warn(
"[student-portal] ARB-002Shell 未暴露 useGraphQLClient降级为独立 urql client",
);
}
return;
}
setActiveClient(shellClient);
});
return (): void => {
mounted = false;
};
}, [clientProp]);
return <Provider value={activeClient}>{children}</Provider>;
}
/**
* 用于测试场景的 Provider强制独立 client不受 MF 配置影响)
* @param props 组件 Props
* @returns ReactNode
*/
export function StandaloneGraphQLProvider({
children,
}: {
children: ReactNode;
}): ReactNode {
const client = createStandaloneClient();
return <Provider value={client}>{children}</Provider>;
}

View File

@@ -0,0 +1,597 @@
/**
* student-portal GraphQL 类型定义ai14
*
* 对齐 student-bff GraphQL schemaARB-001 ActionState 信封)。
* 所有 GraphQL 操作的响应均包裹在 ActionState<T> 信封中:
* { success, errors, data, extensions }
* Hook 层负责解包并提取 data 字段。
*
* 错误码前缀known-issues §2.15
* - IAM_*iam 服务
* - CORE_EDU_*core-edu 服务(考试/作业/成绩/考勤统一前缀)
* - BFF_STUDENT_*student-bff 聚合层
* - GW_*api-gateway
*/
// ---------------------------------------------------------------------------
// ActionState 信封类型ARB-001 §1.3 + 总裁裁决 §3.4 方案 B 降级模式)
// ---------------------------------------------------------------------------
/** ActionState 错误项 */
export interface ActionError {
/** 错误码,前缀标识来源服务(如 BFF_STUDENT_UPSTREAM_UNAVAILABLE */
code: string;
/** 人类可读消息(已 i18n 路由,前端可直接展示) */
message: string;
/** 关联字段(如校验错误指向具体输入字段) */
field?: string;
/** 额外详情(结构化,按 code 解析) */
details?: unknown;
}
/** ActionState 降级扩展§3.4 方案 B */
export interface ActionExtensions {
/** 降级模式标记:部分上游不可用时返回部分数据 */
degraded?: boolean;
/** 降级原因说明 */
degradedReason?: string;
/** 部分聚合失败标记 */
partial?: boolean;
/** 链路追踪 ID */
traceId?: string;
}
/** ActionState 统一响应信封 */
export interface ActionState<T> {
/** 操作是否成功 */
success: boolean;
/** 错误列表(成功时空数组) */
errors: ActionError[];
/** 业务数据(失败时为 null */
data: T | null;
/** 扩展信息(降级标记等) */
extensions?: ActionExtensions;
}
// ---------------------------------------------------------------------------
// 通用领域类型
// ---------------------------------------------------------------------------
/** 学科引用 */
export interface SubjectRef {
id: string;
name: string;
}
/** 考试状态 */
export type ExamStatus =
"not_started" | "in_progress" | "submitted" | "graded" | "expired";
/** 作业状态 */
export type HomeworkStatus = "pending" | "submitted" | "graded" | "overdue";
/** 出勤状态 */
export type AttendanceStatus = "present" | "absent" | "late";
/** 通知类型 */
export type NotificationType = "homework" | "exam" | "grade" | "system";
/** 题目类型 */
export type QuestionType =
"single-choice" | "multiple-choice" | "fill-blank" | "short-answer" | "essay";
/** 学习路径节点状态 */
export type LearningPathStatus =
"locked" | "available" | "in-progress" | "completed";
/** 推荐动作 */
export type RecommendedAction = "review" | "practice" | "mastered";
/** 数据范围(学生固定 L0 */
export type DataScope = "L0" | "L1" | "L2" | "L3" | "L4" | "L5";
// ---------------------------------------------------------------------------
// 实体类型(与 fixture JSON 对齐)
// ---------------------------------------------------------------------------
/** 当前学生信息currentUser 响应数据) */
export interface CurrentUser {
id: string;
name: string;
studentNo: string;
grade: string;
avatar: string | null;
roles: string[];
permissions: string[];
dataScope: DataScope;
}
/** 班级信息 */
export interface ClassInfo {
id: string;
name: string;
homeroomTeacher: string;
studentCount: number;
grade: string;
year: number;
}
/** 考试列表项 */
export interface ExamListItem {
id: string;
name: string;
subject: SubjectRef;
status: ExamStatus;
startsAt: string;
expiresAt: string;
durationSeconds: number;
questionCount: number;
totalScore: number;
/** 已提交时携带 */
submittedAt?: string;
submissionId?: string;
}
/** 题目选项 */
export interface QuestionOption {
id: string;
text: string;
}
/** 题目 */
export interface ExamQuestion {
id: string;
type: QuestionType;
content: string;
options: QuestionOption[];
score: number;
maxScore: number;
}
/** 考试详情(含题目) */
export interface ExamDetail {
id: string;
name: string;
subject: SubjectRef;
status: ExamStatus;
startsAt: string;
expiresAt: string;
durationSeconds: number;
totalScore: number;
questionCount: number;
questions: ExamQuestion[];
}
/** 作业列表项 */
export interface HomeworkListItem {
id: string;
title: string;
subject: SubjectRef;
status: HomeworkStatus;
dueAt: string;
assignedAt: string;
questionCount: number;
totalScore: number;
submittedAt?: string;
gradedAt?: string;
score?: number;
}
/** 作业题目 */
export interface HomeworkQuestion {
id: string;
type: QuestionType;
content: string;
options: QuestionOption[];
maxScore: number;
}
/** 作业详情(含题目) */
export interface HomeworkDetail {
id: string;
title: string;
subject: SubjectRef;
status: HomeworkStatus;
dueAt: string;
assignedAt: string;
questionCount: number;
totalScore: number;
questions: HomeworkQuestion[];
}
/** 成绩记录 */
export interface GradeRecord {
id: string;
examId: string;
examName: string;
subject: SubjectRef;
score: number;
maxScore: number;
grade: string;
rank: number;
submittedAt: string;
gradedAt: string;
}
/** 考勤记录 */
export interface AttendanceRecord {
id: string;
date: string;
status: AttendanceStatus;
subject: SubjectRef;
/** 迟到时携带的迟到分钟数 */
lateMinutes?: number;
/** 缺勤原因 */
reason?: string;
}
/** 教材 */
export interface Textbook {
id: string;
name: string;
subject: SubjectRef;
version: string;
author: string;
}
/** 章节节点树形结构3 级) */
export interface ChapterNode {
id: string;
textbookId: string;
name: string;
level: 1 | 2 | 3;
parentId: string | null;
order: number;
children: ChapterNode[];
}
/** 学习路径节点 */
export interface LearningPathNode {
id: string;
name: string;
knowledgePointId: string;
status: LearningPathStatus;
mastery: number;
dependencies: string[];
recommendedOrder: number;
}
/** 仪表盘即将到期的作业 */
export interface DashboardHomework {
id: string;
title: string;
dueAt: string;
subject: SubjectRef;
status: HomeworkStatus;
}
/** 仪表盘即将到来的考试 */
export interface DashboardExam {
id: string;
name: string;
startsAt: string;
expiresAt: string;
durationSeconds: number;
subject: SubjectRef;
status: ExamStatus;
}
/** 仪表盘近期成绩 */
export interface DashboardGrade {
id: string;
examName: string;
score: number;
maxScore: number;
grade: string;
submittedAt: string;
}
/** 学生仪表盘聚合数据 */
export interface StudentDashboard {
upcomingHomework: DashboardHomework[];
upcomingExams: DashboardExam[];
recentGrades: DashboardGrade[];
attendanceRate: number;
learningStreakDays: number;
avgScore: number;
classRank: number;
}
/** 薄弱点(错题本) */
export interface WeaknessPoint {
id: string;
questionId: string;
questionContent: string;
subject: SubjectRef;
knowledgePointId: string;
knowledgePointName: string;
mastery: number;
wrongCount: number;
lastWrongAt: string;
recommendedAction: RecommendedAction;
}
/** 学习趋势数据点 */
export interface TrendDataPoint {
date: string;
score: number;
examName: string;
}
/** 通知项 */
export interface NotificationItem {
id: string;
type: NotificationType;
title: string;
content: string;
read: boolean;
createdAt: string;
}
/** 服务器时间 */
export interface ServerTime {
/** ISO 8601 UTC 时间戳 */
timestamp: string;
/** 服务器与客户端时间偏移(秒) */
offset: number;
}
// ---------------------------------------------------------------------------
// Mutation 输入类型
// ---------------------------------------------------------------------------
/** 答案输入(联合类型,按题型区分) */
export type AnswerInput =
| { type: "single-choice"; optionId: string }
| { type: "multiple-choice"; optionIds: string[] }
| { type: "fill-blank"; values: string[] }
| { type: "short-answer"; text: string }
| { type: "essay"; text: string; attachments?: string[] };
/** 提交作业输入 */
export interface SubmitHomeworkInput {
homeworkId: string;
answers: Array<{ questionId: string; answer: AnswerInput }>;
note?: string;
}
/** 提交考试输入 */
export interface SubmitExamInput {
examId: string;
answers: Array<{ questionId: string; answer: AnswerInput }>;
}
/** 保存考试草稿输入 */
export interface SaveExamDraftInput {
examId: string;
questionId: string;
answer: AnswerInput;
}
/** 标记已读输入 */
export interface MarkAsReadInput {
notificationId: string;
}
/** 批量标记已读输入 */
export interface MarkAllAsReadInput {
type?: NotificationType;
}
/** 记录考试违规输入 */
export interface RecordExamViolationInput {
examId: string;
violationType:
| "tab-switch"
| "copy-paste"
| "fullscreen-exit"
| "multi-tab"
| "window-blur";
timestamp: string;
duration?: number;
details?: string;
}
/** 记录粘贴事件输入 */
export interface RecordPasteEventInput {
examId: string;
questionId: string;
timestamp: string;
}
/** 更新通知偏好输入 */
export interface UpdateNotificationPreferenceInput {
type: NotificationType;
enabled: boolean;
}
// ---------------------------------------------------------------------------
// Mutation 响应数据类型
// ---------------------------------------------------------------------------
/** 提交作业响应 */
export interface SubmitHomeworkResult {
submissionId: string;
submittedAt: string;
}
/** 提交考试响应 */
export interface SubmitExamResult {
submissionId: string;
submittedAt: string;
score?: number;
maxScore?: number;
}
/** 保存考试草稿响应 */
export interface SaveExamDraftResult {
savedAt: string;
ok: boolean;
}
/** 标记已读响应 */
export interface MarkAsReadResult {
notificationId: string;
read: boolean;
}
/** 批量标记已读响应 */
export interface MarkAllAsReadResult {
updatedCount: number;
}
/** 记录考试违规响应 */
export interface RecordExamViolationResult {
recorded: boolean;
violationCount: number;
}
/** 记录粘贴事件响应 */
export interface RecordPasteEventResult {
recorded: boolean;
}
/** 更新通知偏好响应 */
export interface UpdateNotificationPreferenceResult {
type: NotificationType;
enabled: boolean;
updatedAt: string;
}
// ---------------------------------------------------------------------------
// GraphQL 操作名常量16 查询 + 8 变更)
// ---------------------------------------------------------------------------
/** 查询操作名 */
export type QueryOperationName =
| "currentUser"
| "myClasses"
| "myExams"
| "examDetail"
| "myHomework"
| "homeworkDetail"
| "myGrades"
| "myAttendance"
| "textbooks"
| "chapters"
| "learningPath"
| "studentDashboard"
| "myWeakness"
| "myTrend"
| "myNotifications"
| "serverTime";
/** 变更操作名 */
export type MutationOperationName =
| "submitHomework"
| "submitExam"
| "saveExamDraft"
| "markAsRead"
| "markAllAsRead"
| "recordExamViolation"
| "recordPasteEvent"
| "updateNotificationPreference";
/** 全部操作名 */
export type OperationName = QueryOperationName | MutationOperationName;
// ---------------------------------------------------------------------------
// GraphQL 响应映射operationName → ActionState<数据类型>
// ---------------------------------------------------------------------------
/** 查询响应映射 */
export interface QueryResponses {
currentUser: ActionState<CurrentUser>;
myClasses: ActionState<ClassInfo[]>;
myExams: ActionState<ExamListItem[]>;
examDetail: ActionState<ExamDetail>;
myHomework: ActionState<HomeworkListItem[]>;
homeworkDetail: ActionState<HomeworkDetail>;
myGrades: ActionState<GradeRecord[]>;
myAttendance: ActionState<AttendanceRecord[]>;
textbooks: ActionState<Textbook[]>;
chapters: ActionState<ChapterNode[]>;
learningPath: ActionState<LearningPathNode[]>;
studentDashboard: ActionState<StudentDashboard>;
myWeakness: ActionState<WeaknessPoint[]>;
myTrend: ActionState<TrendDataPoint[]>;
myNotifications: ActionState<{
items: NotificationItem[];
totalCount: number;
unreadCount: number;
}>;
serverTime: ActionState<ServerTime>;
}
/** 变更响应映射 */
export interface MutationResponses {
submitHomework: ActionState<SubmitHomeworkResult>;
submitExam: ActionState<SubmitExamResult>;
saveExamDraft: ActionState<SaveExamDraftResult>;
markAsRead: ActionState<MarkAsReadResult>;
markAllAsRead: ActionState<MarkAllAsReadResult>;
recordExamViolation: ActionState<RecordExamViolationResult>;
recordPasteEvent: ActionState<RecordPasteEventResult>;
updateNotificationPreference: ActionState<UpdateNotificationPreferenceResult>;
}
// ---------------------------------------------------------------------------
// 错误码常量(前缀路由 i18n key
// ---------------------------------------------------------------------------
/** 错误码前缀 */
export const ERROR_CODE_PREFIX = {
IAM: "IAM_",
CORE_EDU: "CORE_EDU_",
BFF_STUDENT: "BFF_STUDENT_",
GW: "GW_",
NETWORK: "NETWORK_",
} as const;
/** 常见错误码 */
export const ERROR_CODES = {
// student-bff 聚合层
BFF_STUDENT_UPSTREAM_UNAVAILABLE: "BFF_STUDENT_UPSTREAM_UNAVAILABLE",
BFF_STUDENT_AGGREGATION_FAILED: "BFF_STUDENT_AGGREGATION_FAILED",
BFF_STUDENT_FORBIDDEN: "BFF_STUDENT_FORBIDDEN",
BFF_STUDENT_NOT_FOUND: "BFF_STUDENT_NOT_FOUND",
BFF_STUDENT_VALIDATION_FAILED: "BFF_STUDENT_VALIDATION_FAILED",
// core-edu 教学核心
CORE_EDU_EXAM_NOT_FOUND: "CORE_EDU_EXAM_NOT_FOUND",
CORE_EDU_EXAM_NOT_AVAILABLE: "CORE_EDU_EXAM_NOT_AVAILABLE",
CORE_EDU_HOMEWORK_NOT_FOUND: "CORE_EDU_HOMEWORK_NOT_FOUND",
CORE_EDU_HOMEWORK_ALREADY_SUBMITTED: "CORE_EDU_HOMEWORK_ALREADY_SUBMITTED",
CORE_EDU_EXAM_ALREADY_SUBMITTED: "CORE_EDU_EXAM_ALREADY_SUBMITTED",
// iam 认证授权
IAM_UNAUTHORIZED: "IAM_UNAUTHORIZED",
IAM_FORBIDDEN: "IAM_FORBIDDEN",
// api-gateway 网关
GW_RATE_LIMITED: "GW_RATE_LIMITED",
GW_CIRCUIT_OPEN: "GW_CIRCUIT_OPEN",
// 前端网络层
NETWORK_TIMEOUT: "NETWORK_TIMEOUT",
NETWORK_OFFLINE: "NETWORK_OFFLINE",
} as const;
/** i18n key 前缀映射(按错误码前缀路由) */
export const ERROR_I18N_PREFIX: Record<string, string> = {
[ERROR_CODE_PREFIX.IAM]: "iam.error",
[ERROR_CODE_PREFIX.CORE_EDU]: "coreEdu.error",
[ERROR_CODE_PREFIX.BFF_STUDENT]: "bffStudent.error",
[ERROR_CODE_PREFIX.GW]: "gateway.error",
[ERROR_CODE_PREFIX.NETWORK]: "network.error",
};
/**
* 根据错误码获取 i18n key 前缀
* @param code 错误码(如 BFF_STUDENT_UPSTREAM_UNAVAILABLE
* @returns i18n key 前缀(如 bffStudent.error
*/
export function getErrorI18nPrefix(code: string): string {
for (const prefix of Object.keys(ERROR_I18N_PREFIX)) {
if (code.startsWith(prefix)) {
return ERROR_I18N_PREFIX[prefix] ?? "common.error";
}
}
return "common.error";
}

View File

@@ -0,0 +1,96 @@
/**
* Sentry 初始化与 PII 过滤ai14P6 硬化)
*
* - 仅当 NEXT_PUBLIC_SENTRY_DSN 配置时初始化
* - beforeSend 过滤学生隐私字段(姓名 / 学号 / 邮箱 / 手机号)
* - 暴露 captureException / captureMessage 包装器,并在 window 上挂载
* __eduCaptureException 供 ErrorBoundary 使用
*/
import * as Sentry from "@sentry/nextjs";
/** 需要脱敏的 PII 字段名(匹配 key不区分大小写 */
const PII_KEYS = [
"studentname",
"name",
"studentno",
"email",
"phone",
"mobile",
"idcard",
];
let initialized = false;
/** 递归移除 PII 字段(返回脱敏后的副本) */
function stripPII(input: unknown): unknown {
if (Array.isArray(input)) {
return input.map(stripPII);
}
if (input && typeof input === "object") {
const obj = input as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
if (PII_KEYS.includes(key.toLowerCase())) {
out[key] = "[REDACTED]";
} else {
out[key] = stripPII(obj[key]);
}
}
return out;
}
return input;
}
/** 初始化 Sentry仅在浏览器/服务端入口调用一次) */
export function initSentry(): void {
if (initialized) return;
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (!dsn) return;
Sentry.init({
dsn,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1,
beforeSend(event) {
// 从 unknown 转换:过滤 PII 后回传事件
const cleaned = stripPII(event);
return cleaned as typeof event;
},
});
initialized = true;
// 挂载捕获器供 ErrorBoundary 使用
if (typeof window !== "undefined") {
(
window as unknown as {
__eduCaptureException?: (e: Error, extra?: unknown) => void;
}
).__eduCaptureException = (err: Error, extra?: unknown) => {
Sentry.captureException(
err,
extra !== undefined ? { extra: { detail: extra } } : undefined,
);
};
}
}
/** 捕获异常(未初始化时降级为 console.error */
export function captureException(err: unknown): void {
if (initialized) {
Sentry.captureException(err);
} else if (typeof console !== "undefined") {
console.error("[student-portal]", err);
}
}
/** 捕获消息(未初始化时降级为 console */
export function captureMessage(message: string): void {
if (initialized) {
Sentry.captureMessage(message);
} else if (typeof console !== "undefined") {
console.warn("[student-portal]", message);
}
}
export default Sentry;

View File

@@ -0,0 +1,63 @@
/**
* Web Vitals 上报ai14P6 硬化)
*
* 采集 LCP / INP / CLS / TTFB通过 sendBeacon 上报到 /api/v1/admin/web-vitals。
* 仅在配置了可观测性端点时上报,避免开发环境噪音。
*/
/** Web Vital 指标结构(与 next/web-vitals Metric 对齐) */
interface WebVitalMetric {
name: string;
value: number;
rating: string;
id: string;
}
const ENDPOINT = "/api/v1/admin/web-vitals";
/** 是否启用上报(配置了任一可观测性变量即启用) */
function shouldReport(): boolean {
return Boolean(
process.env.NEXT_PUBLIC_SENTRY_DSN ||
process.env.NEXT_PUBLIC_OTEL_EXPORTER_URL,
);
}
/**
* 上报单个 Web Vital 指标。
* 由根 layout 通过 export function reportWebVitals(metric) 调用。
*/
export function reportWebVitals(metric: WebVitalMetric): void {
if (typeof window === "undefined") return;
if (!shouldReport()) return;
const payload = {
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
service: "student-portal",
timestamp: Date.now(),
};
try {
const blob = new Blob([JSON.stringify(payload)], {
type: "application/json",
});
if (navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, blob);
return;
}
// sendBeacon 不可用时降级 fetch keepalive
void fetch(ENDPOINT, {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
keepalive: true,
});
} catch {
// 上报失败不影响用户体验,静默忽略
}
}
export default reportWebVitals;

View File

@@ -0,0 +1,86 @@
/**
* MSW 浏览器端 setupai14
*
* 开发环境下在浏览器中启动 MSW Service Worker 拦截 GraphQL 请求。
* 通过 NEXT_PUBLIC_API_MOCKING=enabled 控制是否启用。
*
* 重要setupWorker 仅在浏览器环境调用SSR 时不执行。
* worker 实例延迟初始化,避免 Next.js prerender 时报错。
*/
import { handlers } from "./handlers";
/** MSW worker 类型(延迟初始化)*/
type MSWWorker = Awaited<
ReturnType<(typeof import("msw/browser"))["setupWorker"]>
>;
/** MSW worker 单例(延迟初始化,仅在浏览器环境)*/
let workerInstance: MSWWorker | null = null;
/** MSW worker 是否已启动 */
let isStarted = false;
/**
* 获取或创建 MSW worker 实例
*
* 仅在浏览器环境调用SSR 时返回 null。
* 使用动态 import 避免 Next.js prerender 时加载 msw/browser。
*/
async function getOrCreateWorker(): Promise<MSWWorker | null> {
if (typeof window === "undefined") return null;
if (workerInstance) return workerInstance;
const { setupWorker } = await import("msw/browser");
workerInstance = setupWorker(...handlers);
return workerInstance;
}
/**
* 初始化浏览器 MSW mock
*
* 仅在 NEXT_PUBLIC_API_MOCKING=enabled 时启动。
* 启动后拦截 /api/v1/student/graphql 的请求并返回 mock 响应。
*
* @returns Promise<void>,启动完成后 resolve
*/
export async function initMocks(): Promise<void> {
const isMockingEnabled = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
if (!isMockingEnabled || isStarted) {
return;
}
const worker = await getOrCreateWorker();
if (!worker) return;
await worker.start({
onUnhandledRequest: "bypass",
quiet: false,
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
isStarted = true;
}
/**
* 获取 MSW worker 实例(测试用)
* @returns worker 实例或 null非浏览器环境
*/
export function getWorker(): MSWWorker | null {
return workerInstance;
}
/**
* 停止 MSW worker
* @returns Promise<void>
*/
export async function stopMocks(): Promise<void> {
if (!isStarted || !workerInstance) {
return;
}
await workerInstance.stop();
isStarted = false;
}

View File

@@ -0,0 +1,88 @@
[
{
"id": "ch-001",
"textbookId": "tb-001",
"name": "第一章 函数与导数",
"level": 1,
"parentId": null,
"order": 1,
"children": [
{
"id": "ch-001-01",
"textbookId": "tb-001",
"name": "1.1 函数的概念与性质",
"level": 2,
"parentId": "ch-001",
"order": 1,
"children": [
{
"id": "ch-001-01-01",
"textbookId": "tb-001",
"name": "1.1.1 函数的定义",
"level": 3,
"parentId": "ch-001-01",
"order": 1,
"children": []
},
{
"id": "ch-001-01-02",
"textbookId": "tb-001",
"name": "1.1.2 函数的单调性",
"level": 3,
"parentId": "ch-001-01",
"order": 2,
"children": []
}
]
},
{
"id": "ch-001-02",
"textbookId": "tb-001",
"name": "1.2 导数的概念",
"level": 2,
"parentId": "ch-001",
"order": 2,
"children": [
{
"id": "ch-001-02-01",
"textbookId": "tb-001",
"name": "1.2.1 导数的定义",
"level": 3,
"parentId": "ch-001-02",
"order": 1,
"children": []
}
]
}
]
},
{
"id": "ch-002",
"textbookId": "tb-001",
"name": "第二章 三角函数",
"level": 1,
"parentId": null,
"order": 2,
"children": [
{
"id": "ch-002-01",
"textbookId": "tb-001",
"name": "2.1 任意角的三角函数",
"level": 2,
"parentId": "ch-002",
"order": 1,
"children": [
{
"id": "ch-002-01-01",
"textbookId": "tb-001",
"name": "2.1.1 三角函数定义",
"level": 3,
"parentId": "ch-002-01",
"order": 1,
"children": []
}
]
}
]
}
]

View File

@@ -0,0 +1,20 @@
{
"id": "student-001",
"name": "张小明",
"studentNo": "2024001",
"grade": "高三(2)班",
"avatar": null,
"roles": ["student"],
"permissions": [
"STUDENT_DASHBOARD_VIEW",
"HOMEWORK_READ_OWN",
"HOMEWORK_SUBMIT",
"EXAMS_READ_OWN",
"EXAMS_TAKE",
"EXAMS_RESULT_VIEW",
"GRADES_READ_OWN",
"ATTENDANCE_READ_OWN",
"LEARNING_PATH_VIEW"
],
"dataScope": "L0"
}

View File

@@ -0,0 +1,68 @@
{
"id": "exam-001",
"name": "高三数学期中考试",
"subject": { "id": "subject-math", "name": "数学" },
"status": "not_started",
"startsAt": "2026-07-15T09:00:00.000Z",
"expiresAt": "2026-07-15T11:00:00.000Z",
"durationSeconds": 7200,
"totalScore": 100,
"questionCount": 5,
"questions": [
{
"id": "q-001",
"type": "single-choice",
"content": "已知函数 f(x) = x² + 2x求 f(3) 的值。",
"options": [
{ "id": "opt-a", "text": "9" },
{ "id": "opt-b", "text": "15" },
{ "id": "opt-c", "text": "12" },
{ "id": "opt-d", "text": "18" }
],
"score": 0,
"maxScore": 20
},
{
"id": "q-002",
"type": "single-choice",
"content": "若 sin θ = 1/2且 θ 为锐角,则 cos θ 的值为?",
"options": [
{ "id": "opt-a", "text": "1/2" },
{ "id": "opt-b", "text": "√3/2" },
{ "id": "opt-c", "text": "√2/2" },
{ "id": "opt-d", "text": "1" }
],
"score": 0,
"maxScore": 20
},
{
"id": "q-003",
"type": "multiple-choice",
"content": "下列哪些是偶函数?(多选)",
"options": [
{ "id": "opt-a", "text": "f(x) = x²" },
{ "id": "opt-b", "text": "f(x) = x³" },
{ "id": "opt-c", "text": "f(x) = |x|" },
{ "id": "opt-d", "text": "f(x) = cos x" }
],
"score": 0,
"maxScore": 20
},
{
"id": "q-004",
"type": "short-answer",
"content": "简述导数的几何意义。",
"options": [],
"score": 0,
"maxScore": 20
},
{
"id": "q-005",
"type": "short-answer",
"content": "求不等式 x² - 5x + 6 < 0 的解集。",
"options": [],
"score": 0,
"maxScore": 20
}
]
}

View File

@@ -0,0 +1,38 @@
{
"id": "hw-001",
"title": "数学作业第五章",
"subject": { "id": "subject-math", "name": "数学" },
"status": "pending",
"dueAt": "2026-07-12T23:59:59.000Z",
"assignedAt": "2026-07-08T08:00:00.000Z",
"questionCount": 3,
"totalScore": 100,
"questions": [
{
"id": "hw-q-001",
"type": "single-choice",
"content": "函数 f(x) = 2x + 3 在 x = 2 处的函数值为?",
"options": [
{ "id": "opt-a", "text": "5" },
{ "id": "opt-b", "text": "7" },
{ "id": "opt-c", "text": "9" },
{ "id": "opt-d", "text": "11" }
],
"maxScore": 30
},
{
"id": "hw-q-002",
"type": "short-answer",
"content": "解方程x² - 4 = 0",
"options": [],
"maxScore": 40
},
{
"id": "hw-q-003",
"type": "short-answer",
"content": "证明:三角形内角和为 180 度。",
"options": [],
"maxScore": 30
}
]
}

View File

@@ -0,0 +1,10 @@
[
{ "id": "kp-001", "name": "函数概念", "knowledgePointId": "kp-001", "status": "completed", "mastery": 0.9, "dependencies": [], "recommendedOrder": 1 },
{ "id": "kp-002", "name": "函数性质", "knowledgePointId": "kp-002", "status": "completed", "mastery": 0.85, "dependencies": ["kp-001"], "recommendedOrder": 2 },
{ "id": "kp-003", "name": "导数概念", "knowledgePointId": "kp-003", "status": "in-progress", "mastery": 0.6, "dependencies": ["kp-001", "kp-002"], "recommendedOrder": 3 },
{ "id": "kp-004", "name": "导数运算", "knowledgePointId": "kp-004", "status": "available", "mastery": 0.3, "dependencies": ["kp-003"], "recommendedOrder": 4 },
{ "id": "kp-005", "name": "三角函数定义", "knowledgePointId": "kp-005", "status": "completed", "mastery": 0.95, "dependencies": [], "recommendedOrder": 5 },
{ "id": "kp-006", "name": "三角恒等变换", "knowledgePointId": "kp-006", "status": "in-progress", "mastery": 0.55, "dependencies": ["kp-005"], "recommendedOrder": 6 },
{ "id": "kp-007", "name": "不等式", "knowledgePointId": "kp-007", "status": "available", "mastery": 0.2, "dependencies": ["kp-001"], "recommendedOrder": 7 },
{ "id": "kp-008", "name": "数列", "knowledgePointId": "kp-008", "status": "locked", "mastery": 0, "dependencies": ["kp-002"], "recommendedOrder": 8 }
]

View File

@@ -0,0 +1,12 @@
[
{ "id": "att-001", "date": "2026-07-01", "status": "present", "subject": { "id": "subject-math", "name": "数学" } },
{ "id": "att-002", "date": "2026-07-02", "status": "present", "subject": { "id": "subject-chinese", "name": "语文" } },
{ "id": "att-003", "date": "2026-07-03", "status": "late", "subject": { "id": "subject-english", "name": "英语" }, "lateMinutes": 10 },
{ "id": "att-004", "date": "2026-07-04", "status": "present", "subject": { "id": "subject-physics", "name": "物理" } },
{ "id": "att-005", "date": "2026-07-07", "status": "absent", "subject": { "id": "subject-chemistry", "name": "化学" }, "reason": "病假" },
{ "id": "att-006", "date": "2026-07-08", "status": "present", "subject": { "id": "subject-math", "name": "数学" } },
{ "id": "att-007", "date": "2026-07-09", "status": "present", "subject": { "id": "subject-chinese", "name": "语文" } },
{ "id": "att-008", "date": "2026-07-10", "status": "late", "subject": { "id": "subject-english", "name": "英语" }, "lateMinutes": 5 },
{ "id": "att-009", "date": "2026-07-11", "status": "present", "subject": { "id": "subject-physics", "name": "物理" } },
{ "id": "att-010", "date": "2026-07-12", "status": "present", "subject": { "id": "subject-chemistry", "name": "化学" } }
]

View File

@@ -0,0 +1,10 @@
[
{
"id": "class-001",
"name": "高三(2)班",
"homeroomTeacher": "李老师",
"studentCount": 45,
"grade": "高三",
"year": 2026
}
]

View File

@@ -0,0 +1,26 @@
[
{
"id": "exam-001",
"name": "高三数学期中考试",
"subject": { "id": "subject-math", "name": "数学" },
"status": "not_started",
"startsAt": "2026-07-15T09:00:00.000Z",
"expiresAt": "2026-07-15T11:00:00.000Z",
"durationSeconds": 7200,
"questionCount": 5,
"totalScore": 100
},
{
"id": "exam-002",
"name": "高三语文月考",
"subject": { "id": "subject-chinese", "name": "语文" },
"status": "submitted",
"startsAt": "2026-07-05T09:00:00.000Z",
"expiresAt": "2026-07-05T11:00:00.000Z",
"durationSeconds": 7200,
"questionCount": 4,
"totalScore": 100,
"submittedAt": "2026-07-05T10:45:00.000Z",
"submissionId": "submission-002"
}
]

View File

@@ -0,0 +1,62 @@
[
{
"id": "grade-001",
"examId": "exam-002",
"examName": "高三语文月考",
"subject": { "id": "subject-chinese", "name": "语文" },
"score": 85,
"maxScore": 100,
"grade": "良好",
"rank": 8,
"submittedAt": "2026-07-05T10:45:00.000Z",
"gradedAt": "2026-07-07T14:00:00.000Z"
},
{
"id": "grade-002",
"examId": "exam-003",
"examName": "高三英语模拟考",
"subject": { "id": "subject-english", "name": "英语" },
"score": 92,
"maxScore": 100,
"grade": "优秀",
"rank": 3,
"submittedAt": "2026-06-28T10:00:00.000Z",
"gradedAt": "2026-06-30T09:00:00.000Z"
},
{
"id": "grade-003",
"examId": "exam-004",
"examName": "高三物理单元测验",
"subject": { "id": "subject-physics", "name": "物理" },
"score": 78,
"maxScore": 100,
"grade": "及格",
"rank": 15,
"submittedAt": "2026-06-20T10:00:00.000Z",
"gradedAt": "2026-06-22T09:00:00.000Z"
},
{
"id": "grade-004",
"examId": "exam-005",
"examName": "高三化学月考",
"subject": { "id": "subject-chemistry", "name": "化学" },
"score": 88,
"maxScore": 100,
"grade": "良好",
"rank": 6,
"submittedAt": "2026-06-10T10:00:00.000Z",
"gradedAt": "2026-06-12T09:00:00.000Z"
},
{
"id": "grade-005",
"examId": "exam-006",
"examName": "高三数学月考",
"subject": { "id": "subject-math", "name": "数学" },
"score": 95,
"maxScore": 100,
"grade": "优秀",
"rank": 2,
"submittedAt": "2026-05-28T10:00:00.000Z",
"gradedAt": "2026-05-30T09:00:00.000Z"
}
]

View File

@@ -0,0 +1,36 @@
[
{
"id": "hw-001",
"title": "数学作业第五章",
"subject": { "id": "subject-math", "name": "数学" },
"status": "pending",
"dueAt": "2026-07-12T23:59:59.000Z",
"assignedAt": "2026-07-08T08:00:00.000Z",
"questionCount": 10,
"totalScore": 100
},
{
"id": "hw-002",
"title": "语文阅读理解练习",
"subject": { "id": "subject-chinese", "name": "语文" },
"status": "submitted",
"dueAt": "2026-07-10T23:59:59.000Z",
"assignedAt": "2026-07-06T08:00:00.000Z",
"submittedAt": "2026-07-09T20:30:00.000Z",
"questionCount": 8,
"totalScore": 100
},
{
"id": "hw-003",
"title": "英语单元测试卷",
"subject": { "id": "subject-english", "name": "英语" },
"status": "graded",
"dueAt": "2026-07-05T23:59:59.000Z",
"assignedAt": "2026-07-01T08:00:00.000Z",
"submittedAt": "2026-07-04T21:00:00.000Z",
"gradedAt": "2026-07-06T10:00:00.000Z",
"score": 88,
"totalScore": 100,
"questionCount": 12
}
]

View File

@@ -0,0 +1,12 @@
[
{ "id": "ntf-001", "type": "homework", "title": "新作业发布", "content": "数学作业第五章已发布,截止日期 7月12日", "read": false, "createdAt": "2026-07-08T08:00:00.000Z" },
{ "id": "ntf-002", "type": "exam", "title": "考试即将开始", "content": "高三数学期中考试将于7月15日开始", "read": false, "createdAt": "2026-07-09T10:00:00.000Z" },
{ "id": "ntf-003", "type": "grade", "title": "成绩已发布", "content": "您的语文月考成绩为85分", "read": false, "createdAt": "2026-07-07T14:00:00.000Z" },
{ "id": "ntf-004", "type": "system", "title": "系统维护通知", "content": "系统将于本周日 2:00-4:00 进行维护", "read": true, "createdAt": "2026-07-06T16:00:00.000Z" },
{ "id": "ntf-005", "type": "homework", "title": "作业即将截止", "content": "语文阅读理解练习将于明天截止", "read": false, "createdAt": "2026-07-09T20:00:00.000Z" },
{ "id": "ntf-006", "type": "grade", "title": "成绩已发布", "content": "您的英语模拟考成绩为92分", "read": true, "createdAt": "2026-06-30T09:00:00.000Z" },
{ "id": "ntf-007", "type": "exam", "title": "考试结果发布", "content": "高三语文月考已结束,查看详情", "read": true, "createdAt": "2026-07-05T11:00:00.000Z" },
{ "id": "ntf-008", "type": "system", "title": "隐私政策更新", "content": "平台隐私政策已更新,请查阅", "read": true, "createdAt": "2026-07-01T10:00:00.000Z" },
{ "id": "ntf-009", "type": "homework", "title": "作业已批改", "content": "英语单元测试卷已批改得分88分", "read": true, "createdAt": "2026-07-06T10:00:00.000Z" },
{ "id": "ntf-010", "type": "system", "title": "学期注册提醒", "content": "请于7月20日前完成新学期注册", "read": false, "createdAt": "2026-07-10T08:00:00.000Z" }
]

View File

@@ -0,0 +1,9 @@
[
{ "date": "2026-07-04", "score": 88, "examName": "英语单元测试" },
{ "date": "2026-07-05", "score": 85, "examName": "语文月考" },
{ "date": "2026-06-20", "score": 78, "examName": "物理单元测验" },
{ "date": "2026-06-10", "score": 88, "examName": "化学月考" },
{ "date": "2026-05-28", "score": 95, "examName": "数学月考" },
{ "date": "2026-05-15", "score": 82, "examName": "语文模拟考" },
{ "date": "2026-05-01", "score": 80, "examName": "英语模拟考" }
]

View File

@@ -0,0 +1,38 @@
[
{
"id": "wp-001",
"questionId": "q-weak-001",
"questionContent": "求复合函数的导数",
"subject": { "id": "subject-math", "name": "数学" },
"knowledgePointId": "kp-004",
"knowledgePointName": "导数运算",
"mastery": 0.3,
"wrongCount": 5,
"lastWrongAt": "2026-07-08T10:00:00.000Z",
"recommendedAction": "practice"
},
{
"id": "wp-002",
"questionId": "q-weak-002",
"questionContent": "三角恒等变换应用",
"subject": { "id": "subject-math", "name": "数学" },
"knowledgePointId": "kp-006",
"knowledgePointName": "三角恒等变换",
"mastery": 0.4,
"wrongCount": 3,
"lastWrongAt": "2026-07-06T14:00:00.000Z",
"recommendedAction": "review"
},
{
"id": "wp-003",
"questionId": "q-weak-003",
"questionContent": "不等式解集求解",
"subject": { "id": "subject-math", "name": "数学" },
"knowledgePointId": "kp-007",
"knowledgePointName": "不等式",
"mastery": 0.2,
"wrongCount": 4,
"lastWrongAt": "2026-07-09T09:00:00.000Z",
"recommendedAction": "practice"
}
]

View File

@@ -0,0 +1,4 @@
{
"timestamp": "2026-07-10T12:00:00.000Z",
"offset": 0
}

View File

@@ -0,0 +1,20 @@
{
"upcomingHomework": [
{ "id": "hw-001", "title": "数学作业第五章", "dueAt": "2026-07-12T23:59:59.000Z", "subject": { "id": "subject-math", "name": "数学" }, "status": "pending" },
{ "id": "hw-004", "title": "物理实验报告", "dueAt": "2026-07-13T23:59:59.000Z", "subject": { "id": "subject-physics", "name": "物理" }, "status": "pending" },
{ "id": "hw-005", "title": "英语听力练习", "dueAt": "2026-07-14T23:59:59.000Z", "subject": { "id": "subject-english", "name": "英语" }, "status": "pending" }
],
"upcomingExams": [
{ "id": "exam-001", "name": "高三数学期中考试", "startsAt": "2026-07-15T09:00:00.000Z", "expiresAt": "2026-07-15T11:00:00.000Z", "durationSeconds": 7200, "subject": { "id": "subject-math", "name": "数学" }, "status": "not_started" },
{ "id": "exam-007", "name": "高三英语期末考", "startsAt": "2026-07-18T09:00:00.000Z", "expiresAt": "2026-07-18T11:00:00.000Z", "durationSeconds": 7200, "subject": { "id": "subject-english", "name": "英语" }, "status": "not_started" }
],
"recentGrades": [
{ "id": "grade-001", "examName": "高三语文月考", "score": 85, "maxScore": 100, "grade": "良好", "submittedAt": "2026-07-05T10:45:00.000Z" },
{ "id": "grade-002", "examName": "高三英语模拟考", "score": 92, "maxScore": 100, "grade": "优秀", "submittedAt": "2026-06-28T10:00:00.000Z" },
{ "id": "grade-003", "examName": "高三物理单元测验", "score": 78, "maxScore": 100, "grade": "及格", "submittedAt": "2026-06-20T10:00:00.000Z" }
],
"attendanceRate": 0.95,
"learningStreakDays": 12,
"avgScore": 85.0,
"classRank": 5
}

View File

@@ -0,0 +1,7 @@
[
{ "id": "tb-001", "name": "高三数学(上)", "subject": { "id": "subject-math", "name": "数学" }, "version": "人教版 2024", "author": "人民教育出版社" },
{ "id": "tb-002", "name": "高三语文", "subject": { "id": "subject-chinese", "name": "语文" }, "version": "人教版 2024", "author": "人民教育出版社" },
{ "id": "tb-003", "name": "高三英语", "subject": { "id": "subject-english", "name": "英语" }, "version": "外研版 2024", "author": "外语教学与研究出版社" },
{ "id": "tb-004", "name": "高三物理", "subject": { "id": "subject-physics", "name": "物理" }, "version": "人教版 2024", "author": "人民教育出版社" },
{ "id": "tb-005", "name": "高三化学", "subject": { "id": "subject-chemistry", "name": "化学" }, "version": "鲁科版 2024", "author": "山东科学技术出版社" }
]

View File

@@ -0,0 +1,484 @@
/**
* MSW 请求处理器ai14
*
* 拦截 POST /api/v1/student/graphql按 operationName 分发到对应的 mock 响应。
* 所有响应包裹在 ActionState 信封中:
* { success: true, errors: [], data: <fixture>, extensions: { degraded: false } }
*
* GraphQL 响应结构:
* { data: { <operationName>: ActionState<...> } }
*
* 支持的 24 个操作16 查询 + 8 变更)详见 types.ts OperationName。
*/
import { http, HttpResponse } from "msw";
import type {
ActionState,
MutationOperationName,
QueryOperationName,
} from "../lib/graphql/types";
import { ERROR_CODES } from "../lib/graphql/types";
// 查询 fixture 数据
import currentUserFixture from "./fixtures/currentUser.json";
import myClassesFixture from "./fixtures/myClasses.json";
import myExamsFixture from "./fixtures/myExams.json";
import examDetailFixture from "./fixtures/examDetail.json";
import myHomeworkFixture from "./fixtures/myHomework.json";
import homeworkDetailFixture from "./fixtures/homeworkDetail.json";
import myGradesFixture from "./fixtures/myGrades.json";
import myAttendanceFixture from "./fixtures/myAttendance.json";
import textbooksFixture from "./fixtures/textbooks.json";
import chaptersFixture from "./fixtures/chapters.json";
import learningPathFixture from "./fixtures/learningPath.json";
import studentDashboardFixture from "./fixtures/studentDashboard.json";
import myWeaknessFixture from "./fixtures/myWeakness.json";
import myTrendFixture from "./fixtures/myTrend.json";
import myNotificationsFixture from "./fixtures/myNotifications.json";
import serverTimeFixture from "./fixtures/serverTime.json";
// ---------------------------------------------------------------------------
// GraphQL 请求/响应类型
// ---------------------------------------------------------------------------
/** GraphQL 请求体 */
interface GraphQLRequest {
query: string;
variables?: Record<string, unknown>;
operationName?: string;
}
/** GraphQL 顶层响应 */
interface GraphQLResponseData {
data: Record<string, unknown> | null;
errors?: Array<{ message: string; extensions?: Record<string, unknown> }>;
}
// ---------------------------------------------------------------------------
// ActionState 信封构造工具
// ---------------------------------------------------------------------------
/**
* 构造成功的 ActionState 响应
* @param data 业务数据
* @returns ActionState 信封
*/
function ok<T>(data: T): ActionState<T> {
return {
success: true,
errors: [],
data,
extensions: { degraded: false },
};
}
/**
* 构造失败的 ActionState 响应
* @param code 错误码
* @param message 错误消息
* @returns ActionState 信封data 为 null
*/
function fail<T>(code: string, message: string): ActionState<T> {
return {
success: false,
errors: [{ code, message }],
data: null,
extensions: { degraded: false },
};
}
/**
* 构造降级的 ActionState 响应(部分上游不可用)
* @param data 部分业务数据
* @param reason 降级原因
* @returns ActionState 信封degraded: true
*/
function degraded<T>(data: T, reason: string): ActionState<T> {
return {
success: true,
errors: [],
data,
extensions: { degraded: true, degradedReason: reason },
};
}
// ---------------------------------------------------------------------------
// GraphQL 响应构造
// ---------------------------------------------------------------------------
/**
* 构造 GraphQL 成功响应(单操作)
* @param operationName 操作名
* @param actionState ActionState 信封
* @returns HttpResponse JSON
*/
function graphqlOk(
operationName: string,
actionState: ActionState<unknown>,
): Response {
const body: GraphQLResponseData = {
data: { [operationName]: actionState },
};
return HttpResponse.json(body);
}
/**
* 构造 GraphQL 错误响应(顶层错误,如认证失败)
* @param message 错误消息
* @param code 错误码
* @returns HttpResponse JSON
*/
function graphqlError(message: string, code: string): Response {
const body: GraphQLResponseData = {
data: null,
errors: [{ message, extensions: { code } }],
};
return HttpResponse.json(body, { status: 200 });
}
// ---------------------------------------------------------------------------
// operationName 提取
// ---------------------------------------------------------------------------
/**
* 从 GraphQL 请求中提取 operationName
* 优先使用 body.operationName缺失时从 query 字符串解析
* @param body GraphQL 请求体
* @returns 操作名或 null
*/
function extractOperationName(body: GraphQLRequest): string | null {
if (body.operationName) {
return body.operationName;
}
// 从 query 字符串解析query/mutation OperationName( ...
const match = body.query.match(/(?:query|mutation)\s+(\w+)/);
return match?.[1] ?? null;
}
// ---------------------------------------------------------------------------
// 查询处理器16 个)
// ---------------------------------------------------------------------------
/**
* 处理查询操作
* @param operationName 操作名
* @param variables 查询变量
* @returns HttpResponse
*/
function handleQuery(
operationName: QueryOperationName,
variables: Record<string, unknown>,
): Response {
switch (operationName) {
case "currentUser":
return graphqlOk("currentUser", ok(currentUserFixture));
case "myClasses":
return graphqlOk("myClasses", ok(myClassesFixture));
case "myExams":
return graphqlOk("myExams", ok(myExamsFixture));
case "examDetail": {
const examId = variables["examId"] ?? "exam-001";
// 根据请求的 examId 返回对应数据,默认返回 examDetail fixture
const detail =
examId === "exam-001"
? examDetailFixture
: { ...examDetailFixture, id: examId };
return graphqlOk("examDetail", ok(detail));
}
case "myHomework":
return graphqlOk("myHomework", ok(myHomeworkFixture));
case "homeworkDetail": {
const homeworkId = variables["homeworkId"] ?? "hw-001";
const detail =
homeworkId === "hw-001"
? homeworkDetailFixture
: { ...homeworkDetailFixture, id: homeworkId };
return graphqlOk("homeworkDetail", ok(detail));
}
case "myGrades":
return graphqlOk("myGrades", ok(myGradesFixture));
case "myAttendance":
return graphqlOk("myAttendance", ok(myAttendanceFixture));
case "textbooks":
return graphqlOk("textbooks", ok(textbooksFixture));
case "chapters": {
const textbookId = variables["textbookId"] ?? "tb-001";
const chapters =
textbookId === "tb-001"
? chaptersFixture
: chaptersFixture.filter((c) => c.textbookId === textbookId);
return graphqlOk("chapters", ok(chapters));
}
case "learningPath":
return graphqlOk("learningPath", ok(learningPathFixture));
case "studentDashboard":
return graphqlOk("studentDashboard", ok(studentDashboardFixture));
case "myWeakness":
return graphqlOk("myWeakness", ok(myWeaknessFixture));
case "myTrend":
return graphqlOk("myTrend", ok(myTrendFixture));
case "myNotifications": {
const first = (variables["first"] as number | undefined) ?? 10;
const items = myNotificationsFixture.slice(0, first);
const unreadCount = items.filter((n) => !n.read).length;
const result = {
items,
totalCount: myNotificationsFixture.length,
unreadCount,
};
return graphqlOk("myNotifications", ok(result));
}
case "serverTime":
// 返回当前时间以支持倒计时校正
return graphqlOk(
"serverTime",
ok({
timestamp: new Date().toISOString(),
offset: 0,
}),
);
default: {
// 未覆盖的查询操作名,返回错误
return graphqlError(
`未实现的查询: ${operationName}`,
ERROR_CODES.BFF_STUDENT_NOT_FOUND,
);
}
}
}
// ---------------------------------------------------------------------------
// 变更处理器8 个)
// ---------------------------------------------------------------------------
/**
* 处理变更操作
* @param operationName 操作名
* @param variables 变更变量
* @returns HttpResponse
*/
function handleMutation(
operationName: MutationOperationName,
variables: Record<string, unknown>,
): Response {
switch (operationName) {
case "submitHomework": {
const homeworkId = variables["homeworkId"] ?? "hw-001";
const result = {
submissionId: `submission-${Date.now()}`,
submittedAt: new Date().toISOString(),
};
void homeworkId;
return graphqlOk("submitHomework", ok(result));
}
case "submitExam": {
const examId = variables["examId"] ?? "exam-001";
const result = {
submissionId: `submission-${Date.now()}`,
submittedAt: new Date().toISOString(),
score: 85,
maxScore: 100,
};
void examId;
return graphqlOk("submitExam", ok(result));
}
case "saveExamDraft": {
const result = {
savedAt: new Date().toISOString(),
ok: true,
};
return graphqlOk("saveExamDraft", ok(result));
}
case "markAsRead": {
const notificationId = variables["notificationId"] ?? "";
const result = {
notificationId: notificationId as string,
read: true,
};
return graphqlOk("markAsRead", ok(result));
}
case "markAllAsRead": {
const result = {
updatedCount: myNotificationsFixture.filter((n) => !n.read).length,
};
return graphqlOk("markAllAsRead", ok(result));
}
case "recordExamViolation": {
const result = {
recorded: true,
violationCount: 1,
};
return graphqlOk("recordExamViolation", ok(result));
}
case "recordPasteEvent": {
const result = {
recorded: true,
};
return graphqlOk("recordPasteEvent", ok(result));
}
case "updateNotificationPreference": {
const type = variables["type"] ?? "system";
const enabled = variables["enabled"] ?? true;
const result = {
type: type as string,
enabled: enabled as boolean,
updatedAt: new Date().toISOString(),
};
return graphqlOk("updateNotificationPreference", ok(result));
}
default: {
// 未覆盖的变更操作名,返回错误
return graphqlError(
`未实现的变更: ${operationName}`,
ERROR_CODES.BFF_STUDENT_NOT_FOUND,
);
}
}
}
// ---------------------------------------------------------------------------
// 主处理器
// ---------------------------------------------------------------------------
/** 查询操作名集合 */
const QUERY_NAMES = new Set<QueryOperationName>([
"currentUser",
"myClasses",
"myExams",
"examDetail",
"myHomework",
"homeworkDetail",
"myGrades",
"myAttendance",
"textbooks",
"chapters",
"learningPath",
"studentDashboard",
"myWeakness",
"myTrend",
"myNotifications",
"serverTime",
]);
/** 变更操作名集合 */
const MUTATION_NAMES = new Set<MutationOperationName>([
"submitHomework",
"submitExam",
"saveExamDraft",
"markAsRead",
"markAllAsRead",
"recordExamViolation",
"recordPasteEvent",
"updateNotificationPreference",
]);
/**
* 判断是否为查询操作名
* @param name 操作名
* @returns 是否查询
*/
function isQueryName(name: string): name is QueryOperationName {
return (QUERY_NAMES as Set<string>).has(name);
}
/**
* 判断是否为变更操作名
* @param name 操作名
* @returns 是否变更
*/
function isMutationName(name: string): name is MutationOperationName {
return (MUTATION_NAMES as Set<string>).has(name);
}
/** MSW 请求处理器列表 */
export const handlers = [
http.post(
"*/api/v1/student/graphql",
async ({ request }): Promise<Response> => {
let body: GraphQLRequest;
try {
body = (await request.json()) as GraphQLRequest;
} catch {
return graphqlError(
"请求体解析失败",
ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED,
);
}
const operationName = extractOperationName(body);
if (!operationName) {
return graphqlError(
"无法识别 operationName",
ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED,
);
}
const variables = body.variables ?? {};
// 按操作类型分发
if (isQueryName(operationName)) {
return handleQuery(operationName, variables);
}
if (isMutationName(operationName)) {
return handleMutation(operationName, variables);
}
// 未知操作名
return graphqlError(
`未知操作: ${operationName}`,
ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED,
);
},
),
];
/** 导出 fixture 数据供测试使用 */
export const fixtures = {
currentUser: currentUserFixture,
myClasses: myClassesFixture,
myExams: myExamsFixture,
examDetail: examDetailFixture,
myHomework: myHomeworkFixture,
homeworkDetail: homeworkDetailFixture,
myGrades: myGradesFixture,
myAttendance: myAttendanceFixture,
textbooks: textbooksFixture,
chapters: chaptersFixture,
learningPath: learningPathFixture,
studentDashboard: studentDashboardFixture,
myWeakness: myWeaknessFixture,
myTrend: myTrendFixture,
myNotifications: myNotificationsFixture,
serverTime: serverTimeFixture,
};
/** 导出工具函数供测试使用 */
export { ok, fail, degraded, graphqlOk, graphqlError };
/** 导出操作名集合供测试断言 */
export { QUERY_NAMES, MUTATION_NAMES };

View File

@@ -0,0 +1,20 @@
/**
* MSW 服务端 setupai14
*
* 用于 Node.js 环境Vitest 集成测试 / SSR拦截 GraphQL 请求。
* 通过 setupServer 注册 handler在测试 beforeEach 中启动、afterEach 中停止。
*
* 用法:
* import { server } from "@/mocks/server";
* beforeAll(() => server.listen());
* afterEach(() => server.resetHandlers());
* afterAll(() => server.close());
*/
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
/** MSW Node.js server 单例 */
export const server = setupServer(...handlers);
export { handlers };

View File

@@ -0,0 +1,196 @@
/**
* globals.css — student-portal 全局样式ai14
*
* 引入顺序semantic.css含 primitive.css→ Tailwind 三层
* 对齐 teacher-portal Shell 纸感设计paper/ink/accent/rule
*/
@import './semantic.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* === 基础排版 === */
@layer base {
html,
body {
background: var(--color-paper);
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-serif);
font-weight: 600;
letter-spacing: -0.01em;
color: var(--color-ink);
}
/* 焦点可见性A11y WCAG 2.2*/
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* 链接 */
a {
color: var(--color-accent);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-standard);
}
a:hover {
color: var(--color-accent-hover);
}
}
/* === 组件层 === */
@layer components {
/* 纸感分隔线 */
.rule {
border-top: 1px solid var(--color-rule);
}
.rule-thin {
border-top: 2px solid var(--color-rule);
}
/* 左侧竖线标记(节点展开样式,对齐 teacher-portal*/
.mark-left {
border-left: 2px solid var(--color-rule);
padding-left: var(--space-md);
}
/* 纸感卡片 */
.card-paper {
background: var(--color-paper);
border: 1px solid var(--color-rule);
border-radius: var(--radius-base);
box-shadow: var(--shadow-paper);
}
/* 主按钮 */
.btn-primary {
background: var(--color-accent);
color: var(--color-paper);
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-base);
font-family: var(--font-sans);
font-size: var(--font-size-3);
transition: background var(--duration-fast) var(--ease-standard);
}
.btn-primary:hover {
background: var(--color-accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 次级按钮 */
.btn-secondary {
background: transparent;
color: var(--color-accent);
border: 1px solid var(--color-accent);
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-base);
}
/* 输入框 */
.input-paper {
background: var(--color-paper);
border: 1px solid var(--color-rule);
border-radius: var(--radius-base);
padding: var(--space-sm) var(--space-md);
font-family: var(--font-sans);
font-size: var(--font-size-3);
color: var(--color-ink);
transition: border-color var(--duration-fast) var(--ease-standard);
}
.input-paper:focus {
border-color: var(--color-accent);
outline: none;
box-shadow: var(--shadow-focus);
}
/* 徽标 */
.badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--font-size-2);
font-weight: 500;
}
.badge-success {
background: hsl(var(--hue-success), var(--sat-success), 92%);
color: var(--color-success);
}
.badge-warning {
background: hsl(var(--hue-warning), var(--sat-warning), 92%);
color: var(--color-warning);
}
.badge-danger {
background: hsl(var(--hue-danger), var(--sat-danger), 92%);
color: var(--color-danger);
}
.badge-info {
background: hsl(var(--hue-info), var(--sat-info), 92%);
color: var(--color-info);
}
}
/* === 工具层 === */
@layer utilities {
/* 文本截断 */
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 多行截断 */
.text-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 屏幕阅读器专用 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 减少动画A11y prefers-reduced-motion*/
@media (prefers-reduced-motion: reduce) {
.motion-safe {
animation: none !important;
transition: none !important;
}
}
}

View File

@@ -0,0 +1,89 @@
/**
* Layer 1 — Primitive 原始令牌ai14
*
* 项目规则 §3.10:原始色板/字号/间距/阴影,业务代码不直接引用
* 仅 semantic.css 引用此文件
* 与 teacher-portal Shell 令牌对齐paper/ink/accent/rule
*/
:root {
/* === 原始色板HSL 分量,供 semantic.css 组合)=== */
--hue-paper: 40;
--sat-paper: 20%;
--light-paper: 98%;
--hue-ink: 25;
--sat-ink: 3%;
--light-ink: 15%;
--light-ink-muted: 45%;
--light-ink-subtle: 65%;
--hue-accent: 220;
--sat-accent: 60%;
--light-accent: 35%;
--light-accent-hover: 28%;
--light-accent-muted: 92%;
--hue-rule: 30;
--sat-rule: 10%;
--light-rule: 90%;
/* 语义色相 */
--hue-success: 142;
--sat-success: 50%;
--light-success: 35%;
--hue-warning: 38;
--sat-warning: 80%;
--light-warning: 45%;
--hue-danger: 0;
--sat-danger: 70%;
--light-danger: 45%;
--hue-info: 200;
--sat-info: 70%;
--light-info: 45%;
/* === 字号阶梯(对齐 var(--font-size-1~9)=== */
--font-size-1: 12px;
--font-size-2: 14px;
--font-size-3: 16px;
--font-size-4: 18px;
--font-size-5: 20px;
--font-size-6: 24px;
--font-size-7: 30px;
--font-size-8: 36px;
--font-size-9: 48px;
/* === 间距阶梯 === */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--space-2xl: 32px;
--space-3xl: 48px;
/* === 圆角 === */
--radius-sm: 3px;
--radius-base: 6px;
--radius-md: 8px;
--radius-lg: 12px;
/* === 阴影(纸感)=== */
--shadow-paper: 0 1px 2px hsl(25, 3%, 15%, 0.04);
--shadow-card: 0 2px 8px hsl(25, 3%, 15%, 0.06);
--shadow-focus: 0 0 0 3px hsl(220, 60%, 35%, 0.2);
/* === 字体族(仅 primitive.css 可定义字面量)=== */
--font-serif-literal: 'Fraunces', Georgia, serif;
--font-sans-literal: 'Inter', system-ui, sans-serif;
--font-mono-literal: 'JetBrains Mono', monospace;
/* === 过渡 === */
--duration-fast: 120ms;
--duration-base: 200ms;
--duration-slow: 400ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
}

View File

@@ -0,0 +1,58 @@
/**
* Layer 2 — Semantic 语义令牌ai14
*
* 项目规则 §3.10:业务代码唯一引用入口
* 引用 primitive.css 原始令牌组合为语义令牌
* 支持 light/dark 双主题(当前仅 light
*/
@import './primitive.css';
:root {
/* === 语义色light 主题)=== */
--color-paper: hsl(var(--hue-paper), var(--sat-paper), var(--light-paper));
--color-ink: hsl(var(--hue-ink), var(--sat-ink), var(--light-ink));
--color-ink-muted: hsl(var(--hue-ink), var(--sat-ink), var(--light-ink-muted));
--color-ink-subtle: hsl(var(--hue-ink), var(--sat-ink), var(--light-ink-subtle));
--color-accent: hsl(var(--hue-accent), var(--sat-accent), var(--light-accent));
--color-accent-hover: hsl(var(--hue-accent), var(--sat-accent), var(--light-accent-hover));
--color-accent-muted: hsl(var(--hue-accent), var(--sat-accent), var(--light-accent-muted));
--color-rule: hsl(var(--hue-rule), var(--sat-rule), var(--light-rule));
--color-success: hsl(var(--hue-success), var(--sat-success), var(--light-success));
--color-warning: hsl(var(--hue-warning), var(--sat-warning), var(--light-warning));
--color-danger: hsl(var(--hue-danger), var(--sat-danger), var(--light-danger));
--color-info: hsl(var(--hue-info), var(--sat-info), var(--light-info));
/* === 语义字体族(暴露给 Tailwind=== */
--font-serif: var(--font-serif-literal);
--font-sans: var(--font-sans-literal);
--font-mono: var(--font-mono-literal);
/* === 语义间距 === */
--space-xs: var(--space-xs);
--space-sm: var(--space-sm);
--space-md: var(--space-md);
--space-lg: var(--space-lg);
--space-xl: var(--space-xl);
--space-2xl: var(--space-2xl);
--space-3xl: var(--space-3xl);
}
/* dark 主题预留P6+ 启用)*/
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--color-paper: hsl(var(--hue-paper), var(--sat-paper), 8%);
--color-ink: hsl(var(--hue-ink), var(--sat-ink), 92%);
--color-ink-muted: hsl(var(--hue-ink), var(--sat-ink), 65%);
--color-ink-subtle: hsl(var(--hue-ink), var(--sat-ink), 45%);
--color-accent: hsl(var(--hue-accent), var(--sat-accent), 55%);
--color-accent-hover: hsl(var(--hue-accent), var(--sat-accent), 65%);
--color-accent-muted: hsl(var(--hue-accent), var(--sat-accent), 15%);
--color-rule: hsl(var(--hue-rule), var(--sat-rule), 20%);
}
}

View File

@@ -0,0 +1,78 @@
/**
* student-portal Tailwind 配置ai14
*
* 设计令牌三层规范project_rules §3.10
* - Layer 1 Primitive原始色板/字号/间距src/styles/primitive.css
* - Layer 2 Semantic语义令牌src/styles/semantic.css
* - Layer 3 Tailwind Theme@theme inline 映射(本文件)
*
* 与 teacher-portal Shell 令牌对齐paper/ink/accent/rule
* 禁止硬编码 hsl()/#hexESLint no-restricted-syntax 强制)
*/
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
// 语义令牌:引用 CSS 变量Layer 2 → Layer 3
paper: 'var(--color-paper)',
ink: {
DEFAULT: 'var(--color-ink)',
muted: 'var(--color-ink-muted)',
subtle: 'var(--color-ink-subtle)',
},
accent: {
DEFAULT: 'var(--color-accent)',
hover: 'var(--color-accent-hover)',
muted: 'var(--color-accent-muted)',
},
rule: 'var(--color-rule)',
success: 'var(--color-success)',
warning: 'var(--color-warning)',
danger: 'var(--color-danger)',
info: 'var(--color-info)',
},
fontFamily: {
serif: ['var(--font-serif)', 'Georgia', 'serif'],
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
mono: ['var(--font-mono)', 'monospace'],
},
fontSize: {
// 对齐 var(--font-size-1~9) 语义令牌
xs: 'var(--font-size-1)',
sm: 'var(--font-size-2)',
base: 'var(--font-size-3)',
lg: 'var(--font-size-4)',
xl: 'var(--font-size-5)',
'2xl': 'var(--font-size-6)',
'3xl': 'var(--font-size-7)',
'4xl': 'var(--font-size-8)',
'5xl': 'var(--font-size-9)',
},
spacing: {
// 语义间距令牌
'xs': 'var(--space-xs)',
'sm': 'var(--space-sm)',
'md': 'var(--space-md)',
'lg': 'var(--space-lg)',
'xl': 'var(--space-xl)',
'2xl': 'var(--space-2xl)',
'3xl': 'var(--space-3xl)',
},
borderRadius: {
DEFAULT: 'var(--radius-base)',
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
},
boxShadow: {
paper: 'var(--shadow-paper)',
card: 'var(--shadow-card)',
focus: 'var(--shadow-focus)',
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"allowJs": true,
"noEmit": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"isolatedModules": true
},
"include": [
"next-env.d.ts",
"src/**/*",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}