From 74474a2d0432b866be164ba91f64d35aafb35d7d Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:10:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(student-portal):=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20student-portal=20=E5=BE=AE=E5=89=8D?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含 src 全部实现、Dockerfile、配置文件、contracts 包等 --- apps/student-portal/.env.example | 35 + apps/student-portal/.gitignore | 32 + apps/student-portal/Dockerfile | 58 + apps/student-portal/eslint.config.js | 70 + apps/student-portal/next.config.js | 99 + apps/student-portal/package.json | 52 + apps/student-portal/postcss.config.js | 6 + apps/student-portal/src/app/ai-tutor/page.tsx | 228 + .../src/app/api/health/route.ts | 17 + .../student-portal/src/app/api/ready/route.ts | 49 + .../student-portal/src/app/dashboard/page.tsx | 448 ++ .../src/app/dashboard/trend/page.tsx | 203 + .../src/app/dashboard/weakness/page.tsx | 182 + apps/student-portal/src/app/layout.tsx | 57 + .../src/app/learning-path/page.tsx | 151 + .../src/app/my-attendance/page.tsx | 601 ++ .../src/app/my-classes/page.tsx | 185 + .../src/app/my-exams/[id]/result/page.tsx | 408 ++ .../src/app/my-exams/[id]/take/page.tsx | 36 + apps/student-portal/src/app/my-exams/page.tsx | 364 ++ .../student-portal/src/app/my-grades/page.tsx | 477 ++ .../src/app/my-homework/[id]/submit/page.tsx | 386 ++ .../src/app/my-homework/page.tsx | 372 ++ .../src/app/notifications/page.tsx | 315 + apps/student-portal/src/app/page.tsx | 12 + apps/student-portal/src/app/providers.tsx | 54 + apps/student-portal/src/app/student-app.tsx | 25 + .../src/app/textbooks/[id]/chapters/page.tsx | 130 + .../student-portal/src/app/textbooks/page.tsx | 101 + .../src/components/app-shell.tsx | 316 + .../exam-taking/countdown-timer.tsx | 83 + .../components/exam-taking/draft-recovery.tsx | 96 + .../components/exam-taking/exam-taking.tsx | 797 +++ .../src/components/feature-flag.tsx | 52 + .../src/hooks/use-anti-cheat.ts | 165 + .../src/hooks/use-exam-draft.ts | 160 + .../src/hooks/use-exam-state-machine.ts | 70 + .../src/hooks/use-multi-tab-guard.ts | 109 + .../src/hooks/use-notifications-websocket.ts | 141 + .../src/hooks/use-server-time-sync.ts | 89 + .../src/hooks/use-submit-dedup.ts | 63 + .../student-portal/src/i18n/messages/zh-CN.ts | 283 + .../student-portal/src/lib/error-boundary.tsx | 93 + apps/student-portal/src/lib/exam-types.ts | 144 + apps/student-portal/src/lib/graphql/client.ts | 173 + apps/student-portal/src/lib/graphql/hooks.ts | 699 +++ apps/student-portal/src/lib/graphql/index.ts | 145 + .../src/lib/graphql/operations.ts | 716 +++ .../src/lib/graphql/provider.tsx | 134 + apps/student-portal/src/lib/graphql/types.ts | 597 ++ .../src/lib/observability/sentry.ts | 96 + .../src/lib/observability/web-vitals.ts | 63 + apps/student-portal/src/mocks/browser.ts | 86 + .../src/mocks/fixtures/chapters.json | 88 + .../src/mocks/fixtures/currentUser.json | 20 + .../src/mocks/fixtures/examDetail.json | 68 + .../src/mocks/fixtures/homeworkDetail.json | 38 + .../src/mocks/fixtures/learningPath.json | 10 + .../src/mocks/fixtures/myAttendance.json | 12 + .../src/mocks/fixtures/myClasses.json | 10 + .../src/mocks/fixtures/myExams.json | 26 + .../src/mocks/fixtures/myGrades.json | 62 + .../src/mocks/fixtures/myHomework.json | 36 + .../src/mocks/fixtures/myNotifications.json | 12 + .../src/mocks/fixtures/myTrend.json | 9 + .../src/mocks/fixtures/myWeakness.json | 38 + .../src/mocks/fixtures/serverTime.json | 4 + .../src/mocks/fixtures/studentDashboard.json | 20 + .../src/mocks/fixtures/textbooks.json | 7 + apps/student-portal/src/mocks/handlers.ts | 484 ++ apps/student-portal/src/mocks/server.ts | 20 + apps/student-portal/src/styles/globals.css | 196 + apps/student-portal/src/styles/primitive.css | 89 + apps/student-portal/src/styles/semantic.css | 58 + apps/student-portal/tailwind.config.js | 78 + apps/student-portal/tsconfig.json | 30 + docs/troubleshooting/known-issues.md | 54 +- packages/contracts/package.json | 15 + packages/contracts/src/index.ts | 26 + pnpm-lock.yaml | 5263 ++++++++++++++++- 80 files changed, 17226 insertions(+), 70 deletions(-) create mode 100644 apps/student-portal/.env.example create mode 100644 apps/student-portal/.gitignore create mode 100644 apps/student-portal/Dockerfile create mode 100644 apps/student-portal/eslint.config.js create mode 100644 apps/student-portal/next.config.js create mode 100644 apps/student-portal/package.json create mode 100644 apps/student-portal/postcss.config.js create mode 100644 apps/student-portal/src/app/ai-tutor/page.tsx create mode 100644 apps/student-portal/src/app/api/health/route.ts create mode 100644 apps/student-portal/src/app/api/ready/route.ts create mode 100644 apps/student-portal/src/app/dashboard/page.tsx create mode 100644 apps/student-portal/src/app/dashboard/trend/page.tsx create mode 100644 apps/student-portal/src/app/dashboard/weakness/page.tsx create mode 100644 apps/student-portal/src/app/layout.tsx create mode 100644 apps/student-portal/src/app/learning-path/page.tsx create mode 100644 apps/student-portal/src/app/my-attendance/page.tsx create mode 100644 apps/student-portal/src/app/my-classes/page.tsx create mode 100644 apps/student-portal/src/app/my-exams/[id]/result/page.tsx create mode 100644 apps/student-portal/src/app/my-exams/[id]/take/page.tsx create mode 100644 apps/student-portal/src/app/my-exams/page.tsx create mode 100644 apps/student-portal/src/app/my-grades/page.tsx create mode 100644 apps/student-portal/src/app/my-homework/[id]/submit/page.tsx create mode 100644 apps/student-portal/src/app/my-homework/page.tsx create mode 100644 apps/student-portal/src/app/notifications/page.tsx create mode 100644 apps/student-portal/src/app/page.tsx create mode 100644 apps/student-portal/src/app/providers.tsx create mode 100644 apps/student-portal/src/app/student-app.tsx create mode 100644 apps/student-portal/src/app/textbooks/[id]/chapters/page.tsx create mode 100644 apps/student-portal/src/app/textbooks/page.tsx create mode 100644 apps/student-portal/src/components/app-shell.tsx create mode 100644 apps/student-portal/src/components/exam-taking/countdown-timer.tsx create mode 100644 apps/student-portal/src/components/exam-taking/draft-recovery.tsx create mode 100644 apps/student-portal/src/components/exam-taking/exam-taking.tsx create mode 100644 apps/student-portal/src/components/feature-flag.tsx create mode 100644 apps/student-portal/src/hooks/use-anti-cheat.ts create mode 100644 apps/student-portal/src/hooks/use-exam-draft.ts create mode 100644 apps/student-portal/src/hooks/use-exam-state-machine.ts create mode 100644 apps/student-portal/src/hooks/use-multi-tab-guard.ts create mode 100644 apps/student-portal/src/hooks/use-notifications-websocket.ts create mode 100644 apps/student-portal/src/hooks/use-server-time-sync.ts create mode 100644 apps/student-portal/src/hooks/use-submit-dedup.ts create mode 100644 apps/student-portal/src/i18n/messages/zh-CN.ts create mode 100644 apps/student-portal/src/lib/error-boundary.tsx create mode 100644 apps/student-portal/src/lib/exam-types.ts create mode 100644 apps/student-portal/src/lib/graphql/client.ts create mode 100644 apps/student-portal/src/lib/graphql/hooks.ts create mode 100644 apps/student-portal/src/lib/graphql/index.ts create mode 100644 apps/student-portal/src/lib/graphql/operations.ts create mode 100644 apps/student-portal/src/lib/graphql/provider.tsx create mode 100644 apps/student-portal/src/lib/graphql/types.ts create mode 100644 apps/student-portal/src/lib/observability/sentry.ts create mode 100644 apps/student-portal/src/lib/observability/web-vitals.ts create mode 100644 apps/student-portal/src/mocks/browser.ts create mode 100644 apps/student-portal/src/mocks/fixtures/chapters.json create mode 100644 apps/student-portal/src/mocks/fixtures/currentUser.json create mode 100644 apps/student-portal/src/mocks/fixtures/examDetail.json create mode 100644 apps/student-portal/src/mocks/fixtures/homeworkDetail.json create mode 100644 apps/student-portal/src/mocks/fixtures/learningPath.json create mode 100644 apps/student-portal/src/mocks/fixtures/myAttendance.json create mode 100644 apps/student-portal/src/mocks/fixtures/myClasses.json create mode 100644 apps/student-portal/src/mocks/fixtures/myExams.json create mode 100644 apps/student-portal/src/mocks/fixtures/myGrades.json create mode 100644 apps/student-portal/src/mocks/fixtures/myHomework.json create mode 100644 apps/student-portal/src/mocks/fixtures/myNotifications.json create mode 100644 apps/student-portal/src/mocks/fixtures/myTrend.json create mode 100644 apps/student-portal/src/mocks/fixtures/myWeakness.json create mode 100644 apps/student-portal/src/mocks/fixtures/serverTime.json create mode 100644 apps/student-portal/src/mocks/fixtures/studentDashboard.json create mode 100644 apps/student-portal/src/mocks/fixtures/textbooks.json create mode 100644 apps/student-portal/src/mocks/handlers.ts create mode 100644 apps/student-portal/src/mocks/server.ts create mode 100644 apps/student-portal/src/styles/globals.css create mode 100644 apps/student-portal/src/styles/primitive.css create mode 100644 apps/student-portal/src/styles/semantic.css create mode 100644 apps/student-portal/tailwind.config.js create mode 100644 apps/student-portal/tsconfig.json create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/index.ts diff --git a/apps/student-portal/.env.example b/apps/student-portal/.env.example new file mode 100644 index 0000000..e32a122 --- /dev/null +++ b/apps/student-portal/.env.example @@ -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 diff --git a/apps/student-portal/.gitignore b/apps/student-portal/.gitignore new file mode 100644 index 0000000..0efd07c --- /dev/null +++ b/apps/student-portal/.gitignore @@ -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 diff --git a/apps/student-portal/Dockerfile b/apps/student-portal/Dockerfile new file mode 100644 index 0000000..6814c1d --- /dev/null +++ b/apps/student-portal/Dockerfile @@ -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"] diff --git a/apps/student-portal/eslint.config.js b/apps/student-portal/eslint.config.js new file mode 100644 index 0000000..e3cfed3 --- /dev/null +++ b/apps/student-portal/eslint.config.js @@ -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'], + }, +); diff --git a/apps/student-portal/next.config.js b/apps/student-portal/next.config.js new file mode 100644 index 0000000..f750cc7 --- /dev/null +++ b/apps/student-portal/next.config.js @@ -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; diff --git a/apps/student-portal/package.json b/apps/student-portal/package.json new file mode 100644 index 0000000..035e8c8 --- /dev/null +++ b/apps/student-portal/package.json @@ -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" + } +} diff --git a/apps/student-portal/postcss.config.js b/apps/student-portal/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/student-portal/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/student-portal/src/app/ai-tutor/page.tsx b/apps/student-portal/src/app/ai-tutor/page.tsx new file mode 100644 index 0000000..f557be2 --- /dev/null +++ b/apps/student-portal/src/app/ai-tutor/page.tsx @@ -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([ + { + id: "welcome", + role: "assistant", + content: "你好!我是 AI 学习助手,有什么可以帮你的吗?", + }, + ]); + const [input, setInput] = useState(""); + const [streaming, setStreaming] = useState(false); + const listRef = useRef(null); + + useEffect(() => { + listRef.current?.scrollTo({ + top: listRef.current.scrollHeight, + behavior: "smooth", + }); + }, [messages]); + + const send = useCallback(async (): Promise => { + 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 ( +
+
+
+
+

AI 辅学

+

智能答疑与学习辅导

+
+ +
+
+ +
+ +
+ {messages.map((m) => ( +
+
+

+ {m.role === "user" ? "我" : "AI 助手"} +

+

+ {m.content || (streaming ? "正在思考…" : "")} +

+
+
+ ))} +
+ +
{ + e.preventDefault(); + void send(); + }} + > + + setInput(e.target.value)} + placeholder="请输入你的问题…" + disabled={streaming} + aria-label="问题输入框" + /> + +
+
+ ); +} + +export default function AiTutorPage(): React.ReactNode { + const enabled = useAiTutorEnabled(); + return ( + + + + ); +} diff --git a/apps/student-portal/src/app/api/health/route.ts b/apps/student-portal/src/app/api/health/route.ts new file mode 100644 index 0000000..a52cc72 --- /dev/null +++ b/apps/student-portal/src/app/api/health/route.ts @@ -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", + }); +} diff --git a/apps/student-portal/src/app/api/ready/route.ts b/apps/student-portal/src/app/api/ready/route.ts new file mode 100644 index 0000000..9b2a62f --- /dev/null +++ b/apps/student-portal/src/app/api/ready/route.ts @@ -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 { + 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 { + const graphqlOk = await checkGraphql(); + const result: ReadinessResult = { + ready: graphqlOk, + checks: { graphql: graphqlOk }, + timestamp: new Date().toISOString(), + }; + return NextResponse.json(result, { status: graphqlOk ? 200 : 503 }); +} diff --git a/apps/student-portal/src/app/dashboard/page.tsx b/apps/student-portal/src/app/dashboard/page.tsx new file mode 100644 index 0000000..9cc3c9f --- /dev/null +++ b/apps/student-portal/src/app/dashboard/page.tsx @@ -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 ( +
+ +
+ + {fetching ? ( + + ) : error ? ( + + ) : !data ? ( + + ) : ( + <> + +
+ + +
+
+ +
+ + )} +
+ ); +} + +// === 顶部欢迎区 === +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 ( +
+

