chore(student-portal): merge student-portal full implementation into main
This commit is contained in:
35
apps/student-portal/.env.example
Normal file
35
apps/student-portal/.env.example
Normal 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
|
||||
|
||||
# --- Shell(teacher-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
32
apps/student-portal/.gitignore
vendored
Normal 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
|
||||
58
apps/student-portal/Dockerfile
Normal file
58
apps/student-portal/Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# 多阶段构建:student-portal 生产镜像(ai14,P6 硬化)
|
||||
# 用法:docker build -t edu/student-portal:latest -f apps/student-portal/Dockerfile .
|
||||
# 端口:4001(004 §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"]
|
||||
70
apps/student-portal/eslint.config.js
Normal file
70
apps/student-portal/eslint.config.js
Normal 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: {
|
||||
// 禁止 any(project_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'],
|
||||
},
|
||||
);
|
||||
99
apps/student-portal/next.config.js
Normal file
99
apps/student-portal/next.config.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* student-portal Next.js 配置(ai14)
|
||||
*
|
||||
* MF 角色:Remote(name: 'student_app',ARB-002)
|
||||
* - 暴露:./StudentApp(学生端完整应用入口)
|
||||
* - 复用 Shell 暴露的 singleton:react/react-dom/urql/graphql/@tanstack/react-query 等
|
||||
* - 独立壳模式(NEXT_PUBLIC_MF_ENABLED=false):不依赖 Shell,独立渲染
|
||||
*
|
||||
* 端口:4001(004 §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.2:Shell 暴露的 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;
|
||||
52
apps/student-portal/package.json
Normal file
52
apps/student-portal/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/student-portal/postcss.config.js
Normal file
6
apps/student-portal/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
228
apps/student-portal/src/app/ai-tutor/page.tsx
Normal file
228
apps/student-portal/src/app/ai-tutor/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AI 辅学页面(ai14,P5 可选)
|
||||
*
|
||||
* - 聊天界面,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 {
|
||||
// 真实 SSE:POST 请求并以 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>
|
||||
);
|
||||
}
|
||||
17
apps/student-portal/src/app/api/health/route.ts
Normal file
17
apps/student-portal/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 健康检查端点(ai14,P6 硬化)
|
||||
*
|
||||
* 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",
|
||||
});
|
||||
}
|
||||
49
apps/student-portal/src/app/api/ready/route.ts
Normal file
49
apps/student-portal/src/app/api/ready/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 就绪检查端点(ai14,P6 硬化)
|
||||
*
|
||||
* 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 });
|
||||
}
|
||||
448
apps/student-portal/src/app/dashboard/page.tsx
Normal file
448
apps/student-portal/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Dashboard — 学生仪表盘(ai14)
|
||||
*
|
||||
* 数据源:useStudentDashboard GraphQL hook(student-bff 聚合)
|
||||
* 视图模型:avgScore / classRank / attendanceRate / learningStreakDays /
|
||||
* upcomingHomework(3) / upcomingExams(2) / recentGrades(3)
|
||||
*
|
||||
* 设计依据:01-understanding.md §7(dashboard 视口)
|
||||
* 02-architecture-design.md §14.4(dashboard 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>
|
||||
);
|
||||
}
|
||||
203
apps/student-portal/src/app/dashboard/trend/page.tsx
Normal file
203
apps/student-portal/src/app/dashboard/trend/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学习趋势页(ai14,P4,路由 /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>
|
||||
);
|
||||
}
|
||||
182
apps/student-portal/src/app/dashboard/weakness/page.tsx
Normal file
182
apps/student-portal/src/app/dashboard/weakness/page.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学情诊断页(ai14,P4,路由 /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>
|
||||
);
|
||||
}
|
||||
57
apps/student-portal/src/app/layout.tsx
Normal file
57
apps/student-portal/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
apps/student-portal/src/app/learning-path/page.tsx
Normal file
151
apps/student-portal/src/app/learning-path/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学习路径页(ai14,P4)
|
||||
*
|
||||
* - 消费 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>
|
||||
);
|
||||
}
|
||||
601
apps/student-portal/src/app/my-attendance/page.tsx
Normal file
601
apps/student-portal/src/app/my-attendance/page.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MyAttendance — 我的考勤(ai14)
|
||||
*
|
||||
* 数据源:useMyAttendance GraphQL hook
|
||||
* 视图:
|
||||
* - 顶部摘要:总天数 / 出勤 / 缺勤 / 迟到 / 出勤率
|
||||
* - 日历网格:按月分组,每格显示当日状态颜色(present/absent/late)
|
||||
* - 最近异常列表:缺勤与迟到记录明细
|
||||
*
|
||||
* 设计依据:01-understanding.md §7(my-attendance 视口)
|
||||
* 02-architecture-design.md §14.4(CSR)
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
185
apps/student-portal/src/app/my-classes/page.tsx
Normal file
185
apps/student-portal/src/app/my-classes/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MyClasses — 我的班级(ai14)
|
||||
*
|
||||
* 数据源:useMyClasses GraphQL hook
|
||||
* 视图:班级卡片网格(班级名 + 班主任 + 学生数 + 年级 + 学年)
|
||||
*
|
||||
* 设计依据:01-understanding.md §7 L1 视口(my-classes)
|
||||
* 02-architecture-design.md §14.4(CSR)
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
408
apps/student-portal/src/app/my-exams/[id]/result/page.tsx
Normal file
408
apps/student-portal/src/app/my-exams/[id]/result/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/student-portal/src/app/my-exams/[id]/take/page.tsx
Normal file
36
apps/student-portal/src/app/my-exams/[id]/take/page.tsx
Normal 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} />;
|
||||
}
|
||||
364
apps/student-portal/src/app/my-exams/page.tsx
Normal file
364
apps/student-portal/src/app/my-exams/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MyExams — 我的考试列表(ai14)
|
||||
*
|
||||
* 数据源:useMyExams GraphQL hook
|
||||
* 视图:按状态分组(not_started / in_progress / submitted / graded)
|
||||
* 每张卡片:标题 + 学科 + 起止时间 + 时长 + 状态徽标
|
||||
* 链接:active(in_progress)→ /my-exams/[id]/take
|
||||
* submitted/graded → /my-exams/[id]/result
|
||||
*
|
||||
* 设计依据:01-understanding.md §8(L2 路由表)
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
477
apps/student-portal/src/app/my-grades/page.tsx
Normal file
477
apps/student-portal/src/app/my-grades/page.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MyGrades — 我的成绩(ai14)
|
||||
*
|
||||
* 数据源:useMyGrades GraphQL hook
|
||||
* 视图:成绩表(学科 / 考试名 / 得分 / 等级 / 日期)+ 趋势指示
|
||||
*
|
||||
* 隐私合规(01-understanding.md §12.2):成绩属高敏感数据,
|
||||
* P3 阶段默认展示(无遮罩),P6 可加点击查看。本页仅做展示层。
|
||||
*
|
||||
* 设计依据:01-understanding.md §7(my-grades 视口)
|
||||
* 02-architecture-design.md §14.4(CSR + 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>
|
||||
);
|
||||
}
|
||||
386
apps/student-portal/src/app/my-homework/[id]/submit/page.tsx
Normal file
386
apps/student-portal/src/app/my-homework/[id]/submit/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业提交页(ai14,P3)
|
||||
*
|
||||
* - useParams 获取作业 id
|
||||
* - useHomeworkDetail 加载作业详情
|
||||
* - react-hook-form + zod 校验:每题按类型渲染(简答 textarea / 选择 radio)
|
||||
* - 提交按钮调用 useSubmitHomework mutation(execute + useEffect 监听结果)
|
||||
* - 成功:toast + 跳转 /my-homework;失败:错误提示
|
||||
* - 自动保存草稿到 localStorage(key: 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>
|
||||
);
|
||||
}
|
||||
372
apps/student-portal/src/app/my-homework/page.tsx
Normal file
372
apps/student-portal/src/app/my-homework/page.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MyHomework — 我的作业列表(ai14)
|
||||
*
|
||||
* 数据源:useMyHomework GraphQL hook
|
||||
* 视图:按状态分组(pending / submitted / graded)
|
||||
* 每张卡片:标题 + 学科 + 截止时间 + 状态徽标
|
||||
* 链接:pending → /my-homework/[id]/submit
|
||||
*
|
||||
* 设计依据:01-understanding.md §8(L2 路由表)
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
315
apps/student-portal/src/app/notifications/page.tsx
Normal file
315
apps/student-portal/src/app/notifications/page.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知中心页面(ai14,P5)
|
||||
*
|
||||
* - 消费 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>
|
||||
);
|
||||
}
|
||||
12
apps/student-portal/src/app/page.tsx
Normal file
12
apps/student-portal/src/app/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Home — 根路径重定向到 /dashboard(ai14)
|
||||
*
|
||||
* 设计依据:student-portal_contract.md §1.2(路由无 /student 前缀)
|
||||
* 优先用 Next.js 服务端 redirect,避免客户端闪烁
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home(): never {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
54
apps/student-portal/src/app/providers.tsx
Normal file
54
apps/student-portal/src/app/providers.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Providers — 客户端 Provider 聚合(ai14)
|
||||
*
|
||||
* 职责:
|
||||
* - TanStack Query QueryClientProvider(L2 服务端状态)
|
||||
* - GraphQLProvider(urql 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>
|
||||
);
|
||||
}
|
||||
25
apps/student-portal/src/app/student-app.tsx
Normal file
25
apps/student-portal/src/app/student-app.tsx
Normal 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>;
|
||||
}
|
||||
130
apps/student-portal/src/app/textbooks/[id]/chapters/page.tsx
Normal file
130
apps/student-portal/src/app/textbooks/[id]/chapters/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 章节目录页(ai14,P4)
|
||||
*
|
||||
* - 消费 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>
|
||||
);
|
||||
}
|
||||
101
apps/student-portal/src/app/textbooks/page.tsx
Normal file
101
apps/student-portal/src/app/textbooks/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教材列表页(ai14,P4)
|
||||
*
|
||||
* - 消费 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>
|
||||
);
|
||||
}
|
||||
316
apps/student-portal/src/components/app-shell.tsx
Normal file
316
apps/student-portal/src/components/app-shell.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AppShell — 学生端应用壳(ai14)
|
||||
*
|
||||
* 职责:
|
||||
* - 左侧导航栏:学生端 11 项菜单(含 P3-P5 阶段)
|
||||
* - 顶栏:学生姓名 + 头像 + 退出登录
|
||||
* - 主内容区:渲染 children
|
||||
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
|
||||
*
|
||||
* 设计依据:01-understanding.md §7(L1 导航菜单)/ §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)" }}
|
||||
>
|
||||
{/* 移动端遮罩(A11y:role=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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
797
apps/student-portal/src/components/exam-taking/exam-taking.tsx
Normal file
797
apps/student-portal/src/components/exam-taking/exam-taking.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
52
apps/student-portal/src/components/feature-flag.tsx
Normal file
52
apps/student-portal/src/components/feature-flag.tsx
Normal 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}</>;
|
||||
}
|
||||
165
apps/student-portal/src/hooks/use-anti-cheat.ts
Normal file
165
apps/student-portal/src/hooks/use-anti-cheat.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 防作弊监控 Hook(ai14)
|
||||
*
|
||||
* 监听浏览器事件: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,
|
||||
};
|
||||
}
|
||||
160
apps/student-portal/src/hooks/use-exam-draft.ts
Normal file
160
apps/student-portal/src/hooks/use-exam-draft.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 考试草稿管理 Hook(ai14)
|
||||
*
|
||||
* 使用 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,
|
||||
};
|
||||
}
|
||||
70
apps/student-portal/src/hooks/use-exam-state-machine.ts
Normal file
70
apps/student-portal/src/hooks/use-exam-state-machine.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 考试作答状态机 Hook(ai14)
|
||||
*
|
||||
* 状态流转: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 };
|
||||
}
|
||||
109
apps/student-portal/src/hooks/use-multi-tab-guard.ts
Normal file
109
apps/student-portal/src/hooks/use-multi-tab-guard.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 多标签页检测 Hook(ai14)
|
||||
*
|
||||
* 使用 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 };
|
||||
}
|
||||
141
apps/student-portal/src/hooks/use-notifications-websocket.ts
Normal file
141
apps/student-portal/src/hooks/use-notifications-websocket.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知 WebSocket Hook(ai14,P5)
|
||||
*
|
||||
* - 连接 push-gateway:ws://localhost:8081/ws(NEXT_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 };
|
||||
}
|
||||
89
apps/student-portal/src/hooks/use-server-time-sync.ts
Normal file
89
apps/student-portal/src/hooks/use-server-time-sync.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 服务器时间同步 Hook(ai14)
|
||||
*
|
||||
* 定期轮询服务器时间,计算客户端与服务器的时间偏移
|
||||
* 用于考试倒计时校正,防止客户端篡改时间
|
||||
* 同步失败时保持上次偏移,标记为降级模式
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
63
apps/student-portal/src/hooks/use-submit-dedup.ts
Normal file
63
apps/student-portal/src/hooks/use-submit-dedup.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 提交去重 Hook(ai14)
|
||||
*
|
||||
* 生成幂等 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,
|
||||
};
|
||||
}
|
||||
283
apps/student-portal/src/i18n/messages/zh-CN.ts
Normal file
283
apps/student-portal/src/i18n/messages/zh-CN.ts
Normal 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;
|
||||
93
apps/student-portal/src/lib/error-boundary.tsx
Normal file
93
apps/student-portal/src/lib/error-boundary.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ErrorBoundary 组件(ai14,P6 硬化)
|
||||
*
|
||||
* 捕获子树 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;
|
||||
144
apps/student-portal/src/lib/exam-types.ts
Normal file
144
apps/student-portal/src/lib/exam-types.ts
Normal 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(" ");
|
||||
}
|
||||
173
apps/student-portal/src/lib/graphql/client.ts
Normal file
173
apps/student-portal/src/lib/graphql/client.ts
Normal 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 + errorExchange(401 → 跳转登录)+ 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 或 null(SSR 或未登录时)
|
||||
*/
|
||||
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=true):Shell 通过 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";
|
||||
}
|
||||
699
apps/student-portal/src/lib/graphql/hooks.ts
Normal file
699
apps/student-portal/src/lib/graphql/hooks.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* GraphQL React Hooks(ai14)
|
||||
*
|
||||
* 封装 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询 Hooks(16 个)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 当前学生信息 + 权限 + 数据范围 */
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 变更 Hooks(8 个)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 提交作业 */
|
||||
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 };
|
||||
145
apps/student-portal/src/lib/graphql/index.ts
Normal file
145
apps/student-portal/src/lib/graphql/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* student-portal GraphQL 层 barrel exports(ai14)
|
||||
*
|
||||
* 统一导出 GraphQL client、Provider、类型、操作字符串、Hooks。
|
||||
* 业务组件通过 `@/lib/graphql` 统一引入。
|
||||
*
|
||||
* 导出分类:
|
||||
* - client:createStandaloneClient, getGraphQLClient, resetGraphQLClient, isMfMode
|
||||
* - provider:GraphQLProvider, StandaloneGraphQLProvider
|
||||
* - types:ActionState, 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";
|
||||
716
apps/student-portal/src/lib/graphql/operations.ts
Normal file
716
apps/student-portal/src/lib/graphql/operations.ts
Normal 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.ts;Hook 层负责解包 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
134
apps/student-portal/src/lib/graphql/provider.tsx
Normal file
134
apps/student-portal/src/lib/graphql/provider.tsx
Normal 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;
|
||||
/**
|
||||
* 外部注入的 client(MF 模式下由 Shell 注入)
|
||||
* 未提供时:
|
||||
* - 独立模式:使用 getGraphQLClient() 单例
|
||||
* - MF 模式:尝试从 Shell 动态加载,失败则降级为独立 client
|
||||
*/
|
||||
client?: Client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试动态加载 Shell 暴露的 useGraphQLClient hook
|
||||
*
|
||||
* ARB-002:Shell 通过 @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-002:Shell 未暴露 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>;
|
||||
}
|
||||
597
apps/student-portal/src/lib/graphql/types.ts
Normal file
597
apps/student-portal/src/lib/graphql/types.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* student-portal GraphQL 类型定义(ai14)
|
||||
*
|
||||
* 对齐 student-bff GraphQL schema(ARB-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";
|
||||
}
|
||||
96
apps/student-portal/src/lib/observability/sentry.ts
Normal file
96
apps/student-portal/src/lib/observability/sentry.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Sentry 初始化与 PII 过滤(ai14,P6 硬化)
|
||||
*
|
||||
* - 仅当 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;
|
||||
63
apps/student-portal/src/lib/observability/web-vitals.ts
Normal file
63
apps/student-portal/src/lib/observability/web-vitals.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Web Vitals 上报(ai14,P6 硬化)
|
||||
*
|
||||
* 采集 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;
|
||||
86
apps/student-portal/src/mocks/browser.ts
Normal file
86
apps/student-portal/src/mocks/browser.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* MSW 浏览器端 setup(ai14)
|
||||
*
|
||||
* 开发环境下在浏览器中启动 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;
|
||||
}
|
||||
88
apps/student-portal/src/mocks/fixtures/chapters.json
Normal file
88
apps/student-portal/src/mocks/fixtures/chapters.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
20
apps/student-portal/src/mocks/fixtures/currentUser.json
Normal file
20
apps/student-portal/src/mocks/fixtures/currentUser.json
Normal 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"
|
||||
}
|
||||
68
apps/student-portal/src/mocks/fixtures/examDetail.json
Normal file
68
apps/student-portal/src/mocks/fixtures/examDetail.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
38
apps/student-portal/src/mocks/fixtures/homeworkDetail.json
Normal file
38
apps/student-portal/src/mocks/fixtures/homeworkDetail.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
10
apps/student-portal/src/mocks/fixtures/learningPath.json
Normal file
10
apps/student-portal/src/mocks/fixtures/learningPath.json
Normal 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 }
|
||||
]
|
||||
12
apps/student-portal/src/mocks/fixtures/myAttendance.json
Normal file
12
apps/student-portal/src/mocks/fixtures/myAttendance.json
Normal 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": "化学" } }
|
||||
]
|
||||
10
apps/student-portal/src/mocks/fixtures/myClasses.json
Normal file
10
apps/student-portal/src/mocks/fixtures/myClasses.json
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": "class-001",
|
||||
"name": "高三(2)班",
|
||||
"homeroomTeacher": "李老师",
|
||||
"studentCount": 45,
|
||||
"grade": "高三",
|
||||
"year": 2026
|
||||
}
|
||||
]
|
||||
26
apps/student-portal/src/mocks/fixtures/myExams.json
Normal file
26
apps/student-portal/src/mocks/fixtures/myExams.json
Normal 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"
|
||||
}
|
||||
]
|
||||
62
apps/student-portal/src/mocks/fixtures/myGrades.json
Normal file
62
apps/student-portal/src/mocks/fixtures/myGrades.json
Normal 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"
|
||||
}
|
||||
]
|
||||
36
apps/student-portal/src/mocks/fixtures/myHomework.json
Normal file
36
apps/student-portal/src/mocks/fixtures/myHomework.json
Normal 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
|
||||
}
|
||||
]
|
||||
12
apps/student-portal/src/mocks/fixtures/myNotifications.json
Normal file
12
apps/student-portal/src/mocks/fixtures/myNotifications.json
Normal 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" }
|
||||
]
|
||||
9
apps/student-portal/src/mocks/fixtures/myTrend.json
Normal file
9
apps/student-portal/src/mocks/fixtures/myTrend.json
Normal 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": "英语模拟考" }
|
||||
]
|
||||
38
apps/student-portal/src/mocks/fixtures/myWeakness.json
Normal file
38
apps/student-portal/src/mocks/fixtures/myWeakness.json
Normal 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"
|
||||
}
|
||||
]
|
||||
4
apps/student-portal/src/mocks/fixtures/serverTime.json
Normal file
4
apps/student-portal/src/mocks/fixtures/serverTime.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"timestamp": "2026-07-10T12:00:00.000Z",
|
||||
"offset": 0
|
||||
}
|
||||
20
apps/student-portal/src/mocks/fixtures/studentDashboard.json
Normal file
20
apps/student-portal/src/mocks/fixtures/studentDashboard.json
Normal 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
|
||||
}
|
||||
7
apps/student-portal/src/mocks/fixtures/textbooks.json
Normal file
7
apps/student-portal/src/mocks/fixtures/textbooks.json
Normal 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": "山东科学技术出版社" }
|
||||
]
|
||||
484
apps/student-portal/src/mocks/handlers.ts
Normal file
484
apps/student-portal/src/mocks/handlers.ts
Normal 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 };
|
||||
20
apps/student-portal/src/mocks/server.ts
Normal file
20
apps/student-portal/src/mocks/server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* MSW 服务端 setup(ai14)
|
||||
*
|
||||
* 用于 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 };
|
||||
196
apps/student-portal/src/styles/globals.css
Normal file
196
apps/student-portal/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
89
apps/student-portal/src/styles/primitive.css
Normal file
89
apps/student-portal/src/styles/primitive.css
Normal 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);
|
||||
}
|
||||
58
apps/student-portal/src/styles/semantic.css
Normal file
58
apps/student-portal/src/styles/semantic.css
Normal 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%);
|
||||
}
|
||||
}
|
||||
78
apps/student-portal/tailwind.config.js
Normal file
78
apps/student-portal/tailwind.config.js
Normal 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()/#hex(ESLint 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: [],
|
||||
};
|
||||
30
apps/student-portal/tsconfig.json
Normal file
30
apps/student-portal/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user