feat(student-portal): 完整实现 student-portal 微前端
包含 src 全部实现、Dockerfile、配置文件、contracts 包等
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -482,22 +482,38 @@
|
|||||||
|
|
||||||
> student-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / ErrorBoundary / 设计令牌 / A11y 工具集,本节仅记录 student-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。
|
> student-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / ErrorBoundary / 设计令牌 / A11y 工具集,本节仅记录 student-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| P3 端口分配 | student-portal = **4001**(004 §1.2 强制 4 端 4000-4003;ai07 初稿误用 3001 与 iam 冲突,ai14 修订) |
|
| P3 端口分配 | student-portal = **4001**(004 §1.2 强制 4 端 4000-4003;ai07 初稿误用 3001 与 iam 冲突,ai14 修订) |
|
||||||
| P3 MF Remote 角色 | next.config.js 配 `@module-federation/nextjs-mf`(name: `student_app`,port 4001)暴露 `./pages` + `./ExamTaking`,复用 Shell 的 `AppShell`/`usePermission`/`ApiClient` |
|
| P3 MF Remote 角色 | next.config.js 配 `@module-federation/nextjs-mf`(name: `student_app`,port 4001)暴露 `./pages` + `./ExamTaking`,复用 Shell 的 `AppShell`/`usePermission`/`ApiClient` |
|
||||||
| P3 DataScope SELF | 学生仅看自己数据(004 §5.4 L0),前端**无班级/年级/学校切换器**(与 teacher-portal L1-L5 核心差异),路由参数不含 classId/gradeId/schoolId |
|
| P3 DataScope SELF | 学生仅看自己数据(004 §5.4 L0),前端**无班级/年级/学校切换器**(与 teacher-portal L1-L5 核心差异),路由参数不含 classId/gradeId/schoolId |
|
||||||
| P3 错误码前缀统一 | core-edu 子域统一 `CORE_EDU_`(移除 `EXAMS_`/`HOMEWORK_`/`GRADES_` 独立前缀);BFF 前缀 `BFF_STUDENT_`(coord §5.2 裁决,从 `STUDENT_BFF_` 迁移) |
|
| P3 错误码前缀统一 | core-edu 子域统一 `CORE_EDU_`(移除 `EXAMS_`/`HOMEWORK_`/`GRADES_` 独立前缀);BFF 前缀 `BFF_STUDENT_`(coord §5.2 裁决,从 `STUDENT_BFF_` 迁移) |
|
||||||
| P3 考试防作弊 useAntiCheat | `visibilitychange` 切屏检测 + `blur` 失焦 + `copy`/`paste`/`contextmenu` 禁止 + 违规计数(maxViolations=3)+ 超限自动交卷;前端层仅辅助,最终权威由后端考试时长 + 服务端时间校准保证 |
|
| P3 考试防作弊 useAntiCheat | `visibilitychange` 切屏检测 + `blur` 失焦 + `copy`/`paste`/`contextmenu` 禁止 + 违规计数(maxViolations=3)+ 超限自动交卷;前端层仅辅助,最终权威由后端考试时长 + 服务端时间校准保证 |
|
||||||
| P3 服务器时间同步 | `useServerTimeSync` Hook:RTT 计算 `offset = (t1 + rtt/2) - serverTime` + 5min 重同步周期;`CountdownTimer` 用 `role="timer"` + `aria-live="polite"` 朗读剩余时间 |
|
| P3 服务器时间同步 | `useServerTimeSync` Hook:RTT 计算 `offset = (t1 + rtt/2) - serverTime` + 5min 重同步周期;`CountdownTimer` 用 `role="timer"` + `aria-live="polite"` 朗读剩余时间 |
|
||||||
| P3 考试作答状态机 | NotStarted→InProgress→Paused(切屏警告)→AutoSubmitting(倒计时归零)→Submitting→Submitted;Zustand L3 + localStorage 持久化草稿,自动保存每 30s + blur 时保存 |
|
| P3 考试作答状态机 | NotStarted→InProgress→Paused(切屏警告)→AutoSubmitting(倒计时归零)→Submitting→Submitted;Zustand L3 + localStorage 持久化草稿,自动保存每 30s + blur 时保存 |
|
||||||
| P3 考试页面 CSP iframe 防嵌套 | 考试路由 `frame-ancestors 'none'` + `X-Frame-Options: DENY`;其他路由允许 Shell 同源嵌入 |
|
| P3 考试页面 CSP iframe 防嵌套 | 考试路由 `frame-ancestors 'none'` + `X-Frame-Options: DENY`;其他路由允许 Shell 同源嵌入 |
|
||||||
| P3 考试 chunk 性能预算 | 考试作答 chunk < 200KB(动态 import 隔离);总 Bundle < 300KB;LCP < 2.5s / CLS < 0.1 / INP < 200ms |
|
| P3 考试 chunk 性能预算 | 考试作答 chunk < 200KB(动态 import 隔离);总 Bundle < 300KB;LCP < 2.5s / CLS < 0.1 / INP < 200ms |
|
||||||
| P3 移动端一等公民 | 学生端高频移动场景:Tailwind 响应式断点 + viewport meta + 触摸优化(按钮 ≥ 44px);考试页面支持横屏锁定 |
|
| P3 移动端一等公民 | 学生端高频移动场景:Tailwind 响应式断点 + viewport meta + 触摸优化(按钮 ≥ 44px);考试页面支持横屏锁定 |
|
||||||
| P3 A11y 考试专项 | WCAG 2.2 AA:键盘导航作答(Tab/Enter/方向键)+ `aria-live` 倒计时 + 焦点陷阱(考试中不可 Tab 到地址栏)+ 屏幕阅读器兼容 |
|
| P3 A11y 考试专项 | WCAG 2.2 AA:键盘导航作答(Tab/Enter/方向键)+ `aria-live` 倒计时 + 焦点陷阱(考试中不可 Tab 到地址栏)+ 屏幕阅读器兼容 |
|
||||||
| P3 考试草稿多设备同步预留 | 当前 localStorage 单设备;P5+ 预留草稿同步(LWW 冲突解决 + WebSocket 推送 + version 字段) |
|
| P3 考试草稿多设备同步预留 | 当前 localStorage 单设备;P5+ 预留草稿同步(LWW 冲突解决 + WebSocket 推送 + version 字段) |
|
||||||
| P3 Feature Flag 阶段化 | P3-P5 用环境变量(`NEXT_PUBLIC_FEATURE_*`);P6+ 预留 Consul 配置中心动态开关 |
|
| P3 Feature Flag 阶段化 | P3-P5 用环境变量(`NEXT_PUBLIC_FEATURE_*`);P6+ 预留 Consul 配置中心动态开关 |
|
||||||
| P3 PWA 离线壳预留 | P5+ Service Worker 缓存考试壳,断网时仍可作答,恢复后自动提交 |
|
| P3 PWA 离线壳预留 | P5+ Service Worker 缓存考试壳,断网时仍可作答,恢复后自动提交 |
|
||||||
| P3 MF 降级与版本协商 | Remote `remoteEntry.js` 加载失败 → fallback 独立页面 + 错误提示;共享依赖版本不匹配 → 运行时协商降级 |
|
| P3 MF 降级与版本协商 | Remote `remoteEntry.js` 加载失败 → fallback 独立页面 + 错误提示;共享依赖版本不匹配 → 运行时协商降级 |
|
||||||
| P3 AI 答疑 SSE 流式(P5) | `POST /student/ai/stream-chat` SSE 流式响应 + `streamingText` 逐字渲染 + 打字机效果 + AbortController 取消 |
|
| P3 AI 答疑 SSE 流式(P5) | `POST /student/ai/stream-chat` SSE 流式响应 + `streamingText` 逐字渲染 + 打字机效果 + AbortController 取消 |
|
||||||
| P3 权限点命名 | 与 student-bff §3.2 对齐 `STUDENT_*_READ` 风格(`STUDENT_DASHBOARD_READ`/`STUDENT_EXAM_READ`/`STUDENT_HOMEWORK_SUBMIT`/`STUDENT_GRADE_READ` 等) |
|
| P3 权限点命名 | 与 student-bff §3.2 对齐 `STUDENT_*_READ` 风格(`STUDENT_DASHBOARD_READ`/`STUDENT_EXAM_READ`/`STUDENT_HOMEWORK_SUBMIT`/`STUDENT_GRADE_READ` 等) |
|
||||||
|
| P2 MF Remote 独立壳双模式 | `NEXT_PUBLIC_MF_ENABLED=false` 独立壳渲染(自建 urql client + AppShell);`true` 接入 Shell(复用 Shell 暴露的 GraphQL client 单例 + AppShell) |
|
||||||
|
| P2 设计令牌三层实现 | primitive.css(原始 HSL 分量 + 字面量白名单)→ semantic.css(语义令牌组合)→ tailwind.config.js(映射为 bg-_/text-_ 类),ESLint `no-restricted-syntax` 禁止 #hex 字面量 |
|
||||||
|
| P2 GraphQL endpoint 路径 | `POST /api/v1/student/graphql`(经 api-gateway 反向代理到 student-bff :3009),next.config.js `rewrites` 配置 `/api/v1/*` + `/api/auth/*` → api-gateway |
|
||||||
|
| P2 MSW mock ActionState 信封 | MSW handlers 返回 `{ success: true, errors: [], data: {...}, extensions: { degraded: false } }`,按 operationName 分发到 fixtures/*.json |
|
||||||
|
| P3 考试状态机实现 | useReducer 实现 NotStarted→InProgress→AutoSaving→Submitting→Submitted 状态机,`canSubmit` 防止非 InProgress/AutoSaving 状态提交 |
|
||||||
|
| P3 IDB 草稿持久化 | idb-keyval 存储草稿,key 格式 `exam-draft:${examId}:${studentId}`,自动保存每 30s(NEXT_PUBLIC_EXAM_AUTOSAVE_INTERVAL)+ 卸载时保存 |
|
||||||
|
| P3 服务器时间同步实现 | useServerTimeSync 轮询 serverTime query,RTT 中点估算 offset = (t1 + rtt/2) - serverTime,5min 重同步(NEXT_PUBLIC_EXAM_TIME_SYNC_INTERVAL),失败标记降级 |
|
||||||
|
| P3 多标签检测降级方案 | BroadcastChannel('student-exam-guard') 优先,Safari<15.4 降级到 localStorage StorageEvent |
|
||||||
|
| P3 提交防重复 | crypto.randomUUID 生成幂等 key(降级方案 timestamp+random),useRef 锁防快速点击重复提交 |
|
||||||
|
| P3 防作弊批量上报 | 违规事件队列(满 10 条批量上报 recordExamViolation mutation),visibilitychange hidden 计数 ≥3 显示警告遮罩 |
|
||||||
|
| P5 WebSocket 通知降级 | useNotificationsWebSocket 指数退避重连(1s/2s/4s/8s/max 30s),mock-socket 模拟推送,BroadcastChannel('edu-notification') 跨 Tab 同步 |
|
||||||
|
| P6 健康检查端点 | `/api/health` 返回 liveness(无外部依赖);`/api/ready` 检查 GraphQL endpoint 可达性(fetch simple query),503 if not ready |
|
||||||
|
| P6 Dockerfile 多阶段构建 | node:22-alpine 三阶段(install→build→runtime),非 root 用户运行,EXPOSE 4001,healthcheck 用 /api/health |
|
||||||
|
| P6 Sentry PII 过滤 | beforeSend 过滤 studentName/studentNo/email 字段,仅 NEXT_PUBLIC_SENTRY_DSN 配置时初始化 |
|
||||||
|
| P2 @edu/hooks ESM .js 后缀解析 | webpack `resolve.extensionAlias` 映射 `.js → [.ts, .tsx, .js]`(仅 .js,不映射 .mjs 避免破坏 urql 等库内部解析) |
|
||||||
|
| P2 MSW setupWorker SSR 报错 | `setupWorker` 仅在浏览器调用,使用动态 `import('msw/browser')` + `typeof window === 'undefined'` 守卫,避免 Next.js prerender 时执行 |
|
||||||
|
|||||||
15
packages/contracts/package.json
Normal file
15
packages/contracts/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@edu/contracts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "共享契约:Permissions 常量 + 类型(coord 维护,当前为最小 stub)",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint src",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/contracts/src/index.ts
Normal file
26
packages/contracts/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @edu/contracts - 共享契约包(最小 stub)
|
||||||
|
*
|
||||||
|
* 维护者:coord
|
||||||
|
* 当前为最小 stub,供 student-portal 等 app 解析 workspace 依赖。
|
||||||
|
* 完整实现由 coord 在 P3 起步时交付。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 学生端权限点常量 */
|
||||||
|
export const STUDENT_PERMISSIONS = {
|
||||||
|
STUDENT_DASHBOARD_VIEW: "STUDENT_DASHBOARD_VIEW",
|
||||||
|
HOMEWORK_READ_OWN: "HOMEWORK_READ_OWN",
|
||||||
|
HOMEWORK_SUBMIT: "HOMEWORK_SUBMIT",
|
||||||
|
EXAMS_READ_OWN: "EXAMS_READ_OWN",
|
||||||
|
EXAMS_TAKE: "EXAMS_TAKE",
|
||||||
|
EXAMS_RESULT_VIEW: "EXAMS_RESULT_VIEW",
|
||||||
|
GRADES_READ_OWN: "GRADES_READ_OWN",
|
||||||
|
ATTENDANCE_READ_OWN: "ATTENDANCE_READ_OWN",
|
||||||
|
LEARNING_PATH_VIEW: "LEARNING_PATH_VIEW",
|
||||||
|
DIAGNOSTIC_READ_OWN: "DIAGNOSTIC_READ_OWN",
|
||||||
|
WEAKNESS_READ_OWN: "WEAKNESS_READ_OWN",
|
||||||
|
NOTIFICATION_READ_OWN: "NOTIFICATION_READ_OWN",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StudentPermission =
|
||||||
|
(typeof STUDENT_PERMISSIONS)[keyof typeof STUDENT_PERMISSIONS];
|
||||||
5263
pnpm-lock.yaml
generated
5263
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user