+ {today} +

+

+ 你好,{studentName ?? "同学"} +

+

+ 欢迎回到学习空间,继续保持你的学习节奏。 +

+
+ ); +} + +// === 统计卡片网格 === +function StatGrid({ data }: { data: StudentDashboard }): JSX.Element { + return ( +
+ + + + +
+ ); +} + +function StatCard({ + label, + value, + suffix, + accent, +}: { + label: string; + value: string; + suffix?: string; + accent?: boolean; +}): JSX.Element { + return ( +
+

+ {label} +

+

+ {value} + {suffix && ( + + {suffix} + + )} +

+
+ ); +} + +// === 即将到期的作业 === +function UpcomingHomeworkCard({ + items, +}: { + items: DashboardHomework[]; +}): JSX.Element { + return ( +
+
+

+ 即将到期的作业 +

+ + 查看全部 → + +
+
+ {items.length === 0 ? ( +

+ 暂无待办作业 +

+ ) : ( +
    + {items.map((hw) => ( +
  • + +

    + {hw.title} +

    +

    + {hw.subject.name} · 截止 {formatDueDate(hw.dueAt)} +

    + +
  • + ))} +
+ )} +
+ ); +} + +// === 即将到来的考试 === +function UpcomingExamsCard({ items }: { items: DashboardExam[] }): JSX.Element { + return ( +
+
+

+ 即将到来的考试 +

+ + 查看全部 → + +
+
+ {items.length === 0 ? ( +

+ 暂无考试安排 +

+ ) : ( +
    + {items.map((exam) => ( +
  • +

    + {exam.name} +

    +

    + {exam.subject.name} · {formatDueDate(exam.startsAt)} ·{" "} + {Math.round(exam.durationSeconds / 60)} 分钟 +

    +
  • + ))} +
+ )} +
+ ); +} + +// === 最近成绩 === +function RecentGradesCard({ items }: { items: DashboardGrade[] }): JSX.Element { + return ( +
+
+

+ 最近成绩 +

+ + 查看全部 → + +
+
+ {items.length === 0 ? ( +

+ 暂无成绩记录 +

+ ) : ( +
    + {items.map((grade) => ( +
  • +
    +

    + {grade.examName} +

    +

    + {formatDueDate(grade.submittedAt)} +

    +
    +
    +

    + {grade.score} + + {" "} + / {grade.maxScore} + +

    + + {grade.grade} + +
    +
  • + ))} +
+ )} +
+ ); +} + +// === 加载骨架 === +function LoadingSkeleton(): JSX.Element { + return ( +
+