Compare commits
66 Commits
main
...
2f8f3f3855
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f8f3f3855 | ||
|
|
081cb5fbc3 | ||
|
|
dca25fc42f | ||
|
|
d066da563f | ||
|
|
843c3c0144 | ||
|
|
92f24e2e91 | ||
|
|
7c511e74bd | ||
|
|
0beeff6329 | ||
|
|
a28a6bd6ea | ||
|
|
9358372657 | ||
|
|
da05c9107a | ||
|
|
994441c2dc | ||
|
|
03e3ec4f60 | ||
|
|
98058eb16b | ||
|
|
f92fdf8efe | ||
|
|
dc13a2afb4 | ||
|
|
cfb7b005fd | ||
|
|
682f323bad | ||
|
|
f586a0b19e | ||
|
|
9cedf0c437 | ||
|
|
f7e52b5b7f | ||
|
|
f8db1bfe80 | ||
|
|
bbb43a210f | ||
|
|
9bee920e4d | ||
|
|
caa90eba85 | ||
|
|
b30d43f983 | ||
|
|
1b5781bf42 | ||
|
|
315b954998 | ||
|
|
2910a90271 | ||
|
|
f623dcf4a7 | ||
|
|
7c234947e1 | ||
|
|
989603e318 | ||
|
|
117c89396d | ||
|
|
135efa5ee5 | ||
|
|
80e6468d62 | ||
|
|
83b6919185 | ||
|
|
514e26ebb4 | ||
|
|
47e950c664 | ||
|
|
ce5aeec955 | ||
|
|
1423a0b8eb | ||
|
|
1a5fa78fa6 | ||
|
|
163bff6666 | ||
|
|
6af1aa0d82 | ||
|
|
a75527be80 | ||
|
|
47a062606f | ||
|
|
a3f4fd013e | ||
|
|
1dcdcf23fd | ||
|
|
35aa56537d | ||
|
|
d3b126ed9d | ||
|
|
d59c4e585f | ||
|
|
9ff7a61ee2 | ||
|
|
6bed673d9f | ||
|
|
5fcb831a18 | ||
|
|
3c2ea50c7f | ||
|
|
f212ba0813 | ||
|
|
765f7da4c0 | ||
|
|
ad39a3bb0f | ||
|
|
d11441c9a8 | ||
|
|
aac26c7c6f | ||
|
|
843b370b3d | ||
|
|
7545956202 | ||
|
|
62682b9d61 | ||
|
|
594a4e65fe | ||
|
|
0b858d9069 | ||
|
|
9db7fd917e | ||
|
|
78e406b317 |
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
@@ -12,8 +12,9 @@
|
||||
[
|
||||
'api-gateway', 'push-gateway',
|
||||
'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai',
|
||||
'config-service',
|
||||
'teacher-bff', 'student-bff', 'parent-bff',
|
||||
'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal',
|
||||
'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal', 'portal-shell',
|
||||
'shared-proto', 'shared-ts', 'shared-go', 'shared-py', 'shared-tokens',
|
||||
'arch-scan', 'infra', 'docs', 'deps', 'release',
|
||||
],
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -49,6 +49,13 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: pnpm -r run typecheck
|
||||
|
||||
- name: Portal-shell structural checks (P1-8)
|
||||
working-directory: apps/portal-shell
|
||||
run: |
|
||||
pnpm run check:routes
|
||||
pnpm run check:pages
|
||||
pnpm run check:codegen
|
||||
|
||||
- name: Test
|
||||
run: pnpm -r run test
|
||||
continue-on-error: true # P6: 部分服务无 test 脚本,待补全
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,9 @@ out/
|
||||
target/
|
||||
bin/
|
||||
obj/
|
||||
# Allow Next.js app router route segments named "build" (e.g. exams/[id]/build)
|
||||
!apps/portal-shell/src/app/**/build/
|
||||
!apps/portal-shell/src/app/**/build/**
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
|
||||
29
apps/portal-shell/.env.example
Normal file
29
apps/portal-shell/.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# portal-shell 环境变量模板(v2.1 M8)
|
||||
#
|
||||
# 复制为 .env.local 后按实际环境填写。
|
||||
# 服务端变量不加 NEXT_PUBLIC_ 前缀;前端变量必须加。
|
||||
|
||||
# Apollo Router(GraphQL 联邦入口,M8 验收点)
|
||||
# 前端 Apollo Client 直连此地址
|
||||
NEXT_PUBLIC_APOLLO_ROUTER_URL=http://localhost:3000/graphql
|
||||
# 服务端 RSC 预取用(容器内走内部网络)
|
||||
APOLLO_ROUTER_URL=http://localhost:3000/graphql
|
||||
|
||||
# Apollo Client APQ 开关(v2.1 M3 安全加固)
|
||||
# 生产环境必须为 true(前端只发 query hash,不发明文 query)
|
||||
# 开发环境可设为 false 便于 DevTools 调试
|
||||
NEXT_PUBLIC_APOLLO_APQ=true
|
||||
|
||||
# config-service 直连地址(开发态降级用)
|
||||
# 当 apollo-router 不可用时,fetchPluginConfig 会降级直连此地址的 /graphql 端点
|
||||
# 生产环境不需要配置(apollo-router 必须可用)
|
||||
CONFIG_SERVICE_URL=http://localhost:3011
|
||||
|
||||
# API Gateway(JWT 校验 + 注入 x-user-id / x-user-role)
|
||||
NEXT_PUBLIC_API_GATEWAY_URL=http://localhost:8080
|
||||
|
||||
# Realtime Gateway(SSE 推送)
|
||||
NEXT_PUBLIC_REALTIME_GATEWAY_URL=http://localhost:8081
|
||||
|
||||
# 开发模式(未登录时使用 dev-user / teacher 兜底)
|
||||
NEXT_PUBLIC_DEV_MODE=true
|
||||
44
apps/portal-shell/.eslintrc.tokens.js
Normal file
44
apps/portal-shell/.eslintrc.tokens.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* ESLint Design Tokens 配置(独立运行:eslint -c .eslintrc.tokens.js src)
|
||||
*
|
||||
* 与 eslint.config.js 中的 design-tokens 规则等价,保留以对齐 teacher-portal 习惯。
|
||||
* 使用 typescript-eslint 包的 parser(与 eslint.config.js 一致)。
|
||||
* 关联:project_rules §3.10
|
||||
*/
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default tseslint.config(
|
||||
{
|
||||
files: ["**/*.{ts,tsx,js,jsx}"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module",
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
|
||||
message:
|
||||
"禁止硬编码颜色 #hex,使用 var(--*) 或 Tailwind bg-* 类(project_rules §3.10)",
|
||||
},
|
||||
{
|
||||
selector: "Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]",
|
||||
message:
|
||||
"禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)(project_rules §3.10)",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/primitive.css", "**/manifest.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
17
apps/portal-shell/.gitignore
vendored
Normal file
17
apps/portal-shell/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# graphql-codegen 产物(构建时生成)
|
||||
src/lib/api/__generated__/
|
||||
|
||||
# 本地环境变量(应永远在本地,不入库;根 .gitignore 已覆盖,此处冗余声明)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# TypeScript 增量构建缓存(286KB,不应入库;根 .gitignore 已 *.tsbuildinfo 覆盖)
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Next.js 构建产物
|
||||
.next/
|
||||
out/
|
||||
|
||||
# 测试覆盖率
|
||||
coverage/
|
||||
1116
apps/portal-shell/ARCHITECTURE.md
Normal file
1116
apps/portal-shell/ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
56
apps/portal-shell/Dockerfile
Normal file
56
apps/portal-shell/Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
||||
# 多阶段构建:Next.js 生产镜像(standalone 模式)
|
||||
# 用法:docker build -t edu/portal-shell:latest -f apps/portal-shell/Dockerfile .
|
||||
# 端口规范:portal-shell :4010(避开旧 portal 4000-4003 段位)
|
||||
|
||||
# ============ Builder ============
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# 启用 pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||
|
||||
# 先拷依赖清单,利用缓存(含 workspace 共享包)
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* tsconfig.base.json* ./
|
||||
COPY apps/portal-shell/package.json ./apps/portal-shell/
|
||||
COPY packages/ui-tokens/package.json ./packages/ui-tokens/
|
||||
COPY packages/ui-components/package.json ./packages/ui-components/
|
||||
COPY packages/hooks/package.json ./packages/hooks/
|
||||
# 安装依赖(含 devDependencies,构建需要)
|
||||
RUN pnpm install --filter @edu/portal-shell... --frozen-lockfile || pnpm install --filter @edu/portal-shell...
|
||||
|
||||
# 拷源码
|
||||
COPY apps/portal-shell ./apps/portal-shell
|
||||
COPY packages/ui-tokens ./packages/ui-tokens
|
||||
COPY packages/ui-components ./packages/ui-components
|
||||
COPY packages/hooks ./packages/hooks
|
||||
|
||||
# 构建(禁用 telemetry,生产模式,standalone 输出)
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm --filter @edu/portal-shell run build
|
||||
|
||||
# ============ Runtime ============
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=4010
|
||||
|
||||
# 非 root 用户运行
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||
|
||||
# 拷 standalone 产物(已含 node_modules 和 server.js,自包含)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/portal-shell/public ./public
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 4010
|
||||
|
||||
# 健康检查(/api/health liveness 端点)
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget --quiet --spider http://localhost:4010/api/health || exit 1
|
||||
|
||||
# standalone 模式下直接用 node server.js 启动(已自包含所有依赖)
|
||||
WORKDIR /app/apps/portal-shell
|
||||
CMD ["node", "server.js"]
|
||||
1891
apps/portal-shell/README.md
Normal file
1891
apps/portal-shell/README.md
Normal file
File diff suppressed because it is too large
Load Diff
78
apps/portal-shell/codegen.yml
Normal file
78
apps/portal-shell/codegen.yml
Normal file
@@ -0,0 +1,78 @@
|
||||
# graphql-codegen configuration
|
||||
#
|
||||
# Schema source: combined-schema.graphql (generated by scripts/normalize-schema.ts
|
||||
# from services subgraph SDL files, with federation `extend type Query` normalized).
|
||||
#
|
||||
# Subgraph list (7 with GraphQL): iam / config-service / core-edu / content /
|
||||
# msg / data-ana / ai (classes has no GraphQL subgraph yet).
|
||||
#
|
||||
# Outputs:
|
||||
# - __generated__/types.ts: all GraphQL schema types (always generated)
|
||||
# - __generated__/operations.ts: DocumentNode constants emitted from
|
||||
# operations/*.graphql.ts via typescript-document-nodes plugin.
|
||||
# - __generated__/dashboard-types.ts: per-operation types for data-ana domain
|
||||
# (skipDocumentsValidation: false). Other domains still reference forward-looking
|
||||
# spec fields not yet in services subgraph SDL; they remain on the global
|
||||
# skipDocumentsValidation: true. As services catch up, additional per-domain
|
||||
# outputs can be added (config → core-edu → content → msg → iam order).
|
||||
#
|
||||
# Related: spec section 2.4 / 5.3, ARCHITECTURE.md §10 P1-7
|
||||
|
||||
schema:
|
||||
- src/lib/api/__generated__/combined-schema.graphql
|
||||
|
||||
# Schema validation is skipped because services/ai subgraph has invalid input
|
||||
# types (ChatRequestInput.messages references output type ChatMessage; same
|
||||
# for ChatResponseInput.usage → Usage). normalize-schema.ts rewrites those
|
||||
# field types to String as a codegen-only sanitize.
|
||||
skipSchemaValidation: true
|
||||
|
||||
generates:
|
||||
src/lib/api/__generated__/types.ts:
|
||||
plugins:
|
||||
- typescript
|
||||
|
||||
src/lib/api/__generated__/operations.ts:
|
||||
# typescript-document-nodes only: emits typed DocumentNode constants
|
||||
# from the gql templates in operations/*.graphql.ts. We deliberately
|
||||
# omit typescript-operations here because portal-shell widgets use
|
||||
# forward-looking spec fields (grades, myClasses, lessonPlans, etc.)
|
||||
# that are not yet present in services subgraph SDL; once services
|
||||
# catch up we can re-add typescript-operations for full query typing.
|
||||
# Per-domain typed outputs (e.g. dashboard-types.ts) cover the
|
||||
# operations whose fields DO match the schema today (P1-7).
|
||||
documents: src/lib/api/operations/**/*.graphql.ts
|
||||
plugins:
|
||||
- typescript-document-nodes
|
||||
|
||||
# P1-7: data-ana domain (dashboard.graphql.ts) — skipDocumentsValidation
|
||||
# closed because all 6 operations strictly match the schema. Generates
|
||||
# per-operation types so lib/api/dashboard.ts can drop its hand-written
|
||||
# interfaces. Config domain closure deferred until LayoutTemplateGql
|
||||
# schema adds `availableSlots` (admin.graphql.ts GET_LAYOUT_TEMPLATES_DOC
|
||||
# queries that field but it is not yet in the subgraph SDL).
|
||||
src/lib/api/__generated__/dashboard-types.ts:
|
||||
documents: src/lib/api/operations/dashboard.graphql.ts
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
config:
|
||||
skipDocumentsValidation: false
|
||||
|
||||
config:
|
||||
preResolveTypes: true
|
||||
skipTypename: true
|
||||
exportTypeKeyOnly: true
|
||||
useTypeImports: true
|
||||
# Generated operations.ts should import `gql` from @apollo/client (the
|
||||
# package portal-shell actually uses) instead of the default graphql-tag.
|
||||
gqlImport: "@apollo/client#gql"
|
||||
# Skip document validation against schema. portal-shell widgets use spec
|
||||
# forward-looking fields (grades, myClasses, lessonPlans, etc.) not yet
|
||||
# implemented in services subgraph SDL. Without this flag, codegen would
|
||||
# emit 44 "Cannot query Field X on type Query" errors and refuse to write
|
||||
# operations.ts. The runtime apollo-router validates documents at request
|
||||
# time, so skipping here only affects codegen-time type generation.
|
||||
# Per-output overrides (see dashboard-types.ts above) close this flag for
|
||||
# specific domains whose operations DO match the schema (P1-7).
|
||||
skipDocumentsValidation: true
|
||||
21
apps/portal-shell/components.json
Normal file
21
apps/portal-shell/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/shared/components",
|
||||
"utils": "@/shared/lib/utils",
|
||||
"ui": "@/shared/components/ui",
|
||||
"lib": "@/shared/lib",
|
||||
"hooks": "@/shared/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
148
apps/portal-shell/eslint.config.js
Normal file
148
apps/portal-shell/eslint.config.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* portal-shell ESLint flat config (ESM)
|
||||
*
|
||||
* 包含设计令牌强制规则(project_rules §3.10):
|
||||
* - 禁止 #hex 颜色字面量
|
||||
* - 禁止 'Inter'/'Fraunces'/'JetBrains Mono' 字体名字面量
|
||||
*
|
||||
* P0-7:补 no-restricted-imports(ARCHITECTURE.md §3.4 V3-A3 / §8.3 铁律)
|
||||
* - 禁 widget/页面 直接 import sonner(统一走 @/shared/lib/notify)
|
||||
* - 禁 widget/页面 绕过 lib/api 直接 import @apollo/client
|
||||
* - 禁 widget 跨目录 import 其他 widget(插件隔离)
|
||||
*
|
||||
* 关联:project_rules §3.10、portal-shell ARCHITECTURE.md §8.3、§11.7
|
||||
*/
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/.next/**",
|
||||
"**/coverage/**",
|
||||
"**/*.config.js",
|
||||
"**/*.config.mjs",
|
||||
],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module",
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// 设计令牌强制规则(project_rules §3.10)
|
||||
{
|
||||
files: ["**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
// 禁止 #hex 颜色字面量(如 "#fff"、"#000000")
|
||||
selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
|
||||
message:
|
||||
"禁止硬编码颜色 #hex,使用 var(--*) 或 Tailwind bg-* 类(project_rules §3.10)",
|
||||
},
|
||||
{
|
||||
// 禁止字体名字面量(next/font 的 import 标识符不受影响)
|
||||
selector: "Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]",
|
||||
message:
|
||||
"禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)(project_rules §3.10)",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// P0-7:no-restricted-imports 强制(ARCHITECTURE.md §8.3 / §11.7 红线)
|
||||
// - 禁直接 import sonner(统一走 @/shared/lib/notify 封装)
|
||||
// - 禁页面/widget 绕过 lib/api 直接 import @apollo/client
|
||||
// - 禁 widget 跨目录 import 其他 widget(插件隔离铁律)
|
||||
// 白名单:
|
||||
// - notify 封装本体(src/shared/lib/notify.ts)+ 其测试(__tests__/notify.test.ts)+ Toaster 容器(src/shared/components/ui/sonner.tsx)
|
||||
// - 数据层(src/lib/apollo-client.ts、useWidgetQuery.ts、useWidgetMutation.ts、config-fetcher.ts、src/lib/api/**、src/providers/ApolloProvider.tsx)
|
||||
// - widget 注册中心(src/shell/Registry.tsx)必须 import 各 widget 的 plugin.manifest,是唯一例外
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: [
|
||||
"src/shared/lib/notify.ts",
|
||||
"src/shared/lib/__tests__/notify.test.ts",
|
||||
"src/shared/components/ui/sonner.tsx",
|
||||
"src/lib/apollo-client.ts",
|
||||
"src/lib/useWidgetQuery.ts",
|
||||
"src/lib/useWidgetMutation.ts",
|
||||
"src/lib/config-fetcher.ts",
|
||||
"src/lib/api/**",
|
||||
"src/providers/ApolloProvider.tsx",
|
||||
"src/shell/Registry.tsx",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "sonner",
|
||||
message:
|
||||
"禁止直接 import sonner,统一使用 @/shared/lib/notify(ARCHITECTURE.md §8.3)",
|
||||
},
|
||||
{
|
||||
name: "@apollo/client",
|
||||
message:
|
||||
"禁止绕过 lib/api 直接 import @apollo/client,使用 useWidgetQuery/useWidgetMutation(ARCHITECTURE.md §11.7)",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ["@/widgets/*", "../widgets/*", "../../widgets/*"],
|
||||
message:
|
||||
"禁止 widget 跨目录 import 其他 widget(插件隔离铁律,ARCHITECTURE.md §11.7)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 白名单:令牌原始定义、PWA manifest
|
||||
{
|
||||
files: ["**/primitive.css", "**/manifest.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// 测试文件放宽规则
|
||||
{
|
||||
files: [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/__tests__/**",
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
},
|
||||
},
|
||||
|
||||
prettierConfig,
|
||||
);
|
||||
6
apps/portal-shell/next-env.d.ts
vendored
Normal file
6
apps/portal-shell/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
108
apps/portal-shell/next.config.js
Normal file
108
apps/portal-shell/next.config.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Next.js 配置(v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack + P1-4 next-intl + P1-5 MSW)
|
||||
*
|
||||
* 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker)
|
||||
* - output:standalone(单容器部署)
|
||||
* - transpilePackages: @edu/* workspace 包
|
||||
* - 反向代理:/api/v1/* → api-gateway :8080(JWT 校验 + 注入 x-user-id/x-user-role)
|
||||
* - GraphQL 查询走 apollo-router :3000(M8 验收点,由 Apollo Client 直连)
|
||||
* - next-intl:无 i18n 路由模式(locale 由 cookie 决定,ARCHITECTURE.md §3.4 V3-A6)
|
||||
* - MSW 兜底层(P1-5):NEXT_PUBLIC_MSW!=1 时把 @/mocks 与 @/mocks/graphql-data
|
||||
* 重定向到空 stub,确保生产 bundle 不含 mock 数据(ARCHITECTURE.md §3.4 V3-A7)
|
||||
*
|
||||
* Next 16 默认 Turbopack:
|
||||
* - turbopack.resolveExtensions 处理 ESM 包 .js 后缀导入源码 TS 文件的映射
|
||||
* - webpack 配置保留作为 fallback(--webpack flag 时生效)
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A6/V3-A7、spec §2、project_rules §3.2
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// P1-5:MSW 启用开关。生产构建(NEXT_PUBLIC_MSW 非等于 "1")时把
|
||||
// @/mocks 与 @/mocks/graphql-data 重定向到空 stub,
|
||||
// 确保 bundle 不含真实 mock 数据与 msw 包。
|
||||
// 注意:Turbopack resolveAlias 不支持 Windows 绝对路径("windows imports
|
||||
// are not implemented yet"),故使用 @/mocks/empty 说明符(由 tsconfig
|
||||
// paths 解析为 src/mocks/empty.ts),而非绝对路径。
|
||||
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
|
||||
const MOCKS_STUB_SPECIFIER = "@/mocks/empty";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
transpilePackages: [
|
||||
"@edu/ui-components",
|
||||
"@edu/ui-tokens",
|
||||
"@edu/hooks",
|
||||
"@edu/contracts",
|
||||
"@edu/shared-ts",
|
||||
],
|
||||
experimental: {
|
||||
serverActions: { bodySizeLimit: "2mb" },
|
||||
},
|
||||
// Turbopack 配置(Next 16 默认):处理 ESM 包 .js 后缀导入源码 .ts/.tsx 文件
|
||||
turbopack: {
|
||||
resolveExtensions: [
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".json",
|
||||
],
|
||||
// P1-5:MSW 关闭时把 @/mocks(MswProvider 静态 import)与
|
||||
// @/mocks/graphql-data(route.ts 静态 import)重定向到空 stub。
|
||||
// 使用 @/mocks/empty 说明符作为目标(Turbopack 不支持 Windows 绝对路径)。
|
||||
...(MSW_ENABLED
|
||||
? {}
|
||||
: {
|
||||
resolveAlias: {
|
||||
"@/mocks": MOCKS_STUB_SPECIFIER,
|
||||
"@/mocks/graphql-data": MOCKS_STUB_SPECIFIER,
|
||||
},
|
||||
}),
|
||||
},
|
||||
// Webpack 配置(fallback,使用 --webpack flag 时生效)
|
||||
webpack(config) {
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.extensionAlias = {
|
||||
...config.resolve.extensionAlias,
|
||||
".js": [".ts", ".tsx", ".js"],
|
||||
};
|
||||
// P1-5:MSW 关闭时把 @/mocks 与 @/mocks/graphql-data 重定向到空 stub。
|
||||
// webpack resolve.alias 支持 exact 匹配($ 后缀)与前缀匹配,
|
||||
// 这里用 exact 匹配避免误伤其他 @/mocks/xxx 路径(实际只有这两个 import 点)。
|
||||
if (!MSW_ENABLED) {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
"@/mocks$": MOCKS_STUB_SPECIFIER,
|
||||
"@/mocks/graphql-data$": MOCKS_STUB_SPECIFIER,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
async rewrites() {
|
||||
const gateway =
|
||||
process.env.API_GATEWAY_URL || "http://localhost:8080";
|
||||
return [
|
||||
{
|
||||
source: "/api/v1/:path*",
|
||||
destination: `${gateway}/api/v1/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/api/auth/:path*",
|
||||
destination: `${gateway}/api/v1/iam/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
83
apps/portal-shell/package.json
Normal file
83
apps/portal-shell/package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "@edu/portal-shell",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4010",
|
||||
"build": "next build",
|
||||
"start": "next start -p 4010",
|
||||
"lint": "eslint src",
|
||||
"lint:tokens": "eslint -c .eslintrc.tokens.js src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"codegen": "tsx scripts/normalize-schema.ts && graphql-codegen --config codegen.yml",
|
||||
"codegen:watch": "graphql-codegen --config codegen.yml --watch",
|
||||
"generate-pq-manifest": "tsx scripts/generate-pq-manifest.ts",
|
||||
"check:routes": "tsx scripts/check-route-table.ts",
|
||||
"check:pages": "tsx scripts/check-page-count.ts",
|
||||
"check:codegen": "tsx scripts/check-codegen.ts",
|
||||
"check:all": "pnpm run check:routes && pnpm run check:pages && pnpm run check:codegen",
|
||||
"prebuild": "pnpm run codegen && pnpm run generate-pq-manifest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.11.0",
|
||||
"@edu/hooks": "workspace:*",
|
||||
"@edu/ui-components": "workspace:*",
|
||||
"@edu/ui-tokens": "workspace:*",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-hash": "^4.0.1",
|
||||
"graphql": "^16.8.0",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-react": "^0.562.0",
|
||||
"msw": "2.7.0",
|
||||
"next": "^16.0.10",
|
||||
"next-intl": "^4.13.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^5.0.0",
|
||||
"@graphql-codegen/typescript": "^4.0.0",
|
||||
"@graphql-codegen/typescript-document-nodes": "^4.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.4.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
11
apps/portal-shell/postcss.config.js
Normal file
11
apps/portal-shell/postcss.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* PostCSS 配置(Tailwind v4)
|
||||
*
|
||||
* Tailwind v4 使用 @tailwindcss/postcss 插件,配置通过 CSS 内的
|
||||
* @import "tailwindcss" + @theme inline 指令完成,不再需要 tailwind.config.js。
|
||||
*/
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
307
apps/portal-shell/public/mockServiceWorker.js
Normal file
307
apps/portal-shell/public/mockServiceWorker.js
Normal file
@@ -0,0 +1,307 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.7.0'
|
||||
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
},
|
||||
},
|
||||
[responseClone.body],
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const requestBuffer = await request.arrayBuffer()
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: requestBuffer,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
},
|
||||
[requestBuffer],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(
|
||||
message,
|
||||
[channel.port2].concat(transferrables.filter(Boolean)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
53
apps/portal-shell/public/pq-manifest.json
Normal file
53
apps/portal-shell/public/pq-manifest.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"cc22a52e830be26094f7e628b84ae378b2956adb229e9a52249a660d14b7571a": "query GetAnnouncements($limit: Int) {\n announcements(limit: $limit) {\n id\n title\n body\n author\n publishedAt\n }\n}",
|
||||
"993d23648ccd8b44bc39189067720276325235cb5d7d26c78d78986487c8bbc9": "query GetAttendance($classId: ID!, $termId: ID!) {\n attendance(classId: $classId, termId: $termId) {\n present\n absent\n late\n total\n }\n}",
|
||||
"87bd02d956d7c16182aa0fc4817ea568b6e6ef922fd7891d0eece8d5db2e0caa": "query GetExams($classId: ID!, $limit: Int) {\n exams(classId: $classId, limit: $limit) {\n id\n name\n examDate\n subject\n maxScore\n }\n}",
|
||||
"4fffc072f251c9b9cff1306d3f36f89af726c49aa5d898b2701389a864f4b0ca": "query GetGrades($classId: ID!) {\n grades(classId: $classId) {\n studentId\n score\n }\n}",
|
||||
"6ef5a84c342249898afc04be737102bd48be909244f88e7b36acff046e3fda66": "query GetHomeworks($classId: ID!, $limit: Int) {\n homeworks(classId: $classId, limit: $limit) {\n id\n title\n dueDate\n status\n }\n}",
|
||||
"77a4840fce570fdd776eadee84af6c9196491661287a5f5c1ab710e11a6bed19": "query GetNotificationsList($limit: Int, $offset: Int) {\n notifications(limit: $limit, offset: $offset) {\n items {\n id\n title\n body\n createdAt\n type\n }\n total\n }\n}",
|
||||
"bb5d9a0a34c299962fe296cfd7eea62ba501cb77c425403d045bbb90efb200a2": "query GetSchedule($classId: ID!, $dayOfWeek: Int) {\n schedule(classId: $classId, dayOfWeek: $dayOfWeek) {\n id\n subject\n startTime\n endTime\n teacherName\n }\n}",
|
||||
"6e5b7232d2aec0c106ead6b57a1aff4f1cf5413459b5dca63220ba165b25e37d": "query GetMyChildren {\n myChildren {\n id\n name\n grade\n className\n }\n}",
|
||||
"4bceb2c86a41b705c0e3df5af592716e4b93706a2f1501f91657bbd255e65624": "query GetMyClasses {\n myClasses {\n id\n name\n }\n}",
|
||||
"683b1379ab15ad07c07f1ab64906a904c31f639f2e1f302af263529048d1348d": "query GetTerms {\n terms {\n id\n name\n startDate\n endDate\n isActive\n }\n}",
|
||||
"39bdf6630ce77deed9582b72645ba14cab60874c6f2da28baee676e62e2025bc": "query GetCurrentUser {\n me {\n id\n name\n email\n role\n }\n}",
|
||||
"006af776ff6266b70ef5733f834ef3e33a1b88339a7b7319a76c56bb71a8e89e": "query GetNotifications($limit: Int) {\n notifications(limit: $limit) {\n id\n title\n }\n}",
|
||||
"5afce24b57e8e3db709eab2ca43d19e8fbb893d398a735ba004b0ed07649ad14": "query Search($keyword: String!, $limit: Int) {\n search(keyword: $keyword, limit: $limit) {\n id\n type\n title\n subtitle\n }\n}",
|
||||
"a4c3aac21ef49c7c29303f8ff84da0966f896dbaed20fa84936129d864352930": "query GetLessonPlans($classId: ID!, $unitId: ID) {\n lessonPlans(classId: $classId, unitId: $unitId) {\n id\n title\n objectives\n content\n resources\n }\n}",
|
||||
"10f2d92cf392929afbb5c5bc001d37bd9c51fb37eff0383b11edd23f03a4d007": "query GetQuestions($bankId: ID!, $type: String, $limit: Int) {\n questions(bankId: $bankId, type: $type, limit: $limit) {\n id\n type\n difficulty\n content\n options\n answer\n tags\n }\n}",
|
||||
"8d4c0ebf844b1790b356a0b6d70899741f7271707d214fc79da0e797764f3b48": "query GetSchedulingRules($classId: ID!) {\n schedulingRules(classId: $classId) {\n id\n dayOfWeek\n periods\n subject\n teacherId\n room\n }\n}",
|
||||
"198a9e4bc14a6f2acfb2f0ed82e993ef0ee0bd05fe84c944397a356445985785": "query GetTextbooks($subjectId: ID, $grade: String) {\n textbooks(subjectId: $subjectId, grade: $grade) {\n id\n title\n author\n publisher\n isbn\n chapters {\n id\n title\n }\n }\n}",
|
||||
"34a207efda8909c8906b2bd1134c1c660b515d1787c7f1fcffc1b7f8a0471dc5": "mutation SaveLessonPlan($input: LessonPlanInput!) {\n saveLessonPlan(input: $input) {\n id\n }\n}",
|
||||
"751e8daa3eb334f5080bd8f88c00d1df4bfe4fb1d7093320d4be675ef95f62e5": "mutation UpdateSchedulingRule($id: ID!, $input: SchedulingRuleInput!) {\n updateSchedulingRule(id: $id, input: $input) {\n id\n }\n}",
|
||||
"3600a257d1f00bc7590545b7ac090e60498a90b633c50336476c13feb20f3839": "mutation DropCourse($courseId: ID!) {\n dropCourse(courseId: $courseId)\n}",
|
||||
"eb9bd2e6ce678322ffa50cdc7480385b33eb5b86d0ebebb0b08650af9d067704": "mutation EnrollCourse($courseId: ID!) {\n enrollCourse(courseId: $courseId)\n}",
|
||||
"c52d26c0ef308f029b5efd53598b9d8ecc483d293297167abeccab29fd345aff": "query AiTutorSessions($limit: Int) {\n aiTutorSessions(limit: $limit) {\n id\n title\n lastMessage\n updatedAt\n }\n}",
|
||||
"0a2fdb7cfac1df1fd794a6aa03b2e81741bfc1a63ababe860a85cece89e1c825": "query ElectiveCourses($termId: ID!) {\n electiveCourses(termId: $termId) {\n id\n name\n teacher\n capacity\n enrolled\n schedule\n credits\n category\n }\n}",
|
||||
"ee35ac1fb866bb2919be69e3bc52b276bda5be85543d2d5238e482a1db63681f": "query MyErrorBook($subjectId: ID, $limit: Int) {\n myErrorBook(subjectId: $subjectId, limit: $limit) {\n id\n question\n myAnswer\n correctAnswer\n errorCount\n lastErrorAt\n subject\n }\n}",
|
||||
"736e74c878d89645df6867a84a64fa533a42c996d3fa946ff108afc3aa56bdfe": "query MyLearningPath($subjectId: ID!) {\n myLearningPath(subjectId: $subjectId) {\n nodes {\n id\n title\n type\n status\n dependencies\n }\n progress\n }\n}",
|
||||
"689bf7df7102fd09515c3fafdb1a3e8b2c38ed3c19b81d20f266fbe32a492325": "mutation MarkErrorMastered($id: ID!) {\n markErrorMastered(id: $id)\n}",
|
||||
"7c3e2df6ae1ea41af2b6c994f073ded78541e3e42de8e6cc68f186bcd3c5998b": "mutation SendAiTutorMessage($sessionId: ID, $message: String!) {\n sendAiTutorMessage(sessionId: $sessionId, message: $message) {\n sessionId\n reply\n }\n}",
|
||||
"3d43fd71c95d9b654c786a98a0e531d18b81ae113655de4da52343562eab9a11": "mutation ApproveLeave($id: ID!) {\n approveLeave(id: $id) {\n id\n status\n }\n}",
|
||||
"61a7f7edcaaebdd90f27f61d2b0d7e0ffa6593accbc51b1411bee4faac1da55d": "query GetLeaveRequests($childId: ID, $status: String) {\n leaveRequests(childId: $childId, status: $status) {\n id\n childName\n type\n startDate\n endDate\n reason\n status\n createdAt\n }\n}",
|
||||
"9a6c2fbe99c9a3ce35b6b25ad48f8b898cd56b7b6b216548663d66e7f7aa9e0b": "query GetMyChildrenOverview {\n myChildren {\n id\n name\n grade\n className\n avatar\n recentGrades {\n subject\n score\n }\n attendance {\n present\n total\n }\n homeworkCompletion {\n completed\n total\n }\n }\n}",
|
||||
"40615d4701dbadf4dcd22df3a0fde0a18ba52ab1cc096d3f6c6b0b4fb90247b8": "mutation RejectLeave($id: ID!, $reason: String) {\n rejectLeave(id: $id, reason: $reason) {\n id\n status\n }\n}",
|
||||
"1d02d46cf5602608fa24a193e036982729c98034a1541267f8cea7eeba8e777a": "mutation CreateInvitationCode($input: CreateInvitationCodeInput!) {\n createInvitationCode(input: $input) {\n id\n code\n role\n maxUses\n expiresAt\n }\n}",
|
||||
"b6eab2d2ddb72100fef604261587be516707b45c86d9ba6e6f4c6ec3707ed17a": "query GetAuditLogs($filter: AuditLogFilter, $limit: Int, $offset: Int) {\n auditLogs(filter: $filter, limit: $limit, offset: $offset) {\n items {\n id\n userId\n userName\n action\n resource\n resourceId\n ip\n timestamp\n details\n }\n total\n }\n}",
|
||||
"9ec7b17074866cd384bc5c0d5f3b6d90ea1cbc3220c5533eff98be7fc8bd5506": "query GetInvitationCodes($status: String) {\n invitationCodes(status: $status) {\n id\n code\n role\n status\n usedCount\n maxUses\n expiresAt\n createdAt\n createdBy\n }\n}",
|
||||
"462711c89347b1a40af04f60ab1dd3611c8361163740cf202454d5ec90c0bb71": "query GetLayoutTemplates {\n layoutTemplates {\n layoutId\n displayName\n description\n availableSlots\n }\n}",
|
||||
"00c61040a7495895b84bf3a71e1368892e0757783a7977e101f7c60675538830": "query GetPermissions {\n permissions {\n id\n name\n resource\n action\n description\n }\n}",
|
||||
"7d1683c3ae0b368ca79aa9f07cd34c37b6e7dc7b38d48741c19beb5c0e412136": "query GetPluginRegistry {\n pluginRegistry {\n pluginId\n category\n version\n displayName\n description\n requiredRoles\n isBuiltin\n isActive\n defaultSlot\n defaultSize\n defaultProps\n propsSchema\n }\n}",
|
||||
"013b882847e6aeceebeef7b499e2fdb39f327d6fbd8d9b793f2ec17209d01046": "query GetRoles {\n roles {\n id\n name\n permissions {\n id\n name\n resource\n action\n }\n }\n}",
|
||||
"8c92bccc65a523c79bf18c567f6396be0efdd6b0cfda4567702faa57798fa170": "query GetRoleLayoutDefault($role: String) {\n roleLayoutDefault(role: $role) {\n role\n layoutId\n slotOverrides\n }\n}",
|
||||
"4c44f675ea0fe1ed6303dba87ba27c6feef0393649b0909a3436c38ae0edc331": "query GetRolePluginMapping($role: String) {\n rolePluginMapping(role: $role) {\n role\n pluginId\n slot\n sortOrder\n isEnabled\n widgetProps\n }\n}",
|
||||
"7d372896206547c1fc4edcc2af151543922dcd3d403370c81d59ad784a352572": "query GetSchool {\n school {\n id\n name\n address\n phone\n email\n currentAcademicYear\n currentTerm\n semesterStart\n semesterEnd\n }\n}",
|
||||
"86265727fbd9321020726a78323688432e8e1d3d05f6a01678602e2d96f581d0": "query GetUsers($role: String, $limit: Int, $offset: Int) {\n users(role: $role, limit: $limit, offset: $offset) {\n items {\n id\n name\n email\n role\n status\n createdAt\n }\n total\n }\n}",
|
||||
"44c439b9e2b79a7be455bd2da2e71b76290f51958c22a44ff830a6ca330c06c2": "mutation ResetUserLayoutOverride($userId: ID!) {\n resetUserLayoutOverride(userId: $userId) {\n userId\n }\n}",
|
||||
"42eb5f67bd419bcfb86717b4ce857ff9ce07e87ae7d1c6ceffea84529c4fa6f6": "mutation RevokeInvitationCode($id: ID!) {\n revokeInvitationCode(id: $id) {\n id\n status\n }\n}",
|
||||
"bc0493cf664efd0e3301ff91f601da9e5ffca5b070d309c56f2b3798b9b02d07": "mutation UpdatePluginRegistry($pluginId: ID!, $input: PluginRegistryInput!) {\n updatePluginRegistry(pluginId: $pluginId, input: $input) {\n pluginId\n isActive\n defaultProps\n }\n}",
|
||||
"2677bf6b6c5e064dd35062044d93948b30ddc3855b26444c463dafa911e5ecf8": "mutation UpdateRoleLayoutDefault($role: String!, $layoutId: String!) {\n updateRoleLayoutDefault(role: $role, layoutId: $layoutId) {\n role\n layoutId\n }\n}",
|
||||
"a34209d2f6cb3a0bd30111c45d62cb0aba888cc000408062ee5d3a2eace0f2a4": "mutation UpdateRolePermissions($roleId: ID!, $permissionIds: [ID]!) {\n updateRolePermissions(roleId: $roleId, permissionIds: $permissionIds) {\n id\n }\n}",
|
||||
"f52005687a6d34d6606e60be4f63fe773c846cb3018ee75990e6d0f29d3622e6": "mutation UpdateRoleMapping($role: String!, $mappings: [RolePluginMappingInput!]!) {\n updateRolePluginMapping(role: $role, mappings: $mappings) {\n role\n pluginId\n isEnabled\n }\n}",
|
||||
"aeba6eea7c2723e5f2f522ec688f47bd269fd725f7131e849ab80efa384b4369": "mutation UpdateSchool($input: SchoolInput!) {\n updateSchool(input: $input) {\n id\n name\n address\n phone\n email\n currentAcademicYear\n currentTerm\n semesterStart\n semesterEnd\n updatedAt\n }\n}",
|
||||
"257075c859c0c4dbf9f253e2440a98ea778b2a66e4934055dd64263cd5a99bad": "mutation UpdateUserRole($id: ID!, $role: String!) {\n updateUserRole(id: $id, role: $role) {\n id\n role\n }\n}",
|
||||
"543d328988a535e73794a1c45a53f2f19d6494f4b0be675f90917d5d5578bc46": "mutation UpdateUserStatus($id: ID!, $status: String!) {\n updateUserStatus(id: $id, status: $status) {\n id\n status\n }\n}"
|
||||
}
|
||||
64
apps/portal-shell/scripts/check-codegen.ts
Normal file
64
apps/portal-shell/scripts/check-codegen.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Codegen contract validation check (ARCHITECTURE.md §10 P1-8 / §5.3)
|
||||
//
|
||||
// Runs graphql-codegen and fails if any output with skipDocumentsValidation:false
|
||||
// contains operations that reference non-existent schema fields.
|
||||
// This is the "codegen diff check" — it diffs operations against schema.
|
||||
//
|
||||
// Currently enforces:
|
||||
// - dashboard-types.ts output (skipDocumentsValidation: false, P1-7)
|
||||
// As more domains fix their operations, their outputs will be validated too.
|
||||
//
|
||||
// Usage: tsx scripts/check-codegen.ts
|
||||
// Exit: 0 = codegen success, 1 = validation errors
|
||||
//
|
||||
// Related: ARCHITECTURE.md §5.3 契约纪律, §10 P1-8
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
function main(): void {
|
||||
console.log("=== Codegen Contract Validation Check ===");
|
||||
console.log("Running: pnpm run codegen (normalize-schema + graphql-codegen)");
|
||||
console.log("");
|
||||
|
||||
try {
|
||||
const output = execSync("pnpm run codegen", {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
console.log(output);
|
||||
|
||||
// Check for validation failures even on exit 0 (some may be warnings)
|
||||
if (output.includes("GraphQL Document Validation failed")) {
|
||||
console.log("❌ Codegen reported validation failures despite exit 0");
|
||||
console.log("Result: FAIL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Result: PASS (codegen succeeded, all validated outputs clean)",
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stdout?: string; stderr?: string; message: string };
|
||||
const output = `${e.stdout ?? ""}\n${e.stderr ?? ""}`;
|
||||
console.log(output);
|
||||
|
||||
if (output.includes("GraphQL Document Validation failed")) {
|
||||
console.log(
|
||||
"❌ Codegen validation failed — operations reference non-existent schema fields",
|
||||
);
|
||||
console.log(
|
||||
" Fix: update operations/*.graphql.ts to match combined-schema.graphql",
|
||||
);
|
||||
console.log(
|
||||
" Or: keep skipDocumentsValidation: true for that output until schema is ready",
|
||||
);
|
||||
} else {
|
||||
console.log(`❌ Codegen failed: ${e.message}`);
|
||||
}
|
||||
console.log("Result: FAIL");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
160
apps/portal-shell/scripts/check-page-count.ts
Normal file
160
apps/portal-shell/scripts/check-page-count.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// Page count baseline check (ARCHITECTURE.md §10 P1-8 / §11.6)
|
||||
//
|
||||
// Asserts that the total page.tsx count never drops below the baseline.
|
||||
// Prevents accidental route deletion. When adding new pages, update the
|
||||
// baseline in BASELINE.total. Per-category minimums catch regressions
|
||||
// in specific areas (dashboards, login, etc.).
|
||||
//
|
||||
// Usage: tsx scripts/check-page-count.ts
|
||||
// Exit: 0 = pass, 1 = below baseline
|
||||
//
|
||||
// Related: ARCHITECTURE.md §10 P1-8, §11.6 验收纪律
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const APP_DIR = path.resolve(process.cwd(), "src/app");
|
||||
|
||||
interface Baseline {
|
||||
total: number;
|
||||
categories: Record<string, { pattern: string; min: number; label: string }>;
|
||||
}
|
||||
|
||||
// Baseline as of P2 (2026-07-22, grades module added). Update when adding pages.
|
||||
const BASELINE: Baseline = {
|
||||
total: 31,
|
||||
categories: {
|
||||
dashboards: {
|
||||
pattern: "shell/{admin,teacher,student,parent}/page.tsx",
|
||||
min: 4,
|
||||
label: "Role dashboards (admin/teacher/student/parent)",
|
||||
},
|
||||
login: {
|
||||
pattern: "login/page.tsx",
|
||||
min: 1,
|
||||
label: "Login page",
|
||||
},
|
||||
root: {
|
||||
pattern: "page.tsx",
|
||||
min: 1,
|
||||
label: "Root redirect page",
|
||||
},
|
||||
forbidden: {
|
||||
pattern: "shell/forbidden/page.tsx",
|
||||
min: 1,
|
||||
label: "Forbidden page",
|
||||
},
|
||||
catchAll: {
|
||||
pattern: "shell/[[...route]]/page.tsx",
|
||||
min: 1,
|
||||
label: "Shell catch-all",
|
||||
},
|
||||
devTemplates: {
|
||||
pattern: "shell/dev/templates/**/page.tsx",
|
||||
min: 5,
|
||||
label: "Dev template pages",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function scanPages(): string[] {
|
||||
const pages: string[] = [];
|
||||
|
||||
function walk(dir: string, base: string): void {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
const rel = path.relative(base, full).replace(/\\/g, "/");
|
||||
if (entry.isDirectory()) {
|
||||
walk(full, base);
|
||||
} else if (entry.name === "page.tsx") {
|
||||
pages.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(APP_DIR, APP_DIR);
|
||||
return pages.sort();
|
||||
}
|
||||
|
||||
function matchGlob(pattern: string, relPath: string): boolean {
|
||||
// Glob → regex: ** (any path), * (within segment), {a,b} (alternation)
|
||||
let result = "";
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === "*" && pattern[i + 1] === "*") {
|
||||
// ** — match anything including /; skip trailing /
|
||||
result += ".*";
|
||||
i += 2;
|
||||
if (pattern[i] === "/") i++;
|
||||
} else if (c === "*") {
|
||||
result += "[^/]*";
|
||||
i++;
|
||||
} else if (c === "{") {
|
||||
const end = pattern.indexOf("}", i);
|
||||
if (end === -1) {
|
||||
result += "\\{";
|
||||
i++;
|
||||
} else {
|
||||
const opts = pattern
|
||||
.slice(i + 1, end)
|
||||
.split(",")
|
||||
.map((s) => s.trim());
|
||||
result += `(${opts.join("|")})`;
|
||||
i = end + 1;
|
||||
}
|
||||
} else if (".+?^$()[]|\\".includes(c)) {
|
||||
result += `\\${c}`;
|
||||
i++;
|
||||
} else {
|
||||
result += c;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${result}$`).test(relPath);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const pages = scanPages();
|
||||
const total = pages.length;
|
||||
|
||||
console.log("=== Page Count Baseline Check ===");
|
||||
console.log(`Total page.tsx files: ${total} (baseline: ${BASELINE.total})`);
|
||||
console.log("");
|
||||
|
||||
// Per-category check
|
||||
let categoryFail = false;
|
||||
for (const [, cat] of Object.entries(BASELINE.categories)) {
|
||||
const matched = pages.filter((p) => matchGlob(cat.pattern, p));
|
||||
const count = matched.length;
|
||||
const status = count >= cat.min ? "✅" : "❌";
|
||||
if (count < cat.min) categoryFail = true;
|
||||
console.log(` ${status} ${cat.label}: ${count} (min ${cat.min})`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// Total check
|
||||
const totalOk = total >= BASELINE.total;
|
||||
if (!totalOk) {
|
||||
console.log(`❌ Total ${total} < baseline ${BASELINE.total}`);
|
||||
}
|
||||
|
||||
// List all pages
|
||||
console.log("Pages:");
|
||||
for (const p of pages) {
|
||||
console.log(
|
||||
` /${p.replace(/\/page\.tsx$/, "").replace(/^page\.tsx$/, "")}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
if (!totalOk || categoryFail) {
|
||||
console.log("Result: FAIL");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`Result: PASS (${total} pages, all categories meet minimum)`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
169
apps/portal-shell/scripts/check-route-table.ts
Normal file
169
apps/portal-shell/scripts/check-route-table.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// Route table consistency check (ARCHITECTURE.md §10 P1-8)
|
||||
//
|
||||
// Verifies that every actual /shell/* page.tsx route is registered in
|
||||
// route-permissions.ts (EXACT / PREFIX / DASHBOARD / PUBLIC_ROUTES).
|
||||
// Catches "unregistered routes" that would fall through to the catch-all
|
||||
// and be denied by middleware (fail-closed) — developers get a clear CI
|
||||
// error instead of a confusing runtime 403.
|
||||
//
|
||||
// Also reports "ghost entries" (EXACT table entries without a page.tsx)
|
||||
// as informational output — these are planned future routes (P2-P5).
|
||||
//
|
||||
// Usage: tsx scripts/check-route-table.ts
|
||||
// Exit: 0 = pass, 1 = violations found
|
||||
//
|
||||
// Related: ARCHITECTURE.md §3.4 V3-A1, §5.3, §10 P1-8, §11.7 红线 #5
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const APP_DIR = path.resolve(process.cwd(), "src/app");
|
||||
const ROUTE_PERMS_FILE = path.resolve(
|
||||
process.cwd(),
|
||||
"src/shared/lib/route-permissions.ts",
|
||||
);
|
||||
|
||||
interface Violation {
|
||||
type: "unregistered_route" | "ghost_entry";
|
||||
route: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
function scanActualRoutes(): Set<string> {
|
||||
const routes = new Set<string>();
|
||||
|
||||
function walk(dir: string, prefix: string): void {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Skip catch-all [[...route]] directory
|
||||
if (entry.name.startsWith("[[")) continue;
|
||||
walk(full, `${prefix}/${entry.name}`);
|
||||
} else if (entry.name === "page.tsx") {
|
||||
routes.add(prefix || "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(APP_DIR, "");
|
||||
return routes;
|
||||
}
|
||||
|
||||
function extractRegisteredRoutes(): {
|
||||
exact: Set<string>;
|
||||
prefixes: string[];
|
||||
publicRoutes: Set<string>;
|
||||
} {
|
||||
const content = fs.readFileSync(ROUTE_PERMS_FILE, "utf8");
|
||||
const exact = new Set<string>();
|
||||
const prefixes: string[] = [];
|
||||
const publicRoutes = new Set<string>();
|
||||
|
||||
const routeKeyRe = new RegExp('"(/[^"]*?)":\\s*\\{', "g");
|
||||
const prefixRe = new RegExp('prefix:\\s*"(/[^"]*?)"', "g");
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = routeKeyRe.exec(content)) !== null) {
|
||||
exact.add(m[1]);
|
||||
}
|
||||
while ((m = prefixRe.exec(content)) !== null) {
|
||||
prefixes.push(m[1]);
|
||||
}
|
||||
// PUBLIC_ROUTES array entries (skip past `readonly string[] =` to the real `[`)
|
||||
const publicBlock = content.match(
|
||||
new RegExp("PUBLIC_ROUTES[^=]*=\\s*\\[([\\s\\S]*?)\\]"),
|
||||
)?.[1];
|
||||
if (publicBlock) {
|
||||
const re = new RegExp('"(/[^"]*?)"', "g");
|
||||
while ((m = re.exec(publicBlock)) !== null) {
|
||||
publicRoutes.add(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { exact, prefixes, publicRoutes };
|
||||
}
|
||||
|
||||
function isRegistered(
|
||||
route: string,
|
||||
exact: Set<string>,
|
||||
prefixes: string[],
|
||||
publicRoutes: Set<string>,
|
||||
): boolean {
|
||||
if (publicRoutes.has(route)) return true;
|
||||
if (exact.has(route)) return true;
|
||||
for (const p of prefixes) {
|
||||
if (route.startsWith(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const actualRoutes = scanActualRoutes();
|
||||
const { exact, prefixes, publicRoutes } = extractRegisteredRoutes();
|
||||
|
||||
const violations: Violation[] = [];
|
||||
|
||||
// Check A: every actual /shell/* route must be registered
|
||||
for (const route of [...actualRoutes].sort()) {
|
||||
if (!route.startsWith("/shell")) continue;
|
||||
if (!isRegistered(route, exact, prefixes, publicRoutes)) {
|
||||
violations.push({
|
||||
type: "unregistered_route",
|
||||
route,
|
||||
detail:
|
||||
"page.tsx exists but route not in EXACT/PREFIX/DASHBOARD/PUBLIC_ROUTES",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check B (informational): ghost entries (EXACT entries without page.tsx)
|
||||
const ghostEntries: string[] = [];
|
||||
for (const entry of [...exact].sort()) {
|
||||
if (!entry.startsWith("/shell/")) continue;
|
||||
if (!actualRoutes.has(entry)) {
|
||||
ghostEntries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Report
|
||||
console.log("=== Route Table Consistency Check ===");
|
||||
console.log(
|
||||
`Actual /shell/* routes: ${[...actualRoutes].filter((r) => r.startsWith("/shell")).length}`,
|
||||
);
|
||||
console.log(
|
||||
`EXACT entries: ${[...exact].filter((r) => r.startsWith("/shell/")).length}`,
|
||||
);
|
||||
console.log(`PREFIX entries: ${prefixes.length}`);
|
||||
console.log(`PUBLIC_ROUTES: ${publicRoutes.size}`);
|
||||
console.log("");
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.log("❌ VIOLATIONS (unregistered routes):");
|
||||
for (const v of violations) {
|
||||
console.log(` ${v.route} — ${v.detail}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (ghostEntries.length > 0) {
|
||||
console.log(
|
||||
`ℹ️ GHOST ENTRIES (planned, no page.tsx yet): ${ghostEntries.length}`,
|
||||
);
|
||||
for (const g of ghostEntries) {
|
||||
console.log(` ${g}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.log(`Result: FAIL (${violations.length} violation(s))`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`Result: PASS (0 violations, ${ghostEntries.length} ghost entries)`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
78
apps/portal-shell/scripts/generate-pq-manifest.ts
Normal file
78
apps/portal-shell/scripts/generate-pq-manifest.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Persisted Query Manifest 生成脚本(v2.1 M3 安全加固)
|
||||
*
|
||||
* 构建时遍历 src/lib/api/operations/ 中所有 gql 文档,生成
|
||||
* sha256(query) → query 文本 的白名单 manifest。
|
||||
*
|
||||
* 部署到 apollo-router 容器,生产模式(APOLLO_REQUIRE_PQ_MANIFEST=true)
|
||||
* 拒绝 manifest 之外的查询,防止攻击者构造任意 query 探测 schema。
|
||||
*
|
||||
* 产物:apps/portal-shell/public/pq-manifest.json
|
||||
* 关联:portal-shell spec §4.2 PQ Manifest
|
||||
*/
|
||||
import { print } from "graphql";
|
||||
import { sha256 } from "crypto-hash";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as url from "node:url";
|
||||
|
||||
// 使用 fileURLToPath 兼容 ESM 下 __dirname 缺失
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 动态 import operations barrel(含 51 个 gql DocumentNode)
|
||||
// Windows 下动态 import 需 file:// URL(ESM 限制)
|
||||
const operationsPath = path.resolve(
|
||||
__dirname,
|
||||
"../src/lib/api/operations/index.ts",
|
||||
);
|
||||
const operationsUrl = url.pathToFileURL(operationsPath).href;
|
||||
|
||||
async function generateManifest(): Promise<void> {
|
||||
// tsx 运行时支持直接 import .ts
|
||||
const operationsModule = (await import(operationsUrl)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const manifest: Record<string, string> = {};
|
||||
let skipped = 0;
|
||||
|
||||
for (const [, doc] of Object.entries(operationsModule)) {
|
||||
// 仅处理 DocumentNode 对象(含 loc.source.body)
|
||||
if (
|
||||
typeof doc === "object" &&
|
||||
doc !== null &&
|
||||
"loc" in doc &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(doc as any).loc !== null &&
|
||||
typeof (doc as { loc: { source: { body: string } } }).loc.source?.body ===
|
||||
"string"
|
||||
) {
|
||||
const query = print(doc as never);
|
||||
const hash = await sha256(query);
|
||||
manifest[hash] = query;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
const outDir = path.resolve(__dirname, "../public");
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
const outPath = path.resolve(outDir, "pq-manifest.json");
|
||||
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const count = Object.keys(manifest).length;
|
||||
console.log(
|
||||
`✓ PQ manifest generated: ${count} queries` +
|
||||
(skipped > 0 ? ` (${skipped} non-document exports skipped)` : ""),
|
||||
);
|
||||
console.log(` Output: ${outPath}`);
|
||||
}
|
||||
|
||||
generateManifest().catch((err) => {
|
||||
console.error("✗ Failed to generate PQ manifest:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
197
apps/portal-shell/scripts/normalize-schema.ts
Normal file
197
apps/portal-shell/scripts/normalize-schema.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// Normalize federation subgraph schemas for graphql-codegen.
|
||||
//
|
||||
// Problem: services schema.graphql files are federation subgraphs that use
|
||||
// `extend type Query` (with no body) and multiple `extend type Query { ... }`
|
||||
// blocks. Standard GraphQL parser used by codegen cannot parse these.
|
||||
//
|
||||
// Solution: read all 7 subgraph schema files, merge all `extend type Query`
|
||||
// blocks into a single `type Query { ... }`, output to a combined SDL file.
|
||||
//
|
||||
// Usage: tsx scripts/normalize-schema.ts
|
||||
// Output: src/lib/api/__generated__/combined-schema.graphql
|
||||
//
|
||||
// Related: spec section 2.4
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
interface SubgraphSchema {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const SCHEMA_FILES = [
|
||||
{
|
||||
name: "iam",
|
||||
path: "../../services/iam/src/graphql/generated/schema.graphql",
|
||||
},
|
||||
{
|
||||
name: "config-service",
|
||||
path: "../../services/config-service/src/graphql/generated/schema.graphql",
|
||||
},
|
||||
{
|
||||
name: "core-edu",
|
||||
path: "../../services/core-edu/src/graphql/generated/schema.graphql",
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
path: "../../services/content/src/graphql/generated/schema.graphql",
|
||||
},
|
||||
{
|
||||
name: "msg",
|
||||
path: "../../services/msg/src/graphql/generated/schema.graphql",
|
||||
},
|
||||
{
|
||||
name: "data-ana",
|
||||
path: "../../services/data-ana/src/graphql/generated/schema.graphql",
|
||||
},
|
||||
{
|
||||
name: "ai",
|
||||
path: "../../services/ai/src/graphql/generated/schema.graphql",
|
||||
},
|
||||
];
|
||||
|
||||
function loadSchemas(): SubgraphSchema[] {
|
||||
const baseDir = process.cwd();
|
||||
return SCHEMA_FILES.map((f) => ({
|
||||
name: f.name,
|
||||
content: fs.readFileSync(path.resolve(baseDir, f.path), "utf8"),
|
||||
}));
|
||||
}
|
||||
|
||||
// Strip federation `extend type Query` syntax:
|
||||
// - Remove standalone `extend type Query` (no body)
|
||||
// - Convert `extend type Query { ... }` blocks to `type Query { ... }`
|
||||
// (we collect fields and merge later)
|
||||
function normalizeSchema(content: string): {
|
||||
staticDefs: string;
|
||||
queryFields: string[];
|
||||
} {
|
||||
const lines = content.split("\n");
|
||||
const staticDefs: string[] = [];
|
||||
const queryFields: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
// Match `extend type Query` (no body, single line)
|
||||
if (/^\s*extend\s+type\s+Query\s*$/.test(line)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Match `extend type Query {` block start
|
||||
const blockStart = line.match(/^\s*extend\s+type\s+Query\s*\{/);
|
||||
if (blockStart) {
|
||||
// Collect fields until matching `}`
|
||||
i++;
|
||||
while (i < lines.length && !/^\s*\}\s*$/.test(lines[i])) {
|
||||
const fieldLine = lines[i];
|
||||
if (fieldLine.trim()) {
|
||||
queryFields.push(fieldLine);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
// Skip closing `}`
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Match `type Query {` block (already standard, but merge fields)
|
||||
const stdBlockStart = line.match(/^\s*type\s+Query\s*\{/);
|
||||
if (stdBlockStart) {
|
||||
i++;
|
||||
while (i < lines.length && !/^\s*\}\s*$/.test(lines[i])) {
|
||||
const fieldLine = lines[i];
|
||||
if (fieldLine.trim()) {
|
||||
queryFields.push(fieldLine);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
staticDefs.push(line);
|
||||
i++;
|
||||
}
|
||||
return { staticDefs: staticDefs.join("\n"), queryFields };
|
||||
}
|
||||
|
||||
// Sanitize invalid input field types.
|
||||
//
|
||||
// Problem: services/ai subgraph declares `input ChatRequestInput { messages:
|
||||
// ChatMessage }` and `input ChatResponseInput { usage: Usage }` where
|
||||
// ChatMessage/Usage are OUTPUT types. GraphQL spec forbids input fields
|
||||
// referencing output types; graphql-codegen's typescript plugin rejects this.
|
||||
//
|
||||
// Solution: rewrite those offending input field types to `String` in the
|
||||
// combined schema. This is a codegen-only sanitize; the runtime apollo-router
|
||||
// uses the original subgraph schemas directly.
|
||||
//
|
||||
// Related: spec section 2.4
|
||||
const SANITIZE_INPUT_FIELD_REPLACEMENTS: Array<{
|
||||
inputName: string;
|
||||
fieldName: string;
|
||||
replacement: string;
|
||||
}> = [
|
||||
// services/ai: input ChatRequestInput { messages: ChatMessage }
|
||||
{
|
||||
inputName: "ChatRequestInput",
|
||||
fieldName: "messages",
|
||||
replacement: "String",
|
||||
},
|
||||
// services/ai: input ChatResponseInput { usage: Usage }
|
||||
{ inputName: "ChatResponseInput", fieldName: "usage", replacement: "String" },
|
||||
];
|
||||
|
||||
function sanitizeInputFields(content: string): string {
|
||||
let out = content;
|
||||
for (const r of SANITIZE_INPUT_FIELD_REPLACEMENTS) {
|
||||
// Match ` fieldName: OriginalType` lines within `input InputName { ... }`
|
||||
// blocks. We rely on the simple field-line format generated above.
|
||||
const inputBlockRe = new RegExp(
|
||||
`(input\\s+${r.inputName}\\s*\\{[^}]*?)` +
|
||||
`(\\s{2,}${r.fieldName}\\s*:\\s*)[A-Za-z_][A-Za-z0-9_\\[\\]!]*`,
|
||||
"g",
|
||||
);
|
||||
out = out.replace(inputBlockRe, `$1$2${r.replacement}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const schemas = loadSchemas();
|
||||
const allStaticDefs: string[] = [];
|
||||
const allQueryFields: string[] = [];
|
||||
|
||||
for (const s of schemas) {
|
||||
const { staticDefs, queryFields } = normalizeSchema(s.content);
|
||||
allStaticDefs.push(`# === ${s.name} subgraph ===`);
|
||||
allStaticDefs.push(staticDefs.trim());
|
||||
allQueryFields.push(...queryFields);
|
||||
}
|
||||
|
||||
// Deduplicate query fields (in case multiple subgraphs define same field)
|
||||
const uniqueQueryFields = Array.from(
|
||||
new Set(allQueryFields.map((f) => f.trim())),
|
||||
);
|
||||
|
||||
let combined = [
|
||||
"# Combined normalized schema for graphql-codegen (federation stripped)",
|
||||
"# DO NOT EDIT - generated by scripts/normalize-schema.ts",
|
||||
"",
|
||||
...allStaticDefs,
|
||||
"",
|
||||
"type Query {",
|
||||
...uniqueQueryFields.map((f) => ` ${f}`),
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
combined = sanitizeInputFields(combined);
|
||||
|
||||
const outDir = path.resolve(process.cwd(), "src/lib/api/__generated__");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const outPath = path.join(outDir, "combined-schema.graphql");
|
||||
fs.writeFileSync(outPath, combined);
|
||||
console.log(`Combined schema written to ${outPath}`);
|
||||
console.log(` Query fields: ${uniqueQueryFields.length}`);
|
||||
}
|
||||
|
||||
main();
|
||||
153
apps/portal-shell/src/__tests__/e2e/error-boundaries.test.tsx
Normal file
153
apps/portal-shell/src/__tests__/e2e/error-boundaries.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* E2E 集成测试:三级错误边界
|
||||
*
|
||||
* 模拟 portal-shell 三级错误边界层级:
|
||||
* 1. Route 级(error.tsx)→ 捕获整个路由的渲染错误
|
||||
* 2. Section 级(DashboardSection)→ 捕获单个区块的错误
|
||||
* 3. Widget 级(PluginBoundary)→ 捕获单个插件的错误
|
||||
*
|
||||
* 验证:低级错误不冒泡到高级边界,高级边界兜底未捕获的低级错误
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||
*/
|
||||
|
||||
// mock useErrorReport
|
||||
const reportErrorMock = vi.fn();
|
||||
vi.mock("@edu/hooks", () => ({
|
||||
useErrorReport: () => reportErrorMock,
|
||||
}));
|
||||
|
||||
import { ErrorBoundary } from "@edu/ui-components";
|
||||
import { PluginBoundary } from "@/shared/components/plugin-boundary";
|
||||
|
||||
/** 制造抛错组件 */
|
||||
function ThrowOnRender({ message }: { message: string }): ReactNode {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function GoodComponent({ label }: { label: string }): ReactNode {
|
||||
return <div data-testid={`good-${label}`}>{label}</div>;
|
||||
}
|
||||
|
||||
describe("E2E: 三级错误边界", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("L3 Widget 级错误不冒泡到 L2 Section 级", () => {
|
||||
render(
|
||||
<ErrorBoundary
|
||||
fallback={<div data-testid="section-error">Section 崩溃</div>}
|
||||
>
|
||||
<div data-testid="section">
|
||||
<PluginBoundary pluginId="bad-widget">
|
||||
<ThrowOnRender message="Widget 崩溃" />
|
||||
</PluginBoundary>
|
||||
<GoodComponent label="sibling" />
|
||||
</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
// Widget 级 fallback 显示
|
||||
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||
// Section 级 fallback 不显示
|
||||
expect(screen.queryByTestId("section-error")).toBeNull();
|
||||
// 兄弟组件正常渲染
|
||||
expect(screen.getByTestId("good-sibling")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("L3 Widget 级错误被上报到 /api/log", () => {
|
||||
render(
|
||||
<PluginBoundary pluginId="reported-widget">
|
||||
<ThrowOnRender message="需上报的 Widget 错误" />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
|
||||
expect(reportErrorMock).toHaveBeenCalledTimes(1);
|
||||
const [error, options] = reportErrorMock.mock.calls[0]!;
|
||||
expect((error as Error).message).toBe("需上报的 Widget 错误");
|
||||
expect(options).toEqual({
|
||||
pluginId: "reported-widget",
|
||||
level: "error",
|
||||
});
|
||||
});
|
||||
|
||||
it("L2 Section 级错误不冒泡到 L1 Route 级", () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div data-testid="route-error">Route 崩溃</div>}>
|
||||
<div data-testid="route">
|
||||
<ErrorBoundary
|
||||
fallback={<div data-testid="section-error">Section 崩溃</div>}
|
||||
>
|
||||
<ThrowOnRender message="Section 崩溃" />
|
||||
</ErrorBoundary>
|
||||
<GoodComponent label="route-sibling" />
|
||||
</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
// Section 级 fallback 显示
|
||||
expect(screen.getByTestId("section-error")).toBeTruthy();
|
||||
// Route 级 fallback 不显示
|
||||
expect(screen.queryByTestId("route-error")).toBeNull();
|
||||
// Route 级兄弟组件正常渲染
|
||||
expect(screen.getByTestId("good-route-sibling")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("未捕获的 L1 Route 级错误由 Route ErrorBoundary 兜底", () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div data-testid="route-error">Route 崩溃</div>}>
|
||||
<ThrowOnRender message="未捕获的顶层错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("route-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Widget 重试后恢复正常", () => {
|
||||
let shouldThrow = true;
|
||||
function FlakyWidget(): ReactNode {
|
||||
if (shouldThrow) throw new Error("偶发错误");
|
||||
return <div data-testid="recovered">已恢复</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<PluginBoundary pluginId="flaky">
|
||||
<FlakyWidget />
|
||||
</PluginBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("插件加载失败")).toBeTruthy();
|
||||
|
||||
shouldThrow = false;
|
||||
fireEvent.click(screen.getByText("重试"));
|
||||
|
||||
expect(screen.getByTestId("recovered")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("多个 Widget 同时出错互不影响", () => {
|
||||
render(
|
||||
<div>
|
||||
<PluginBoundary pluginId="widget-1">
|
||||
<ThrowOnRender message="Widget 1 崩溃" />
|
||||
</PluginBoundary>
|
||||
<PluginBoundary pluginId="widget-2">
|
||||
<ThrowOnRender message="Widget 2 崩溃" />
|
||||
</PluginBoundary>
|
||||
<PluginBoundary pluginId="widget-3">
|
||||
<GoodComponent label="widget-3" />
|
||||
</PluginBoundary>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// 两个崩溃的 Widget 都显示 fallback
|
||||
expect(screen.getAllByText("插件加载失败").length).toBe(2);
|
||||
// 正常的 Widget 不受影响
|
||||
expect(screen.getByTestId("good-widget-3")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
379
apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts
Normal file
379
apps/portal-shell/src/__tests__/e2e/security-boundaries.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
checkRoutePermission,
|
||||
batchCheckRoutePermission,
|
||||
} from "@/shared/lib/route-permissions";
|
||||
import {
|
||||
decodePermissionsBitmap,
|
||||
encodePermissionsBitmap,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
|
||||
/**
|
||||
* E2E 集成测试:三层安全边界
|
||||
*
|
||||
* 模拟用户访问不同路由时的权限校验全流程:
|
||||
* 1. L1 角色门禁:4 角色(admin/teacher/student/parent)路由隔离
|
||||
* 2. L2 权限点门禁:67 权限点位图校验(AND/OR 语义)
|
||||
* 3. L3 数据范围:运行时校验(此处模拟路由级检查)
|
||||
*
|
||||
* 验证:跨角色访问被拒、跨权限访问被拒、合法访问放行
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §3.3 三层安全边界
|
||||
*/
|
||||
|
||||
// ── 测试用户 ──────────────────────────────────────────────
|
||||
const ADMIN_USER = {
|
||||
role: "admin" as const,
|
||||
bitmap: encodePermissionsBitmap([
|
||||
"DASHBOARD_ADMIN_READ",
|
||||
"DASHBOARD_READ",
|
||||
"USER_MANAGE",
|
||||
"ROLE_MANAGE",
|
||||
"PERMISSION_MANAGE",
|
||||
"AUDIT_LOG_READ",
|
||||
"SCHOOL_MANAGE",
|
||||
"PLUGIN_REGISTRY_MANAGE",
|
||||
"INVITATION_CODE_CREATE",
|
||||
"ANNOUNCEMENT_MANAGE",
|
||||
"CLASS_MANAGE",
|
||||
]),
|
||||
};
|
||||
|
||||
const TEACHER_USER = {
|
||||
role: "teacher" as const,
|
||||
bitmap: encodePermissionsBitmap([
|
||||
"DASHBOARD_TEACHER_READ",
|
||||
"DASHBOARD_READ",
|
||||
"LESSON_PLAN_READ",
|
||||
"LESSON_PLAN_CREATE",
|
||||
"QUESTION_READ",
|
||||
"TEXTBOOK_READ",
|
||||
"EXAM_READ",
|
||||
"HOMEWORK_READ",
|
||||
"GRADE_RECORD_MANAGE",
|
||||
"ATTENDANCE_READ",
|
||||
]),
|
||||
};
|
||||
|
||||
const STUDENT_USER = {
|
||||
role: "student" as const,
|
||||
bitmap: encodePermissionsBitmap([
|
||||
"DASHBOARD_STUDENT_READ",
|
||||
"DASHBOARD_READ",
|
||||
"ERROR_BOOK_READ",
|
||||
"LEARNING_PATH_READ",
|
||||
"AI_TUTOR_USE",
|
||||
"ELECTIVE_SELECT",
|
||||
]),
|
||||
};
|
||||
|
||||
const PARENT_USER = {
|
||||
role: "parent" as const,
|
||||
bitmap: encodePermissionsBitmap([
|
||||
"DASHBOARD_PARENT_READ",
|
||||
"DASHBOARD_READ",
|
||||
"GRADE_READ_CHILD",
|
||||
"LEAVE_APPROVAL_MANAGE",
|
||||
]),
|
||||
};
|
||||
|
||||
describe("E2E: 三层安全边界", () => {
|
||||
describe("L1 角色门禁", () => {
|
||||
it("admin 访问 admin 仪表盘 → 放行", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin",
|
||||
ADMIN_USER.bitmap,
|
||||
ADMIN_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("teacher 访问 admin 仪表盘 → 拒绝(missing_role)", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin",
|
||||
TEACHER_USER.bitmap,
|
||||
TEACHER_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_role");
|
||||
});
|
||||
|
||||
it("student 访问 teacher 仪表盘 → 拒绝(missing_role)", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/teacher",
|
||||
STUDENT_USER.bitmap,
|
||||
STUDENT_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_role");
|
||||
});
|
||||
|
||||
it("parent 访问 student 仪表盘 → 拒绝(missing_role)", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/student",
|
||||
PARENT_USER.bitmap,
|
||||
PARENT_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_role");
|
||||
});
|
||||
|
||||
it("4 角色各自访问自己的仪表盘 → 全部放行", () => {
|
||||
expect(
|
||||
checkRoutePermission("/shell/admin", ADMIN_USER.bitmap, ADMIN_USER.role)
|
||||
.allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkRoutePermission(
|
||||
"/shell/teacher",
|
||||
TEACHER_USER.bitmap,
|
||||
TEACHER_USER.role,
|
||||
).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkRoutePermission(
|
||||
"/shell/student",
|
||||
STUDENT_USER.bitmap,
|
||||
STUDENT_USER.role,
|
||||
).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkRoutePermission(
|
||||
"/shell/parent",
|
||||
PARENT_USER.bitmap,
|
||||
PARENT_USER.role,
|
||||
).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("L2 权限点门禁(AND 语义)", () => {
|
||||
it("admin 有 USER_MANAGE → 访问用户管理放行", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/users",
|
||||
ADMIN_USER.bitmap,
|
||||
ADMIN_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("admin 缺少 USER_MANAGE → 访问用户管理拒绝(missing_permission)", () => {
|
||||
const noUserManage = encodePermissionsBitmap([
|
||||
"DASHBOARD_ADMIN_READ",
|
||||
"DASHBOARD_READ",
|
||||
]);
|
||||
const result = checkRoutePermission(
|
||||
"/shell/admin/users",
|
||||
noUserManage,
|
||||
"admin",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_permission");
|
||||
expect(result.missingPermissions).toEqual(["USER_MANAGE"]);
|
||||
});
|
||||
|
||||
it("student 访问 AI 辅导需要 AI_TUTOR_USE 权限", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/student/ai-tutor",
|
||||
STUDENT_USER.bitmap,
|
||||
STUDENT_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
|
||||
const noAi = encodePermissionsBitmap([
|
||||
"DASHBOARD_STUDENT_READ",
|
||||
"DASHBOARD_READ",
|
||||
]);
|
||||
const denied = checkRoutePermission(
|
||||
"/shell/student/ai-tutor",
|
||||
noAi,
|
||||
"student",
|
||||
);
|
||||
expect(denied.allowed).toBe(false);
|
||||
expect(denied.reason).toBe("missing_permission");
|
||||
});
|
||||
});
|
||||
|
||||
describe("L2 权限点门禁(OR 语义 - anyOfPermissions)", () => {
|
||||
it("teacher 有 LESSON_PLAN_READ → 访问备课管理放行", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/teacher/lesson-plans",
|
||||
TEACHER_USER.bitmap,
|
||||
TEACHER_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("teacher 有 QUESTION_READ → 访问题库放行(OR 语义)", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/teacher/question-bank",
|
||||
TEACHER_USER.bitmap,
|
||||
TEACHER_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("teacher 缺少所有备课权限 → 拒绝", () => {
|
||||
const noLessonPlan = encodePermissionsBitmap([
|
||||
"DASHBOARD_TEACHER_READ",
|
||||
"DASHBOARD_READ",
|
||||
"QUESTION_READ",
|
||||
]);
|
||||
const result = checkRoutePermission(
|
||||
"/shell/teacher/lesson-plans",
|
||||
noLessonPlan,
|
||||
"teacher",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe("missing_permission");
|
||||
});
|
||||
});
|
||||
|
||||
describe("L3 数据范围(模拟)", () => {
|
||||
it("parent 有 GRADE_READ_CHILD → 访问子女管理放行", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/parent/children",
|
||||
PARENT_USER.bitmap,
|
||||
PARENT_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("parent 有 LEAVE_APPROVAL_MANAGE → 访问请假审批放行", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/parent/leave-approval",
|
||||
PARENT_USER.bitmap,
|
||||
PARENT_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("student 有 ELECTIVE_SELECT → 访问选修课选择放行(OR 语义)", () => {
|
||||
const result = checkRoutePermission(
|
||||
"/shell/student/electives",
|
||||
STUDENT_USER.bitmap,
|
||||
STUDENT_USER.role,
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("student 缺少 ELECTIVE_SELECT 和 ELECTIVE_READ → 拒绝", () => {
|
||||
const noElective = encodePermissionsBitmap([
|
||||
"DASHBOARD_STUDENT_READ",
|
||||
"DASHBOARD_READ",
|
||||
]);
|
||||
const result = checkRoutePermission(
|
||||
"/shell/student/electives",
|
||||
noElective,
|
||||
"student",
|
||||
);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("跨角色越权访问全量测试", () => {
|
||||
it("admin 访问所有 admin 路由 → 全部放行", () => {
|
||||
const adminRoutes = [
|
||||
"/shell/admin",
|
||||
"/shell/admin/users",
|
||||
"/shell/admin/roles",
|
||||
"/shell/admin/permissions",
|
||||
"/shell/admin/audit-logs",
|
||||
"/shell/admin/school",
|
||||
"/shell/admin/plugins",
|
||||
"/shell/admin/invitation-codes",
|
||||
];
|
||||
const results = batchCheckRoutePermission(
|
||||
adminRoutes,
|
||||
ADMIN_USER.bitmap,
|
||||
ADMIN_USER.role,
|
||||
);
|
||||
for (const route of adminRoutes) {
|
||||
expect(results[route]).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("teacher 访问所有 admin 路由 → 全部拒绝", () => {
|
||||
const adminRoutes = [
|
||||
"/shell/admin/users",
|
||||
"/shell/admin/roles",
|
||||
"/shell/admin/audit-logs",
|
||||
];
|
||||
const results = batchCheckRoutePermission(
|
||||
adminRoutes,
|
||||
TEACHER_USER.bitmap,
|
||||
TEACHER_USER.role,
|
||||
);
|
||||
for (const route of adminRoutes) {
|
||||
expect(results[route]).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("student 访问 teacher 路由 → 全部拒绝", () => {
|
||||
const teacherRoutes = [
|
||||
"/shell/teacher",
|
||||
"/shell/teacher/lesson-plans",
|
||||
"/shell/teacher/question-bank",
|
||||
];
|
||||
const results = batchCheckRoutePermission(
|
||||
teacherRoutes,
|
||||
STUDENT_USER.bitmap,
|
||||
STUDENT_USER.role,
|
||||
);
|
||||
for (const route of teacherRoutes) {
|
||||
expect(results[route]).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("公共路由所有角色均可访问", () => {
|
||||
const publicRoutes = [
|
||||
"/",
|
||||
"/login",
|
||||
"/shell/forbidden",
|
||||
"/api/log",
|
||||
"/api/healthz",
|
||||
];
|
||||
for (const user of [
|
||||
ADMIN_USER,
|
||||
TEACHER_USER,
|
||||
STUDENT_USER,
|
||||
PARENT_USER,
|
||||
]) {
|
||||
const results = batchCheckRoutePermission(
|
||||
publicRoutes,
|
||||
user.bitmap,
|
||||
user.role,
|
||||
);
|
||||
for (const route of publicRoutes) {
|
||||
expect(results[route]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("权限提升模拟", () => {
|
||||
it("admin 添加新权限后可访问新路由", () => {
|
||||
// 初始 admin 没有 EXAM_GRADE
|
||||
const initialResult = checkRoutePermission(
|
||||
"/shell/teacher/exams/1",
|
||||
ADMIN_USER.bitmap,
|
||||
"admin",
|
||||
);
|
||||
// admin 角色匹配,但 anyOfPermissions 需要 EXAM_READ/EXAM_CREATE/EXAM_UPDATE/EXAM_GRADE
|
||||
// ADMIN_USER 没有 EXAM_READ 等 → 拒绝
|
||||
expect(initialResult.allowed).toBe(false);
|
||||
|
||||
// 添加 EXAM_READ 权限后
|
||||
const withExam = encodePermissionsBitmap([
|
||||
...decodePermissionsBitmap(ADMIN_USER.bitmap),
|
||||
"EXAM_READ",
|
||||
]);
|
||||
const afterResult = checkRoutePermission(
|
||||
"/shell/teacher/exams/1",
|
||||
withExam,
|
||||
"admin",
|
||||
);
|
||||
expect(afterResult.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
170
apps/portal-shell/src/__tests__/e2e/streaming.test.tsx
Normal file
170
apps/portal-shell/src/__tests__/e2e/streaming.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
use,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* E2E 集成测试:流式渲染(React 19 use() + Suspense)
|
||||
*
|
||||
* 测试策略:
|
||||
* - jsdom 环境下 React 19 use(promise) 在 promise 从 pending→resolved 切换时
|
||||
* 依赖 React 内部调度器重新渲染,在测试环境中无法可靠触发。
|
||||
* - 因此采用「预解析 Promise」模式:promise 在 render 前已 resolved,
|
||||
* React 首次渲染时 use() 直接返回值(Suspense 不触发 fallback)。
|
||||
* - 对「pending → resolved」切换的验证,改用多层 Suspense + 异步渲染断言。
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §4 流式渲染
|
||||
*/
|
||||
|
||||
/** 模拟使用 use() 消费 Promise 的组件 */
|
||||
function AsyncContent<T>({
|
||||
promise,
|
||||
render,
|
||||
}: {
|
||||
promise: Promise<T>;
|
||||
render: (data: T) => ReactNode;
|
||||
}): ReactNode {
|
||||
const data = use(promise);
|
||||
return <>{render(data)}</>;
|
||||
}
|
||||
|
||||
/** 简化版 ErrorBoundary(用于测试 Promise reject 由 ErrorBoundary 捕获) */
|
||||
class TestErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; fallback: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return { hasError: true };
|
||||
}
|
||||
override componentDidCatch(_error: Error, _info: ErrorInfo): void {
|
||||
// 测试中无需上报
|
||||
}
|
||||
override render(): ReactNode {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建一个可控的 pending Promise(永不自动 resolve) */
|
||||
function createPendingPromise<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolveFn!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
});
|
||||
return { promise, resolve: resolveFn };
|
||||
}
|
||||
|
||||
describe("E2E: 流式渲染", () => {
|
||||
it("Suspense 边界在 Promise pending 时显示骨架屏", () => {
|
||||
const { promise } = createPendingPromise<string>();
|
||||
|
||||
render(
|
||||
<Suspense fallback={<div data-testid="skeleton">加载中...</div>}>
|
||||
<AsyncContent
|
||||
promise={promise}
|
||||
render={(data) => <div data-testid="content">{data}</div>}
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("skeleton")).toBeTruthy();
|
||||
expect(screen.queryByTestId("content")).toBeNull();
|
||||
});
|
||||
|
||||
it("已解析的 Promise 渲染实际内容(不触发 Suspense fallback)", async () => {
|
||||
// 预解析的 Promise:React 首次渲染时 use() 直接返回值
|
||||
const resolvedPromise = Promise.resolve("实际数据");
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Suspense fallback={<div data-testid="skeleton">加载中...</div>}>
|
||||
<AsyncContent
|
||||
promise={resolvedPromise}
|
||||
render={(data) => <div data-testid="content">{data}</div>}
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
// 等待微任务队列清空,让 React 处理已解析的 Promise
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("content")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("content").textContent).toBe("实际数据");
|
||||
expect(screen.queryByTestId("skeleton")).toBeNull();
|
||||
});
|
||||
|
||||
it("多层 Suspense 边界各自独立解析(外层预解析、内层 pending)", async () => {
|
||||
const outerPromise = Promise.resolve("外层");
|
||||
const inner = createPendingPromise<string>();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<Suspense fallback={<div data-testid="outer-skeleton">外层骨架</div>}>
|
||||
<AsyncContent
|
||||
promise={outerPromise}
|
||||
render={() => (
|
||||
<div data-testid="outer-content">
|
||||
外层已加载
|
||||
<Suspense
|
||||
fallback={<div data-testid="inner-skeleton">内层骨架</div>}
|
||||
>
|
||||
<AsyncContent
|
||||
promise={inner.promise}
|
||||
render={() => (
|
||||
<div data-testid="inner-content">内层已加载</div>
|
||||
)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// 外层已解析 → 外层内容显示,内层仍 pending → 内层骨架显示
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("outer-content")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("inner-skeleton")).toBeTruthy();
|
||||
expect(screen.queryByTestId("inner-content")).toBeNull();
|
||||
});
|
||||
|
||||
it("已 reject 的 Promise 由 ErrorBoundary 捕获(而非 Suspense)", async () => {
|
||||
// 预 reject 的 Promise:React 渲染时 use() 抛出错误
|
||||
const rejectedPromise = Promise.reject(new Error("数据加载失败"));
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestErrorBoundary fallback={<div data-testid="error">加载失败</div>}>
|
||||
<Suspense fallback={<div data-testid="skeleton">加载中...</div>}>
|
||||
<AsyncContent
|
||||
promise={rejectedPromise}
|
||||
render={() => <div data-testid="content">不应显示</div>}
|
||||
/>
|
||||
</Suspense>
|
||||
</TestErrorBoundary>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("content")).toBeNull();
|
||||
});
|
||||
});
|
||||
15
apps/portal-shell/src/__tests__/setup.ts
Normal file
15
apps/portal-shell/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* vitest 全局 setup(portal-shell v2.0 P4 E2E 测试)
|
||||
*
|
||||
* 1. 显式启用 React act 环境(React 19 + @testing-library/react 需要)
|
||||
* 2. 注册 @testing-library/jest-dom matchers(toBeInTheDocument 等)
|
||||
*/
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// React 19 act 环境标记:确保所有 React 状态更新都在 act() 内执行
|
||||
// 缺失此标记会导致 Suspense/use() 在测试中不触发重新渲染
|
||||
declare global {
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
239
apps/portal-shell/src/app/api/auth/login/route.ts
Normal file
239
apps/portal-shell/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 登录代理 Route Handler(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.3)
|
||||
*
|
||||
* 流程:
|
||||
* Browser POST /api/auth/login { email, password }
|
||||
* → 本 Route Handler 调 api-gateway /api/v1/iam/login
|
||||
* → 成功后把 accessToken 写入 httpOnly cookie `edu_session`
|
||||
* → 把 permissions 位图写入非 httpOnly cookie `edu_perms`(按钮级 UX 用,非安全依据)
|
||||
* → 返回 { user } 给前端(不返回 token,JS 永不接触 token)
|
||||
*
|
||||
* 安全(§4.3):
|
||||
* - cookie 名 `edu_session`:HttpOnly + Secure(生产) + SameSite=Strict + Path=/
|
||||
* - Max-Age 与 iam 返回的 expiresIn 对齐
|
||||
* - 失败归一化错误:401 / 429 / 5xx 分别处理
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.3、§6.1、§11.7 红线 #2
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
encodePermissionsBitmap,
|
||||
PERMISSION_BITMAP_ORDER,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
const PERMS_COOKIE = "edu_perms";
|
||||
|
||||
const GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ||
|
||||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
|
||||
"http://localhost:8080";
|
||||
|
||||
const IAM_LOGIN_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/login`;
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
dataScope: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface IamLoginResponse {
|
||||
success: true;
|
||||
data: { user: UserInfo; tokens: TokenPair };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析角色字符串为 portal-shell 4 角色之一。
|
||||
* iam 返回 roles[],取主角色。
|
||||
*/
|
||||
function pickPrimaryRole(roles: string[]): Role {
|
||||
for (const r of roles) {
|
||||
if (r === "admin" || r === "teacher" || r === "student" || r === "parent") {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
return "teacher";
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出 PERMISSION_BITMAP_ORDER 中存在的权限点(避免位图编码丢失)。
|
||||
*/
|
||||
function filterKnownPermissions(perms: string[]): string[] {
|
||||
const known = new Set<string>(PERMISSION_BITMAP_ORDER);
|
||||
return perms.filter((p) => known.has(p));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// ── 1. 解析与校验请求体 ──
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "INVALID_BODY", message: "Request body must be JSON" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "INVALID_INPUT",
|
||||
message: "Email and password are required",
|
||||
details: parsed.error.issues,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. 调用 iam 登录 ──
|
||||
let iamResponse: Response;
|
||||
try {
|
||||
iamResponse = await fetch(IAM_LOGIN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// 透传客户端 IP 与 UA 用于审计
|
||||
...(req.headers.get("x-forwarded-for")
|
||||
? { "X-Forwarded-For": req.headers.get("x-forwarded-for") as string }
|
||||
: {}),
|
||||
...(req.headers.get("user-agent")
|
||||
? { "User-Agent": req.headers.get("user-agent") as string }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(parsed.data),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error(
|
||||
`[portal-shell] /api/auth/login: iam unreachable: ${message} (url=${IAM_LOGIN_ENDPOINT})`,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "IAM_UNREACHABLE",
|
||||
message: "Authentication service unavailable",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. 处理 iam 响应 ──
|
||||
if (iamResponse.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ error: "INVALID_CREDENTIALS", message: "邮箱或密码错误" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (iamResponse.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ error: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试" },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
if (!iamResponse.ok) {
|
||||
// 其他错误(403 账户锁定 / 5xx)
|
||||
let message = "登录失败";
|
||||
try {
|
||||
const errJson = (await iamResponse.json()) as { message?: string };
|
||||
if (errJson.message) message = errJson.message;
|
||||
} catch {
|
||||
// 忽略 JSON 解析失败
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_ERROR", message },
|
||||
{ status: iamResponse.status },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. 提取 token 与 user ──
|
||||
let iamData: IamLoginResponse;
|
||||
try {
|
||||
iamData = (await iamResponse.json()) as IamLoginResponse;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_BAD_RESPONSE", message: "登录服务返回数据异常" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const { user, tokens } = iamData.data;
|
||||
if (!tokens?.accessToken || typeof tokens.expiresIn !== "number") {
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_BAD_RESPONSE", message: "登录响应缺少 token" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 5. 计算 cookie 值 ──
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
const maxAge = Math.min(tokens.expiresIn, 60 * 60 * 8); // 最长 8 小时
|
||||
const knownPerms = filterKnownPermissions(user.permissions ?? []);
|
||||
const permsBitmap = encodePermissionsBitmap(knownPerms);
|
||||
|
||||
// ── 6. 构建响应(不返回 token 给前端) ──
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: pickPrimaryRole(user.roles),
|
||||
permissions: knownPerms,
|
||||
dataScope: user.dataScope,
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
|
||||
// 设置 Set-Cookie 头(多 cookie 用逗号分隔,NextResponse.cookies 更稳)
|
||||
response.cookies.set(SESSION_COOKIE, tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge,
|
||||
});
|
||||
response.cookies.set(PERMS_COOKIE, permsBitmap, {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/login → 简单状态端点(不暴露任何敏感信息)。
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{ ok: true, endpoint: "/api/auth/login", method: "POST" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
108
apps/portal-shell/src/app/api/auth/logout/route.ts
Normal file
108
apps/portal-shell/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 登出代理 Route Handler(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.2 / §4.3)
|
||||
*
|
||||
* 流程:
|
||||
* Browser POST /api/auth/logout(携带 edu_session cookie)
|
||||
* → 本 Route Handler 从 cookie 取 access token
|
||||
* → 清除 edu_session + edu_perms cookie(无论 iam 是否成功)
|
||||
* → best-effort 调 iam /api/v1/iam/logout(带 Authorization)使 refresh token 失效
|
||||
* → 返回 { success: true },前端跳转 /login
|
||||
*
|
||||
* 容错策略:
|
||||
* - iam 不可达 / 返回错误 → 静默忽略,仍然清 cookie(用户体验优先:本地登出必成功)
|
||||
* - iam 端的 edu_refresh httpOnly cookie 由 iam 自行清除(path=/api/v1/iam,本代理无法跨 path 清)
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.2、§4.3、§11.7 红线 #2
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
const PERMS_COOKIE = "edu_perms";
|
||||
|
||||
const GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ||
|
||||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
|
||||
"http://localhost:8080";
|
||||
|
||||
const IAM_LOGOUT_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/logout`;
|
||||
|
||||
/**
|
||||
* 解析 cookie 头中的指定 cookie。
|
||||
*/
|
||||
function readCookie(cookieHeader: string | null, name: string): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
const match = cookieHeader
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith(`${name}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(name.length + 1));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
// ── 1. best-effort 调 iam logout(使服务端 refresh token 失效) ──
|
||||
if (token) {
|
||||
try {
|
||||
await fetch(IAM_LOGOUT_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(req.headers.get("x-forwarded-for")
|
||||
? {
|
||||
"X-Forwarded-For": req.headers.get("x-forwarded-for") as string,
|
||||
}
|
||||
: {}),
|
||||
...(req.headers.get("user-agent")
|
||||
? { "User-Agent": req.headers.get("user-agent") as string }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
// iam 不可达 → 静默,本地登出仍然完成
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.warn(
|
||||
`[portal-shell] /api/auth/logout: iam unreachable: ${message} (url=${IAM_LOGOUT_ENDPOINT})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. 清除 edu_session + edu_perms cookie(无论 iam 是否成功) ──
|
||||
const response = NextResponse.json({ success: true }, { status: 200 });
|
||||
response.cookies.set(SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
response.cookies.set(PERMS_COOKIE, "", {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/logout → 简单状态端点(不暴露任何敏感信息)。
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{ ok: true, endpoint: "/api/auth/logout", method: "POST" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
147
apps/portal-shell/src/app/api/graphql/route.ts
Normal file
147
apps/portal-shell/src/app/api/graphql/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* GraphQL 同域代理(P0-3 + P1-5,ARCHITECTURE.md §3.4 V3-A2/V3-A7 / §4 / §5.2 / §10 P1-5)
|
||||
*
|
||||
* 浏览器 Apollo Client 一律走同域 `/api/graphql`:
|
||||
* Browser → /api/graphql (本 Route Handler) → apollo-router :3000
|
||||
*
|
||||
* MSW 兜底层(P1-5):
|
||||
* - NEXT_PUBLIC_MSW=1 时,SSR 端 Apollo Client 也走 /api/graphql(见 apollo-client.ts)
|
||||
* - 本 Route Handler 检测 MSW 开关,开启时直接返回 mock 数据(不连接后端)
|
||||
* - Mock 数据来自 mocks/graphql-data.ts,与 MSW browser worker 共用
|
||||
* - 生产构建 NEXT_PUBLIC_MSW 不为 "1",此分支被 tree-shake 移除
|
||||
*
|
||||
* 职责:
|
||||
* 1. 从 httpOnly cookie `edu_session` 取 JWT,注入 `Authorization: Bearer`
|
||||
* 2. 透传 body(APQ hash 或 query)与 Apollo 相关头
|
||||
* 3. 响应 status / JSON 原样回传,不缓存
|
||||
* 4. 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }`
|
||||
*
|
||||
* 安全收益(§4.2):
|
||||
* - JWT 全程不出 httpOnly cookie,消除 XSS 窃取凭证面
|
||||
* - 修复"浏览器绕过 api-gateway"问题(代理在服务端调 router)
|
||||
*
|
||||
* 验收命令:
|
||||
* DevTools Network 面板无 `localhost:3000` 直连;所有 GraphQL 请求走 `/api/graphql`
|
||||
* NEXT_PUBLIC_MSW=1 pnpm dev → 仪表盘/users 有数据,无需后端
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { graphqlResponse } from "@/mocks/graphql-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
|
||||
const UPSTREAM_URL =
|
||||
process.env.APOLLO_ROUTER_URL ||
|
||||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
|
||||
"http://localhost:3000/graphql";
|
||||
|
||||
const MSW_ENABLED = process.env.NEXT_PUBLIC_MSW === "1";
|
||||
|
||||
/**
|
||||
* 从 Cookie 头解析指定 cookie 值。
|
||||
*/
|
||||
function readCookie(cookieHeader: string | null, name: string): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
const match = cookieHeader
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith(`${name}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(name.length + 1));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// P1-5 MSW 兜底层:开启时直接返回 mock 数据,不连接后端
|
||||
if (MSW_ENABLED) {
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
operationName?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
return NextResponse.json(
|
||||
graphqlResponse(body.operationName, body.variables),
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||
|
||||
// 透传 body(APQ hash 请求或完整 query),不解析不修改
|
||||
const body = await req.text();
|
||||
|
||||
const upstreamHeaders: Record<string, string> = {
|
||||
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
||||
Accept: req.headers.get("accept") ?? "application/json",
|
||||
// Apollo Persisted Query 协议头透传
|
||||
"X-APQ": req.headers.get("x-apq") ?? "1",
|
||||
// 服务端追踪:透传客户端 X-Request-Id(若有)
|
||||
...(req.headers.get("x-request-id")
|
||||
? { "X-Request-Id": req.headers.get("x-request-id") as string }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// 注入 Authorization(若 cookie 中有 JWT)
|
||||
if (token) {
|
||||
upstreamHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(UPSTREAM_URL, {
|
||||
method: "POST",
|
||||
headers: upstreamHeaders,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseText = await upstream.text();
|
||||
return new NextResponse(responseText, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
// 不缓存:GraphQL 响应可能因身份/变量而异
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unknown upstream error";
|
||||
console.error(
|
||||
`[portal-shell] /api/graphql upstream error: ${message} (url=${UPSTREAM_URL})`,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: "UPSTREAM_UNAVAILABLE",
|
||||
extensions: {
|
||||
code: "UPSTREAM_UNAVAILABLE",
|
||||
reason: message,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/graphql → 简单健康标识,便于排查路由是否挂载。
|
||||
* Apollo Router 自身的 health 在 :8088/health。
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
proxy: "/api/graphql",
|
||||
upstream: UPSTREAM_URL,
|
||||
msw: MSW_ENABLED,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
13
apps/portal-shell/src/app/api/health/route.ts
Normal file
13
apps/portal-shell/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Liveness 健康检查(project_rules §12)。
|
||||
* GET /api/health — 进程存活即返回 200。
|
||||
*/
|
||||
export function GET(_request: NextRequest): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ status: "ok", service: "portal-shell", timestamp: Date.now() },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
56
apps/portal-shell/src/app/api/log/route.ts
Normal file
56
apps/portal-shell/src/app/api/log/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 客户端错误上报端点(mock 实现)
|
||||
*
|
||||
* 当前阶段:输出到 stdout,便于开发调试
|
||||
* 未来演进:接入 OpenTelemetry / Sentry / 后端 /api/v1/log
|
||||
*
|
||||
* 端点:POST /api/log
|
||||
* Body: ErrorReportPayload(见 @edu/hooks/use-error-report)
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface ErrorReportPayload {
|
||||
level: "error" | "warning";
|
||||
message: string;
|
||||
stack?: string;
|
||||
digest?: string;
|
||||
path: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
pluginId?: string;
|
||||
userId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const payload = (await request.json()) as ErrorReportPayload;
|
||||
|
||||
// 开发阶段:结构化输出到 stdout
|
||||
// 生产阶段:这里应替换为 OTel export 或 Sentry capture
|
||||
console.error("[client-error]", {
|
||||
level: payload.level,
|
||||
message: payload.message,
|
||||
digest: payload.digest,
|
||||
path: payload.path,
|
||||
pluginId: payload.pluginId,
|
||||
userId: payload.userId,
|
||||
timestamp: payload.timestamp,
|
||||
// stack 太长,单独一行输出便于阅读
|
||||
stack: payload.stack?.split("\n").slice(0, 5).join("\n"),
|
||||
});
|
||||
|
||||
// 返回 204,让 sendBeacon 认为成功
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch {
|
||||
// 解析失败也返回 204,避免客户端重试
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
/** 健康检查 */
|
||||
export function GET(): NextResponse {
|
||||
return NextResponse.json({ ok: true, endpoint: "/api/log" });
|
||||
}
|
||||
43
apps/portal-shell/src/app/api/ready/route.ts
Normal file
43
apps/portal-shell/src/app/api/ready/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Readiness 健康检查(project_rules §12)。
|
||||
* GET /api/ready — 检查下游 apollo-router 是否可达。
|
||||
*/
|
||||
export async function GET(_request: NextRequest): Promise<NextResponse> {
|
||||
const routerUrl =
|
||||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
|
||||
process.env.APOLLO_ROUTER_URL ||
|
||||
"http://localhost:3000/graphql";
|
||||
|
||||
try {
|
||||
const res = await fetch(routerUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: "{ __typename }",
|
||||
}),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ status: "degraded", router: routerUrl, code: res.status },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ status: "ready", router: routerUrl, timestamp: Date.now() },
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "not-ready",
|
||||
router: routerUrl,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
62
apps/portal-shell/src/app/globals.css
Normal file
62
apps/portal-shell/src/app/globals.css
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* portal-shell 全局样式(Tailwind v4 + shadcn 标准令牌)
|
||||
*
|
||||
* 引入 @edu/ui-tokens 三层设计令牌(primitive → semantic → tailwind-theme)
|
||||
* 业务代码使用 Tailwind 类(bg-background / text-foreground / bg-card ...)或 hsl(var(--*)) 引用。
|
||||
*
|
||||
* 禁止规则(ESLint + project_rules §3.10):
|
||||
* - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类)
|
||||
* - 禁止字体名字面量(用 var(--font-family-*))
|
||||
* - 禁止 font-size: Npx(用 var(--font-size-*) 或 Tailwind text-* 类)
|
||||
*
|
||||
* 对齐:CICD 项目 src/app/globals.css
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "@edu/ui-tokens/all.css";
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* 排除非源码目录,防止文档中的 Tailwind 任意值语法字符串被误识别为类名 */
|
||||
@source not "../../docs";
|
||||
@source not "../../scripts";
|
||||
@source not "../../tests";
|
||||
|
||||
/* Reduced Motion */
|
||||
@layer base {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-family-sans);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-family-sans);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
68
apps/portal-shell/src/app/layout.tsx
Normal file
68
apps/portal-shell/src/app/layout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
|
||||
/**
|
||||
* 字体加载(next/font/google self-host)
|
||||
*
|
||||
* 通过 CSS 变量 --font-inter 暴露字体族。
|
||||
* ui-tokens 的 primitive 层将 --font-family-sans 映射为 var(--font-inter, ...)。
|
||||
* 禁止字体名字面量(project_rules §3.10)。
|
||||
*
|
||||
* 对齐:CICD 项目 src/app/layout.tsx(仅 Inter,shadcn 标准)
|
||||
*/
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Edu Portal Shell",
|
||||
description: "K12 智慧教务平台 - 插件化仪表盘",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* RootLayout(P1-4:next-intl 接入)
|
||||
*
|
||||
* 职责:
|
||||
* - <html lang={locale}>:从 next-intl getLocale() 获取(cookie 驱动)
|
||||
* - NextIntlClientProvider:将 messages 注入客户端,useTranslations 全局可用
|
||||
* - 字体变量 + 全局 Toaster
|
||||
*
|
||||
* 业务 Providers(Apollo/Auth/Theme)在 ClientShell 中挂载(spec §5.5)。
|
||||
*
|
||||
* suppressHydrationWarning:ThemeProvider 在客户端切换 .dark class,
|
||||
* 与 SSR 输出的 <html class=""> 不一致,需抑制 hydration 警告。
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A6、§8.7
|
||||
*/
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): Promise<ReactNode> {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning className={inter.variable}>
|
||||
<body className="font-sans antialiased">
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
220
apps/portal-shell/src/app/login/login-form.tsx
Normal file
220
apps/portal-shell/src/app/login/login-form.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 登录表单(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §8.3)
|
||||
*
|
||||
* 客户端组件,shadcn 令牌登录表单。
|
||||
* - 提交:POST /api/auth/login { email, password }
|
||||
* - 成功:router.push(next || "/shell")
|
||||
* - 失败:notify.error 显示归一化错误
|
||||
* - DEV_MODE:显示提示横幅 + 一键填充 dev 凭证按钮
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§8.3、§11.7 红线 #2
|
||||
*/
|
||||
import { useState, useTransition, type FormEvent } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { GraduationCap, Loader2, LogIn } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";
|
||||
|
||||
interface LoginSuccessResponse {
|
||||
success: true;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
dataScope: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type LoginResponse = LoginSuccessResponse | LoginErrorResponse;
|
||||
|
||||
/**
|
||||
* 把后端返回的错误码映射为中文文案。
|
||||
*/
|
||||
function mapLoginError(errorCode: string, fallback: string): string {
|
||||
switch (errorCode) {
|
||||
case "INVALID_BODY":
|
||||
case "INVALID_INPUT":
|
||||
return "请输入有效的邮箱和密码";
|
||||
case "INVALID_CREDENTIALS":
|
||||
return "邮箱或密码错误";
|
||||
case "RATE_LIMITED":
|
||||
return "登录尝试过于频繁,请稍后再试";
|
||||
case "IAM_UNREACHABLE":
|
||||
case "IAM_BAD_RESPONSE":
|
||||
return "登录服务暂不可用,请稍后再试";
|
||||
case "IAM_ERROR":
|
||||
return fallback || "登录失败,请重试";
|
||||
default:
|
||||
return fallback || "登录失败,请重试";
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginForm(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
if (isPending) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const next = searchParams.get("next") || "/shell";
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
notify.error("网络错误,请检查网络连接后重试");
|
||||
return;
|
||||
}
|
||||
|
||||
let data: LoginResponse;
|
||||
try {
|
||||
data = (await response.json()) as LoginResponse;
|
||||
} catch {
|
||||
notify.error("登录服务返回数据异常");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok || !("success" in data)) {
|
||||
const errorResp = data as LoginErrorResponse;
|
||||
notify.error(mapLoginError(errorResp.error, errorResp.message));
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success(`欢迎回来,${data.user.name || data.user.email}`);
|
||||
// 用 router.push 而非 location.href,保留 SPA 体验;cookie 已由 Set-Cookie 写入
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function fillDevCredentials(): void {
|
||||
setEmail("dev@edu.local");
|
||||
setPassword("dev-password");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<div className="bg-primary text-primary-foreground flex size-12 items-center justify-center rounded-xl">
|
||||
<GraduationCap className="size-6" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edu Portal</h1>
|
||||
<p className="text-muted-foreground text-sm">K12 智慧教务平台</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">登录</CardTitle>
|
||||
<CardDescription>使用邮箱与密码登录你的账号</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
邮箱
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@school.edu.cn"
|
||||
aria-label="邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
密码
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={isPending}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
aria-label="密码"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || !email || !password}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="size-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
登录中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="size-4" aria-hidden="true" />
|
||||
登录
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{DEV_MODE ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={fillDevCredentials}
|
||||
disabled={isPending}
|
||||
>
|
||||
开发模式:填充 dev 凭证
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-xs">
|
||||
登录即表示同意 Edu Portal 使用条款与隐私政策
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/portal-shell/src/app/login/page.tsx
Normal file
31
apps/portal-shell/src/app/login/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 登录页(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.2 / §7.1)
|
||||
*
|
||||
* 路由:/login
|
||||
*
|
||||
* 职责:
|
||||
* - RSC 入口:检测已登录(cookie edu_session 存在)→ redirect /shell
|
||||
* - 渲染 <LoginForm /> 客户端组件
|
||||
*
|
||||
* 已登录检测策略:
|
||||
* - middleware 将 /login 列入 PUBLIC_ROUTES,不强制身份校验
|
||||
* - 本页面 RSC 通过 cookies() 读 edu_session cookie(仅看存在性,不验签——验签由 middleware 负责)
|
||||
* - 存在 cookie 视为已登录,直接 redirect /shell,避免登录页"闪现"
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.2、§11.7 红线 #2
|
||||
*/
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LoginPage(): Promise<React.ReactElement> {
|
||||
const cookieStore = await cookies();
|
||||
const session = cookieStore.get("edu_session");
|
||||
if (session?.value) {
|
||||
redirect("/shell");
|
||||
}
|
||||
return <LoginForm />;
|
||||
}
|
||||
8
apps/portal-shell/src/app/page.tsx
Normal file
8
apps/portal-shell/src/app/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* 根路径重定向到 /shell(portal-shell spec §8.2:路由前缀 /shell/*)。
|
||||
*/
|
||||
export default function RootPage(): never {
|
||||
redirect("/shell");
|
||||
}
|
||||
59
apps/portal-shell/src/app/shell/[[...route]]/page.tsx
Normal file
59
apps/portal-shell/src/app/shell/[[...route]]/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { fetchPluginConfig } from "@/lib/config-fetcher";
|
||||
import { ClientShell } from "@/shell/ClientShell";
|
||||
import type { Role } from "@/lib/types";
|
||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* Shell catch-all 入口(RSC Server Component,v3.0 P0-2 + P1-2)
|
||||
*
|
||||
* 路由行为(ARCHITECTURE.md §3.4 V3-A1 混合路由模型):
|
||||
* - `/shell`(空路由)→ 302 重定向到 `/shell/{role}` 角色仪表盘(P1-2)
|
||||
* - `/shell/{role}/{module}/...`(有路由段)→ 微内核仪表盘兜底
|
||||
* (P2-P5 将逐步替换为显式路由页面)
|
||||
*
|
||||
* fail-closed(P0-2,§11.7 红线 #5):
|
||||
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头
|
||||
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx,禁止默认 teacher
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A1/A2/A3、§5.5、§10 P1-2、§11.7 红线 #5
|
||||
*/
|
||||
export default async function ShellPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ route?: string[] }>;
|
||||
}): Promise<React.ReactElement> {
|
||||
const headerList = await headers();
|
||||
const userId = headerList.get("x-user-id");
|
||||
const roleHeader = headerList.get("x-user-role");
|
||||
|
||||
// fail-closed:middleware 必须注入身份头,缺失即异常(不再默认 teacher)
|
||||
if (!userId || !roleHeader) {
|
||||
throw new Error(
|
||||
"[portal-shell] ShellPage missing identity headers " +
|
||||
"(middleware must inject x-user-id / x-user-role). " +
|
||||
"If middleware is configured, this indicates a routing misconfiguration.",
|
||||
);
|
||||
}
|
||||
|
||||
const role = roleHeader as Role;
|
||||
const { route } = await params;
|
||||
|
||||
// P1-2:`/shell`(空路由)→ 重定向到角色仪表盘
|
||||
// 显式路由页面(/shell/teacher、/shell/student 等)由 Next.js 优先匹配,
|
||||
// 不会进入此 catch-all;仅当用户直接访问 /shell 时重定向。
|
||||
if (!route || route.length === 0) {
|
||||
redirect(`/shell/${role}`);
|
||||
}
|
||||
|
||||
// 有路由段时:微内核仪表盘兜底(P2-P5 将逐步替换为显式路由页面)
|
||||
const configPromise: Promise<PluginConfigResponse> = fetchPluginConfig(
|
||||
userId,
|
||||
role,
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientShell configPromise={configPromise} role={role} userId={userId} />
|
||||
);
|
||||
}
|
||||
125
apps/portal-shell/src/app/shell/admin/page.tsx
Normal file
125
apps/portal-shell/src/app/shell/admin/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, Building2, GraduationCap, Users } from "lucide-react";
|
||||
|
||||
import { useAdminDashboard } from "@/lib/api";
|
||||
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
|
||||
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* 管理员仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2)
|
||||
*
|
||||
* 改接 data-ana 的 adminDashboard 真实聚合查询,替换原
|
||||
* announcements 假契约 widget 查询。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §10 P1-2
|
||||
*/
|
||||
export default function AdminDashboardPage(): React.ReactElement {
|
||||
const { data, loading, error } = useAdminDashboard();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell title="管理员仪表盘" description="全校概览">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<StatCard key={i} title="" value="" isLoading />
|
||||
))}
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<DashboardShell title="管理员仪表盘" description="全校概览">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
仪表盘数据加载失败,请稍后重试。
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="管理员仪表盘"
|
||||
description="全校概览"
|
||||
stats={
|
||||
<>
|
||||
<StatCard
|
||||
title="教师总数"
|
||||
value={data.total_teachers ?? "--"}
|
||||
icon={Users}
|
||||
/>
|
||||
<StatCard
|
||||
title="学生总数"
|
||||
value={data.total_students ?? "--"}
|
||||
icon={GraduationCap}
|
||||
/>
|
||||
<StatCard
|
||||
title="班级总数"
|
||||
value={data.total_classes ?? "--"}
|
||||
icon={Building2}
|
||||
/>
|
||||
<StatCard
|
||||
title="全校平均分"
|
||||
value={data.school_avg_score?.toFixed(1) ?? "--"}
|
||||
icon={Activity}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DashboardSection title="近期预警" variant="list">
|
||||
{data.recent_warnings ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{data.recent_warnings.target_name ?? "--"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{data.recent_warnings.severity ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
类型:{data.recent_warnings.warning_type ?? "--"} · 当前值{" "}
|
||||
{data.recent_warnings.current_value?.toFixed(1) ?? "--"} / 阈值{" "}
|
||||
{data.recent_warnings.threshold?.toFixed(1) ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无预警</p>
|
||||
)}
|
||||
</DashboardSection>
|
||||
|
||||
<DashboardSection title="AI 用量" variant="card">
|
||||
{data.ai_usage ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">总请求</span>
|
||||
<span className="font-medium">
|
||||
{data.ai_usage.total_requests ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">总 Token</span>
|
||||
<span className="font-medium">
|
||||
{data.ai_usage.total_tokens ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">总费用(分)</span>
|
||||
<span className="font-medium">
|
||||
{data.ai_usage.total_cost_cents ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无 AI 用量数据</p>
|
||||
)}
|
||||
</DashboardSection>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 详情页模板示例(ARCHITECTURE.md §10 P1-3)
|
||||
*
|
||||
* /shell/dev/templates/detail — 仅 dev 可见
|
||||
*/
|
||||
export default function DetailTemplatePage(): React.ReactElement {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title="2026 春季期末考试"
|
||||
description="创建于 2026-07-15 · 进行中"
|
||||
backHref="/shell/dev/templates"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline">编辑</Button>
|
||||
<Button variant="destructive">删除</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DetailSection title="基本信息" description="考试的元数据">
|
||||
<DetailField label="考试名称" value="2026 春季期末考试" />
|
||||
<DetailField label="科目" value="数学" />
|
||||
<DetailField label="年级" value="高一" />
|
||||
<DetailField label="满分" value={150} />
|
||||
<DetailField label="时长" value="120 分钟" />
|
||||
<DetailField label="状态" value="进行中" />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="参与班级"
|
||||
description="3 个班级共 120 名学生"
|
||||
actions={
|
||||
<Button variant="outline" size="sm">
|
||||
添加班级
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="divide-y">
|
||||
{[
|
||||
{ name: "高一(1)班", count: 40 },
|
||||
{ name: "高一(2)班", count: 42 },
|
||||
{ name: "高一(3)班", count: 38 },
|
||||
].map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex items-center justify-between py-2"
|
||||
>
|
||||
<span className="text-sm">{c.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{c.count} 人
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
69
apps/portal-shell/src/app/shell/dev/templates/form/page.tsx
Normal file
69
apps/portal-shell/src/app/shell/dev/templates/form/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 表单页模板示例(ARCHITECTURE.md §10 P1-3)
|
||||
*
|
||||
* /shell/dev/templates/form — 仅 dev 可见
|
||||
*
|
||||
* 演示:表单字段 + 提交/取消按钮 + 错误摘要
|
||||
*/
|
||||
export default function FormTemplatePage(): React.ReactElement {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title="新建考试"
|
||||
description="填写考试基本信息(表单页模板示例)"
|
||||
backHref="/shell/dev/templates"
|
||||
onSubmit={() => {
|
||||
// 示例:实际应调用 useExamCreate mutation
|
||||
}}
|
||||
submitting={false}
|
||||
submitLabel="保存"
|
||||
errorSummary={
|
||||
// 示例:实际从 form.formState.errors 读取
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<FormField label="考试名称" name="title" required />
|
||||
<FormField label="科目" name="subject" required />
|
||||
<FormField label="年级" name="grade" />
|
||||
<FormField label="满分" name="maxScore" type="number" />
|
||||
<FormField label="考试时长(分钟)" name="duration" type="number" />
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
name,
|
||||
required = false,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
type?: "text" | "number";
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="block text-xs uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
{required ? <span className="text-destructive"> *</span> : null}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-1 focus-visible:ring-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/portal-shell/src/app/shell/dev/templates/list/page.tsx
Normal file
103
apps/portal-shell/src/app/shell/dev/templates/list/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { ListPageShell } from "@/shared/components/page-templates";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
|
||||
/**
|
||||
* 列表页模板示例(ARCHITECTURE.md §10 P1-3)
|
||||
*
|
||||
* /shell/dev/templates/list — 仅 dev 可见
|
||||
*
|
||||
* 展示三态:loading / empty / success(通过 query 参数 ?state= 切换)
|
||||
* 默认显示 success 态的假数据表格。
|
||||
*/
|
||||
export default async function ListTemplatePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ state?: string }>;
|
||||
}): Promise<React.ReactElement> {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { state = "success" } = await searchParams;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title="列表页模板"
|
||||
description="PageHeader + FilterBar + DataTable + Pagination + 三态规范"
|
||||
actions={<Button>新建</Button>}
|
||||
filters={
|
||||
<FilterSearchInput placeholder="搜索..." value="" onChange={() => {}} />
|
||||
}
|
||||
loading={state === "loading"}
|
||||
empty={state === "empty"}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>共 42 条</span>
|
||||
<Button variant="outline" size="sm">
|
||||
上一页
|
||||
</Button>
|
||||
<span>第 1 / 5 页</span>
|
||||
<Button variant="outline" size="sm">
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ListTemplateTable />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ListTemplateTable(): React.ReactElement {
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
name: "考试 A",
|
||||
subject: "数学",
|
||||
status: "进行中",
|
||||
createdAt: "2026-07-20",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "考试 B",
|
||||
subject: "语文",
|
||||
status: "已结束",
|
||||
createdAt: "2026-07-18",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "考试 C",
|
||||
subject: "英语",
|
||||
status: "草稿",
|
||||
createdAt: "2026-07-22",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">名称</th>
|
||||
<th className="p-3 text-left font-medium">科目</th>
|
||||
<th className="p-3 text-left font-medium">状态</th>
|
||||
<th className="p-3 text-left font-medium">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">{r.name}</td>
|
||||
<td className="p-3">{r.subject}</td>
|
||||
<td className="p-3">{r.status}</td>
|
||||
<td className="p-3 font-mono">{r.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
apps/portal-shell/src/app/shell/dev/templates/page.tsx
Normal file
71
apps/portal-shell/src/app/shell/dev/templates/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { PageHeader } from "@/shared/components/ui/page-header";
|
||||
|
||||
/**
|
||||
* 模板预览首页(ARCHITECTURE.md §10 P1-3 + P1-4 next-intl)
|
||||
*
|
||||
* /shell/dev/templates — 仅 dev 可见
|
||||
*
|
||||
* 生产环境调用 notFound() 渲染 404,避免模板示例暴露到线上。
|
||||
* middleware 已放行 /shell/dev/**(仅校验登录),此文件做二次守卫。
|
||||
*
|
||||
* P1-4:文案走 next-intl getTranslations(Server Component),
|
||||
* 切换 NEXT_LOCALE cookie 后页面文案即时切换。
|
||||
*
|
||||
* 注:原 ARCHITECTURE.md 使用 `_dev` 命名,但 Next.js 将下划线开头的文件夹
|
||||
* 视为私有文件夹(不参与路由),故改用 `dev` 命名。
|
||||
*/
|
||||
export default async function TemplatesIndexPage(): Promise<React.ReactElement> {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslations("shell.dev.templates");
|
||||
|
||||
const templates = [
|
||||
{
|
||||
href: "/shell/dev/templates/list",
|
||||
title: t("list"),
|
||||
description: t("listDescription"),
|
||||
},
|
||||
{
|
||||
href: "/shell/dev/templates/detail",
|
||||
title: t("detail"),
|
||||
description: t("detailDescription"),
|
||||
},
|
||||
{
|
||||
href: "/shell/dev/templates/form",
|
||||
title: t("form"),
|
||||
description: t("formDescription"),
|
||||
},
|
||||
{
|
||||
href: "/shell/dev/templates/workbench",
|
||||
title: t("workbench"),
|
||||
description: t("workbenchDescription"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title={t("title")} description={t("description")} />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{templates.map((tpl) => (
|
||||
<Link
|
||||
key={tpl.href}
|
||||
href={tpl.href}
|
||||
className="block rounded-xl border bg-card p-6 transition-colors hover:bg-accent"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">{tpl.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{tpl.description}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{tpl.href}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
apps/portal-shell/src/app/shell/dev/templates/workbench/page.tsx
Normal file
131
apps/portal-shell/src/app/shell/dev/templates/workbench/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPanel,
|
||||
} from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 工作台页模板示例(ARCHITECTURE.md §10 P1-3)
|
||||
*
|
||||
* /shell/dev/templates/workbench — 仅 dev 可见
|
||||
*
|
||||
* 演示:三栏(左:大纲树 / 中:画布 / 右:属性面板)
|
||||
*/
|
||||
export default function WorkbenchTemplatePage(): React.ReactElement {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkbenchPageShell
|
||||
title="教案编辑器"
|
||||
description="工作台页模板示例(三栏布局)"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline">预览</Button>
|
||||
<Button>保存</Button>
|
||||
</>
|
||||
}
|
||||
left={
|
||||
<WorkbenchPanel
|
||||
title="章节大纲"
|
||||
actions={
|
||||
<Button variant="ghost" size="sm">
|
||||
+
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<OutlineTree />
|
||||
</WorkbenchPanel>
|
||||
}
|
||||
center={
|
||||
<WorkbenchPanel title="画布">
|
||||
<CanvasDemo />
|
||||
</WorkbenchPanel>
|
||||
}
|
||||
right={
|
||||
<WorkbenchPanel title="节点属性">
|
||||
<PropertiesPanel />
|
||||
</WorkbenchPanel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OutlineTree(): React.ReactElement {
|
||||
const nodes = [
|
||||
{ id: 1, title: "第一章 集合与函数", indent: 0 },
|
||||
{ id: 2, title: "1.1 集合的概念", indent: 1 },
|
||||
{ id: 3, title: "1.2 函数的定义", indent: 1 },
|
||||
{ id: 4, title: "第二章 三角函数", indent: 0 },
|
||||
{ id: 5, title: "2.1 任意角", indent: 1 },
|
||||
];
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{nodes.map((n) => (
|
||||
<li
|
||||
key={n.id}
|
||||
className="cursor-pointer rounded px-2 py-1 text-sm hover:bg-accent"
|
||||
style={{ paddingLeft: `${n.indent * 12 + 8}px` }}
|
||||
>
|
||||
{n.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasDemo(): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-dashed p-4">
|
||||
<h3 className="text-base font-medium">第一章 集合与函数</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
教学目标:理解集合的概念,掌握函数的定义与表示。
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed p-4">
|
||||
<h3 className="text-base font-medium">教学活动</h3>
|
||||
<ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">
|
||||
<li>导入:生活中的集合实例(5 分钟)</li>
|
||||
<li>新授:集合的表示方法(15 分钟)</li>
|
||||
<li>练习:教材 P5 例题(10 分钟)</li>
|
||||
<li>小结与作业(5 分钟)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertiesPanel(): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Field label="节点类型" value="章节" />
|
||||
<Field label="标题" value="第一章 集合与函数" />
|
||||
<Field label="预计时长" value="45 分钟" />
|
||||
<Field label="教学方式" value="讲授 + 互动" />
|
||||
<Field label="关联资源" value="教材 P1-P8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<div className="rounded-md border bg-background px-3 py-1.5 text-sm">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/portal-shell/src/app/shell/error.tsx
Normal file
30
apps/portal-shell/src/app/shell/error.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error-boundary";
|
||||
|
||||
/**
|
||||
* Shell 路由错误兜底(Next.js App Router error.tsx)
|
||||
*
|
||||
* 触发条件:
|
||||
* - RSC 渲染抛错(如 fetchPluginConfig 失败)
|
||||
* - ClientShell 渲染抛错(Provider 嵌套问题)
|
||||
* - 任何子 segment 未捕获的错误
|
||||
*
|
||||
* 职责(对齐 portal-shell README v2.0 §5.4 L1 路由级):
|
||||
* 1. 隔离错误,避免整页白屏
|
||||
* 2. 通过 useErrorReport 上报到 /api/log
|
||||
* 3. 提供 reset 按钮重试
|
||||
*
|
||||
* 注意:error.tsx 必须是 Client Component("use client")
|
||||
*
|
||||
* 关联:Next.js App Router § error.tsx、portal-shell README v2.0 §5.4
|
||||
*/
|
||||
export default function ShellError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactNode {
|
||||
return <RouteErrorBoundary error={error} reset={reset} namespace="Shell" />;
|
||||
}
|
||||
62
apps/portal-shell/src/app/shell/forbidden/page.tsx
Normal file
62
apps/portal-shell/src/app/shell/forbidden/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldX } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
/**
|
||||
* 403 Forbidden 页(P0-2,ARCHITECTURE.md §3.4 V3-A3 / §4.2 / §6.2)
|
||||
*
|
||||
* middleware 的 checkRoutePermission 拒绝时 302 重定向到此页。
|
||||
* 页面渲染:
|
||||
* - 友好提示 + 图标 + 返回仪表盘链接
|
||||
* - 不暴露内部权限配置细节(仅显示通用 403 文案)
|
||||
* - 支持查询参数 reason=no_config | missing_role | missing_permission(仅 UX 提示)
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A3、§6.2、§11.7 红线 #5
|
||||
*/
|
||||
export default async function ForbiddenPage(): Promise<React.ReactElement> {
|
||||
const headerList = await headers();
|
||||
const userId = headerList.get("x-user-id") ?? "unknown";
|
||||
const role = headerList.get("x-user-role") ?? "unknown";
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<ShieldX className="h-8 w-8" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold leading-tight">
|
||||
403 · 无权访问此页面
|
||||
</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
你的账号没有访问该页面的权限。如果认为这是错误,请联系管理员调整角色或权限。
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用户:<span className="font-mono">{userId}</span>
|
||||
{role !== "unknown" && (
|
||||
<>
|
||||
{" · "}角色:<span className="font-mono">{role}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<nav className="flex gap-3" aria-label="操作">
|
||||
<Link
|
||||
href="/shell"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
返回仪表盘
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
切换账号登录
|
||||
</Link>
|
||||
</nav>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
54
apps/portal-shell/src/app/shell/layout.tsx
Normal file
54
apps/portal-shell/src/app/shell/layout.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
import { ShellSidebar } from "@/shared/components/layout/shell-sidebar";
|
||||
import { SidebarProvider } from "@/shared/components/layout/sidebar-provider";
|
||||
import { SiteHeader } from "@/shared/components/layout/site-header";
|
||||
import { UserMenu } from "@/shared/components/layout/user-menu";
|
||||
|
||||
/**
|
||||
* AppFrame - 全站页面框架(RSC,ARCHITECTURE.md §7.2 / §10 P1-1)
|
||||
*
|
||||
* 结构:SidebarProvider > ShellSidebar + (SiteHeader + main)
|
||||
*
|
||||
* fail-closed(P0-2,§11.7 红线 #5):
|
||||
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role / x-user-permissions
|
||||
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx,禁止默认 teacher
|
||||
*
|
||||
* RSC 边界(P1-1 修正):
|
||||
* - 本 layout 是 RSC,仅向 Client Components 传可序列化数据(string)
|
||||
* - 导航项过滤 + icon 函数引用由 ShellSidebar(Client Component)持有
|
||||
* - 不直接传 lucide icon 或 hasPermission 函数给 Client Components
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §7.2、§10 P1-1、§11.7 红线 #5
|
||||
*/
|
||||
export default async function ShellLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): Promise<React.ReactElement> {
|
||||
const headerList = await headers();
|
||||
const userId = headerList.get("x-user-id");
|
||||
const roleHeader = headerList.get("x-user-role");
|
||||
const permsBitmap = headerList.get("x-user-permissions");
|
||||
|
||||
// fail-closed:middleware 必须注入身份头,缺失即异常
|
||||
if (!userId || !roleHeader || !permsBitmap) {
|
||||
throw new Error(
|
||||
"[portal-shell] ShellLayout missing identity headers " +
|
||||
"(middleware must inject x-user-id / x-user-role / x-user-permissions).",
|
||||
);
|
||||
}
|
||||
|
||||
const role = roleHeader as Role;
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<ShellSidebar role={role} permsBitmap={permsBitmap} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<SiteHeader actions={<UserMenu userId={userId} role={role} />} />
|
||||
<main className="flex-1 overflow-y-auto p-4">{children}</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
104
apps/portal-shell/src/app/shell/loading.tsx
Normal file
104
apps/portal-shell/src/app/shell/loading.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Shell 路由加载兜底(Next.js App Router loading.tsx)
|
||||
*
|
||||
* 触发条件:
|
||||
* - RSC 正在解析(fetchPluginConfig 等待中)
|
||||
* - 路由切换时的过渡态
|
||||
*
|
||||
* 职责:
|
||||
* - 整页骨架占位,避免白屏闪烁
|
||||
* - 与 Shell classic 布局结构对齐(顶栏 + 侧栏 + 主区)
|
||||
*
|
||||
* 流式渲染上下文(portal-shell README v2.0 §5.3):
|
||||
* - loading.tsx 在 RSC Promise resolve 之前显示
|
||||
* - 配合 PluginBoundary(widget 级 Suspense)形成多层流式体验
|
||||
*
|
||||
* 关联:Next.js App Router § loading.tsx、portal-shell README v2.0 §5.3
|
||||
*/
|
||||
export default function ShellLoading(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
{/* 顶栏 */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="flex h-16 items-center gap-3 px-6">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1">
|
||||
{/* 侧栏 */}
|
||||
<aside className="w-64 border-r bg-card p-4">
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 主区:仪表盘骨架 */}
|
||||
<main className="flex-1 p-6">
|
||||
{/* 标题区 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* 统计卡片网格 */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-xl border bg-card p-6">
|
||||
<Skeleton className="mb-3 h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区:图表 + 列表 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
|
||||
<Skeleton className="mb-4 h-6 w-32" />
|
||||
<div className="flex h-64 items-end gap-2">
|
||||
{[60, 80, 45, 90, 70, 55, 85, 75, 65, 95, 50, 88].map(
|
||||
(h, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="flex-1 rounded-t"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<Skeleton className="mb-4 h-6 w-24" />
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/portal-shell/src/app/shell/parent/page.tsx
Normal file
105
apps/portal-shell/src/app/shell/parent/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { GraduationCap, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useParentDashboard } from "@/lib/api";
|
||||
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
|
||||
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* 家长仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2)
|
||||
*
|
||||
* 改接 data-ana 的 parentDashboard 真实聚合查询。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §10 P1-2
|
||||
*/
|
||||
export default function ParentDashboardPage(): React.ReactElement {
|
||||
const { data, loading, error } = useParentDashboard();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell title="家长仪表盘" description="孩子学习概览">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<StatCard key={i} title="" value="" isLoading />
|
||||
))}
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<DashboardShell title="家长仪表盘" description="孩子学习概览">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
仪表盘数据加载失败,请稍后重试。
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="家长仪表盘"
|
||||
description="孩子学习概览"
|
||||
stats={
|
||||
<>
|
||||
<StatCard
|
||||
title="孩子平均分"
|
||||
value={data.child_avg_score?.toFixed(1) ?? "--"}
|
||||
icon={GraduationCap}
|
||||
/>
|
||||
<StatCard
|
||||
title="班级排名"
|
||||
value={`${data.child_class_rank ?? "--"} / ${data.total_class_students ?? "--"}`}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DashboardSection title="薄弱知识点" variant="list">
|
||||
{data.child_weak_points ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{data.child_weak_points.title ?? "--"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
掌握度 {data.child_weak_points.mastery?.toFixed(1) ?? "--"}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
错误次数:{data.child_weak_points.error_count ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无薄弱知识点数据</p>
|
||||
)}
|
||||
</DashboardSection>
|
||||
|
||||
<DashboardSection title="预警通知" variant="list">
|
||||
{data.child_warnings ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{data.child_warnings.target_name ?? "--"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{data.child_warnings.severity ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
类型:{data.child_warnings.warning_type ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无预警</p>
|
||||
)}
|
||||
</DashboardSection>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
107
apps/portal-shell/src/app/shell/student/page.tsx
Normal file
107
apps/portal-shell/src/app/shell/student/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, GraduationCap, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useStudentDashboard } from "@/lib/api";
|
||||
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
|
||||
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* 学生仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2)
|
||||
*
|
||||
* 改接 data-ana 的 studentDashboard 真实聚合查询,替换原
|
||||
* grades/homeworks/schedule/exams 假契约 widget 查询。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §10 P1-2
|
||||
*/
|
||||
export default function StudentDashboardPage(): React.ReactElement {
|
||||
const { data, loading, error } = useStudentDashboard();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell title="学生仪表盘" description="学习概览">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<StatCard key={i} title="" value="" isLoading />
|
||||
))}
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<DashboardShell title="学生仪表盘" description="学习概览">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
仪表盘数据加载失败,请稍后重试。
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="学生仪表盘"
|
||||
description="学习概览"
|
||||
stats={
|
||||
<>
|
||||
<StatCard
|
||||
title="平均分"
|
||||
value={data.avg_score?.toFixed(1) ?? "--"}
|
||||
icon={GraduationCap}
|
||||
/>
|
||||
<StatCard
|
||||
title="班级排名"
|
||||
value={`${data.class_rank ?? "--"} / ${data.total_students ?? "--"}`}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
<StatCard
|
||||
title="待交作业"
|
||||
value={data.pending_homework ?? 0}
|
||||
icon={BookOpen}
|
||||
highlight={(data.pending_homework ?? 0) > 0}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DashboardSection title="薄弱知识点" variant="list">
|
||||
{data.weak_points ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{data.weak_points.title ?? "--"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
掌握度 {data.weak_points.mastery?.toFixed(1) ?? "--"}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
错误次数:{data.weak_points.error_count ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无薄弱知识点数据</p>
|
||||
)}
|
||||
</DashboardSection>
|
||||
|
||||
<DashboardSection title="近期成绩趋势" variant="chart">
|
||||
{data.recent_trends ? (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{data.recent_trends.date ?? "--"}:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{data.recent_trends.score?.toFixed(1) ?? "--"} 分
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无趋势数据</p>
|
||||
)}
|
||||
</DashboardSection>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ExamAnalyticsClient } from "@/features/teacher/exams/exam-analytics-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 考试分析页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 ExamAnalyticsClient(client component)中。
|
||||
*
|
||||
* 数据契约:混合契约
|
||||
* - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪)
|
||||
* - 扩展字段(排名/每题正确率)❌ → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function ExamAnalyticsPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ExamAnalyticsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ExamBuildClient } from "@/features/teacher/exams/exam-build-client";
|
||||
import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 组卷工作台页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 ExamBuildClient(client component)中。
|
||||
*
|
||||
* 数据契约:❌ @contract-pending
|
||||
* - examBuild(examId) 根字段不存在 → MSW 兜底
|
||||
* - questionsLibrary(filter) 根字段不存在 → MSW 兜底
|
||||
* - saveExamBuild(input) mutation 不存在 → MSW 兜底
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function ExamBuildPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<WorkbenchPageSkeleton />}>
|
||||
<ExamBuildClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ExamEditClient } from "@/features/teacher/exams/exam-edit-client";
|
||||
import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 富文本试卷编辑页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 ExamEditClient(client component)中。
|
||||
*
|
||||
* 数据契约:❌ @contract-pending
|
||||
* - examRichEditor(examId) 根字段不存在 → MSW 兜底
|
||||
* - saveExamRichContent(input) mutation 不存在 → MSW 兜底
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function ExamEditPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<WorkbenchPageSkeleton />}>
|
||||
<ExamEditClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
22
apps/portal-shell/src/app/shell/teacher/exams/[id]/page.tsx
Normal file
22
apps/portal-shell/src/app/shell/teacher/exams/[id]/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ExamDetailClient } from "@/features/teacher/exams/exam-detail-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 考试详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 ExamDetailClient(client component)中。
|
||||
*
|
||||
* 数据契约:单查 exam(id: ID!) ✅ schema 真实字段(core-edu 子图)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3
|
||||
*/
|
||||
export default function ExamDetailPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ExamDetailClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
33
apps/portal-shell/src/app/shell/teacher/exams/error.tsx
Normal file
33
apps/portal-shell/src/app/shell/teacher/exams/error.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考试路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
export default function ExamsError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] exams route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||
<h2 className="text-lg font-semibold text-destructive">考试页面出错了</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || "未知错误"}
|
||||
</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 考试列表页加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*/
|
||||
export default function ExamsLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/exams/new/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/exams/new/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { NewExamClient } from "@/features/teacher/exams/new-exam-client";
|
||||
import { FormPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 新建考试表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 NewExamClient(client component)中。
|
||||
*
|
||||
* 数据契约:mutation createExam(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-exam-mutation
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function NewExamPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<FormPageSkeleton />}>
|
||||
<NewExamClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/exams/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/exams/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ExamsListClient } from "@/features/teacher/exams/exams-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 考试管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 ExamsListClient(client component)中。
|
||||
*
|
||||
* 数据契约:列表查询 exams(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#exams-list
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function ExamsListPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<ExamsListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { GradeAnalyticsClient } from "@/features/teacher/grades/grade-analytics-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩分析页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 GradeAnalyticsClient(client component)中。
|
||||
*
|
||||
* 数据契约:混合契约
|
||||
* - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪)
|
||||
* - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function GradeAnalyticsPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<GradeAnalyticsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { GradeEntryClient } from "@/features/teacher/grades/grade-entry-client";
|
||||
import { FormPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩录入表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 GradeEntryClient(client component)中。
|
||||
*
|
||||
* 数据契约:mutation createGrade(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-grade-mutation
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function GradeEntryPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<FormPageSkeleton />}>
|
||||
<GradeEntryClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/app/shell/teacher/grades/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/grades/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
export default function GradesError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] grades route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
{t("error.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || t("error.unknown")}
|
||||
</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/portal-shell/src/app/shell/teacher/grades/loading.tsx
Normal file
12
apps/portal-shell/src/app/shell/teacher/grades/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*
|
||||
* 子页面(录入/分析/统计/成绩单)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
|
||||
* 本文件仅在 /shell/teacher/grades 列表/重定向期间显示。
|
||||
*/
|
||||
export default function GradesLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/grades/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/grades/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { GradesListClient } from "@/features/teacher/grades/grades-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 GradesListClient(client component)中。
|
||||
*
|
||||
* 数据契约:列表查询 grades(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grades-list
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function GradesListPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<GradesListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ReportCardClient } from "@/features/teacher/grades/report-card-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 学生成绩单页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 ReportCardClient(client component)中。
|
||||
*
|
||||
* 数据契约:reportCard(studentId, period) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#report-card
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function ReportCardPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<ReportCardClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { GradeStatsClient } from "@/features/teacher/grades/grade-stats-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 成绩统计页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 GradeStatsClient(client component)中。
|
||||
*
|
||||
* 数据契约:gradeStats(classId, period) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grade-stats
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function GradeStatsPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<GradeStatsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { HomeworkDetailClient } from "@/features/teacher/homework/homework-detail-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 作业详情页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 HomeworkDetailClient(client component)中。
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 homework(id: ID!) ✅ schema 真实字段(core-edu 子图)
|
||||
* - 提交列表 homeworkSubmissions(homeworkId) ❌ schema 无此字段 → MSW 兜底
|
||||
* - mutation recordGrade(input) ❌ schema 无 Mutation → MSW 兜底
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function HomeworkDetailPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<HomeworkDetailClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { AssignmentSubmissionsClient } from "@/features/teacher/homework/assignment-submissions-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 按作业批量批改页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 AssignmentSubmissionsClient(client component)中。
|
||||
*
|
||||
* 数据契约:
|
||||
* - 查询 assignmentSubmissions(homeworkId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 查询 aiBatchGrading(homeworkId) ❌ schema 无此字段 → MSW 兜底
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function AssignmentSubmissionsPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton rows={6} />}>
|
||||
<AssignmentSubmissionsClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/app/shell/teacher/homework/error.tsx
Normal file
38
apps/portal-shell/src/app/shell/teacher/homework/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业路由错误边界(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment error.tsx,捕获子树未处理异常。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function HomeworkError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[portal-shell] homework route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-destructive/30 bg-destructive/5 p-10">
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
{t("error.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || t("error.unknown")}
|
||||
</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/portal-shell/src/app/shell/teacher/homework/loading.tsx
Normal file
12
apps/portal-shell/src/app/shell/teacher/homework/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 作业路由段加载骨架(ARCHITECTURE.md §7.4 三态规范 / §11.3 DoD)。
|
||||
* Next.js Route Segment loading.tsx,自动包裹页面渲染期间。
|
||||
*
|
||||
* 子页面(详情/批改/扫描批改)的 Skeleton 由各自 server page 的 <Suspense> 兜底,
|
||||
* 本文件仅在 /shell/teacher/homework 列表/重定向期间显示。
|
||||
*/
|
||||
export default function HomeworkLoading(): React.ReactElement {
|
||||
return <ListPageSkeleton rows={5} />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { NewHomeworkClient } from "@/features/teacher/homework/new-homework-client";
|
||||
import { FormPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 布置作业表单页(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 NewHomeworkClient(client component)中。
|
||||
*
|
||||
* 数据契约:mutation assignHomework(input) ❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#assign-homework-mutation
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function NewHomeworkPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<FormPageSkeleton />}>
|
||||
<NewHomeworkClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
23
apps/portal-shell/src/app/shell/teacher/homework/page.tsx
Normal file
23
apps/portal-shell/src/app/shell/teacher/homework/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { HomeworkListClient } from "@/features/teacher/homework/homework-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 作业管理列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 HomeworkListClient(client component)中。
|
||||
*
|
||||
* 数据契约:列表查询 homeworks(classId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#homework-list
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function HomeworkListPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton rows={5} />}>
|
||||
<HomeworkListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SubmissionGradingClient } from "@/features/teacher/homework/submission-grading-client";
|
||||
import { DetailPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 单份提交批改页(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 SubmissionGradingClient(client component)中。
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 submissionDetail(submissionId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation gradeSubmission(input) ❌ schema 无 Mutation → MSW 兜底
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function SubmissionGradingPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<DetailPageSkeleton />}>
|
||||
<SubmissionGradingClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ScanGradingClient } from "@/features/teacher/homework/scan-grading-client";
|
||||
import { WorkbenchPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 扫描批改页(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹。
|
||||
* 业务逻辑在 ScanGradingClient(client component)中。
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 submissionDetail(submissionId) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation saveScanGrading(input) ❌ schema 无 Mutation → MSW 兜底
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function ScanGradingPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<WorkbenchPageSkeleton />}>
|
||||
<ScanGradingClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SubmissionsListClient } from "@/features/teacher/homework/submissions-list-client";
|
||||
import { ListPageSkeleton } from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 提交批改列表页(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。
|
||||
* 业务逻辑在 SubmissionsListClient(client component)中。
|
||||
*
|
||||
* 数据契约:列表查询 homeworkSubmissions(filter) ❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#homework-submissions
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
export default function HomeworkSubmissionsPage(): React.ReactElement {
|
||||
return (
|
||||
<Suspense fallback={<ListPageSkeleton rows={8} />}>
|
||||
<SubmissionsListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
117
apps/portal-shell/src/app/shell/teacher/page.tsx
Normal file
117
apps/portal-shell/src/app/shell/teacher/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, BookOpen, GraduationCap, Users } from "lucide-react";
|
||||
|
||||
import { useTeacherDashboard } from "@/lib/api";
|
||||
import { DashboardShell } from "@/shared/components/dashboard/dashboard-shell";
|
||||
import { DashboardSection } from "@/shared/components/dashboard/dashboard-section";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
|
||||
/**
|
||||
* 教师仪表盘(ARCHITECTURE.md §7.1 / §10 P1-2)
|
||||
*
|
||||
* 改接 data-ana 的 teacherDashboard 真实聚合查询,替换原
|
||||
* grades/homeworks/schedule/attendance/exams 假契约 widget 查询。
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §10 P1-2
|
||||
*/
|
||||
export default function TeacherDashboardPage(): React.ReactElement {
|
||||
const { data, loading, error } = useTeacherDashboard();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardShell title="教师仪表盘" description="今日教学概览">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<StatCard key={i} title="" value="" isLoading />
|
||||
))}
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<DashboardShell title="教师仪表盘" description="今日教学概览">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
仪表盘数据加载失败,请稍后重试。
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
title="教师仪表盘"
|
||||
description="今日教学概览"
|
||||
stats={
|
||||
<>
|
||||
<StatCard
|
||||
title="班级总数"
|
||||
value={data.total_classes ?? "--"}
|
||||
icon={Users}
|
||||
/>
|
||||
<StatCard
|
||||
title="学生总数"
|
||||
value={data.total_students ?? "--"}
|
||||
icon={GraduationCap}
|
||||
/>
|
||||
<StatCard
|
||||
title="班级平均分"
|
||||
value={data.class_avg_score?.toFixed(1) ?? "--"}
|
||||
icon={Activity}
|
||||
/>
|
||||
<StatCard
|
||||
title="待批作业"
|
||||
value={data.pending_homework_count ?? 0}
|
||||
icon={BookOpen}
|
||||
highlight={(data.pending_homework_count ?? 0) > 0}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DashboardSection title="班级概况" variant="card">
|
||||
{data.classes ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
{data.classes.class_name ?? "--"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{data.classes.student_count ?? 0} 人 · 均分{" "}
|
||||
{data.classes.average_score?.toFixed(1) ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无班级数据</p>
|
||||
)}
|
||||
</DashboardSection>
|
||||
|
||||
<DashboardSection title="近期预警" variant="list">
|
||||
{data.recent_warnings ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{data.recent_warnings.target_name ?? "--"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{data.recent_warnings.severity ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
类型:{data.recent_warnings.warning_type ?? "--"} · 当前值{" "}
|
||||
{data.recent_warnings.current_value?.toFixed(1) ?? "--"} / 阈值{" "}
|
||||
{data.recent_warnings.threshold?.toFixed(1) ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无预警</p>
|
||||
)}
|
||||
</DashboardSection>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Exams 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Exam } from "@/lib/api";
|
||||
|
||||
import {
|
||||
EXAM_STATUS_LABEL,
|
||||
countByType,
|
||||
formatDuration,
|
||||
formatExamDate,
|
||||
formatExamStatus,
|
||||
formatPassRate,
|
||||
formatPercent,
|
||||
formatScore,
|
||||
isExamEditable,
|
||||
isExamPublished,
|
||||
levelToColorClass,
|
||||
nextSortOrder,
|
||||
parseTotalScore,
|
||||
rateToColorClass,
|
||||
rateToLevel,
|
||||
sortBySortOrder,
|
||||
sumSelectedScores,
|
||||
toExamListItem,
|
||||
} from "../transformations";
|
||||
|
||||
describe("formatExamStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatExamStatus("DRAFT")).toBe("草稿");
|
||||
expect(formatExamStatus("PUBLISHED")).toBe("已发布");
|
||||
expect(formatExamStatus("IN_PROGRESS")).toBe("进行中");
|
||||
expect(formatExamStatus("GRADING")).toBe("批改中");
|
||||
expect(formatExamStatus("SCORED")).toBe("已完成");
|
||||
expect(formatExamStatus("ARCHIVED")).toBe("已归档");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatExamStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
expect(formatExamStatus("")).toBe("");
|
||||
});
|
||||
|
||||
it("EXAM_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(EXAM_STATUS_LABEL)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExamDate", () => {
|
||||
it("formats valid ISO date string", () => {
|
||||
const result = formatExamDate("2026-07-22T10:00:00Z");
|
||||
// 不同时区下日期字符串会有差异,但应包含 2026 与 07
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatExamDate(null)).toBe("--");
|
||||
expect(formatExamDate(undefined)).toBe("--");
|
||||
expect(formatExamDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatExamDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTotalScore", () => {
|
||||
it("parses numeric string", () => {
|
||||
expect(parseTotalScore("100")).toBe(100);
|
||||
expect(parseTotalScore("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("passes through number input", () => {
|
||||
expect(parseTotalScore(150)).toBe(150);
|
||||
});
|
||||
|
||||
it("returns 0 for non-numeric string", () => {
|
||||
expect(parseTotalScore("abc")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for NaN", () => {
|
||||
expect(parseTotalScore(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExamEditable / isExamPublished", () => {
|
||||
it("DRAFT is editable but not published", () => {
|
||||
expect(isExamEditable("DRAFT")).toBe(true);
|
||||
expect(isExamPublished("DRAFT")).toBe(false);
|
||||
});
|
||||
|
||||
it("PUBLISHED and later statuses are published but not editable", () => {
|
||||
for (const s of [
|
||||
"PUBLISHED",
|
||||
"IN_PROGRESS",
|
||||
"GRADING",
|
||||
"SCORED",
|
||||
"ARCHIVED",
|
||||
]) {
|
||||
expect(isExamEditable(s)).toBe(false);
|
||||
expect(isExamPublished(s)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("unknown status is neither editable nor published", () => {
|
||||
expect(isExamEditable("UNKNOWN")).toBe(false);
|
||||
expect(isExamPublished("UNKNOWN")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toExamListItem", () => {
|
||||
it("extracts list fields from full exam", () => {
|
||||
const exam: Exam = {
|
||||
id: "exam-001",
|
||||
classId: "cls-001",
|
||||
subjectId: "sub-math",
|
||||
title: "期中考试",
|
||||
description: "包含集合与函数",
|
||||
examDate: "2026-07-22T10:00:00Z",
|
||||
duration: 120,
|
||||
totalScore: "100",
|
||||
status: "DRAFT",
|
||||
statusChangedAt: "2026-07-20T00:00:00Z",
|
||||
statusChangedBy: "usr-001",
|
||||
schoolId: "sch-001",
|
||||
createdBy: "usr-001",
|
||||
createdAt: "2026-07-19T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
|
||||
const item = toExamListItem(exam);
|
||||
expect(item.id).toBe("exam-001");
|
||||
expect(item.title).toBe("期中考试");
|
||||
expect(item.status).toBe("DRAFT");
|
||||
// 裁剪掉的字段不在列表项类型上
|
||||
expect(item).not.toHaveProperty("statusChangedAt");
|
||||
expect(item).not.toHaveProperty("schoolId");
|
||||
expect(item).not.toHaveProperty("createdBy");
|
||||
});
|
||||
|
||||
it("handles null description", () => {
|
||||
const exam: Exam = {
|
||||
id: "exam-002",
|
||||
classId: "cls-001",
|
||||
subjectId: "sub-math",
|
||||
title: "无描述考试",
|
||||
description: null,
|
||||
examDate: "2026-07-22T10:00:00Z",
|
||||
duration: 60,
|
||||
totalScore: "50",
|
||||
status: "PUBLISHED",
|
||||
statusChangedAt: "2026-07-20T00:00:00Z",
|
||||
statusChangedBy: null,
|
||||
schoolId: "sch-001",
|
||||
createdBy: "usr-001",
|
||||
createdAt: "2026-07-19T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
const item = toExamListItem(exam);
|
||||
expect(item.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("formats minutes under 60", () => {
|
||||
expect(formatDuration(30)).toBe("30 分钟");
|
||||
expect(formatDuration(45)).toBe("45 分钟");
|
||||
expect(formatDuration(1)).toBe("1 分钟");
|
||||
});
|
||||
|
||||
it("formats exact hours", () => {
|
||||
expect(formatDuration(60)).toBe("1 小时");
|
||||
expect(formatDuration(120)).toBe("2 小时");
|
||||
});
|
||||
|
||||
it("formats hours with remainder minutes", () => {
|
||||
expect(formatDuration(90)).toBe("1 小时 30 分钟");
|
||||
expect(formatDuration(75)).toBe("1 小时 15 分钟");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid input", () => {
|
||||
expect(formatDuration(0)).toBe("--");
|
||||
expect(formatDuration(-1)).toBe("--");
|
||||
expect(formatDuration(Number.NaN)).toBe("--");
|
||||
expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Analytics 纯函数单测 ──────────────────────────────────────
|
||||
|
||||
describe("formatScore", () => {
|
||||
it("formats finite numbers with 1 decimal place", () => {
|
||||
expect(formatScore(82.5)).toBe("82.5");
|
||||
expect(formatScore(98)).toBe("98.0");
|
||||
expect(formatScore(0)).toBe("0.0");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite input", () => {
|
||||
expect(formatScore(Number.NaN)).toBe("--");
|
||||
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPassRate / formatPercent", () => {
|
||||
it("formats rate in [0,1] as percentage", () => {
|
||||
expect(formatPassRate(0.86)).toBe("86%");
|
||||
expect(formatPassRate(0)).toBe("0%");
|
||||
expect(formatPassRate(1)).toBe("100%");
|
||||
expect(formatPercent(0.92)).toBe("92%");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range or non-finite input", () => {
|
||||
expect(formatPassRate(-0.1)).toBe("--");
|
||||
expect(formatPassRate(1.1)).toBe("--");
|
||||
expect(formatPassRate(Number.NaN)).toBe("--");
|
||||
expect(formatPercent(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateToLevel", () => {
|
||||
it("maps rate to A/B/C/D levels", () => {
|
||||
expect(rateToLevel(0.9)).toBe("A");
|
||||
expect(rateToLevel(0.85)).toBe("A");
|
||||
expect(rateToLevel(0.75)).toBe("B");
|
||||
expect(rateToLevel(0.7)).toBe("B");
|
||||
expect(rateToLevel(0.65)).toBe("C");
|
||||
expect(rateToLevel(0.6)).toBe("C");
|
||||
expect(rateToLevel(0.5)).toBe("D");
|
||||
expect(rateToLevel(0)).toBe("D");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite input", () => {
|
||||
expect(rateToLevel(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateToColorClass", () => {
|
||||
it("returns emerald for high rates", () => {
|
||||
expect(rateToColorClass(0.8)).toBe("text-emerald-600");
|
||||
expect(rateToColorClass(0.95)).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns amber for medium rates", () => {
|
||||
expect(rateToColorClass(0.6)).toBe("text-amber-600");
|
||||
expect(rateToColorClass(0.79)).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns destructive for low rates", () => {
|
||||
expect(rateToColorClass(0.59)).toBe("text-destructive");
|
||||
expect(rateToColorClass(0)).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for non-finite input", () => {
|
||||
expect(rateToColorClass(Number.NaN)).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("levelToColorClass", () => {
|
||||
it("maps each level to correct color class", () => {
|
||||
expect(levelToColorClass("A")).toBe("text-emerald-600");
|
||||
expect(levelToColorClass("B")).toBe("text-blue-600");
|
||||
expect(levelToColorClass("C")).toBe("text-amber-600");
|
||||
expect(levelToColorClass("D")).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for unknown level", () => {
|
||||
expect(levelToColorClass("X")).toBe("text-muted-foreground");
|
||||
expect(levelToColorClass("")).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Build 纯函数单测 ──────────────────────────────────────────
|
||||
|
||||
describe("sumSelectedScores", () => {
|
||||
it("sums all scores", () => {
|
||||
expect(
|
||||
sumSelectedScores([{ score: 10 }, { score: 15 }, { score: 20 }]),
|
||||
).toBe(45);
|
||||
});
|
||||
|
||||
it("returns 0 for empty array", () => {
|
||||
expect(sumSelectedScores([])).toBe(0);
|
||||
});
|
||||
|
||||
it("handles single item", () => {
|
||||
expect(sumSelectedScores([{ score: 5 }])).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countByType", () => {
|
||||
it("counts items per type", () => {
|
||||
const items = [
|
||||
{ type: "single_choice" },
|
||||
{ type: "single_choice" },
|
||||
{ type: "multiple_choice" },
|
||||
{ type: "fill_blank" },
|
||||
];
|
||||
const result = countByType(items);
|
||||
expect(result.single_choice).toBe(2);
|
||||
expect(result.multiple_choice).toBe(1);
|
||||
expect(result.fill_blank).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty object for empty array", () => {
|
||||
expect(countByType([])).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortBySortOrder", () => {
|
||||
it("sorts ascending by sortOrder", () => {
|
||||
const items = [
|
||||
{ sortOrder: 3, id: "c" },
|
||||
{ sortOrder: 1, id: "a" },
|
||||
{ sortOrder: 2, id: "b" },
|
||||
];
|
||||
const result = sortBySortOrder(items);
|
||||
expect(result.map((i) => i.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("does not mutate the original array", () => {
|
||||
const items = [
|
||||
{ sortOrder: 2, id: "b" },
|
||||
{ sortOrder: 1, id: "a" },
|
||||
];
|
||||
const original = [...items];
|
||||
sortBySortOrder(items);
|
||||
expect(items).toEqual(original);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(sortBySortOrder([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextSortOrder", () => {
|
||||
it("returns 1 for empty array", () => {
|
||||
expect(nextSortOrder([])).toBe(1);
|
||||
});
|
||||
|
||||
it("returns max + 1 for non-empty array", () => {
|
||||
expect(
|
||||
nextSortOrder([{ sortOrder: 1 }, { sortOrder: 3 }, { sortOrder: 2 }]),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it("handles single item", () => {
|
||||
expect(nextSortOrder([{ sortOrder: 5 }])).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考试分析页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(混合契约):
|
||||
* - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪)
|
||||
* - 扩展字段(排名/每题正确率/分布)❌ → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BarChart3, FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useExamAnalytics } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatPassRate,
|
||||
formatPercent,
|
||||
formatScore,
|
||||
} from "@/features/teacher/exams/transformations";
|
||||
|
||||
/**
|
||||
* 分析客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ExamAnalyticsClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
// 混合契约:基础统计真实 + 扩展字段 MSW
|
||||
const { data, loading, error } = useExamAnalytics(examId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.examTitle ?? t("analytics.title")}
|
||||
description={
|
||||
data ? t("analytics.subtitle", { examId: data.examId }) : undefined
|
||||
}
|
||||
icon={<BarChart3 className="size-6" />}
|
||||
backHref={`/shell/teacher/exams/${examId}`}
|
||||
actions={
|
||||
<Button variant="outline" type="button">
|
||||
<FileText className="mr-1 size-4" />
|
||||
{t("analytics.exportCsv")}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("analytics.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <ExamAnalyticsBody data={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析内容区(汇总卡片 + 分布图 + 每题正确率 + 学生排名)。
|
||||
*/
|
||||
function ExamAnalyticsBody({
|
||||
data,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useExamAnalytics>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("analytics.sectionSummary")}>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryExpected")}
|
||||
value={String(data.summary.expectedCount)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryAttended")}
|
||||
value={String(data.summary.attendedCount)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryAvg")}
|
||||
value={formatScore(data.summary.avgScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryMax")}
|
||||
value={formatScore(data.summary.maxScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryMin")}
|
||||
value={formatScore(data.summary.minScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryPassRate")}
|
||||
value={formatPassRate(data.summary.passRate)}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionDistribution")}>
|
||||
<DistributionChart items={data.distribution} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionQuestionAccuracy")}>
|
||||
<QuestionAccuracyTable items={data.questionAccuracy} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionRankings")}>
|
||||
<RankingsTable items={data.rankings} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总卡片(单字段)。
|
||||
*/
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分数段分布柱状图(纯 SVG,无外部图表库依赖)。
|
||||
*/
|
||||
function DistributionChart({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ label: string; count: number }>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyDistribution")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const maxCount = Math.max(...items.map((i) => i.count), 1);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="w-16 shrink-0 text-sm text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="h-6 flex-1 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 shrink-0 text-right text-sm">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每题正确率表格。
|
||||
*/
|
||||
function QuestionAccuracyTable({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{
|
||||
questionId: string;
|
||||
order: number;
|
||||
questionTitle: string;
|
||||
correctRate: number;
|
||||
avgScore: number;
|
||||
maxScore: number;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyQuestionAccuracy")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colOrder")}</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colQuestionTitle")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colCorrectRate")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colAvgScore")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colMaxScore")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.questionId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">{item.order}</td>
|
||||
<td className="py-2 pr-4">{item.questionTitle}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
item.correctRate >= 0.8
|
||||
? "text-emerald-600"
|
||||
: item.correctRate >= 0.6
|
||||
? "text-amber-600"
|
||||
: "text-destructive"
|
||||
}
|
||||
>
|
||||
{formatPercent(item.correctRate)}
|
||||
</span>
|
||||
<span className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
className="block h-full bg-current"
|
||||
style={{ width: `${item.correctRate * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">{formatScore(item.avgScore)}</td>
|
||||
<td className="py-2 pr-4">{formatScore(item.maxScore)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生排名表格。
|
||||
*/
|
||||
function RankingsTable({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{
|
||||
studentId: string;
|
||||
studentNo: string;
|
||||
studentName: string;
|
||||
totalScore: number;
|
||||
rank: number;
|
||||
level: string;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyRankings")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colRank")}</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colStudentNo")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colStudentName")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colTotalScore")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colLevel")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.studentId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-medium">{item.rank}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{item.studentNo}</td>
|
||||
<td className="py-2 pr-4">{item.studentName}</td>
|
||||
<td className="py-2 pr-4">{formatScore(item.totalScore)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span
|
||||
className={
|
||||
item.level === "A"
|
||||
? "text-emerald-600"
|
||||
: item.level === "B"
|
||||
? "text-blue-600"
|
||||
: item.level === "C"
|
||||
? "text-amber-600"
|
||||
: "text-destructive"
|
||||
}
|
||||
>
|
||||
{item.level}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 组卷工作台页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - examBuild(examId) ❌ → MSW 兜底
|
||||
* - questionsLibrary(filter) ❌ → MSW 兜底
|
||||
* - saveExamBuild(input) mutation ❌ → MSW 兜底
|
||||
*
|
||||
* 三栏布局(WorkbenchPageShell):
|
||||
* - left:题库候选列表(带 q/type/difficulty 筛选)
|
||||
* - center:已选题目列表(可上移/下移/编辑分值/移除)
|
||||
* - right:预览(总分、题型分布、及格分提示)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:WorkbenchPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { PencilLine, Plus, Trash2 } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
useExamBuild,
|
||||
useQuestionsLibrary,
|
||||
useSaveExamBuild,
|
||||
type ExamBuildNode,
|
||||
type QuestionsLibraryFilter,
|
||||
} from "@/lib/api";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPageSkeleton,
|
||||
WorkbenchPanel,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
countByType,
|
||||
nextSortOrder,
|
||||
sortBySortOrder,
|
||||
sumSelectedScores,
|
||||
} from "@/features/teacher/exams/transformations";
|
||||
|
||||
/**
|
||||
* 组卷工作台客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ExamBuildClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
// 已选题目(本地状态,从 useExamBuild 初始化)
|
||||
const { data, loading, error } = useExamBuild(examId);
|
||||
const [selected, setSelected] = useState<ExamBuildNode[] | null>(null);
|
||||
|
||||
// 首次拿到数据时初始化本地状态(useEffect 处理副作用,不用 useMemo)
|
||||
useEffect(() => {
|
||||
if (data && selected === null) {
|
||||
setSelected(sortBySortOrder(data.selected));
|
||||
}
|
||||
}, [data, selected]);
|
||||
|
||||
const saveMutation = useSaveExamBuild();
|
||||
|
||||
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
|
||||
// (工作台页"无数据"等价于"无法工作",合并表达更直接)
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : !loading && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("build.notFound")}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await saveMutation.run({
|
||||
examId,
|
||||
questions: selected.map((q) => ({
|
||||
questionId: q.questionId,
|
||||
score: q.score,
|
||||
sortOrder: q.sortOrder,
|
||||
})),
|
||||
});
|
||||
notify.success(t("build.saveSuccess"));
|
||||
} catch (err) {
|
||||
notify.error(t("build.saveFailed", { message: String(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkbenchPageShell
|
||||
title={data?.title ?? t("build.title")}
|
||||
description={
|
||||
data
|
||||
? t("build.subtitle", {
|
||||
total: sumSelectedScores(selected ?? data.selected),
|
||||
pass: data.passScore,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<PencilLine className="size-6" />}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saveMutation.loading || !selected}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveMutation.loading ? tCommon("status.loading") : t("build.save")}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<WorkbenchPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
left={
|
||||
data && selected ? (
|
||||
<QuestionsLibraryPanel
|
||||
onAdd={(item) => {
|
||||
if (selected.some((s) => s.questionId === item.questionId)) {
|
||||
notify.warning(t("build.alreadyAdded"));
|
||||
return;
|
||||
}
|
||||
const newNode: ExamBuildNode = {
|
||||
questionId: item.questionId,
|
||||
score: item.score,
|
||||
sortOrder: nextSortOrder(selected),
|
||||
content: item.content,
|
||||
type: item.type,
|
||||
difficulty: item.difficulty,
|
||||
};
|
||||
setSelected([...selected, newNode]);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
center={
|
||||
data && selected ? (
|
||||
<SelectedQuestionsPanel selected={selected} onChange={setSelected} />
|
||||
) : null
|
||||
}
|
||||
right={
|
||||
data && selected ? (
|
||||
<PreviewPanel
|
||||
selected={selected}
|
||||
totalScoreBaseline={data.totalScore}
|
||||
passScore={data.passScore}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左栏:题库候选列表(带筛选)。
|
||||
*/
|
||||
function QuestionsLibraryPanel({
|
||||
onAdd,
|
||||
}: {
|
||||
onAdd: (item: {
|
||||
questionId: string;
|
||||
content: string;
|
||||
type: string;
|
||||
difficulty: string;
|
||||
score: number;
|
||||
textbookName: string | null;
|
||||
}) => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const [filter, setFilter] = useState<QuestionsLibraryFilter>({
|
||||
q: "",
|
||||
type: "",
|
||||
difficulty: "",
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data, loading, error } = useQuestionsLibrary(filter);
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<WorkbenchPanel title={t("build.libraryTitle")}>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("build.searchPlaceholder")}
|
||||
value={filter.q ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({ ...prev, q: e.target.value, page: 1 }))
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm"
|
||||
value={filter.type ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
type: e.target.value || null,
|
||||
page: 1,
|
||||
}))
|
||||
}
|
||||
aria-label={t("build.filterType")}
|
||||
>
|
||||
<option value="">{t("build.allTypes")}</option>
|
||||
<option value="single_choice">{t("build.typeSingle")}</option>
|
||||
<option value="multiple_choice">{t("build.typeMultiple")}</option>
|
||||
<option value="fill_blank">{t("build.typeFill")}</option>
|
||||
<option value="essay">{t("build.typeEssay")}</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm"
|
||||
value={filter.difficulty ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
difficulty: e.target.value || null,
|
||||
page: 1,
|
||||
}))
|
||||
}
|
||||
aria-label={t("build.filterDifficulty")}
|
||||
>
|
||||
<option value="">{t("build.allDifficulties")}</option>
|
||||
<option value="easy">{t("build.diffEasy")}</option>
|
||||
<option value="medium">{t("build.diffMedium")}</option>
|
||||
<option value="hard">{t("build.diffHard")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("build.loadingLibrary")}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{t("build.loadLibraryFailed")}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("build.libraryEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.questionId}
|
||||
className="rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm">{item.content}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="mr-2">{item.type}</span>
|
||||
<span className="mr-2">{item.difficulty}</span>
|
||||
<span>
|
||||
{item.score} {t("build.unitScore")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAdd(item)}
|
||||
aria-label={t("build.addToExam")}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{data ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("build.libraryTotal", { total: data.total })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中栏:已选题目列表(可上移/下移/编辑分值/移除)。
|
||||
*/
|
||||
function SelectedQuestionsPanel({
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
selected: ExamBuildNode[];
|
||||
onChange: (next: ExamBuildNode[]) => void;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
|
||||
const moveUp = (index: number): void => {
|
||||
if (index === 0) return;
|
||||
const next = [...selected];
|
||||
const a = next[index - 1];
|
||||
const b = next[index];
|
||||
if (!a || !b) return;
|
||||
next[index - 1] = b;
|
||||
next[index] = a;
|
||||
onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 })));
|
||||
};
|
||||
|
||||
const moveDown = (index: number): void => {
|
||||
if (index === selected.length - 1) return;
|
||||
const next = [...selected];
|
||||
const a = next[index];
|
||||
const b = next[index + 1];
|
||||
if (!a || !b) return;
|
||||
next[index] = b;
|
||||
next[index + 1] = a;
|
||||
onChange(next.map((node, i) => ({ ...node, sortOrder: i + 1 })));
|
||||
};
|
||||
|
||||
const remove = (questionId: string): void => {
|
||||
onChange(
|
||||
selected
|
||||
.filter((s) => s.questionId !== questionId)
|
||||
.map((node, i) => ({ ...node, sortOrder: i + 1 })),
|
||||
);
|
||||
};
|
||||
|
||||
const updateScore = (questionId: string, score: number): void => {
|
||||
onChange(
|
||||
selected.map((s) => (s.questionId === questionId ? { ...s, score } : s)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkbenchPanel
|
||||
title={t("build.selectedTitle")}
|
||||
actions={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("build.selectedCount", { count: selected.length })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{selected.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("build.selectedEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{selected.map((node, idx) => (
|
||||
<li
|
||||
key={node.questionId}
|
||||
className="rounded-md border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 text-sm font-semibold text-muted-foreground">
|
||||
{idx + 1}.
|
||||
</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm">{node.content}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{node.type} · {node.difficulty}
|
||||
</span>
|
||||
<label className="flex items-center gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("build.scoreLabel")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={node.score}
|
||||
onChange={(e) =>
|
||||
updateScore(
|
||||
node.questionId,
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="h-7 w-16"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveUp(idx)}
|
||||
disabled={idx === 0}
|
||||
aria-label={t("build.moveUp")}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveDown(idx)}
|
||||
disabled={idx === selected.length - 1}
|
||||
aria-label={t("build.moveDown")}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(node.questionId)}
|
||||
aria-label={t("build.remove")}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 右栏:预览(总分、题型分布、及格分提示)。
|
||||
*/
|
||||
function PreviewPanel({
|
||||
selected,
|
||||
totalScoreBaseline,
|
||||
passScore,
|
||||
}: {
|
||||
selected: ExamBuildNode[];
|
||||
totalScoreBaseline: number;
|
||||
passScore: number;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const total = sumSelectedScores(selected);
|
||||
const typeCounts = countByType(selected);
|
||||
const diff = total - totalScoreBaseline;
|
||||
|
||||
return (
|
||||
<WorkbenchPanel title={t("build.previewTitle")}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("build.previewTotal")}
|
||||
</p>
|
||||
<p className="mt-1 text-3xl font-semibold">{total}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("build.previewBaseline", { baseline: totalScoreBaseline })}
|
||||
{diff !== 0 ? (
|
||||
<span
|
||||
className={diff > 0 ? "text-amber-600" : "text-destructive"}
|
||||
>
|
||||
{" "}
|
||||
({diff > 0 ? "+" : ""}
|
||||
{diff})
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("build.previewPassScore")}
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold">{passScore}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-background p-4">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t("build.previewTypeBreakdown")}
|
||||
</p>
|
||||
{Object.keys(typeCounts).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">--</p>
|
||||
) : (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(typeCounts).map(([type, count]) => (
|
||||
<li key={type} className="flex justify-between">
|
||||
<span className="text-muted-foreground">{type}</span>
|
||||
<span>{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考试详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 exam(id: ID!):✅ schema 真实字段(core-edu 子图)
|
||||
* - 无需 MSW 兜底,但 MSW 开启时也会返回 mock 数据(dev 体验)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useExam, type Exam } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatExamDate,
|
||||
formatExamStatus,
|
||||
formatDuration,
|
||||
isExamEditable,
|
||||
parseTotalScore,
|
||||
} from "@/features/teacher/exams/transformations";
|
||||
|
||||
/**
|
||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ExamDetailClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
// ✅ 真实查询:exam(id: ID!),schema 已就绪
|
||||
const { data, loading, error } = useExam(examId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("detail.title")}
|
||||
description={
|
||||
data
|
||||
? t("detail.createdAtPrefix", {
|
||||
date: formatExamDate(data.createdAt),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/teacher/exams"
|
||||
actions={
|
||||
data && isExamEditable(data.status) ? (
|
||||
<Button variant="outline">{t("detail.edit")}</Button>
|
||||
) : null
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("detail.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <ExamDetailBody exam={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情内容区(基本信息 + 状态信息两个分区)。
|
||||
*/
|
||||
function ExamDetailBody({ exam }: { exam: Exam }): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("detail.sectionBasic")}>
|
||||
<DetailField label={t("detail.fieldTitle")} value={exam.title} />
|
||||
<DetailField
|
||||
label={t("detail.fieldDescription")}
|
||||
value={exam.description ?? "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldExamDate")}
|
||||
value={formatExamDate(exam.examDate)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldDuration")}
|
||||
value={formatDuration(exam.duration)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldTotalScore")}
|
||||
value={`${parseTotalScore(exam.totalScore)} ${t("detail.unitScore")}`}
|
||||
/>
|
||||
<DetailField label={t("detail.fieldClassId")} value={exam.classId} />
|
||||
<DetailField
|
||||
label={t("detail.fieldSubjectId")}
|
||||
value={exam.subjectId}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("detail.sectionStatus")}>
|
||||
<DetailField
|
||||
label={t("detail.fieldCurrentStatus")}
|
||||
value={formatExamStatus(exam.status)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldStatusChangedAt")}
|
||||
value={formatExamDate(exam.statusChangedAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldStatusChangedBy")}
|
||||
value={exam.statusChangedBy ?? "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldCreatedBy")}
|
||||
value={exam.createdBy}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldCreatedAt")}
|
||||
value={formatExamDate(exam.createdAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldUpdatedAt")}
|
||||
value={formatExamDate(exam.updatedAt)}
|
||||
/>
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 富文本试卷编辑页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(@contract-pending 全 MSW):
|
||||
* - examRichEditor(examId) ❌ → MSW 兜底
|
||||
* - saveExamRichContent(input) mutation ❌ → MSW 兜底
|
||||
*
|
||||
* 布局(WorkbenchPageShell 单 center 栏):
|
||||
* - center:富文本编辑器(contentEditable + 工具栏)
|
||||
* - 工具栏:加粗/斜体/标题 H1/H2/H3/列表/插入题目占位
|
||||
* - 内容区:可编辑 div
|
||||
* - 底部:保存/预览按钮
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:WorkbenchPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useExamRichEditor, useSaveExamRichContent } from "@/lib/api";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPageSkeleton,
|
||||
WorkbenchPanel,
|
||||
} from "@/shared/components/page-templates";
|
||||
|
||||
/**
|
||||
* 富文本编辑客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ExamEditClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
// @contract-pending MSW 兜底
|
||||
const { data, loading, error } = useExamRichEditor(examId);
|
||||
const saveMutation = useSaveExamRichContent();
|
||||
|
||||
// 本地 HTML 内容(从 data.content 初始化)
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 首次拿到数据时初始化 HTML 内容
|
||||
useEffect(() => {
|
||||
if (data && html === null) {
|
||||
setHtml(contentToHtml(data.content));
|
||||
}
|
||||
}, [data, html]);
|
||||
|
||||
// WorkbenchPageShell 的 errorNode 同时承担 error + empty 两种降级场景
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : !loading && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("edit.notFound")}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!data) return;
|
||||
const currentHtml = editorRef.current?.innerHTML ?? html ?? "";
|
||||
try {
|
||||
await saveMutation.run({ examId, content: currentHtml });
|
||||
notify.success(t("edit.saveSuccess"));
|
||||
} catch (err) {
|
||||
notify.error(t("edit.saveFailed", { message: String(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string): void => {
|
||||
if (!editorRef.current) return;
|
||||
editorRef.current.focus();
|
||||
// document.execCommand 已 deprecated 但 contentEditable 简单方案仍可用
|
||||
// 后续可换 tiptap/lexical(@contract-pending 富文本库)
|
||||
document.execCommand(command, false, value);
|
||||
setHtml(editorRef.current.innerHTML);
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkbenchPageShell
|
||||
title={data?.title ?? t("edit.title")}
|
||||
description={
|
||||
data
|
||||
? t("edit.subtitle", {
|
||||
total: data.totalScore,
|
||||
count: data.questionCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<FileText className="size-6" />}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saveMutation.loading || !data}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveMutation.loading ? tCommon("status.loading") : t("edit.save")}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<WorkbenchPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
center={
|
||||
data && html !== null ? (
|
||||
<RichEditor
|
||||
ref={editorRef}
|
||||
html={html}
|
||||
onChange={setHtml}
|
||||
onExec={exec}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
right={
|
||||
data ? (
|
||||
<WorkbenchPanel title={t("edit.propertiesTitle")}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.propExamId")}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs">{data.examId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.propTotalScore")}
|
||||
</p>
|
||||
<p className="mt-1 font-semibold">{data.totalScore}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.propQuestionCount")}
|
||||
</p>
|
||||
<p className="mt-1 font-semibold">{data.questionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.propUpdatedAt")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs">{data.updatedAt}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit.contractPending")}
|
||||
</p>
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本编辑器(contentEditable + 工具栏)。
|
||||
*
|
||||
* 简化实现:使用 document.execCommand(deprecated 但仍可用)。
|
||||
* 后续可升级到 tiptap/lexical(@contract-pending 富文本库契约)。
|
||||
*/
|
||||
interface RichEditorProps {
|
||||
html: string;
|
||||
onChange: (html: string) => void;
|
||||
onExec: (command: string, value?: string) => void;
|
||||
}
|
||||
|
||||
const RichEditor = forwardRef<HTMLDivElement, RichEditorProps>(
|
||||
function RichEditor({ html, onChange, onExec }, ref) {
|
||||
const t = useTranslations("exams");
|
||||
return (
|
||||
<WorkbenchPanel title={t("edit.editorTitle")}>
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap gap-1 border-b pb-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("bold")}
|
||||
aria-label={t("edit.toolbarBold")}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("italic")}
|
||||
aria-label={t("edit.toolbarItalic")}
|
||||
>
|
||||
<em>I</em>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("underline")}
|
||||
aria-label={t("edit.toolbarUnderline")}
|
||||
>
|
||||
<u>U</u>
|
||||
</Button>
|
||||
<span className="mx-1 border-l" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("formatBlock", "<h1>")}
|
||||
>
|
||||
H1
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("formatBlock", "<h2>")}
|
||||
>
|
||||
H2
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("formatBlock", "<h3>")}
|
||||
>
|
||||
H3
|
||||
</Button>
|
||||
<span className="mx-1 border-l" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("insertUnorderedList")}
|
||||
aria-label={t("edit.toolbarBulletList")}
|
||||
>
|
||||
•
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("insertOrderedList")}
|
||||
aria-label={t("edit.toolbarOrderedList")}
|
||||
>
|
||||
1.
|
||||
</Button>
|
||||
<span className="mx-1 border-l" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onExec("formatBlock", "<p>")}
|
||||
>
|
||||
P
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 可编辑区域 */}
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="flex-1 overflow-y-auto rounded-md border bg-background p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
|
||||
style={{ minHeight: "400px" }}
|
||||
/>
|
||||
</div>
|
||||
</WorkbenchPanel>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 将 mock data.content(结构化数组)转换为 HTML 字符串。
|
||||
* 输入是 unknown(来自 MSW),输出是可编辑的 HTML。
|
||||
*/
|
||||
function contentToHtml(content: unknown): string {
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((node: Record<string, unknown>) => {
|
||||
const type = node.type as string;
|
||||
const text = (node.text as string) ?? "";
|
||||
switch (type) {
|
||||
case "heading": {
|
||||
const level = node.level as number;
|
||||
const tag = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
|
||||
return `<${tag}>${text}</${tag}>`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `<p>${text}</p>`;
|
||||
case "question": {
|
||||
const order = node.order as number;
|
||||
const options = (node.options as string[]) ?? [];
|
||||
const optsHtml = options.map((opt) => `<div>${opt}</div>`).join("");
|
||||
return `<div><strong>${order}. ${text}</strong>${optsHtml}</div>`;
|
||||
}
|
||||
default:
|
||||
return `<div>${text}</div>`;
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考试管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 exams(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#exams-list
|
||||
*
|
||||
* URL 状态:?classId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useExams, type ExamListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatExamDate,
|
||||
formatExamStatus,
|
||||
formatDuration,
|
||||
parseTotalScore,
|
||||
} from "@/features/teacher/exams/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function ExamsListClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useExams(classId);
|
||||
|
||||
// 客户端二次筛选(status + q)—— 后端补齐列表查询后改服务端筛选
|
||||
const filteredItems = useMemo<ExamListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items.filter((item) => {
|
||||
if (statusFilter && item.status !== statusFilter) return false;
|
||||
if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [data, statusFilter, q]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/exams?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{/* @contract-pending 提示:MSW 兜底时若未开启 NEXT_PUBLIC_MSW=1 会失败 */}
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<FileText className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href={`/shell/teacher/exams/new?classId=${classId}`}>
|
||||
{t("list.new")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.statusFilter")}
|
||||
>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||
<option value="PUBLISHED">{t("list.statusPublished")}</option>
|
||||
<option value="IN_PROGRESS">{t("list.statusInProgress")}</option>
|
||||
<option value="SCORED">{t("list.statusScored")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ExamsTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考试列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function ExamsTable({ items }: { items: ExamListItem[] }): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colExamDate")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colDuration")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colTotalScore")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("list.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((exam) => (
|
||||
<tr key={exam.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/exams/${exam.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{exam.title}
|
||||
</Link>
|
||||
{exam.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{exam.description}
|
||||
</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<ExamStatusBadge status={exam.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{formatExamDate(exam.examDate)}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{formatDuration(exam.duration)}</td>
|
||||
<td className="p-3">
|
||||
{parseTotalScore(exam.totalScore)} {t("list.unitScore")}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/exams/${exam.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("list.viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 考试状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
function ExamStatusBadge({ status }: { status: string }): React.ReactElement {
|
||||
const label = formatExamStatus(status);
|
||||
const cls =
|
||||
status === "DRAFT"
|
||||
? "bg-muted text-muted-foreground"
|
||||
: status === "PUBLISHED" || status === "IN_PROGRESS"
|
||||
? "bg-primary/10 text-primary"
|
||||
: status === "SCORED"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
244
apps/portal-shell/src/features/teacher/exams/new-exam-client.tsx
Normal file
244
apps/portal-shell/src/features/teacher/exams/new-exam-client.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 新建考试表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - mutation createExam(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-exam-mutation
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:FormPageSkeleton(初始数据加载,由 server page Suspense 兜底)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.success + router.push 回列表
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useCreateExam, type CreateExamInput } from "@/lib/api";
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function NewExamClient(): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { run: createExam, loading: submitting } = useCreateExam();
|
||||
|
||||
const handleSubmit = async (input: CreateExamInput): Promise<void> => {
|
||||
try {
|
||||
const result = await createExam(input);
|
||||
notify.success(t("new.success"));
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/exams?classId=${input.classId}`);
|
||||
});
|
||||
void result;
|
||||
} catch (err) {
|
||||
notify.error(`${t("new.error")}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NewExamFormInner
|
||||
presetClassId={presetClassId}
|
||||
submitting={submitting}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单主体(受控表单 + 内联校验)。
|
||||
*
|
||||
* 注:未引入 react-hook-form + zod,因当前仅一个表单,引入会增加依赖。
|
||||
* 后续表单数量增多后统一迁移到 react-hook-form(§7.3 表单页模板建议)。
|
||||
*/
|
||||
function NewExamFormInner({
|
||||
presetClassId,
|
||||
submitting,
|
||||
onSubmit,
|
||||
}: {
|
||||
presetClassId: string;
|
||||
submitting: boolean;
|
||||
onSubmit: (input: CreateExamInput) => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("exams");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [subjectId, setSubjectId] = useState("sub-math");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [examDate, setExamDate] = useState("");
|
||||
const [duration, setDuration] = useState("120");
|
||||
const [totalScore, setTotalScore] = useState("100");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFormSubmit = (): void => {
|
||||
setError(null);
|
||||
|
||||
if (!classId.trim()) {
|
||||
setError(t("new.errorClassRequired"));
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setError(t("new.errorTitleRequired"));
|
||||
return;
|
||||
}
|
||||
if (!examDate) {
|
||||
setError(t("new.errorDateRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const input: CreateExamInput = {
|
||||
classId: classId.trim(),
|
||||
subjectId: subjectId.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
examDate: new Date(examDate).toISOString(),
|
||||
duration: Number(duration) || 0,
|
||||
totalScore: Number(totalScore) || 0,
|
||||
};
|
||||
|
||||
void onSubmit(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("new.title")}
|
||||
description={t("new.description")}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref={`/shell/teacher/exams?classId=${classId}`}
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("new.submit")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
error ? <p className="text-sm text-destructive">{error}</p> : undefined
|
||||
}
|
||||
>
|
||||
{/* 班级 ID */}
|
||||
<FormField label={t("new.classId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="cls-001"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 科目 ID */}
|
||||
<FormField label={t("new.subjectId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="sub-math"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 标题 */}
|
||||
<FormField label={t("new.titleLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("new.titlePlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 描述 */}
|
||||
<FormField label={t("new.descriptionLabel")}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("new.descriptionPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 考试日期 */}
|
||||
<FormField label={t("new.examDate")} required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={examDate}
|
||||
onChange={(e) => setExamDate(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 时长 + 满分 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label={t("new.duration")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("new.totalScore")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={totalScore}
|
||||
onChange={(e) => setTotalScore(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* @contract-pending 提示 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("new.contractPending")}
|
||||
</p>
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段容器(label + children)。
|
||||
* 简化版,后续可提取到 shared/components/ui/form-field.tsx 复用。
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
apps/portal-shell/src/features/teacher/exams/transformations.ts
Normal file
218
apps/portal-shell/src/features/teacher/exams/transformations.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Exams 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Exam, ExamListItem } from "@/lib/api";
|
||||
|
||||
/** 考试状态中文标签映射(对齐旧 teacher-portal EXAM_STATUS_LABEL) */
|
||||
export const EXAM_STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
IN_PROGRESS: "进行中",
|
||||
GRADING: "批改中",
|
||||
SCORED: "已完成",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将考试状态枚举值映射为中文标签。
|
||||
* 未知状态回退为原始值。
|
||||
*/
|
||||
export function formatExamStatus(status: string): string {
|
||||
return EXAM_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatExamDate(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 schema 的 totalScore(String)转为数值用于展示。
|
||||
* 转换失败返回 0。
|
||||
*/
|
||||
export function parseTotalScore(totalScore: string | number): number {
|
||||
const n = typeof totalScore === "number" ? totalScore : Number(totalScore);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断考试是否处于可编辑状态(DRAFT)。
|
||||
*/
|
||||
export function isExamEditable(status: string): boolean {
|
||||
return status === "DRAFT";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断考试是否处于已发布后的状态(不可再编辑)。
|
||||
*/
|
||||
export function isExamPublished(status: string): boolean {
|
||||
return (
|
||||
status === "PUBLISHED" ||
|
||||
status === "IN_PROGRESS" ||
|
||||
status === "GRADING" ||
|
||||
status === "SCORED" ||
|
||||
status === "ARCHIVED"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从考试详情中提取列表项视图模型(裁剪字段)。
|
||||
*/
|
||||
export function toExamListItem(exam: Exam): ExamListItem {
|
||||
return {
|
||||
id: exam.id,
|
||||
classId: exam.classId,
|
||||
subjectId: exam.subjectId,
|
||||
title: exam.title,
|
||||
description: exam.description,
|
||||
examDate: exam.examDate,
|
||||
duration: exam.duration,
|
||||
totalScore: exam.totalScore,
|
||||
status: exam.status,
|
||||
createdAt: exam.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时长(分钟)为更友好的展示。
|
||||
* - 60 分钟以下:返回 "N 分钟"
|
||||
* - 60 分钟以上:返回 "X 小时 Y 分钟"
|
||||
*/
|
||||
export function formatDuration(minutes: number): string {
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return "--";
|
||||
if (minutes < 60) return `${minutes} 分钟`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分钟`;
|
||||
}
|
||||
|
||||
// ── Analytics 纯函数(ARCHITECTURE.md §9.1 analytics 页 / §11.3 DoD)──
|
||||
|
||||
/**
|
||||
* 格式化分数(数值)为展示字符串,保留 1 位小数。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatScore(score: number): string {
|
||||
if (!Number.isFinite(score)) return "--";
|
||||
return score.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数(如 0.86)格式化为百分比字符串 "86%"。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatPassRate(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数(如 0.92)格式化为百分比字符串 "92%"。
|
||||
* 与 formatPassRate 同义,语义区分:用于正确率/通过率等不同场景。
|
||||
*/
|
||||
export function formatPercent(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据正确率返回等级标签(A/B/C/D)。
|
||||
* - rate >= 0.85 → A
|
||||
* - rate >= 0.7 → B
|
||||
* - rate >= 0.6 → C
|
||||
* - 其他 → D
|
||||
*/
|
||||
export function rateToLevel(rate: number): string {
|
||||
if (!Number.isFinite(rate)) return "--";
|
||||
if (rate >= 0.85) return "A";
|
||||
if (rate >= 0.7) return "B";
|
||||
if (rate >= 0.6) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据正确率返回 Tailwind 文本语义类名。
|
||||
* - >= 0.8 → text-emerald-600(高)
|
||||
* - >= 0.6 → text-amber-600(中)
|
||||
* - 其他 → text-destructive(低)
|
||||
*/
|
||||
export function rateToColorClass(rate: number): string {
|
||||
if (!Number.isFinite(rate)) return "text-muted-foreground";
|
||||
if (rate >= 0.8) return "text-emerald-600";
|
||||
if (rate >= 0.6) return "text-amber-600";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据等级(A/B/C/D)返回 Tailwind 文本语义类名。
|
||||
*/
|
||||
export function levelToColorClass(level: string): string {
|
||||
switch (level) {
|
||||
case "A":
|
||||
return "text-emerald-600";
|
||||
case "B":
|
||||
return "text-blue-600";
|
||||
case "C":
|
||||
return "text-amber-600";
|
||||
case "D":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build 纯函数(ARCHITECTURE.md §9.1 build 工作台页 / §11.3 DoD)──
|
||||
|
||||
/**
|
||||
* 计算已选题目总分。
|
||||
*/
|
||||
export function sumSelectedScores(items: Array<{ score: number }>): number {
|
||||
return items.reduce((sum, item) => sum + item.score, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按题量统计题型分布(如 { single_choice: 5, multiple_choice: 3 })。
|
||||
*/
|
||||
export function countByType(
|
||||
items: Array<{ type: string }>,
|
||||
): Record<string, number> {
|
||||
return items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.type] = (acc[item.type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 sortOrder 升序排序已选题目(返回新数组,不修改原数组)。
|
||||
*/
|
||||
export function sortBySortOrder<T extends { sortOrder: number }>(
|
||||
items: ReadonlyArray<T>,
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一个 sortOrder(当前最大值 + 1)。
|
||||
*/
|
||||
export function nextSortOrder(
|
||||
items: ReadonlyArray<{ sortOrder: number }>,
|
||||
): number {
|
||||
if (items.length === 0) return 1;
|
||||
return Math.max(...items.map((i) => i.sortOrder)) + 1;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Grades 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Grade } from "@/lib/api";
|
||||
|
||||
import {
|
||||
GRADE_LEVEL_LABEL,
|
||||
GRADE_STATUS_LABEL,
|
||||
calcGradeDistribution,
|
||||
formatGradeDate,
|
||||
formatGradeLevel,
|
||||
formatGradeStatus,
|
||||
formatPassRate,
|
||||
formatPercent,
|
||||
formatScore,
|
||||
isPassing,
|
||||
levelToColorClass,
|
||||
parseScore,
|
||||
scoreRateToColorClass,
|
||||
scoreToLevel,
|
||||
toGradeListItem,
|
||||
} from "../transformations";
|
||||
|
||||
describe("formatGradeStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatGradeStatus("GRADED")).toBe("已评分");
|
||||
expect(formatGradeStatus("PENDING")).toBe("待评分");
|
||||
expect(formatGradeStatus("EXEMPT")).toBe("免考");
|
||||
expect(formatGradeStatus("ABSENT")).toBe("缺考");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatGradeStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
expect(formatGradeStatus("")).toBe("");
|
||||
});
|
||||
|
||||
it("GRADE_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(GRADE_STATUS_LABEL)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGradeLevel", () => {
|
||||
it("maps known levels to Chinese labels", () => {
|
||||
expect(formatGradeLevel("A")).toBe("优秀");
|
||||
expect(formatGradeLevel("B")).toBe("良好");
|
||||
expect(formatGradeLevel("C")).toBe("合格");
|
||||
expect(formatGradeLevel("D")).toBe("不合格");
|
||||
});
|
||||
|
||||
it("returns original value for unknown level", () => {
|
||||
expect(formatGradeLevel("X")).toBe("X");
|
||||
expect(formatGradeLevel("")).toBe("");
|
||||
});
|
||||
|
||||
it("GRADE_LEVEL_LABEL covers A/B/C/D", () => {
|
||||
expect(Object.keys(GRADE_LEVEL_LABEL)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGradeDate", () => {
|
||||
it("formats valid ISO date string", () => {
|
||||
const result = formatGradeDate("2026-07-22T10:00:00Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatGradeDate(null)).toBe("--");
|
||||
expect(formatGradeDate(undefined)).toBe("--");
|
||||
expect(formatGradeDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatGradeDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScore", () => {
|
||||
it("parses numeric string", () => {
|
||||
expect(parseScore("95")).toBe(95);
|
||||
expect(parseScore("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("passes through number input", () => {
|
||||
expect(parseScore(88)).toBe(88);
|
||||
});
|
||||
|
||||
it("returns 0 for non-numeric string", () => {
|
||||
expect(parseScore("abc")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for NaN", () => {
|
||||
expect(parseScore(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatScore", () => {
|
||||
it("formats finite numbers with 1 decimal place", () => {
|
||||
expect(formatScore(82.5)).toBe("82.5");
|
||||
expect(formatScore(98)).toBe("98.0");
|
||||
expect(formatScore(0)).toBe("0.0");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite input", () => {
|
||||
expect(formatScore(Number.NaN)).toBe("--");
|
||||
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
expect(formatScore(Number.NEGATIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPassRate / formatPercent", () => {
|
||||
it("formats rate in [0,1] as percentage", () => {
|
||||
expect(formatPassRate(0.86)).toBe("86%");
|
||||
expect(formatPassRate(0)).toBe("0%");
|
||||
expect(formatPassRate(1)).toBe("100%");
|
||||
expect(formatPercent(0.92)).toBe("92%");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range or non-finite input", () => {
|
||||
expect(formatPassRate(-0.1)).toBe("--");
|
||||
expect(formatPassRate(1.1)).toBe("--");
|
||||
expect(formatPassRate(Number.NaN)).toBe("--");
|
||||
expect(formatPercent(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreToLevel", () => {
|
||||
it("maps score ratio to A/B/C/D levels", () => {
|
||||
expect(scoreToLevel(90, 100)).toBe("A");
|
||||
expect(scoreToLevel(85, 100)).toBe("A");
|
||||
expect(scoreToLevel(75, 100)).toBe("B");
|
||||
expect(scoreToLevel(70, 100)).toBe("B");
|
||||
expect(scoreToLevel(65, 100)).toBe("C");
|
||||
expect(scoreToLevel(60, 100)).toBe("C");
|
||||
expect(scoreToLevel(59, 100)).toBe("D");
|
||||
expect(scoreToLevel(0, 100)).toBe("D");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid input", () => {
|
||||
expect(scoreToLevel(Number.NaN, 100)).toBe("--");
|
||||
expect(scoreToLevel(80, 0)).toBe("--");
|
||||
expect(scoreToLevel(80, -1)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPassing", () => {
|
||||
it("returns true for score >= 60% of totalScore by default", () => {
|
||||
expect(isPassing(60, 100)).toBe(true);
|
||||
expect(isPassing(59, 100)).toBe(false);
|
||||
expect(isPassing(80, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects custom passScore", () => {
|
||||
expect(isPassing(50, 100, 50)).toBe(true);
|
||||
expect(isPassing(49, 100, 50)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid input", () => {
|
||||
expect(isPassing(Number.NaN, 100)).toBe(false);
|
||||
expect(isPassing(80, 0)).toBe(false);
|
||||
expect(isPassing(80, -1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreRateToColorClass", () => {
|
||||
it("returns emerald for high rates (>= 0.8)", () => {
|
||||
expect(scoreRateToColorClass(80, 100)).toBe("text-emerald-600");
|
||||
expect(scoreRateToColorClass(95, 100)).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns amber for medium rates (>= 0.6)", () => {
|
||||
expect(scoreRateToColorClass(60, 100)).toBe("text-amber-600");
|
||||
expect(scoreRateToColorClass(79, 100)).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns destructive for low rates (< 0.6)", () => {
|
||||
expect(scoreRateToColorClass(59, 100)).toBe("text-destructive");
|
||||
expect(scoreRateToColorClass(0, 100)).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for invalid input", () => {
|
||||
expect(scoreRateToColorClass(Number.NaN, 100)).toBe(
|
||||
"text-muted-foreground",
|
||||
);
|
||||
expect(scoreRateToColorClass(80, 0)).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("levelToColorClass", () => {
|
||||
it("maps each level to correct color class", () => {
|
||||
expect(levelToColorClass("A")).toBe("text-emerald-600");
|
||||
expect(levelToColorClass("B")).toBe("text-blue-600");
|
||||
expect(levelToColorClass("C")).toBe("text-amber-600");
|
||||
expect(levelToColorClass("D")).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for unknown level", () => {
|
||||
expect(levelToColorClass("X")).toBe("text-muted-foreground");
|
||||
expect(levelToColorClass("")).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calcGradeDistribution", () => {
|
||||
it("distributes scores into 5 buckets", () => {
|
||||
const scores = [
|
||||
{ score: 95, totalScore: 100 },
|
||||
{ score: 85, totalScore: 100 },
|
||||
{ score: 75, totalScore: 100 },
|
||||
{ score: 65, totalScore: 100 },
|
||||
{ score: 45, totalScore: 100 },
|
||||
];
|
||||
const result = calcGradeDistribution(scores);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0]!.count).toBe(1); // 0-59
|
||||
expect(result[1]!.count).toBe(1); // 60-69
|
||||
expect(result[2]!.count).toBe(1); // 70-79
|
||||
expect(result[3]!.count).toBe(1); // 80-89
|
||||
expect(result[4]!.count).toBe(1); // 90-100
|
||||
});
|
||||
|
||||
it("handles empty array", () => {
|
||||
const result = calcGradeDistribution([]);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("skips invalid scores", () => {
|
||||
const scores = [
|
||||
{ score: 90, totalScore: 100 },
|
||||
{ score: Number.NaN, totalScore: 100 },
|
||||
{ score: 80, totalScore: 0 },
|
||||
];
|
||||
const result = calcGradeDistribution(scores);
|
||||
expect(result[4]!.count).toBe(1); // only 90/100 counted
|
||||
expect(result[3]!.count).toBe(0);
|
||||
});
|
||||
|
||||
it("handles boundary values correctly", () => {
|
||||
const scores = [
|
||||
{ score: 60, totalScore: 100 }, // 60-69
|
||||
{ score: 70, totalScore: 100 }, // 70-79
|
||||
{ score: 80, totalScore: 100 }, // 80-89
|
||||
{ score: 90, totalScore: 100 }, // 90-100
|
||||
{ score: 59, totalScore: 100 }, // 0-59
|
||||
];
|
||||
const result = calcGradeDistribution(scores);
|
||||
expect(result[0]!.count).toBe(1); // 59 → 0-59
|
||||
expect(result[1]!.count).toBe(1); // 60 → 60-69
|
||||
expect(result[2]!.count).toBe(1); // 70 → 70-79
|
||||
expect(result[3]!.count).toBe(1); // 80 → 80-89
|
||||
expect(result[4]!.count).toBe(1); // 90 → 90-100
|
||||
});
|
||||
});
|
||||
|
||||
describe("toGradeListItem", () => {
|
||||
it("extracts list fields from full grade with extra info", () => {
|
||||
const grade: Grade = {
|
||||
id: "grade-001",
|
||||
studentId: "stu-001",
|
||||
examId: "exam-001",
|
||||
homeworkId: null,
|
||||
score: "95",
|
||||
totalScore: "100",
|
||||
feedback: "基础扎实",
|
||||
gradedBy: "usr-teacher-001",
|
||||
schoolId: "sch-001",
|
||||
idempotencyKey: null,
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
};
|
||||
|
||||
const item = toGradeListItem(grade, {
|
||||
studentName: "张明",
|
||||
studentNo: "2026001",
|
||||
classId: "cls-001",
|
||||
className: "高三(1)班",
|
||||
examName: "期中考试",
|
||||
status: "GRADED",
|
||||
});
|
||||
|
||||
expect(item.id).toBe("grade-001");
|
||||
expect(item.studentName).toBe("张明");
|
||||
expect(item.score).toBe(95); // string → number
|
||||
expect(item.totalScore).toBe(100);
|
||||
expect(item.examName).toBe("期中考试");
|
||||
expect(item.status).toBe("GRADED");
|
||||
});
|
||||
|
||||
it("handles missing extra info with defaults", () => {
|
||||
const grade: Grade = {
|
||||
id: "grade-002",
|
||||
studentId: "stu-002",
|
||||
examId: null,
|
||||
homeworkId: "hw-001",
|
||||
score: "88",
|
||||
totalScore: "100",
|
||||
feedback: null,
|
||||
gradedBy: "usr-teacher-001",
|
||||
schoolId: "sch-001",
|
||||
idempotencyKey: null,
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
|
||||
const item = toGradeListItem(grade);
|
||||
expect(item.studentName).toBe("--");
|
||||
expect(item.studentNo).toBe("--");
|
||||
expect(item.className).toBe("--");
|
||||
expect(item.examName).toBeNull();
|
||||
expect(item.homeworkTitle).toBeNull();
|
||||
expect(item.status).toBe("GRADED");
|
||||
expect(item.score).toBe(88);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩分析页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约(混合契约):
|
||||
* - 基础统计 ✅ assignmentAnalysis(data-ana 子图,schema 已就绪)
|
||||
* - 扩展字段(排名/分布)❌ → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.4 / §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useGradeAnalytics } from "@/lib/api";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatPassRate,
|
||||
formatScore,
|
||||
levelToColorClass,
|
||||
} from "@/features/teacher/grades/transformations";
|
||||
|
||||
/**
|
||||
* 分析客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function GradeAnalyticsClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const examId = searchParams.get("examId") ?? "exam-001";
|
||||
const classId = searchParams.get("classId") ?? undefined;
|
||||
|
||||
// 混合契约:基础统计 + 扩展字段 MSW
|
||||
const { data, loading, error } = useGradeAnalytics({ examId, classId });
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.scopeName ?? t("analytics.title")}
|
||||
description={
|
||||
data ? t("analytics.subtitle", { scope: data.scope }) : undefined
|
||||
}
|
||||
icon={<BarChart3 className="size-6" />}
|
||||
backHref="/shell/teacher/grades"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("analytics.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <GradeAnalyticsBody data={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析内容区(汇总卡片 + 分布图 + 学生排名)。
|
||||
*/
|
||||
function GradeAnalyticsBody({
|
||||
data,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useGradeAnalytics>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("analytics.sectionSummary")}>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryTotal")}
|
||||
value={String(data.summary.totalStudents)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryGraded")}
|
||||
value={String(data.summary.gradedCount)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryAvg")}
|
||||
value={formatScore(data.summary.avgScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryMax")}
|
||||
value={formatScore(data.summary.maxScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryMin")}
|
||||
value={formatScore(data.summary.minScore)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryPassRate")}
|
||||
value={formatPassRate(data.summary.passRate)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("analytics.summaryPassScore")}
|
||||
value={formatScore(data.summary.passScore)}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionDistribution")}>
|
||||
<DistributionChart items={data.distribution} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("analytics.sectionRankings")}>
|
||||
<RankingsTable items={data.rankings} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总卡片。
|
||||
*/
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分数段分布柱状图(纯 SVG)。
|
||||
*/
|
||||
function DistributionChart({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ label: string; count: number }>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyDistribution")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const maxCount = Math.max(...items.map((i) => i.count), 1);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="w-16 shrink-0 text-sm text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="h-6 flex-1 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 shrink-0 text-right text-sm">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生排名表格。
|
||||
*/
|
||||
function RankingsTable({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{
|
||||
studentId: string;
|
||||
studentNo: string;
|
||||
studentName: string;
|
||||
score: number;
|
||||
rank: number;
|
||||
level: string;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("analytics.emptyRankings")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colRank")}</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colStudentNo")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("analytics.colStudentName")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("analytics.colLevel")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.studentId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-medium">{item.rank}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{item.studentNo}</td>
|
||||
<td className="py-2 pr-4">{item.studentName}</td>
|
||||
<td className="py-2 pr-4">{formatScore(item.score)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={levelToColorClass(item.level)}>
|
||||
{item.level}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩录入表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - mutation createGrade(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#create-grade-mutation
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:FormPageSkeleton(由 server page Suspense 兜底)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.success + router.push 回列表
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Award } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useCreateGrade, type CreateGradeInput } from "@/lib/api";
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function GradeEntryClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { run: createGrade, loading: submitting } = useCreateGrade();
|
||||
|
||||
const handleSubmit = async (input: CreateGradeInput): Promise<void> => {
|
||||
try {
|
||||
const result = await createGrade(input);
|
||||
notify.success(t("entry.success"));
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/grades?classId=${input.classId}`);
|
||||
});
|
||||
void result;
|
||||
} catch (err) {
|
||||
notify.error(`${t("entry.error")}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GradeEntryFormInner
|
||||
presetClassId={presetClassId}
|
||||
submitting={submitting}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单主体(受控表单 + 内联校验)。
|
||||
*/
|
||||
function GradeEntryFormInner({
|
||||
presetClassId,
|
||||
submitting,
|
||||
onSubmit,
|
||||
}: {
|
||||
presetClassId: string;
|
||||
submitting: boolean;
|
||||
onSubmit: (input: CreateGradeInput) => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [studentId, setStudentId] = useState("");
|
||||
const [examId, setExamId] = useState("");
|
||||
const [homeworkId, setHomeworkId] = useState("");
|
||||
const [score, setScore] = useState("");
|
||||
const [totalScore, setTotalScore] = useState("100");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFormSubmit = (): void => {
|
||||
setError(null);
|
||||
|
||||
if (!classId.trim()) {
|
||||
setError(t("entry.errorClassRequired"));
|
||||
return;
|
||||
}
|
||||
if (!studentId.trim()) {
|
||||
setError(t("entry.errorStudentRequired"));
|
||||
return;
|
||||
}
|
||||
const scoreNum = Number(score);
|
||||
if (!Number.isFinite(scoreNum) || scoreNum < 0) {
|
||||
setError(t("entry.errorScoreInvalid"));
|
||||
return;
|
||||
}
|
||||
const totalNum = Number(totalScore);
|
||||
if (!Number.isFinite(totalNum) || totalNum <= 0) {
|
||||
setError(t("entry.errorTotalInvalid"));
|
||||
return;
|
||||
}
|
||||
if (!examId.trim() && !homeworkId.trim()) {
|
||||
setError(t("entry.errorSourceRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const input: CreateGradeInput = {
|
||||
classId: classId.trim(),
|
||||
studentId: studentId.trim(),
|
||||
examId: examId.trim() || undefined,
|
||||
homeworkId: homeworkId.trim() || undefined,
|
||||
score: scoreNum,
|
||||
totalScore: totalNum,
|
||||
feedback: feedback.trim() || undefined,
|
||||
};
|
||||
|
||||
void onSubmit(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("entry.title")}
|
||||
description={t("entry.description")}
|
||||
icon={<Award className="size-6" />}
|
||||
backHref={`/shell/teacher/grades?classId=${classId}`}
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("entry.submit")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
error ? <p className="text-sm text-destructive">{error}</p> : undefined
|
||||
}
|
||||
>
|
||||
{/* 班级 ID */}
|
||||
<FormField label={t("entry.classId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="cls-001"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 学生 ID */}
|
||||
<FormField label={t("entry.studentId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="stu-001"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 考试 ID(可选) */}
|
||||
<FormField label={t("entry.examId")}>
|
||||
<input
|
||||
type="text"
|
||||
value={examId}
|
||||
onChange={(e) => setExamId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("entry.examIdPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 作业 ID(可选) */}
|
||||
<FormField label={t("entry.homeworkId")}>
|
||||
<input
|
||||
type="text"
|
||||
value={homeworkId}
|
||||
onChange={(e) => setHomeworkId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("entry.homeworkIdPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 分数 + 满分 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label={t("entry.score")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={score}
|
||||
onChange={(e) => setScore(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("entry.totalScore")} required>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={totalScore}
|
||||
onChange={(e) => setTotalScore(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* 反馈 */}
|
||||
<FormField label={t("entry.feedbackLabel")}>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("entry.feedbackPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* @contract-pending 提示 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("entry.contractPending")}
|
||||
</p>
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段容器(label + children)。
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩统计页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - gradeStats(classId, period):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grade-stats
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useGradeStats, type GradeStatsSummary } from "@/lib/api";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatPassRate,
|
||||
formatScore,
|
||||
scoreRateToColorClass,
|
||||
} from "@/features/teacher/grades/transformations";
|
||||
|
||||
/**
|
||||
* 统计客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function GradeStatsClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const period = searchParams.get("period") ?? undefined;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useGradeStats(classId, period);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={t("stats.title")}
|
||||
description={
|
||||
data
|
||||
? t("stats.subtitle", { classId: data.classId, period: data.period })
|
||||
: undefined
|
||||
}
|
||||
icon={<BarChart3 className="size-6" />}
|
||||
backHref="/shell/teacher/grades"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("stats.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <GradeStatsBody summaries={data.summaries} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计内容区(学科维度汇总卡片 + 表格)。
|
||||
*/
|
||||
function GradeStatsBody({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: GradeStatsSummary[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
if (summaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("stats.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("stats.sectionSummary")}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{summaries.map((s) => (
|
||||
<SubjectSummaryCard
|
||||
key={`${s.classId}-${s.subjectId}`}
|
||||
summary={s}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("stats.sectionTable")}>
|
||||
<StatsTable summaries={summaries} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学科维度统计卡片。
|
||||
*/
|
||||
function SubjectSummaryCard({
|
||||
summary,
|
||||
}: {
|
||||
summary: GradeStatsSummary;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold">{summary.subjectName}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("stats.studentCount", { count: summary.studentCount })}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t("stats.avgScore")}</dt>
|
||||
<dd className="text-right font-medium">
|
||||
{formatScore(summary.avgScore)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.maxScore")}</dt>
|
||||
<dd className="text-right font-medium">
|
||||
{formatScore(summary.maxScore)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.minScore")}</dt>
|
||||
<dd className="text-right font-medium">
|
||||
{formatScore(summary.minScore)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.passRate")}</dt>
|
||||
<dd
|
||||
className={`text-right font-medium ${scoreRateToColorClass(summary.passRate, 1)}`}
|
||||
>
|
||||
{formatPassRate(summary.passRate)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.passCount")}</dt>
|
||||
<dd className="text-right font-medium">{summary.passCount}</dd>
|
||||
<dt className="text-muted-foreground">{t("stats.failCount")}</dt>
|
||||
<dd className="text-right font-medium text-destructive">
|
||||
{summary.failCount}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学科维度统计表格。
|
||||
*/
|
||||
function StatsTable({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: GradeStatsSummary[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colSubject")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colClass")}</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("stats.colStudentCount")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colAvgScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colMaxScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colMinScore")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colPassRate")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colPassCount")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{t("stats.colFailCount")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summaries.map((s) => (
|
||||
<tr
|
||||
key={`${s.classId}-${s.subjectId}`}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-2 pr-4 font-medium">{s.subjectName}</td>
|
||||
<td className="py-2 pr-4 text-xs">{s.className}</td>
|
||||
<td className="py-2 pr-4">{s.studentCount}</td>
|
||||
<td className="py-2 pr-4 font-medium">
|
||||
{formatScore(s.avgScore)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">{formatScore(s.maxScore)}</td>
|
||||
<td className="py-2 pr-4">{formatScore(s.minScore)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={scoreRateToColorClass(s.passRate, 1)}>
|
||||
{formatPassRate(s.passRate)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">{s.passCount}</td>
|
||||
<td className="py-2 pr-4 text-destructive">{s.failCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 grades(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#grades-list
|
||||
*
|
||||
* URL 状态:?classId=xxx &examId=xxx &studentId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Award } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useGradesList, type GradeListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatGradeDate,
|
||||
formatGradeStatus,
|
||||
formatScore,
|
||||
scoreRateToColorClass,
|
||||
} from "@/features/teacher/grades/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function GradesListClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useGradesList(classId);
|
||||
|
||||
// 客户端二次筛选(status + q)
|
||||
const filteredItems = useMemo<GradeListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items.filter((item) => {
|
||||
if (statusFilter && item.status !== statusFilter) return false;
|
||||
if (
|
||||
q &&
|
||||
!item.studentName.toLowerCase().includes(q.toLowerCase()) &&
|
||||
!item.studentNo.toLowerCase().includes(q.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [data, statusFilter, q]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/grades?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<Award className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href={`/shell/teacher/grades/entry?classId=${classId}`}>
|
||||
{t("list.entry")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.statusFilter")}
|
||||
>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="GRADED">{t("list.statusGraded")}</option>
|
||||
<option value="PENDING">{t("list.statusPending")}</option>
|
||||
<option value="EXEMPT">{t("list.statusExempt")}</option>
|
||||
<option value="ABSENT">{t("list.statusAbsent")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<GradesTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩列表表格。
|
||||
*/
|
||||
function GradesTable({
|
||||
items,
|
||||
}: {
|
||||
items: GradeListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colStudent")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colClass")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colSource")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colScore")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colGradedAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((grade) => (
|
||||
<tr key={grade.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<p className="font-medium">{grade.studentName}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{grade.studentNo}
|
||||
</p>
|
||||
</td>
|
||||
<td className="p-3 text-xs">{grade.className}</td>
|
||||
<td className="p-3 text-xs">
|
||||
{grade.examName ?? grade.homeworkTitle ?? "--"}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className={`font-semibold ${scoreRateToColorClass(grade.score, grade.totalScore)}`}
|
||||
>
|
||||
{formatScore(grade.score)}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
/ {formatScore(grade.totalScore)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<GradeStatusBadge status={grade.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{formatGradeDate(grade.gradedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩状态徽章。
|
||||
*/
|
||||
function GradeStatusBadge({ status }: { status: string }): React.ReactElement {
|
||||
const label = formatGradeStatus(status);
|
||||
const cls =
|
||||
status === "GRADED"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: status === "PENDING"
|
||||
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
: status === "EXEMPT"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: status === "ABSENT"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生成绩单页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - reportCard(studentId, period):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#report-card
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - empty:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useReportCard, type ReportCardEntry } from "@/lib/api";
|
||||
import {
|
||||
DetailField,
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatGradeDate,
|
||||
formatScore,
|
||||
levelToColorClass,
|
||||
} from "@/features/teacher/grades/transformations";
|
||||
|
||||
/**
|
||||
* 成绩单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ReportCardClient(): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
const tCommon = useTranslations("common");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const studentId = searchParams.get("studentId") ?? "stu-001";
|
||||
const period = searchParams.get("period") ?? undefined;
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useReportCard(studentId, period);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={
|
||||
data
|
||||
? t("reportCard.titleWithName", { name: data.studentName })
|
||||
: t("reportCard.title")
|
||||
}
|
||||
description={
|
||||
data
|
||||
? t("reportCard.subtitle", {
|
||||
no: data.studentNo,
|
||||
className: data.className,
|
||||
period: data.period,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<FileText className="size-6" />}
|
||||
backHref="/shell/teacher/grades"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("reportCard.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <ReportCardBody data={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩单内容区(学生信息 + 科目成绩 + 总评)。
|
||||
*/
|
||||
function ReportCardBody({
|
||||
data,
|
||||
}: {
|
||||
data: NonNullable<ReturnType<typeof useReportCard>["data"]>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("reportCard.sectionBasic")}>
|
||||
<DetailField
|
||||
label={t("reportCard.fieldStudentName")}
|
||||
value={data.studentName}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("reportCard.fieldStudentNo")}
|
||||
value={data.studentNo}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("reportCard.fieldClass")}
|
||||
value={data.className}
|
||||
/>
|
||||
<DetailField label={t("reportCard.fieldPeriod")} value={data.period} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("reportCard.sectionEntries")}>
|
||||
<ReportCardTable entries={data.entries} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("reportCard.sectionSummary")}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<SummaryStat
|
||||
label={t("reportCard.totalAvgScore")}
|
||||
value={formatScore(data.totalAvgScore)}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("reportCard.totalRank")}
|
||||
value={t("reportCard.rankValue", {
|
||||
rank: data.totalRank,
|
||||
total: data.totalStudents,
|
||||
})}
|
||||
/>
|
||||
<SummaryStat
|
||||
label={t("reportCard.totalStudents")}
|
||||
value={String(data.totalStudents)}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩单科目表格。
|
||||
*/
|
||||
function ReportCardTable({
|
||||
entries,
|
||||
}: {
|
||||
entries: ReportCardEntry[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("grades");
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("reportCard.emptyEntries")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colSubject")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colSource")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colScore")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colLevel")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colFeedback")}
|
||||
</th>
|
||||
<th className="py-2 pr-4 font-medium">
|
||||
{t("reportCard.colGradedAt")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.subjectId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-medium">{entry.subjectName}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{entry.examName ?? entry.homeworkTitle ?? "--"}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-semibold">
|
||||
{formatScore(entry.score)}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
/ {formatScore(entry.totalScore)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={levelToColorClass(entry.gradeLevel)}>
|
||||
{entry.gradeLevel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">
|
||||
{entry.feedback ?? "--"}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{formatGradeDate(entry.gradedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 总评统计卡片。
|
||||
*/
|
||||
function SummaryStat({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
apps/portal-shell/src/features/teacher/grades/transformations.ts
Normal file
248
apps/portal-shell/src/features/teacher/grades/transformations.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Grades 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Grade, GradeListItem } from "@/lib/api";
|
||||
|
||||
/** 成绩状态中文标签映射 */
|
||||
export const GRADE_STATUS_LABEL: Record<string, string> = {
|
||||
GRADED: "已评分",
|
||||
PENDING: "待评分",
|
||||
EXEMPT: "免考",
|
||||
ABSENT: "缺考",
|
||||
};
|
||||
|
||||
/** 等级中文标签映射 */
|
||||
export const GRADE_LEVEL_LABEL: Record<string, string> = {
|
||||
A: "优秀",
|
||||
B: "良好",
|
||||
C: "合格",
|
||||
D: "不合格",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将成绩状态枚举值映射为中文标签。
|
||||
* 未知状态回退为原始值。
|
||||
*/
|
||||
export function formatGradeStatus(status: string): string {
|
||||
return GRADE_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将等级(A/B/C/D)映射为中文标签。
|
||||
* 未知等级回退为原始值。
|
||||
*/
|
||||
export function formatGradeLevel(level: string): string {
|
||||
return GRADE_LEVEL_LABEL[level] ?? level;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatGradeDate(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 schema 的 score/totalScore(String 或 Number)转为数值用于展示。
|
||||
* 转换失败返回 0。
|
||||
*/
|
||||
export function parseScore(score: string | number): number {
|
||||
const n = typeof score === "number" ? score : Number(score);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分数(数值)为展示字符串,保留 1 位小数。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatScore(score: number): string {
|
||||
if (!Number.isFinite(score)) return "--";
|
||||
return score.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数(如 0.86)格式化为百分比字符串 "86%"。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatPassRate(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数格式化为百分比字符串。
|
||||
* 与 formatPassRate 同义,语义区分用于不同场景。
|
||||
*/
|
||||
export function formatPercent(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据得分比例(0-1)返回等级标签(A/B/C/D)。
|
||||
* - rate >= 0.85 → A
|
||||
* - rate >= 0.7 → B
|
||||
* - rate >= 0.6 → C
|
||||
* - 其他 → D
|
||||
*/
|
||||
export function scoreToLevel(score: number, totalScore: number): string {
|
||||
if (
|
||||
!Number.isFinite(score) ||
|
||||
!Number.isFinite(totalScore) ||
|
||||
totalScore <= 0
|
||||
) {
|
||||
return "--";
|
||||
}
|
||||
const rate = score / totalScore;
|
||||
if (rate >= 0.85) return "A";
|
||||
if (rate >= 0.7) return "B";
|
||||
if (rate >= 0.6) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断成绩是否及格(score >= passScore,默认 passScore 为 totalScore * 0.6)。
|
||||
*/
|
||||
export function isPassing(
|
||||
score: number,
|
||||
totalScore: number,
|
||||
passScore?: number,
|
||||
): boolean {
|
||||
if (
|
||||
!Number.isFinite(score) ||
|
||||
!Number.isFinite(totalScore) ||
|
||||
totalScore <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const threshold = passScore ?? totalScore * 0.6;
|
||||
return score >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据得分比例返回 Tailwind 文本语义类名。
|
||||
* - >= 0.8 → text-emerald-600(高)
|
||||
* - >= 0.6 → text-amber-600(中)
|
||||
* - 其他 → text-destructive(低)
|
||||
*/
|
||||
export function scoreRateToColorClass(
|
||||
score: number,
|
||||
totalScore: number,
|
||||
): string {
|
||||
if (
|
||||
!Number.isFinite(score) ||
|
||||
!Number.isFinite(totalScore) ||
|
||||
totalScore <= 0
|
||||
) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
const rate = score / totalScore;
|
||||
if (rate >= 0.8) return "text-emerald-600";
|
||||
if (rate >= 0.6) return "text-amber-600";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据等级(A/B/C/D)返回 Tailwind 文本语义类名。
|
||||
*/
|
||||
export function levelToColorClass(level: string): string {
|
||||
switch (level) {
|
||||
case "A":
|
||||
return "text-emerald-600";
|
||||
case "B":
|
||||
return "text-blue-600";
|
||||
case "C":
|
||||
return "text-amber-600";
|
||||
case "D":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算分数段分布(0-59, 60-69, 70-79, 80-89, 90-100)。
|
||||
* 返回按分数段排序的数组。
|
||||
*/
|
||||
export function calcGradeDistribution(
|
||||
scores: ReadonlyArray<{ score: number; totalScore: number }>,
|
||||
): Array<{ label: string; count: number }> {
|
||||
const buckets = [
|
||||
{ label: "0-59", count: 0 },
|
||||
{ label: "60-69", count: 0 },
|
||||
{ label: "70-79", count: 0 },
|
||||
{ label: "80-89", count: 0 },
|
||||
{ label: "90-100", count: 0 },
|
||||
];
|
||||
for (const item of scores) {
|
||||
if (
|
||||
!Number.isFinite(item.score) ||
|
||||
!Number.isFinite(item.totalScore) ||
|
||||
item.totalScore <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rate = item.score / item.totalScore;
|
||||
if (rate < 0.6) {
|
||||
buckets[0]!.count++;
|
||||
} else if (rate < 0.7) {
|
||||
buckets[1]!.count++;
|
||||
} else if (rate < 0.8) {
|
||||
buckets[2]!.count++;
|
||||
} else if (rate < 0.9) {
|
||||
buckets[3]!.count++;
|
||||
} else {
|
||||
buckets[4]!.count++;
|
||||
}
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从成绩详情中提取列表项视图模型(裁剪字段)。
|
||||
* 注意:Grade 的 score/totalScore 是 string,GradeListItem 的是 number。
|
||||
*/
|
||||
export function toGradeListItem(
|
||||
grade: Grade,
|
||||
extra?: {
|
||||
studentName?: string;
|
||||
studentNo?: string;
|
||||
classId?: string;
|
||||
className?: string;
|
||||
examName?: string | null;
|
||||
homeworkTitle?: string | null;
|
||||
status?: string;
|
||||
},
|
||||
): GradeListItem {
|
||||
return {
|
||||
id: grade.id,
|
||||
studentId: grade.studentId,
|
||||
studentName: extra?.studentName ?? "--",
|
||||
studentNo: extra?.studentNo ?? "--",
|
||||
classId: extra?.classId ?? "",
|
||||
className: extra?.className ?? "--",
|
||||
examId: grade.examId,
|
||||
examName: extra?.examName ?? null,
|
||||
homeworkId: grade.homeworkId,
|
||||
homeworkTitle: extra?.homeworkTitle ?? null,
|
||||
score: parseScore(grade.score),
|
||||
totalScore: parseScore(grade.totalScore),
|
||||
status: extra?.status ?? "GRADED",
|
||||
gradedAt: grade.updatedAt,
|
||||
gradedBy: grade.gradedBy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Homework 数据变换工具单测(ARCHITECTURE.md §11.3 DoD)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §11.3 "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Homework } from "@/lib/api";
|
||||
|
||||
import {
|
||||
HOMEWORK_STATUS_LABEL,
|
||||
SUBMISSION_STATUS_LABEL,
|
||||
calcSubmissionRate,
|
||||
confidenceToColorClass,
|
||||
formatConfidence,
|
||||
formatDueDate,
|
||||
formatGradeLevel,
|
||||
formatGracePeriod,
|
||||
formatHomeworkStatus,
|
||||
formatScore,
|
||||
formatSubmissionRate,
|
||||
formatSubmissionStatus,
|
||||
gradeLevelToColorClass,
|
||||
homeworkStatusToBadgeClass,
|
||||
isGraded,
|
||||
isHomeworkEditable,
|
||||
isHomeworkPublished,
|
||||
isOverdue,
|
||||
isPendingGrading,
|
||||
submissionStatusToBadgeClass,
|
||||
toHomeworkListItem,
|
||||
} from "../transformations";
|
||||
|
||||
describe("formatHomeworkStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatHomeworkStatus("DRAFT")).toBe("草稿");
|
||||
expect(formatHomeworkStatus("PUBLISHED")).toBe("已发布");
|
||||
expect(formatHomeworkStatus("CLOSED")).toBe("已关闭");
|
||||
expect(formatHomeworkStatus("ARCHIVED")).toBe("已归档");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatHomeworkStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
expect(formatHomeworkStatus("")).toBe("");
|
||||
});
|
||||
|
||||
it("HOMEWORK_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(HOMEWORK_STATUS_LABEL)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSubmissionStatus", () => {
|
||||
it("maps known statuses to Chinese labels", () => {
|
||||
expect(formatSubmissionStatus("SUBMITTED")).toBe("已提交");
|
||||
expect(formatSubmissionStatus("GRADING")).toBe("批改中");
|
||||
expect(formatSubmissionStatus("GRADED")).toBe("已批改");
|
||||
expect(formatSubmissionStatus("RETURNED")).toBe("已退回");
|
||||
expect(formatSubmissionStatus("LATE")).toBe("迟交");
|
||||
});
|
||||
|
||||
it("returns original value for unknown status", () => {
|
||||
expect(formatSubmissionStatus("UNKNOWN")).toBe("UNKNOWN");
|
||||
});
|
||||
|
||||
it("SUBMISSION_STATUS_LABEL covers all standard statuses", () => {
|
||||
expect(Object.keys(SUBMISSION_STATUS_LABEL)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDueDate", () => {
|
||||
it("formats valid ISO date string", () => {
|
||||
const result = formatDueDate("2026-07-25T23:59:59Z");
|
||||
expect(result).toContain("2026");
|
||||
expect(result).toContain("07");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined/empty", () => {
|
||||
expect(formatDueDate(null)).toBe("--");
|
||||
expect(formatDueDate(undefined)).toBe("--");
|
||||
expect(formatDueDate("")).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid date", () => {
|
||||
expect(formatDueDate("not-a-date")).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGracePeriod", () => {
|
||||
it("formats hours under 24", () => {
|
||||
expect(formatGracePeriod(12)).toBe("12 小时");
|
||||
expect(formatGracePeriod(1)).toBe("1 小时");
|
||||
});
|
||||
|
||||
it("formats exact days", () => {
|
||||
expect(formatGracePeriod(24)).toBe("1 天");
|
||||
expect(formatGracePeriod(48)).toBe("2 天");
|
||||
});
|
||||
|
||||
it("formats days with remainder hours", () => {
|
||||
expect(formatGracePeriod(36)).toBe("1 天 12 小时");
|
||||
expect(formatGracePeriod(25)).toBe("1 天 1 小时");
|
||||
});
|
||||
|
||||
it("returns placeholder for invalid or zero input", () => {
|
||||
expect(formatGracePeriod(0)).toBe("无宽限期");
|
||||
expect(formatGracePeriod(-1)).toBe("无宽限期");
|
||||
expect(formatGracePeriod(Number.NaN)).toBe("无宽限期");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOverdue", () => {
|
||||
it("returns true when dueDate is in the past", () => {
|
||||
const past = "2020-01-01T00:00:00Z";
|
||||
expect(isOverdue(past, new Date("2026-07-22T00:00:00Z"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when dueDate is in the future", () => {
|
||||
const future = "2030-01-01T00:00:00Z";
|
||||
expect(isOverdue(future, new Date("2026-07-22T00:00:00Z"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null/undefined/invalid date", () => {
|
||||
expect(isOverdue(null)).toBe(false);
|
||||
expect(isOverdue(undefined)).toBe(false);
|
||||
expect(isOverdue("not-a-date")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when dueDate equals now (not strictly past)", () => {
|
||||
const now = new Date("2026-07-22T00:00:00Z");
|
||||
expect(isOverdue("2026-07-22T00:00:00Z", now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHomeworkEditable / isHomeworkPublished", () => {
|
||||
it("DRAFT is editable but not published", () => {
|
||||
expect(isHomeworkEditable("DRAFT")).toBe(true);
|
||||
expect(isHomeworkPublished("DRAFT")).toBe(false);
|
||||
});
|
||||
|
||||
it("PUBLISHED is published but not editable", () => {
|
||||
expect(isHomeworkEditable("PUBLISHED")).toBe(false);
|
||||
expect(isHomeworkPublished("PUBLISHED")).toBe(true);
|
||||
});
|
||||
|
||||
it("unknown status is neither editable nor published", () => {
|
||||
expect(isHomeworkEditable("UNKNOWN")).toBe(false);
|
||||
expect(isHomeworkPublished("UNKNOWN")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toHomeworkListItem", () => {
|
||||
it("extracts list fields from full homework", () => {
|
||||
const hw: Homework = {
|
||||
id: "hw-001",
|
||||
classId: "cls-001",
|
||||
subjectId: "sub-math",
|
||||
title: "集合练习",
|
||||
description: "完成教材练习",
|
||||
dueDate: "2026-07-25T23:59:59Z",
|
||||
gracePeriod: 24,
|
||||
status: "PUBLISHED",
|
||||
schoolId: "sch-001",
|
||||
createdBy: "usr-001",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
|
||||
const item = toHomeworkListItem(hw);
|
||||
expect(item.id).toBe("hw-001");
|
||||
expect(item.title).toBe("集合练习");
|
||||
expect(item.status).toBe("PUBLISHED");
|
||||
expect(item).not.toHaveProperty("schoolId");
|
||||
expect(item).not.toHaveProperty("createdBy");
|
||||
expect(item).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
|
||||
it("handles null description", () => {
|
||||
const hw: Homework = {
|
||||
id: "hw-002",
|
||||
classId: "cls-001",
|
||||
subjectId: "sub-math",
|
||||
title: "无描述作业",
|
||||
description: null,
|
||||
dueDate: "2026-07-25T23:59:59Z",
|
||||
gracePeriod: 12,
|
||||
status: "DRAFT",
|
||||
schoolId: "sch-001",
|
||||
createdBy: "usr-001",
|
||||
createdAt: "2026-07-20T00:00:00Z",
|
||||
updatedAt: "2026-07-20T00:00:00Z",
|
||||
};
|
||||
const item = toHomeworkListItem(hw);
|
||||
expect(item.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGraded / isPendingGrading", () => {
|
||||
it("GRADED and RETURNED are graded", () => {
|
||||
expect(isGraded("GRADED")).toBe(true);
|
||||
expect(isGraded("RETURNED")).toBe(true);
|
||||
});
|
||||
|
||||
it("SUBMITTED and LATE are pending grading", () => {
|
||||
expect(isPendingGrading("SUBMITTED")).toBe(true);
|
||||
expect(isPendingGrading("LATE")).toBe(true);
|
||||
});
|
||||
|
||||
it("GRADED is not pending", () => {
|
||||
expect(isPendingGrading("GRADED")).toBe(false);
|
||||
});
|
||||
|
||||
it("GRADING is neither graded nor pending", () => {
|
||||
expect(isGraded("GRADING")).toBe(false);
|
||||
expect(isPendingGrading("GRADING")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatScore", () => {
|
||||
it("formats finite numbers with 1 decimal place", () => {
|
||||
expect(formatScore(82.5)).toBe("82.5");
|
||||
expect(formatScore(98)).toBe("98.0");
|
||||
expect(formatScore(0)).toBe("0.0");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined", () => {
|
||||
expect(formatScore(null)).toBe("--");
|
||||
expect(formatScore(undefined)).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for non-finite input", () => {
|
||||
expect(formatScore(Number.NaN)).toBe("--");
|
||||
expect(formatScore(Number.POSITIVE_INFINITY)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSubmissionRate", () => {
|
||||
it("formats rate in [0,1] as percentage", () => {
|
||||
expect(formatSubmissionRate(0.789)).toBe("79%");
|
||||
expect(formatSubmissionRate(0)).toBe("0%");
|
||||
expect(formatSubmissionRate(1)).toBe("100%");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range or non-finite input", () => {
|
||||
expect(formatSubmissionRate(-0.1)).toBe("--");
|
||||
expect(formatSubmissionRate(1.1)).toBe("--");
|
||||
expect(formatSubmissionRate(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calcSubmissionRate", () => {
|
||||
it("calculates rate correctly", () => {
|
||||
expect(calcSubmissionRate(30, 38)).toBeCloseTo(0.789, 2);
|
||||
expect(calcSubmissionRate(0, 38)).toBe(0);
|
||||
expect(calcSubmissionRate(38, 38)).toBe(1);
|
||||
});
|
||||
|
||||
it("caps at 1 when submitted > total", () => {
|
||||
expect(calcSubmissionRate(40, 38)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for zero or invalid total", () => {
|
||||
expect(calcSubmissionRate(10, 0)).toBe(0);
|
||||
expect(calcSubmissionRate(10, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for negative submitted count", () => {
|
||||
expect(calcSubmissionRate(-5, 38)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGradeLevel", () => {
|
||||
it("maps score rate to A/B/C/D levels", () => {
|
||||
expect(formatGradeLevel(90, 100)).toBe("A");
|
||||
expect(formatGradeLevel(85, 100)).toBe("A");
|
||||
expect(formatGradeLevel(75, 100)).toBe("B");
|
||||
expect(formatGradeLevel(70, 100)).toBe("B");
|
||||
expect(formatGradeLevel(65, 100)).toBe("C");
|
||||
expect(formatGradeLevel(60, 100)).toBe("C");
|
||||
expect(formatGradeLevel(50, 100)).toBe("D");
|
||||
expect(formatGradeLevel(0, 100)).toBe("D");
|
||||
});
|
||||
|
||||
it("returns placeholder for null/undefined score", () => {
|
||||
expect(formatGradeLevel(null, 100)).toBe("--");
|
||||
expect(formatGradeLevel(undefined, 100)).toBe("--");
|
||||
});
|
||||
|
||||
it("returns placeholder for zero or invalid maxScore", () => {
|
||||
expect(formatGradeLevel(80, 0)).toBe("--");
|
||||
expect(formatGradeLevel(80, -1)).toBe("--");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gradeLevelToColorClass", () => {
|
||||
it("maps each level to correct color class", () => {
|
||||
expect(gradeLevelToColorClass("A")).toBe("text-emerald-600");
|
||||
expect(gradeLevelToColorClass("B")).toBe("text-blue-600");
|
||||
expect(gradeLevelToColorClass("C")).toBe("text-amber-600");
|
||||
expect(gradeLevelToColorClass("D")).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for unknown level", () => {
|
||||
expect(gradeLevelToColorClass("X")).toBe("text-muted-foreground");
|
||||
expect(gradeLevelToColorClass("")).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submissionStatusToBadgeClass", () => {
|
||||
it("returns correct badge class for each status", () => {
|
||||
expect(submissionStatusToBadgeClass("SUBMITTED")).toContain("amber");
|
||||
expect(submissionStatusToBadgeClass("LATE")).toContain("amber");
|
||||
expect(submissionStatusToBadgeClass("GRADING")).toContain("primary");
|
||||
expect(submissionStatusToBadgeClass("GRADED")).toContain("emerald");
|
||||
expect(submissionStatusToBadgeClass("RETURNED")).toContain("blue");
|
||||
});
|
||||
|
||||
it("returns muted for unknown status", () => {
|
||||
expect(submissionStatusToBadgeClass("UNKNOWN")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("homeworkStatusToBadgeClass", () => {
|
||||
it("returns correct badge class for each status", () => {
|
||||
expect(homeworkStatusToBadgeClass("DRAFT")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
expect(homeworkStatusToBadgeClass("PUBLISHED")).toContain("primary");
|
||||
expect(homeworkStatusToBadgeClass("CLOSED")).toContain("amber");
|
||||
});
|
||||
|
||||
it("returns muted for unknown status", () => {
|
||||
expect(homeworkStatusToBadgeClass("UNKNOWN")).toBe(
|
||||
"bg-muted text-muted-foreground",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confidenceToColorClass", () => {
|
||||
it("returns emerald for high confidence", () => {
|
||||
expect(confidenceToColorClass(0.8)).toBe("text-emerald-600");
|
||||
expect(confidenceToColorClass(0.95)).toBe("text-emerald-600");
|
||||
});
|
||||
|
||||
it("returns amber for medium confidence", () => {
|
||||
expect(confidenceToColorClass(0.6)).toBe("text-amber-600");
|
||||
expect(confidenceToColorClass(0.79)).toBe("text-amber-600");
|
||||
});
|
||||
|
||||
it("returns destructive for low confidence", () => {
|
||||
expect(confidenceToColorClass(0.59)).toBe("text-destructive");
|
||||
expect(confidenceToColorClass(0)).toBe("text-destructive");
|
||||
});
|
||||
|
||||
it("returns muted for non-finite input", () => {
|
||||
expect(confidenceToColorClass(Number.NaN)).toBe("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatConfidence", () => {
|
||||
it("formats confidence in [0,1] as percentage", () => {
|
||||
expect(formatConfidence(0.92)).toBe("92%");
|
||||
expect(formatConfidence(0)).toBe("0%");
|
||||
expect(formatConfidence(1)).toBe("100%");
|
||||
});
|
||||
|
||||
it("returns placeholder for out-of-range or non-finite input", () => {
|
||||
expect(formatConfidence(-0.1)).toBe("--");
|
||||
expect(formatConfidence(1.1)).toBe("--");
|
||||
expect(formatConfidence(Number.NaN)).toBe("--");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 按作业批量批改页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 查询 assignmentSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 查询 aiBatchGrading(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { Layers } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useAssignmentSubmissions,
|
||||
useAiBatchGrading,
|
||||
type AiGradingSuggestion,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
calcSubmissionRate,
|
||||
confidenceToColorClass,
|
||||
formatConfidence,
|
||||
formatDueDate,
|
||||
formatScore,
|
||||
formatSubmissionRate,
|
||||
formatSubmissionStatus,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 批量批改客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function AssignmentSubmissionsClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const homeworkId = params?.id ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useAssignmentSubmissions(homeworkId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("assignment.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const homeworkTitle = data?.homework.title ?? "";
|
||||
const stats = data?.stats;
|
||||
const submissions = data?.submissions ?? [];
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("assignment.title")}
|
||||
description={
|
||||
homeworkTitle
|
||||
? t("assignment.description", { title: homeworkTitle })
|
||||
: t("assignment.descriptionLoading")
|
||||
}
|
||||
icon={<Layers className="size-6" />}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/shell/teacher/homework/${homeworkId}`}>
|
||||
{t("assignment.backToHomework")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={6} />}
|
||||
empty={!loading && !error && submissions.length === 0}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("assignment.total", { count: submissions.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{data ? (
|
||||
<AssignmentSubmissionsBody
|
||||
homeworkId={homeworkId}
|
||||
homeworkTitle={homeworkTitle}
|
||||
dueDate={data.homework.dueDate}
|
||||
className={data.homework.className}
|
||||
stats={stats}
|
||||
submissions={submissions}
|
||||
/>
|
||||
) : null}
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主体:统计卡片 + AI 建议区 + 提交表格。
|
||||
*/
|
||||
function AssignmentSubmissionsBody({
|
||||
homeworkId,
|
||||
homeworkTitle,
|
||||
dueDate,
|
||||
className,
|
||||
stats,
|
||||
submissions,
|
||||
}: {
|
||||
homeworkId: string;
|
||||
homeworkTitle: string;
|
||||
dueDate: string;
|
||||
className: string;
|
||||
stats:
|
||||
| {
|
||||
totalStudents: number;
|
||||
submittedCount: number;
|
||||
gradedCount: number;
|
||||
pendingCount: number;
|
||||
avgScore: number;
|
||||
submissionRate: number;
|
||||
}
|
||||
| undefined;
|
||||
submissions: ReadonlyArray<{
|
||||
id: string;
|
||||
homeworkId: string;
|
||||
homeworkTitle: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
status: string;
|
||||
submittedAt: string | null;
|
||||
gradedAt: string | null;
|
||||
gradedBy: string | null;
|
||||
totalScore: number | null;
|
||||
maxScore: number;
|
||||
}>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片区 */}
|
||||
{stats ? <StatsCard stats={stats} /> : null}
|
||||
|
||||
{/* 作业信息 */}
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<h2 className="mb-2 text-sm font-semibold">
|
||||
{t("assignment.sectionHomework")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<InfoItem
|
||||
label={t("assignment.homeworkTitle")}
|
||||
value={homeworkTitle}
|
||||
/>
|
||||
<InfoItem label={t("assignment.homeworkId")} value={homeworkId} />
|
||||
<InfoItem label={t("assignment.className")} value={className} />
|
||||
<InfoItem
|
||||
label={t("assignment.dueDate")}
|
||||
value={formatDueDate(dueDate)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI 批量评分区 */}
|
||||
<AiBatchGradingSection homeworkId={homeworkId} />
|
||||
|
||||
{/* 提交列表 */}
|
||||
<section className="rounded-xl border">
|
||||
<header className="border-b bg-muted/30 p-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("assignment.sectionSubmissions")}
|
||||
</h2>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("assignment.colStudent")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("assignment.colStatus")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("assignment.colSubmittedAt")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("assignment.colScore")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("assignment.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{submissions.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{s.studentName}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.studentNo}
|
||||
</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(s.status)}`}
|
||||
>
|
||||
{formatSubmissionStatus(s.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{formatDueDate(s.submittedAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatScore(s.totalScore)} / {s.maxScore}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}/scan-grading`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("assignment.scanGrade")}
|
||||
</Link>
|
||||
<span className="mx-1 text-muted-foreground/40">·</span>
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("assignment.detailGrade")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计卡片(4 个指标 + 提交率)。
|
||||
*/
|
||||
function StatsCard({
|
||||
stats,
|
||||
}: {
|
||||
stats: {
|
||||
totalStudents: number;
|
||||
submittedCount: number;
|
||||
gradedCount: number;
|
||||
pendingCount: number;
|
||||
avgScore: number;
|
||||
submissionRate: number;
|
||||
};
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const items = [
|
||||
{
|
||||
label: t("assignment.statsTotalStudents"),
|
||||
value: String(stats.totalStudents),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsSubmitted"),
|
||||
value: String(stats.submittedCount),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsGraded"),
|
||||
value: String(stats.gradedCount),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsPending"),
|
||||
value: String(stats.pendingCount),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsAvgScore"),
|
||||
value: formatScore(stats.avgScore),
|
||||
},
|
||||
{
|
||||
label: t("assignment.statsSubmissionRate"),
|
||||
value: formatSubmissionRate(
|
||||
calcSubmissionRate(stats.submittedCount, stats.totalStudents),
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{items.map((it) => (
|
||||
<div key={it.label} className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">{it.label}</p>
|
||||
<p className="mt-1 text-lg font-semibold">{it.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 批量评分区(点击按钮触发拉取建议 + 显示建议列表)。
|
||||
*/
|
||||
function AiBatchGradingSection({
|
||||
homeworkId,
|
||||
}: {
|
||||
homeworkId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const { data, loading, error, refetch } = useAiBatchGrading(homeworkId, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const handleTrigger = (): void => {
|
||||
void refetch();
|
||||
notify.info(t("assignment.aiTriggered"));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const suggestions = data?.suggestions ?? [];
|
||||
const summary = data?.summary;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<header className="mb-3 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("assignment.sectionAiGrading")}
|
||||
</h2>
|
||||
{summary ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("assignment.aiSummary", {
|
||||
processed: summary.processed,
|
||||
total: summary.totalSubmissions,
|
||||
avg: formatConfidence(summary.avgConfidence),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTrigger}
|
||||
>
|
||||
{t("assignment.aiBatchGrade")}
|
||||
</Button>
|
||||
</header>
|
||||
{suggestions.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{suggestions.map((s) => (
|
||||
<AiSuggestionRow key={s.submissionId} suggestion={s} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("assignment.aiEmpty")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("assignment.aiContractPending")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AiSuggestionRow({
|
||||
suggestion,
|
||||
}: {
|
||||
suggestion: AiGradingSuggestion;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<li className="flex flex-col gap-1 rounded-md border p-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{suggestion.studentName}</p>
|
||||
<p className="text-xs text-muted-foreground">{suggestion.reasoning}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono">
|
||||
{t("assignment.suggestedScore")}:{" "}
|
||||
<span className="font-semibold">{suggestion.suggestedScore}</span>
|
||||
</span>
|
||||
<span className={confidenceToColorClass(suggestion.confidence)}>
|
||||
{t("assignment.confidence")}:{" "}
|
||||
{formatConfidence(suggestion.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业详情页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 homework(id: ID!):✅ schema 真实字段(core-edu 子图)
|
||||
* - 列表查询 homeworkSubmissions(homeworkId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation recordGrade(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.5 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useHomework,
|
||||
useHomeworkSubmissions,
|
||||
useRecordGrade,
|
||||
type Homework,
|
||||
type HomeworkSubmissionItem,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
formatDueDate,
|
||||
formatGracePeriod,
|
||||
formatHomeworkStatus,
|
||||
formatScore,
|
||||
formatSubmissionStatus,
|
||||
homeworkStatusToBadgeClass,
|
||||
isHomeworkEditable,
|
||||
isOverdue,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 详情客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function HomeworkDetailClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ id: string }>();
|
||||
const homeworkId = params?.id ?? "";
|
||||
|
||||
// ✅ 真实查询:homework(id: ID!),schema 已就绪
|
||||
const { data, loading, error } = useHomework(homeworkId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={data?.title ?? t("detail.title")}
|
||||
description={
|
||||
data
|
||||
? t("detail.createdAtPrefix", {
|
||||
date: formatDueDate(data.createdAt),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
backHref="/shell/teacher/homework"
|
||||
actions={
|
||||
data && isHomeworkEditable(data.status) ? (
|
||||
<Button variant="outline">{t("detail.edit")}</Button>
|
||||
) : null
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("detail.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <HomeworkDetailBody homework={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情内容区(基本信息 + 提交列表 + 内联批改表单)。
|
||||
*/
|
||||
function HomeworkDetailBody({
|
||||
homework,
|
||||
}: {
|
||||
homework: Homework;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("detail.sectionBasic")}>
|
||||
<DetailField label={t("detail.fieldTitle")} value={homework.title} />
|
||||
<DetailField
|
||||
label={t("detail.fieldDescription")}
|
||||
value={homework.description ?? "-"}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldDueDate")}
|
||||
value={
|
||||
<span
|
||||
className={isOverdue(homework.dueDate) ? "text-destructive" : ""}
|
||||
>
|
||||
{formatDueDate(homework.dueDate)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldGracePeriod")}
|
||||
value={formatGracePeriod(homework.gracePeriod)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldStatus")}
|
||||
value={
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${homeworkStatusToBadgeClass(homework.status)}`}
|
||||
>
|
||||
{formatHomeworkStatus(homework.status)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldClassId")}
|
||||
value={homework.classId}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldSubjectId")}
|
||||
value={homework.subjectId}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldCreatedBy")}
|
||||
value={homework.createdBy}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("detail.fieldUpdatedAt")}
|
||||
value={formatDueDate(homework.updatedAt)}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title={t("detail.sectionSubmissions")}
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/assignments/${homework.id}/submissions`}
|
||||
>
|
||||
{t("detail.viewAllSubmissions")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<HomeworkSubmissionsSection homeworkId={homework.id} />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("detail.sectionInlineGrade")}>
|
||||
<InlineGradeForm homeworkId={homework.id} />
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交列表(按 homeworkId 拉取,@contract-pending MSW 兜底)。
|
||||
*/
|
||||
function HomeworkSubmissionsSection({
|
||||
homeworkId,
|
||||
}: {
|
||||
homeworkId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const { data, loading, error } = useHomeworkSubmissions({ homeworkId });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.noSubmissions")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-2 text-left font-medium">
|
||||
{t("detail.colStudent")}
|
||||
</th>
|
||||
<th className="p-2 text-left font-medium">
|
||||
{t("detail.colStatus")}
|
||||
</th>
|
||||
<th className="p-2 text-left font-medium">
|
||||
{t("detail.colSubmittedAt")}
|
||||
</th>
|
||||
<th className="p-2 text-right font-medium">
|
||||
{t("detail.colScore")}
|
||||
</th>
|
||||
<th className="p-2 text-right font-medium">
|
||||
{t("detail.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((s) => (
|
||||
<SubmissionRow key={s.id} submission={s} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionRow({
|
||||
submission,
|
||||
}: {
|
||||
submission: HomeworkSubmissionItem;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<tr className="hover:bg-muted/30">
|
||||
<td className="p-2">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${submission.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{submission.studentName}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">{submission.studentNo}</p>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(submission.status)}`}
|
||||
>
|
||||
{formatSubmissionStatus(submission.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 font-mono text-xs">
|
||||
{formatDueDate(submission.submittedAt)}
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
{formatScore(submission.totalScore)} / {submission.maxScore}
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${submission.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("detail.grade")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联批改表单(录入单个学生单次成绩,@contract-pending MSW 兜底)。
|
||||
*/
|
||||
function InlineGradeForm({
|
||||
homeworkId,
|
||||
}: {
|
||||
homeworkId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const { run: recordGrade, loading: submitting } = useRecordGrade();
|
||||
|
||||
const [studentId, setStudentId] = useState("");
|
||||
const [score, setScore] = useState("");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setError(null);
|
||||
if (!studentId.trim()) {
|
||||
setError(t("detail.gradeErrorStudentRequired"));
|
||||
return;
|
||||
}
|
||||
const scoreNum = Number(score);
|
||||
if (!Number.isFinite(scoreNum) || scoreNum < 0) {
|
||||
setError(t("detail.gradeErrorScoreInvalid"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await recordGrade({
|
||||
homeworkId,
|
||||
studentId: studentId.trim(),
|
||||
score: scoreNum,
|
||||
feedback: feedback.trim() || undefined,
|
||||
});
|
||||
notify.success(t("detail.gradeSuccess"));
|
||||
setStudentId("");
|
||||
setScore("");
|
||||
setFeedback("");
|
||||
} catch (err) {
|
||||
notify.error(`${t("detail.gradeError")}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{t("detail.gradeStudentId")}
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
placeholder="stu-001"
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{t("detail.gradeScore")}
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={score}
|
||||
onChange={(e) => setScore(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{t("detail.gradeFeedback")}
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("detail.gradeFeedbackPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t("detail.gradeSubmitting") : t("detail.gradeSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detail.gradeContractPending")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业管理列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 homeworks(classId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#homework-list
|
||||
*
|
||||
* URL 状态:?classId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useHomeworkList, type HomeworkListItem } from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatDueDate,
|
||||
formatGracePeriod,
|
||||
formatHomeworkStatus,
|
||||
homeworkStatusToBadgeClass,
|
||||
isOverdue,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中
|
||||
* (useSearchParams 要求 Suspense 边界,Next.js 15 强制)。
|
||||
*/
|
||||
export function HomeworkListClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "cls-001";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useHomeworkList(classId, {
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
|
||||
// 客户端二次筛选(q)—— 后端补齐列表查询后改服务端筛选
|
||||
const filteredItems = useMemo<HomeworkListItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
return items.filter((item) => {
|
||||
if (q && !item.title.toLowerCase().includes(q.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [data, q]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/homework?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("list.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("list.title")}
|
||||
description={t("list.description")}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href={`/shell/teacher/homework/new?classId=${classId}`}>
|
||||
{t("list.new")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("list.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("list.statusFilter")}
|
||||
>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="DRAFT">{t("list.statusDraft")}</option>
|
||||
<option value="PUBLISHED">{t("list.statusPublished")}</option>
|
||||
<option value="CLOSED">{t("list.statusClosed")}</option>
|
||||
<option value="ARCHIVED">{t("list.statusArchived")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={5} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("list.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HomeworkTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业列表表格(纯展示组件,对齐 §8.2 排版规范)。
|
||||
*/
|
||||
function HomeworkTable({
|
||||
items,
|
||||
}: {
|
||||
items: HomeworkListItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">{t("list.colName")}</th>
|
||||
<th className="p-3 text-left font-medium">{t("list.colStatus")}</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colDueDate")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("list.colGracePeriod")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("list.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((hw) => (
|
||||
<tr key={hw.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/${hw.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{hw.title}
|
||||
</Link>
|
||||
{hw.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{hw.description}
|
||||
</p>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<HomeworkStatusBadge status={hw.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
<span
|
||||
className={isOverdue(hw.dueDate) ? "text-destructive" : ""}
|
||||
>
|
||||
{formatDueDate(hw.dueDate)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-xs">
|
||||
{formatGracePeriod(hw.gracePeriod)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/${hw.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("list.viewDetail")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业状态徽章(按状态色阶展示)。
|
||||
*/
|
||||
function HomeworkStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
}): React.ReactElement {
|
||||
const label = formatHomeworkStatus(status);
|
||||
const cls = homeworkStatusToBadgeClass(status);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 布置作业表单页 - 客户端组件(ARCHITECTURE.md §7.3 表单页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - mutation assignHomework(input):❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#assign-homework-mutation
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:FormPageSkeleton(初始数据加载,由 server page Suspense 兜底)
|
||||
* - error:errorSummary 表单级错误
|
||||
* - success:notify.success + router.push 回列表
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useAssignHomework, type AssignHomeworkInput } from "@/lib/api";
|
||||
import { FormPageShell } from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
/**
|
||||
* 表单客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function NewHomeworkClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { run: assignHomework, loading: submitting } = useAssignHomework();
|
||||
|
||||
const handleSubmit = async (input: AssignHomeworkInput): Promise<void> => {
|
||||
try {
|
||||
const result = await assignHomework(input);
|
||||
notify.success(t("new.success"));
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/homework?classId=${input.classId}`);
|
||||
});
|
||||
void result;
|
||||
} catch (err) {
|
||||
notify.error(`${t("new.error")}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NewHomeworkFormInner
|
||||
presetClassId={presetClassId}
|
||||
submitting={submitting}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单主体(受控表单 + 内联校验)。
|
||||
*
|
||||
* 注:未引入 react-hook-form + zod,因当前仅一个表单,引入会增加依赖。
|
||||
* 后续表单数量增多后统一迁移到 react-hook-form(§7.3 表单页模板建议)。
|
||||
*/
|
||||
function NewHomeworkFormInner({
|
||||
presetClassId,
|
||||
submitting,
|
||||
onSubmit,
|
||||
}: {
|
||||
presetClassId: string;
|
||||
submitting: boolean;
|
||||
onSubmit: (input: AssignHomeworkInput) => Promise<void>;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [subjectId, setSubjectId] = useState("sub-math");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [gracePeriod, setGracePeriod] = useState("24");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFormSubmit = (): void => {
|
||||
setError(null);
|
||||
|
||||
if (!classId.trim()) {
|
||||
setError(t("new.errorClassRequired"));
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setError(t("new.errorTitleRequired"));
|
||||
return;
|
||||
}
|
||||
if (!dueDate) {
|
||||
setError(t("new.errorDateRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const input: AssignHomeworkInput = {
|
||||
classId: classId.trim(),
|
||||
subjectId: subjectId.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
dueDate: new Date(dueDate).toISOString(),
|
||||
gracePeriod: Number(gracePeriod) || 0,
|
||||
};
|
||||
|
||||
void onSubmit(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormPageShell
|
||||
title={t("new.title")}
|
||||
description={t("new.description")}
|
||||
icon={<ClipboardList className="size-6" />}
|
||||
backHref={`/shell/teacher/homework?classId=${classId}`}
|
||||
onSubmit={handleFormSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel={t("new.submit")}
|
||||
cancelLabel={tCommon("button.cancel")}
|
||||
errorSummary={
|
||||
error ? <p className="text-sm text-destructive">{error}</p> : undefined
|
||||
}
|
||||
>
|
||||
{/* 班级 ID */}
|
||||
<FormField label={t("new.classId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="cls-001"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 科目 ID */}
|
||||
<FormField label={t("new.subjectId")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder="sub-math"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 标题 */}
|
||||
<FormField label={t("new.titleLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("new.titlePlaceholder")}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 描述 */}
|
||||
<FormField label={t("new.descriptionLabel")}>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("new.descriptionPlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 截止日期 */}
|
||||
<FormField label={t("new.dueDate")} required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 宽限期(小时) */}
|
||||
<FormField label={t("new.gracePeriod")}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={gracePeriod}
|
||||
onChange={(e) => setGracePeriod(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("new.gracePeriodHint")}
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
{/* @contract-pending 提示 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("new.contractPending")}
|
||||
</p>
|
||||
</FormPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段容器(label + children)。
|
||||
* 简化版,后续可提取到 shared/components/ui/form-field.tsx 复用。
|
||||
*/
|
||||
function FormField({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 扫描批改页 - 客户端组件(ARCHITECTURE.md §7.3 工作台页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation saveScanGrading(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:WorkbenchPageSkeleton(loading=true)
|
||||
* - error:errorNode 局部降级
|
||||
* - 局部加载:左/中/右各自支持独立 loading(本页用整体 loading)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*
|
||||
* 注:WorkbenchPageShell 没有 emptyNode 属性;notFound 由 errorNode 节点呈现。
|
||||
*/
|
||||
import { ScanLine } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useSubmissionDetail,
|
||||
useSaveScanGrading,
|
||||
type SubmissionAnswer,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
WorkbenchPageShell,
|
||||
WorkbenchPanel,
|
||||
WorkbenchPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
confidenceToColorClass,
|
||||
formatConfidence,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 扫描批改客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function ScanGradingClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ submissionId: string }>();
|
||||
const submissionId = params?.submissionId ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useSubmissionDetail(submissionId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const notFoundNode =
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("scan.notFound")}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<WorkbenchPageShell
|
||||
title={
|
||||
data
|
||||
? t("scan.title", { name: data.submission.studentName })
|
||||
: t("scan.titleLoading")
|
||||
}
|
||||
description={
|
||||
data
|
||||
? t("scan.subtitle", {
|
||||
homework: data.submission.homeworkTitle,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<ScanLine className="size-6" />}
|
||||
loading={loading}
|
||||
loadingNode={<WorkbenchPageSkeleton />}
|
||||
errorNode={errorNode ?? notFoundNode}
|
||||
left={
|
||||
data ? (
|
||||
<WorkbenchPanel title={t("scan.scanPreview")}>
|
||||
<ScanPreviewPanel submissionId={data.submission.id} />
|
||||
</WorkbenchPanel>
|
||||
) : null
|
||||
}
|
||||
center={
|
||||
data ? (
|
||||
<WorkbenchPanel title={t("scan.recognizedAnswers")}>
|
||||
<RecognizedAnswersPanel answers={data.answers} />
|
||||
</WorkbenchPanel>
|
||||
) : null
|
||||
}
|
||||
right={
|
||||
data ? (
|
||||
<WorkbenchPanel title={t("scan.gradingForm")}>
|
||||
<ScanGradingForm
|
||||
submissionId={data.submission.id}
|
||||
answers={data.answers}
|
||||
maxScore={data.submission.maxScore}
|
||||
/>
|
||||
</WorkbenchPanel>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描图片预览面板(左栏)。
|
||||
* 注:当前无真实扫描图片,使用占位符 + 提示。
|
||||
*/
|
||||
function ScanPreviewPanel({
|
||||
submissionId,
|
||||
}: {
|
||||
submissionId: string;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
|
||||
<div className="text-center">
|
||||
<ScanLine className="mx-auto size-12 text-muted-foreground" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("scan.imagePlaceholder", { id: submissionId })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.imageContractPending")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已识别答案面板(中栏)。
|
||||
*/
|
||||
function RecognizedAnswersPanel({
|
||||
answers,
|
||||
}: {
|
||||
answers: SubmissionAnswer[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{answers.map((a, idx) => (
|
||||
<div key={a.id} className="rounded-md border p-3">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{idx + 1}. {a.questionTitle}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{a.maxScore} {t("scan.unitScore")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.questionType")}: {a.questionType}
|
||||
</p>
|
||||
<div className="mt-2 rounded-md bg-muted/30 p-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.recognizedAnswer")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{a.answer}</p>
|
||||
</div>
|
||||
{a.aiSuggestion ? (
|
||||
<p className="mt-2 text-xs">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t("scan.aiSuggestionLabel")}
|
||||
</span>
|
||||
<span className="ml-1">{a.aiSuggestion}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{a.isCorrect !== null ? (
|
||||
<p
|
||||
className={`mt-1 text-xs ${a.isCorrect ? "text-emerald-600" : "text-destructive"}`}
|
||||
>
|
||||
{a.isCorrect
|
||||
? t("scan.recognizedCorrect")
|
||||
: t("scan.recognizedIncorrect")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{answers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("scan.noAnswers")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描批改表单(右栏):每题分数 + 评语 + 总反馈 + 保存。
|
||||
*/
|
||||
function ScanGradingForm({
|
||||
submissionId,
|
||||
answers,
|
||||
maxScore,
|
||||
}: {
|
||||
submissionId: string;
|
||||
answers: SubmissionAnswer[];
|
||||
maxScore: number;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const { run: saveScanGrading, loading: submitting } = useSaveScanGrading();
|
||||
|
||||
const initialScores = useMemo<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const a of answers) {
|
||||
m[a.questionId] = a.score === null ? "" : String(a.score);
|
||||
}
|
||||
return m;
|
||||
}, [answers]);
|
||||
|
||||
const [scores, setScores] = useState<Record<string, string>>(initialScores);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applyAllAi = (): void => {
|
||||
const next: Record<string, string> = { ...scores };
|
||||
let applied = 0;
|
||||
for (const a of answers) {
|
||||
if (!a.aiSuggestion) continue;
|
||||
if (a.aiSuggestion.includes("满分")) {
|
||||
next[a.questionId] = String(a.maxScore);
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
setScores(next);
|
||||
notify.info(t("scan.aiAppliedCount", { count: applied }));
|
||||
};
|
||||
|
||||
const totalScore = answers.reduce((sum, a) => {
|
||||
const raw = scores[a.questionId] ?? "";
|
||||
const num = Number(raw);
|
||||
return Number.isFinite(num) ? sum + num : sum;
|
||||
}, 0);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setError(null);
|
||||
const payload = answers.map((a) => {
|
||||
const raw = scores[a.questionId] ?? "";
|
||||
const num = Number(raw);
|
||||
if (raw === "" || !Number.isFinite(num)) {
|
||||
throw new Error(t("scan.errorScoreInvalid", { qid: a.questionId }));
|
||||
}
|
||||
return {
|
||||
questionId: a.questionId,
|
||||
score: num,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await saveScanGrading({
|
||||
submissionId,
|
||||
answers: payload,
|
||||
feedback: feedback.trim() || undefined,
|
||||
});
|
||||
notify.success(t("scan.saveSuccess"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
notify.error(`${t("scan.saveError")}: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("scan.totalScore")}: {totalScore} / {maxScore}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={applyAllAi}>
|
||||
{t("scan.applyAllAi")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 overflow-y-auto">
|
||||
{answers.map((a, idx) => {
|
||||
const raw = scores[a.questionId] ?? "";
|
||||
const aiConf = a.aiSuggestion
|
||||
? a.aiSuggestion.includes("满分")
|
||||
? 0.95
|
||||
: 0.7
|
||||
: null;
|
||||
return (
|
||||
<div key={a.id} className="space-y-1 rounded-md border p-2">
|
||||
<p className="text-xs font-medium">
|
||||
{idx + 1}. {a.questionTitle}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={a.maxScore}
|
||||
value={raw}
|
||||
onChange={(e) =>
|
||||
setScores((prev) => ({
|
||||
...prev,
|
||||
[a.questionId]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 w-20 rounded-md border border-input bg-background px-2 text-sm"
|
||||
aria-label={t("scan.scoreLabel", { qid: a.questionId })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
/ {a.maxScore}
|
||||
</span>
|
||||
{aiConf !== null ? (
|
||||
<span
|
||||
className={`ml-auto text-xs ${confidenceToColorClass(aiConf)}`}
|
||||
>
|
||||
{t("scan.confidence")}: {formatConfidence(aiConf)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
{t("scan.overallFeedback")}
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm"
|
||||
placeholder={t("scan.overallFeedbackPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t("scan.saving") : t("scan.save")}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scan.contractPending")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 单份提交批改页 - 客户端组件(ARCHITECTURE.md §7.3 详情页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 单查 submissionDetail(submissionId):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - mutation gradeSubmission(input):❌ schema 无 Mutation → MSW 兜底(@contract-pending)
|
||||
*
|
||||
* 三态规范(§11.3 DoD):
|
||||
* - loading:DetailPageSkeleton
|
||||
* - error:errorNode 局部降级
|
||||
* - notFound:data 为 null 时显示空态节点
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
useSubmissionDetail,
|
||||
useGradeSubmission,
|
||||
type SubmissionAnswer,
|
||||
type SubmissionDetail,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DetailPageShell,
|
||||
DetailPageSkeleton,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
} from "@/shared/components/page-templates";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
import {
|
||||
formatDueDate,
|
||||
formatSubmissionStatus,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 批改客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function SubmissionGradingClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const params = useParams<{ submissionId: string }>();
|
||||
const submissionId = params?.submissionId ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useSubmissionDetail(submissionId);
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<DetailPageShell
|
||||
title={
|
||||
data
|
||||
? t("grading.title", { name: data.submission.studentName })
|
||||
: t("grading.titleLoading")
|
||||
}
|
||||
description={
|
||||
data
|
||||
? t("grading.subtitle", {
|
||||
homework: data.submission.homeworkTitle,
|
||||
no: data.submission.studentNo,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<ClipboardCheck className="size-6" />}
|
||||
backHref="/shell/teacher/homework/submissions"
|
||||
loading={loading}
|
||||
loadingNode={<DetailPageSkeleton />}
|
||||
errorNode={errorNode}
|
||||
emptyNode={
|
||||
!loading && !error && !data ? (
|
||||
<div className="rounded-xl border p-6 text-center text-muted-foreground">
|
||||
{t("grading.notFound")}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{data ? <SubmissionGradingBody detail={data} /> : null}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批改主体:学生信息 + 题目作答 + 批改表单 + 上下导航。
|
||||
*/
|
||||
function SubmissionGradingBody({
|
||||
detail,
|
||||
}: {
|
||||
detail: SubmissionDetail;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const { submission, navigation } = detail;
|
||||
const router = useRouter();
|
||||
|
||||
const handleNav = (target: string | null): void => {
|
||||
if (!target) return;
|
||||
router.push(`/shell/teacher/homework/submissions/${target}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailSection title={t("grading.sectionStudent")}>
|
||||
<DetailField
|
||||
label={t("grading.fieldStudentName")}
|
||||
value={submission.studentName}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldStudentNo")}
|
||||
value={submission.studentNo}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldClass")}
|
||||
value={submission.className}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldHomework")}
|
||||
value={
|
||||
<Link
|
||||
href={`/shell/teacher/homework/${submission.homeworkId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{submission.homeworkTitle}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldStatus")}
|
||||
value={
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(submission.status)}`}
|
||||
>
|
||||
{formatSubmissionStatus(submission.status)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldSubmittedAt")}
|
||||
value={formatDueDate(submission.submittedAt)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("grading.fieldFeedback")}
|
||||
value={submission.feedback ?? "-"}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("grading.sectionAnswers")}>
|
||||
<AnswersGradingForm
|
||||
submissionId={submission.id}
|
||||
answers={detail.answers}
|
||||
maxScore={submission.maxScore}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={t("grading.sectionNavigation")}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!navigation.prevId}
|
||||
onClick={() => handleNav(navigation.prevId)}
|
||||
>
|
||||
{t("grading.prev")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{t("grading.position", {
|
||||
current: navigation.currentIndex + 1,
|
||||
total: navigation.totalCount,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!navigation.nextId}
|
||||
onClick={() => handleNav(navigation.nextId)}
|
||||
>
|
||||
{t("grading.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目作答 + 批改表单。
|
||||
*/
|
||||
function AnswersGradingForm({
|
||||
submissionId,
|
||||
answers,
|
||||
maxScore,
|
||||
}: {
|
||||
submissionId: string;
|
||||
answers: SubmissionAnswer[];
|
||||
maxScore: number;
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const { run: gradeSubmission, loading: submitting } = useGradeSubmission();
|
||||
|
||||
// 每题分数与教师评语,初始化为已有值
|
||||
const initialScores = useMemo<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const a of answers) {
|
||||
m[a.questionId] = a.score === null ? "" : String(a.score);
|
||||
}
|
||||
return m;
|
||||
}, [answers]);
|
||||
|
||||
const initialComments = useMemo<Record<string, string>>(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const a of answers) {
|
||||
m[a.questionId] = a.teacherComment ?? "";
|
||||
}
|
||||
return m;
|
||||
}, [answers]);
|
||||
|
||||
const [scores, setScores] = useState<Record<string, string>>(initialScores);
|
||||
const [comments, setComments] =
|
||||
useState<Record<string, string>>(initialComments);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applyAiSuggestion = (answer: SubmissionAnswer): void => {
|
||||
if (!answer.aiSuggestion) return;
|
||||
// 解析 AI 建议(如 "答案正确,可直接给满分。" → 直接给满分)
|
||||
if (answer.aiSuggestion.includes("满分")) {
|
||||
setScores((prev) => ({
|
||||
...prev,
|
||||
[answer.questionId]: String(answer.maxScore),
|
||||
}));
|
||||
}
|
||||
setComments((prev) => ({
|
||||
...prev,
|
||||
[answer.questionId]: answer.aiSuggestion ?? "",
|
||||
}));
|
||||
notify.info(t("grading.aiSuggestionApplied"));
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setError(null);
|
||||
const payload = answers.map((a) => {
|
||||
const raw = scores[a.questionId] ?? "";
|
||||
const num = Number(raw);
|
||||
if (raw === "" || !Number.isFinite(num)) {
|
||||
throw new Error(t("grading.errorScoreInvalid", { qid: a.questionId }));
|
||||
}
|
||||
return {
|
||||
questionId: a.questionId,
|
||||
score: num,
|
||||
teacherComment: (comments[a.questionId] ?? "").trim() || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await gradeSubmission({
|
||||
submissionId,
|
||||
answers: payload,
|
||||
feedback: feedback.trim() || undefined,
|
||||
});
|
||||
notify.success(t("grading.success"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
notify.error(`${t("grading.error")}: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{answers.map((a, idx) => (
|
||||
<div key={a.id} className="rounded-md border p-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{idx + 1}. {a.questionTitle}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("grading.questionType")}: {a.questionType} ·{" "}
|
||||
{t("grading.maxScore")}: {a.maxScore}
|
||||
</p>
|
||||
</div>
|
||||
{a.aiSuggestion ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => applyAiSuggestion(a)}
|
||||
>
|
||||
{t("grading.applyAi")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-2 rounded-md bg-muted/30 p-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("grading.studentAnswer")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{a.answer}</p>
|
||||
</div>
|
||||
{a.aiSuggestion ? (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">
|
||||
{t("grading.aiSuggestionLabel")}:
|
||||
</span>{" "}
|
||||
{a.aiSuggestion}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
{t("grading.score")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={a.maxScore}
|
||||
value={scores[a.questionId] ?? ""}
|
||||
onChange={(e) =>
|
||||
setScores((prev) => ({
|
||||
...prev,
|
||||
[a.questionId]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<label className="text-xs font-medium">
|
||||
{t("grading.teacherComment")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comments[a.questionId] ?? ""}
|
||||
onChange={(e) =>
|
||||
setComments((prev) => ({
|
||||
...prev,
|
||||
[a.questionId]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{t("grading.overallFeedback")}
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t("grading.overallFeedbackPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("grading.maxScoreTotal")}: {maxScore}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t("grading.submitting") : t("grading.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("grading.contractPending")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 提交批改列表页 - 客户端组件(ARCHITECTURE.md §7.3 列表页 / §9.1 / §10 P2)
|
||||
*
|
||||
* 数据契约:
|
||||
* - 列表查询 homeworkSubmissions(filter):❌ schema 无此字段 → MSW 兜底(@contract-pending)
|
||||
* - 契约工单:docs/architecture/issues/contracts/core-edu_contract.md#homework-submissions
|
||||
*
|
||||
* URL 状态:?classId=xxx &status=xxx &q=xxx
|
||||
*
|
||||
* 三态规范(§11.3 DoD):loading(骨架)/ error(局部降级)/ empty(EmptyState)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.1 / §10 P2 / §11.3 / §11.4
|
||||
*/
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useHomeworkSubmissions, type HomeworkSubmissionItem } from "@/lib/api";
|
||||
import { FilterSearchInput } from "@/shared/components/ui/filter-bar";
|
||||
import {
|
||||
ListPageShell,
|
||||
ListPageSkeleton,
|
||||
} from "@/shared/components/page-templates";
|
||||
import {
|
||||
formatDueDate,
|
||||
formatScore,
|
||||
formatSubmissionStatus,
|
||||
submissionStatusToBadgeClass,
|
||||
} from "@/features/teacher/homework/transformations";
|
||||
|
||||
/**
|
||||
* 列表客户端主体。需由 server page 包裹在 <Suspense> 中。
|
||||
*/
|
||||
export function SubmissionsListClient(): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
const statusFilter = searchParams.get("status") ?? "";
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
// @contract-pending:MSW 兜底
|
||||
const { data, loading, error } = useHomeworkSubmissions({
|
||||
classId: classId || undefined,
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
|
||||
const filteredItems = useMemo<HomeworkSubmissionItem[]>(() => {
|
||||
const items = data?.items ?? [];
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.studentName.toLowerCase().includes(q.toLowerCase()) ||
|
||||
item.studentNo.toLowerCase().includes(q.toLowerCase()) ||
|
||||
item.homeworkTitle.toLowerCase().includes(q.toLowerCase()),
|
||||
);
|
||||
}, [data, q]);
|
||||
|
||||
const updateQuery = (key: string, value: string): void => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`/shell/teacher/homework/submissions?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const errorNode = error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-destructive">
|
||||
{tCommon("error.loadFailed", { message: String(error) })}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("submissions.mswNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title={t("submissions.title")}
|
||||
description={t("submissions.description")}
|
||||
icon={<ClipboardCheck className="size-6" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSearchInput
|
||||
placeholder={t("submissions.searchPlaceholder")}
|
||||
value={q}
|
||||
onChange={(v) => updateQuery("q", v)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={classId}
|
||||
onChange={(e) => updateQuery("classId", e.target.value)}
|
||||
className="h-9 w-40 rounded-md border border-input bg-background px-3 text-sm"
|
||||
placeholder={t("submissions.classIdPlaceholder")}
|
||||
aria-label={t("submissions.classIdFilter")}
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => updateQuery("status", e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("submissions.statusFilter")}
|
||||
>
|
||||
<option value="">{t("submissions.statusAll")}</option>
|
||||
<option value="SUBMITTED">
|
||||
{t("submissions.statusSubmitted")}
|
||||
</option>
|
||||
<option value="GRADING">{t("submissions.statusGrading")}</option>
|
||||
<option value="GRADED">{t("submissions.statusGraded")}</option>
|
||||
<option value="RETURNED">{t("submissions.statusReturned")}</option>
|
||||
<option value="LATE">{t("submissions.statusLate")}</option>
|
||||
</select>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
loadingNode={<ListPageSkeleton rows={8} />}
|
||||
empty={filteredItems.length === 0 && !loading}
|
||||
errorNode={errorNode}
|
||||
pagination={
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("submissions.total", { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SubmissionsTable items={filteredItems} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交列表表格(纯展示组件)。
|
||||
*/
|
||||
function SubmissionsTable({
|
||||
items,
|
||||
}: {
|
||||
items: HomeworkSubmissionItem[];
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("homework");
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/30">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colStudent")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colHomework")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colClass")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colStatus")}
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">
|
||||
{t("submissions.colSubmittedAt")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("submissions.colScore")}
|
||||
</th>
|
||||
<th className="p-3 text-right font-medium">
|
||||
{t("submissions.colActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{s.studentName}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">{s.studentNo}</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/${s.homeworkId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{s.homeworkTitle}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-xs">{s.className}</td>
|
||||
<td className="p-3">
|
||||
<span
|
||||
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${submissionStatusToBadgeClass(s.status)}`}
|
||||
>
|
||||
{formatSubmissionStatus(s.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{formatDueDate(s.submittedAt)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{formatScore(s.totalScore)} / {s.maxScore}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
href={`/shell/teacher/homework/submissions/${s.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("submissions.toGrade")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Homework 数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测)
|
||||
*
|
||||
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
|
||||
* 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
|
||||
*/
|
||||
|
||||
import type { Homework, HomeworkListItem } from "@/lib/api";
|
||||
|
||||
/** 作业状态中文标签映射(对齐旧 teacher-portal HOMEWORK_STATUS_LABEL) */
|
||||
export const HOMEWORK_STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
CLOSED: "已关闭",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
/** 提交状态中文标签映射 */
|
||||
export const SUBMISSION_STATUS_LABEL: Record<string, string> = {
|
||||
SUBMITTED: "已提交",
|
||||
GRADING: "批改中",
|
||||
GRADED: "已批改",
|
||||
RETURNED: "已退回",
|
||||
LATE: "迟交",
|
||||
};
|
||||
|
||||
/**
|
||||
* 将作业状态枚举值映射为中文标签。
|
||||
* 未知状态回退为原始值。
|
||||
*/
|
||||
export function formatHomeworkStatus(status: string): string {
|
||||
return HOMEWORK_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将提交状态枚举值映射为中文标签。
|
||||
* 未知状态回退为原始值。
|
||||
*/
|
||||
export function formatSubmissionStatus(status: string): string {
|
||||
return SUBMISSION_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 ISO 日期字符串为本地化展示(zh-CN,含年月日时分)。
|
||||
* 输入无效时返回占位符。
|
||||
*/
|
||||
export function formatDueDate(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return "--";
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return "--";
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化宽限期(小时)为友好展示。
|
||||
* - 0 小时:返回 "无宽限期"
|
||||
* - < 24 小时:返回 "N 小时"
|
||||
* - >= 24 小时:返回 "X 天 Y 小时"
|
||||
*/
|
||||
export function formatGracePeriod(hours: number): string {
|
||||
if (!Number.isFinite(hours) || hours <= 0) return "无宽限期";
|
||||
if (hours < 24) return `${hours} 小时`;
|
||||
const days = Math.floor(hours / 24);
|
||||
const rest = hours % 24;
|
||||
return rest === 0 ? `${days} 天` : `${days} 天 ${rest} 小时`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断作业是否已过截止时间(基于 dueDate)。
|
||||
* 输入无效返回 false。
|
||||
*/
|
||||
export function isOverdue(
|
||||
dueDate: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!dueDate) return false;
|
||||
const d = new Date(dueDate);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
return d.getTime() < now.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断作业是否处于可编辑状态(DRAFT)。
|
||||
*/
|
||||
export function isHomeworkEditable(status: string): boolean {
|
||||
return status === "DRAFT";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断作业是否已发布(PUBLISHED)。
|
||||
*/
|
||||
export function isHomeworkPublished(status: string): boolean {
|
||||
return status === "PUBLISHED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从作业详情中提取列表项视图模型(裁剪字段)。
|
||||
*/
|
||||
export function toHomeworkListItem(homework: Homework): HomeworkListItem {
|
||||
return {
|
||||
id: homework.id,
|
||||
classId: homework.classId,
|
||||
subjectId: homework.subjectId,
|
||||
title: homework.title,
|
||||
description: homework.description,
|
||||
dueDate: homework.dueDate,
|
||||
gracePeriod: homework.gracePeriod,
|
||||
status: homework.status,
|
||||
createdAt: homework.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断提交是否已完成批改(GRADED / RETURNED)。
|
||||
*/
|
||||
export function isGraded(status: string): boolean {
|
||||
return status === "GRADED" || status === "RETURNED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断提交是否处于待批改状态(SUBMITTED / LATE)。
|
||||
*/
|
||||
export function isPendingGrading(status: string): boolean {
|
||||
return status === "SUBMITTED" || status === "LATE";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分数(数值)格式化为展示字符串,保留 1 位小数。
|
||||
* 输入无效(null/NaN/Infinity)返回 "--"。
|
||||
*/
|
||||
export function formatScore(score: number | null | undefined): string {
|
||||
if (score === null || score === undefined) return "--";
|
||||
if (!Number.isFinite(score)) return "--";
|
||||
return score.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 0-1 的小数格式化为百分比字符串(如 0.789 → "79%")。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatSubmissionRate(rate: number): string {
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 1) return "--";
|
||||
return `${(rate * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算提交率(已提交数 / 总人数)。
|
||||
* 总人数为 0 时返回 0。
|
||||
*/
|
||||
export function calcSubmissionRate(
|
||||
submittedCount: number,
|
||||
totalStudents: number,
|
||||
): number {
|
||||
if (!Number.isFinite(totalStudents) || totalStudents <= 0) return 0;
|
||||
if (!Number.isFinite(submittedCount) || submittedCount < 0) return 0;
|
||||
return Math.min(submittedCount / totalStudents, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据得分率(score/maxScore)返回等级标签(A/B/C/D)。
|
||||
* - rate >= 0.85 → A
|
||||
* - rate >= 0.7 → B
|
||||
* - rate >= 0.6 → C
|
||||
* - 其他 → D
|
||||
*
|
||||
* maxScore 为 0 或输入无效时返回 "--"。
|
||||
*/
|
||||
export function formatGradeLevel(
|
||||
score: number | null | undefined,
|
||||
maxScore: number,
|
||||
): string {
|
||||
if (score === null || score === undefined) return "--";
|
||||
if (!Number.isFinite(score) || !Number.isFinite(maxScore) || maxScore <= 0) {
|
||||
return "--";
|
||||
}
|
||||
const rate = score / maxScore;
|
||||
if (rate >= 0.85) return "A";
|
||||
if (rate >= 0.7) return "B";
|
||||
if (rate >= 0.6) return "C";
|
||||
return "D";
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据等级(A/B/C/D)返回 Tailwind 文本语义类名。
|
||||
*/
|
||||
export function gradeLevelToColorClass(level: string): string {
|
||||
switch (level) {
|
||||
case "A":
|
||||
return "text-emerald-600";
|
||||
case "B":
|
||||
return "text-blue-600";
|
||||
case "C":
|
||||
return "text-amber-600";
|
||||
case "D":
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据提交状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function submissionStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
case "LATE":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "GRADING":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "GRADED":
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
|
||||
case "RETURNED":
|
||||
return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据作业状态返回 Tailwind 徽章语义类名。
|
||||
*/
|
||||
export function homeworkStatusToBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "DRAFT":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "PUBLISHED":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "CLOSED":
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
|
||||
case "ARCHIVED":
|
||||
return "bg-muted text-muted-foreground";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 AI 评分置信度返回 Tailwind 文本语义类名。
|
||||
* - >= 0.8 → text-emerald-600(高置信)
|
||||
* - >= 0.6 → text-amber-600(中置信)
|
||||
* - 其他 → text-destructive(低置信)
|
||||
*/
|
||||
export function confidenceToColorClass(confidence: number): string {
|
||||
if (!Number.isFinite(confidence)) return "text-muted-foreground";
|
||||
if (confidence >= 0.8) return "text-emerald-600";
|
||||
if (confidence >= 0.6) return "text-amber-600";
|
||||
return "text-destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化置信度(0-1)为百分比字符串(如 0.92 → "92%")。
|
||||
* 输入无效返回 "--"。
|
||||
*/
|
||||
export function formatConfidence(confidence: number): string {
|
||||
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
|
||||
return "--";
|
||||
}
|
||||
return `${(confidence * 100).toFixed(0)}%`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user