feat(parent-portal): 完整实现 P4+P5+P6 家长端微前端

实现内容(仲裁裁决驱动,首次即最终方案):

P4 核心功能
- 认证:localStorage token 存储(F12)+ REST 登录(ISSUE-004)+ refreshAccessToken 竞态防护
- 子女切换:ChildSwitcher(Tab ≤3 / 下拉 ≥4)+ Zustand store(ISSUE-009 纯前端切换)
- 数据查询:urql GraphQL 消费 parent-bff(F9)+ TanStack Query 缓存
- 通知中心:NotificationFeed + 已读/全部已读 mutations
- 通知偏好:三维矩阵 + ISSUE-033 localStorage 降级
- 5 层状态管理:URL/Server/Client/Global UI/Form
- 跨标签同步:BroadcastChannel + storage 事件

P5 实时推送
- WebSocket 连接 push-gateway + 指数退避重连
- HTTP 轮询降级(60s)+ 实时通知 Hook

P6 硬化
- Web Vitals 上报 + OTel trace
- i18n 5 语言(zh-CN/en-US/zh-TW/ja-JP/ar-SA 含 RTL)
- PWA manifest + Service Worker
- CSP 安全头 + 权限点 F7 命名 + 设计令牌三层

测试与构建
- Vitest 92 测试全通过(utils/auth/child-store/ChildSwitcher/NotificationFeed/login)
- MSW mock 未就绪上游(parent-bff GraphQL + iam REST + iam GetChildrenByParent P0 阻塞用 fixtures)
- Dockerfile 多阶段构建(G1,端口 4002,HEALTHCHECK /api/health)
- typecheck + lint 零错误

经验沉淀
- known-issues.md §2.13 追加 12 条实现期经验(无 AI 身份标注)
- arch.db 已更新(15 TS 模块 / 482 符号 / 138 proto)

依据:02-architecture-design.md(回写总裁裁决)、coord-final-decisions.md、
president-final-rulings.md、parent-portal_workline.md、parent-portal_contract.md
This commit is contained in:
SpecialX
2026-07-10 17:40:27 +08:00
parent b54bfd101b
commit 5661938cc0
76 changed files with 9525 additions and 70 deletions

View File

@@ -0,0 +1,36 @@
# parent-portal 环境变量示例
# 复制为 .env.local 使用
# ===== API 网关 =====
# api-gateway 地址dev :8080
API_GATEWAY_URL=http://localhost:8080
# ===== 微前端MF=====
# MF 开关enabled 启用 MF Remotedisabled 作为独立应用运行
# 依据ARB-002 MF Shell 暴露清单
NEXT_PUBLIC_MF_ENABLED=disabled
# ===== GraphQLF9 裁决)=====
# parent-bff GraphQL 端点(经 api-gateway 代理)
# 批次 3P4parent-bff 就绪前用 MSW mock
NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/parent/graphql
# ===== Mock =====
# MSW mock 开关enabled 启用 mockdisabled 调真实后端
# 依据workline.md §4.3 全并行 Mock 策略
NEXT_PUBLIC_API_MOCKING=enabled
# ===== 推送P5=====
# push-gateway WebSocket 地址
NEXT_PUBLIC_PUSH_GATEWAY_WS_URL=ws://localhost:8081/ws
# ===== 应用 =====
# 应用端口004 §1.2 强制 4 端 4000-4003
PORT=4002
# ===== 可观测性P6=====
# OTel collector 端点
NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT=
# Web Vitals 上报端点
NEXT_PUBLIC_WEB_VITALS_ENDPOINT=/api/v1/admin/web-vitals

View File

@@ -0,0 +1,52 @@
# 多阶段构建Next.js 生产镜像parent-portal MF Remote
# 用法docker build -t edu/parent-portal:latest -f apps/parent-portal/Dockerfile .
# 依据02-architecture-design.md §16 部署、G1 裁决(首次即多阶段)
# 端口40024 端 portal 强制 4000-4003
# ============ Builder ============
FROM node:20-alpine AS builder
WORKDIR /app
# 启用 pnpm
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# 先拷依赖清单,利用缓存
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
COPY apps/parent-portal/package.json ./apps/parent-portal/
# 安装依赖(含 devDependencies构建需要
RUN pnpm install --filter @edu/parent-portal... --frozen-lockfile || pnpm install --filter @edu/parent-portal...
# 拷源码
COPY apps/parent-portal ./apps/parent-portal
# 构建(禁用 telemetry生产模式
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN pnpm --filter @edu/parent-portal run build
# ============ Runtime ============
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=4002
# 非 root 用户运行
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# 拷构建产物与必要清单
COPY --from=builder /app/apps/parent-portal/package.json ./package.json
COPY --from=builder /app/apps/parent-portal/.next ./.next
COPY --from=builder /app/apps/parent-portal/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/parent-portal/next.config.js ./next.config.js
USER nextjs
EXPOSE 4002
# 健康检查
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --quiet --spider http://localhost:4002/api/health || exit 1
CMD ["node_modules/.bin/next", "start", "-p", "4002"]

View File

@@ -15,6 +15,21 @@
> 4. **遗漏补全**通知偏好数据模型细化、详细组件设计图、跨标签同步实现、API 版本演进、未来扩展铺垫、监控降级、模块演化策略
> 5. **新增章节**§14 通知偏好数据模型细化、§15 详细组件设计、§16 跨标签同步实现、§17 API 契约版本管理、§18 监控与降级、§19 模块演化与解耦、§20 长远架构愿景
> **总裁裁决回写**2026-07-10依据 [president-final-rulings.md](../../../docs/architecture/president-final-rulings.md) + [coord-final-decisions.md](../../../docs/architecture/coord-final-decisions.md)
>
> 1. **F9 GraphQL 裁决**:删除"P4 用 REST 后续切 GraphQL"过渡方案描述ISSUE-034-ai15 / ISSUE-032-ai15。parent-portal **首次实现即用 GraphQLurql**,不再使用 REST 消费 parent-bff。登录端点 `POST /api/v1/iam/login` 是唯一走 REST 的端点ISSUE-004
> 2. **F7 权限点命名**:统一 `<RESOURCE>_<ACTION>[_<SCOPE>]`,数据范围后缀 `_OWN`/`_CHILD`。原文档权限点已对齐(如 `PARENT_DASHBOARD_VIEW`、`GRADE_READ_CHILD`)。本文档原 `GRADES_READ_CHILD` 改为 `GRADE_READ_CHILD``HOMEWORK_READ_CHILD` 改为 `HOMEWORK_READ_CHILD`(单数资源)。
> 3. **F4 i18n key 命名**:统一 `error.<service>.<code_snake>`(如 `error.iam.invalid_credentials`、`error.bffParent.validation_error`)。
> 4. **F5 错误码子前缀**:删除 `GRADES_`/`HOMEWORK_` 子前缀,统一用 `CORE_EDU_*`。
> 5. **F8 packages 归属**ai13 维护 ui-tokens/ui-components/hookscoord 维护 shared-ts/contracts。
> 6. **F10 MF 暴露粒度**:暴露 AppShell 整体。
> 7. **F12 localStorage token**P4 用 localStoragehttpOnly Cookie + CSRF Token P6 单独评估)。
> 8. **ISSUE-033-ai15 通知偏好**P4 阶段通知偏好设置页面 UI + 表单校验,偏好暂存前端 localStorage降级方案不调后端 APIP5 阶段 parent-bff 接入 msg 服务 GraphQL mutation `updateNotificationPreferences`。
> 9. **ISSUE-009 switchChild**纯前端状态localStorage + Zustand不调后端 API。
> 10. **ISSUE-010 iam GetChildrenByParent**P0 阻塞,用 mock固定 2 个子女 student-001 + student-002开发。
> **实现状态**:本文档描述的最终方案已全部落地于 `apps/parent-portal/`,使用 MSW mock 模拟未就绪的上游parent-bff GraphQL / iam login / push-gateway WebSocket由 `NEXT_PUBLIC_API_MOCKING=enabled` 控制。
---
## 1. 模块内部分层图简化版Remote 角色)

5
apps/parent-portal/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,87 @@
// parent-portal next.config.js
// Module Federation Remote 角色Shell = teacher-portal :4000
// 当 NEXT_PUBLIC_MF_ENABLED=enabled 时启用 MF否则作为独立 Next.js 应用运行
//
// 依据:
// - 02-architecture-design.md §1.2 MF 配置
// - ARB-002 MF Shell 暴露清单
// - F10 MF 暴露粒度AppShell 整体)
// - F9 GraphQLurql
const NextFederationPlugin =
process.env.NEXT_PUBLIC_MF_ENABLED === "enabled"
? require("@module-federation/nextjs-mf")
: null;
const remotes = (isServer) => ({
teacher: `teacher_app@http://localhost:4000/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// ESM 兼容MF 2.0 需要)
experimental: {
externalDir: true,
},
// 安全头F-安全硬化,复用 Shell 配置)
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
],
},
];
},
// API 代理:/api/v1/* → api-gateway
async rewrites() {
return [
{
source: "/api/v1/:path*",
destination: `${process.env.API_GATEWAY_URL || "http://localhost:8080"}/api/v1/:path*`,
},
];
},
};
// MF Remote 配置(仅当 NEXT_PUBLIC_MF_ENABLED=enabled 时启用)
if (NextFederationPlugin) {
const originalWebpack = nextConfig.webpack;
nextConfig.webpack = (config, options) => {
config.plugins.push(
new NextFederationPlugin({
name: "parent_app",
filename: "static/chunks/remoteEntry.js",
remotes: remotes(options.isServer),
exposes: {
"./pages": "./src/pages",
"./ChildSwitcher": "./src/components/ChildSwitcher",
},
shared: {
react: { singleton: true, requiredVersion: "^18.3.0" },
"react-dom": { singleton: true, requiredVersion: "^18.3.0" },
urql: { singleton: true },
graphql: { singleton: true },
"@tanstack/react-query": { singleton: true },
zustand: { singleton: true },
nuqs: { singleton: true },
},
extraOptions: { exposePages: false },
}),
);
if (typeof originalWebpack === "function") {
return originalWebpack(config, options);
}
return config;
};
}
module.exports = nextConfig;

View File

@@ -0,0 +1,55 @@
{
"name": "@edu/parent-portal",
"version": "0.1.0",
"private": true,
"description": "Edu 家长端微前端 RemoteMF",
"scripts": {
"dev": "next dev -p 4002",
"build": "next build",
"start": "next start -p 4002",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@module-federation/nextjs-mf": "^8.3.0",
"@tanstack/react-query": "^5.59.0",
"@urql/next": "^1.1.0",
"clsx": "^2.1.0",
"graphql": "^16.9.0",
"next": "^14.2.0",
"nuqs": "^1.19.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"@hookform/resolvers": "^3.9.0",
"recharts": "^2.12.0",
"tailwind-merge": "^2.5.0",
"urql": "^4.2.0",
"zod": "^3.23.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@playwright/test": "^1.47.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^2.1.0",
"autoprefixer": "^10.4.0",
"eslint": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"jsdom": "^25.0.0",
"msw": "^2.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}

View File

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

View File

@@ -0,0 +1,25 @@
{
"name": "Edu 家长端",
"short_name": "家长端",
"description": "K12 智慧教务平台 - 家长端",
"start_url": "/parent/dashboard",
"display": "standalone",
"background_color": "#faf9f6",
"theme_color": "#1e3a5f",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["education", "productivity"]
}

View File

@@ -0,0 +1,275 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker (2.4.0).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.4.0'
const INTEGRITY_CHECKSUM = '0070d3e49f0d1f2c83d8e93b2c2c1a35'
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
})
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 Mock Service Worker.
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.
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
}
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
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() {
const headers = Object.fromEntries(requestClone.headers.entries())
// Remove internal MSW request header so the passthrough request
// matches the original request body.
delete headers['x-msw-intention']
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests.
if (request.mode === 'same-origin' && request.destination === 'document') {
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': {
await respondWithMock(event, clientMessage.data)
return respondWithMock(event, 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(event, response) {
// Setting response status code to 0 is a known no-op.
if (response.status === 0) {
return
}
const mockedResponse = new Response(response.body, response)
// Mark the mocked response for MSW internals.
mockedResponse[IS_MOCKED_RESPONSE] = true
return mockedResponse
}

View File

@@ -0,0 +1,18 @@
// 健康检查Liveness进程存活
// 依据project_rules §12 可观测性规范、02-architecture-design.md §7 健康检查
// 路径GET /api/health
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export function GET(): NextResponse {
return NextResponse.json(
{
status: "ok",
service: "parent-portal",
ts: Date.now(),
},
{ status: 200 },
);
}

View File

@@ -0,0 +1,38 @@
// 健康检查Readiness依赖就绪
// 依据project_rules §12 可观测性规范、02-architecture-design.md §7 健康检查
// 路径GET /api/ready
// 检查 api-gateway 可达性(超时 2s
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const maxDuration = 5;
export async function GET(): Promise<NextResponse> {
const gatewayUrl = process.env.API_GATEWAY_URL || "http://localhost:8080";
const checks: Record<string, "ok" | "fail"> = {};
// 检查 api-gateway 可达性(仅生产/预览环境,开发环境 mock 跳过)
const mockEnabled = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
if (mockEnabled) {
checks["api-gateway"] = "ok"; // mock 模式下视为就绪
} else {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${gatewayUrl}/healthz`, {
signal: controller.signal,
});
clearTimeout(timeout);
checks["api-gateway"] = res.ok ? "ok" : "fail";
} catch {
checks["api-gateway"] = "fail";
}
}
const allReady = Object.values(checks).every((v) => v === "ok");
return NextResponse.json(
{ status: allReady ? "ready" : "not_ready", checks, ts: Date.now() },
{ status: allReady ? 200 : 503 },
);
}

View File

@@ -0,0 +1,203 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* parent-portal 设计令牌三层模型
* 依据02-architecture-design.md §9 设计令牌三层(复用 packages/ui-tokens
* project_rules §3.10 设计令牌规范
*
* Layer 1 Primitive原始色板/字号/间距/阴影(业务代码不直接引用)
* Layer 2 Semantic语义令牌light/dark业务代码唯一引用入口
* Layer 3 Tailwind Theme@theme inline 暴露为 bg-*/text-*/font-*
*/
:root {
/* ===== Layer 1 Primitive ===== */
/* 色板HSL避免 #hex 字面量project_rules §3.10*/
--color-paper-50: 40 20% 98%;
--color-paper-100: 40 18% 96%;
--color-ink-900: 25 3% 15%;
--color-ink-600: 30 5% 45%;
--color-ink-300: 30 10% 75%;
--color-accent-600: 220 60% 35%;
--color-accent-100: 220 60% 95%;
--color-success-600: 142 60% 35%;
--color-warning-600: 38 80% 45%;
--color-danger-600: 0 70% 45%;
/* 字号(禁止 font-size: Npxproject_rules §3.10*/
--font-size-1: 0.75rem;
--font-size-2: 0.875rem;
--font-size-3: 1rem;
--font-size-4: 1.125rem;
--font-size-5: 1.25rem;
--font-size-6: 1.5rem;
--font-size-7: 1.875rem;
--font-size-8: 2.25rem;
--font-size-9: 3rem;
/* 行高 */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-loose: 1.75;
/* 间距 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--space-8: 3rem;
--space-10: 4rem;
--space-12: 6rem;
--space-16: 8rem;
/* 圆角 */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
/* 阴影 */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
/* ===== Layer 2 Semanticlight===== */
--paper: var(--color-paper-50);
--paper-elevated: var(--color-paper-100);
--ink: var(--color-ink-900);
--ink-muted: var(--color-ink-600);
--ink-subtle: var(--color-ink-300);
--accent: var(--color-accent-600);
--accent-soft: var(--color-accent-100);
--success: var(--color-success-600);
--warning: var(--color-warning-600);
--danger: var(--color-danger-600);
--rule: 30 10% 90%;
/* 字体禁止硬编码字体名project_rules §3.10*/
--font-serif: var(--font-fraunces), Georgia, serif;
--font-sans: var(--font-inter), system-ui, sans-serif;
--font-mono: var(--font-jetbrains-mono), monospace;
}
[data-theme="dark"] {
/* ===== Layer 2 Semanticdark===== */
--paper: 220 15% 12%;
--paper-elevated: 220 15% 16%;
--ink: 40 20% 95%;
--ink-muted: 40 10% 65%;
--ink-subtle: 40 5% 45%;
--accent: 220 70% 60%;
--accent-soft: 220 70% 20%;
--success: 142 60% 50%;
--warning: 38 80% 55%;
--danger: 0 70% 55%;
--rule: 220 10% 25%;
}
/* ===== 全局基础样式 ===== */
html,
body {
background: hsl(var(--paper));
color: hsl(var(--ink));
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-serif);
font-weight: 600;
letter-spacing: -0.01em;
}
/* 纸感分隔线(复用 teacher-portal 风格)*/
.rule {
border-top: 1px solid hsl(var(--rule));
}
.rule-thin {
border-top: 2px solid hsl(var(--rule));
}
.mark-left {
border-left: 2px solid hsl(var(--rule));
padding-left: 12px;
}
/* 可访问性聚焦轮廓WCAG 2.2 AA*/
:focus-visible {
outline: 2px solid hsl(var(--accent));
outline-offset: 2px;
}
/* 跳转到主内容链接a11y*/
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: hsl(var(--accent));
color: hsl(var(--paper));
padding: 8px 16px;
z-index: 100;
border-radius: 0 0 var(--radius-md) 0;
}
.skip-link:focus {
top: 0;
}
/* 骨架屏动画 */
@keyframes skeleton-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.skeleton {
background: hsl(var(--rule));
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
/* 滚动条样式Webkit*/
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--paper));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--ink-subtle));
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--ink-muted));
}
/* 禁用动画(用户偏好)*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,49 @@
// RootLayout字体 + 全局样式 + Providers
// 依据02-architecture-design.md §9 设计令牌三层、project_rules §3.10 设计令牌规范
// - 字体Intersans/ Frauncesserif/ JetBrains Monomono经 next/font 加载
// - 全局样式globals.css设计令牌 + 基础样式 + a11y
// - ProvidersQueryClient + Urql + MSW
import "./globals.css";
import type { Metadata, Viewport } from "next";
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
import { Providers } from "./providers";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-fraunces" });
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
});
export const metadata: Metadata = {
title: "Edu 家长端",
description: "K12 智慧教务平台 - 家长端",
manifest: "/manifest.json",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "hsl(40, 20%, 98%)",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="zh-CN"
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
>
<body>
<a href="#main-content" className="skip-link">
</a>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,93 @@
// 登录页集成测试MSW + Next.js 路由)
// 依据02-architecture-design.md §13 测试策略、ISSUE-004 登录走 REST
// 覆盖:正确凭据登录跳转 / 错误凭据显示错误 / 加载态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
vi,
} from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse, delay } from "msw";
import LoginPage from "./page";
import { server } from "@/test/mocks/server";
import { mockLoginResponse } from "@/test/mocks/fixtures";
// mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, replace: vi.fn(), refresh: vi.fn() }),
}));
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
server.resetHandlers();
localStorage.clear();
mockPush.mockReset();
});
afterAll(() => server.close());
describe("LoginPage 集成测试", () => {
it("渲染登录表单", () => {
render(<LoginPage />);
expect(
screen.getByRole("heading", { name: "Edu 家长端" }),
).toBeInTheDocument();
expect(screen.getByLabelText("邮箱")).toBeInTheDocument();
expect(screen.getByLabelText("密码")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "登录" })).toBeInTheDocument();
});
it("默认填充 mock 账号", () => {
render(<LoginPage />);
expect(screen.getByLabelText("邮箱")).toHaveValue("parent@example.com");
expect(screen.getByLabelText("密码")).toHaveValue("password");
});
it("正确凭据登录后跳转到 dashboard", async () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.click(screen.getByRole("button", { name: "登录" }));
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/parent/dashboard");
});
// token 已写入 localStorage
expect(localStorage.getItem("parent_access_token")).toBeTruthy();
});
it("错误凭据显示错误信息", async () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.clear(screen.getByLabelText("邮箱"));
await user.type(screen.getByLabelText("邮箱"), "wrong@example.com");
await user.clear(screen.getByLabelText("密码"));
await user.type(screen.getByLabelText("密码"), "wrong");
await user.click(screen.getByRole("button", { name: "登录" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/邮箱或密码错误/);
});
expect(mockPush).not.toHaveBeenCalled();
});
it("登录中显示加载态", async () => {
// 使用延迟响应以观察加载态
server.use(
http.post("/api/v1/iam/login", async () => {
await delay(200);
return HttpResponse.json({ success: true, data: mockLoginResponse });
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await user.click(screen.getByRole("button", { name: "登录" }));
// 按钮文本变为"登录中..."
expect(screen.getByRole("button")).toHaveTextContent(/登录中/);
});
});

View File

@@ -0,0 +1,102 @@
// 登录页(独立模式 / MF 未启用时使用)
// 依据02-architecture-design.md §3.1 路由结构、ISSUE-004 登录走 REST
// MF 启用时由 Shell 提供登录;独立模式由本页提供
// mock 账号parent@example.com / password
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { login } from "@/lib/auth";
import { cn } from "@/lib/utils";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("parent@example.com");
const [password, setPassword] = useState("password");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(email, password);
router.push("/parent/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<header className="mb-8 text-center">
<h1 className="font-serif text-3xl">Edu </h1>
<p className="mt-2 text-sm text-ink-muted"></p>
</header>
<form
onSubmit={handleSubmit}
className="space-y-4 rounded-lg border border-rule bg-paper-elevated p-6"
aria-describedby={error ? "login-error" : undefined}
>
<div className="space-y-1">
<label htmlFor="email" className="block text-sm font-medium">
</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-rule bg-paper px-3 py-2 text-sm outline-none focus:border-accent"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-sm font-medium">
</label>
<input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-rule bg-paper px-3 py-2 text-sm outline-none focus:border-accent"
/>
</div>
{error && (
<p id="login-error" role="alert" className="text-sm text-danger">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className={cn(
"w-full rounded bg-accent px-4 py-2 text-sm font-medium text-paper",
"transition-opacity hover:opacity-90",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{loading ? "登录中..." : "登录"}
</button>
</form>
<p className="mt-4 text-center text-xs text-ink-muted">
mock parent@example.com / password
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,11 @@
// 首页:重定向到仪表盘或登录页
// 依据02-architecture-design.md §3.1 路由结构
// MF Remote 模式下Shell 会直接挂载 /parent/* 路由;独立模式下首页负责重定向
import { redirect } from "next/navigation";
export default function HomePage(): never {
// 客户端导航由 middleware 处理认证;首页直接重定向到 dashboard
// 未认证时 dashboard 路由会再次重定向到 /login
redirect("/parent/dashboard");
}

View File

@@ -0,0 +1,33 @@
// 考勤页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/attendance
"use client";
import { useChildAttendance } from "@/hooks/useChildAttendance";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { AttendanceCalendar } from "@/components/AttendanceCalendar";
export default function AttendancePage() {
const { currentChild } = useChildSwitcher();
const { attendance, loading, error } = useChildAttendance();
if (loading) {
return <span className="skeleton h-64 w-full rounded" />;
}
if (error) {
return (
<div className="rounded border border-danger p-4 text-sm text-danger">
{error.message}
</div>
);
}
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
<AttendanceCalendar records={attendance} />
</div>
);
}

View File

@@ -0,0 +1,9 @@
// 仪表盘页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/dashboard
import { ParentDashboard } from "@/components/ParentDashboard";
export default function DashboardPage() {
return <ParentDashboard />;
}

View File

@@ -0,0 +1,85 @@
// 成绩页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/grades
"use client";
import { useChildGrades } from "@/hooks/useChildGrades";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { ChildGradeChart } from "@/components/ChildGradeChart";
import { formatDate, scoreToGrade, gradeToChinese, cn } from "@/lib/utils";
export default function GradesPage() {
const { currentChild } = useChildSwitcher();
const { grades, loading, error } = useChildGrades();
if (loading) {
return (
<div className="space-y-4">
<span className="skeleton h-8 w-48 rounded" />
<span className="skeleton h-72 w-full rounded" />
</div>
);
}
if (error) {
return (
<div className="rounded border border-danger p-4 text-sm text-danger">
{error.message}
</div>
);
}
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
{grades.length > 0 && <ChildGradeChart grades={grades} />}
<div className="rounded border border-rule bg-paper-elevated p-4">
<h3 className="font-serif text-base"></h3>
<table className="mt-3 w-full text-sm">
<thead>
<tr className="border-b border-rule text-left text-xs text-ink-muted">
<th className="pb-2"></th>
<th className="pb-2"></th>
<th className="pb-2"></th>
<th className="pb-2 text-right"></th>
<th className="pb-2 text-right"></th>
<th className="pb-2 text-right"></th>
</tr>
</thead>
<tbody>
{grades.map((g) => {
const grade = scoreToGrade(g.studentScore, g.classMax ?? 100);
return (
<tr
key={g.examId}
className="border-b border-rule last:border-b-0"
>
<td className="py-2 font-medium">{g.subject}</td>
<td className="py-2 text-ink-muted">{g.examName}</td>
<td className="py-2 text-ink-muted">
{formatDate(g.examDate)}
</td>
<td className="py-2 text-right font-mono">
{g.studentScore}
</td>
<td className="py-2 text-right font-mono text-ink-muted">
{g.classAverage ?? "-"}
</td>
<td
className={cn("py-2 text-right font-medium")}
title={gradeToChinese(grade)}
>
{grade}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
// 作业页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/homework
"use client";
import { useState } from "react";
import { useChildHomework } from "@/hooks/useChildHomework";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { formatDate, cn } from "@/lib/utils";
import type { HomeworkItem } from "@/types";
const STATUS_LABELS: Record<HomeworkItem["status"], string> = {
not_started: "未开始",
in_progress: "进行中",
submitted: "已提交",
graded: "已评分",
};
const STATUS_STYLES: Record<HomeworkItem["status"], string> = {
not_started: "text-danger",
in_progress: "text-warning",
submitted: "text-ink-muted",
graded: "text-success",
};
const FILTERS = [
{ value: "", label: "全部" },
{ value: "not_started", label: "未开始" },
{ value: "in_progress", label: "进行中" },
{ value: "submitted", label: "已提交" },
{ value: "graded", label: "已评分" },
];
export default function HomeworkPage() {
const { currentChild } = useChildSwitcher();
const [filter, setFilter] = useState("");
const { homework, loading, error } = useChildHomework(filter || undefined);
if (loading) {
return <span className="skeleton h-64 w-full rounded" />;
}
if (error) {
return (
<div className="rounded border border-danger p-4 text-sm text-danger">
{error.message}
</div>
);
}
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
<div className="flex gap-2">
{FILTERS.map((f) => (
<button
key={f.value}
type="button"
onClick={() => setFilter(f.value)}
className={cn(
"rounded px-3 py-1 text-sm transition-colors",
filter === f.value
? "bg-accent text-paper"
: "border border-rule text-ink-muted hover:bg-paper-elevated",
)}
>
{f.label}
</button>
))}
</div>
<div className="space-y-3">
{homework.length === 0 ? (
<p className="text-sm text-ink-muted"></p>
) : (
homework.map((hw) => (
<div
key={hw.id}
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-serif text-base">{hw.title}</h3>
<p className="mt-1 text-xs text-ink-muted">
{hw.subject} · {hw.className}
</p>
</div>
<span
className={cn(
"text-sm font-medium",
STATUS_STYLES[hw.status],
)}
>
{STATUS_LABELS[hw.status]}
</span>
</div>
<div className="mt-3 flex gap-6 text-xs text-ink-muted">
<span>{formatDate(hw.assignedDate)}</span>
<span>{formatDate(hw.dueDate)}</span>
{hw.score !== undefined && (
<span>
<span className="font-mono text-ink">{hw.score}</span>{" "}
/ {hw.maxScore}
</span>
)}
</div>
{hw.feedback && (
<p className="mt-2 border-t border-rule pt-2 text-sm mark-left">
{hw.feedback}
</p>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
// parent 布局:认证守卫 + AppShell + MultiChildTabBar
// 依据02-architecture-design.md §3.1 路由结构、§6 应用外壳
// - 未认证重定向到 /login
// - MF 启用时由 Shell 提供外壳(本布局的 AppShell 仅独立模式使用)
"use client";
import { type ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated } from "@/lib/auth";
import { AppShell } from "@/components/AppShell";
import { MultiChildTabBar } from "@/components/MultiChildTabBar";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { useCrossTabSync } from "@/hooks/useCrossTabSync";
import { EmptyChildState } from "@/components/EmptyChildState";
export default function ParentLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const [checked, setChecked] = useState(false);
const { children: childList, loading } = useChildSwitcher();
useCrossTabSync();
useEffect(() => {
if (!isAuthenticated()) {
router.replace("/login");
return;
}
setChecked(true);
}, [router]);
if (!checked) {
return (
<div className="flex min-h-screen items-center justify-center">
<span className="skeleton h-8 w-48 rounded" />
</div>
);
}
return (
<AppShell>
{childList.length === 0 && !loading ? (
<EmptyChildState />
) : (
<>
<MultiChildTabBar />
{children}
</>
)}
</AppShell>
);
}

View File

@@ -0,0 +1,17 @@
// 通知中心页面
// 依据02-architecture-design.md §3.1 路由结构、§15.7 通知列表
// 路由:/parent/notifications
// P5 增强WebSocket 实时推送 + HTTP 轮询降级
"use client";
import { NotificationFeed } from "@/components/NotificationFeed";
export default function NotificationsPage() {
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl"></h1>
<NotificationFeed />
</div>
);
}

View File

@@ -0,0 +1,17 @@
// 通知偏好页面
// 依据02-architecture-design.md §3.1 路由结构、§14 通知偏好
// 路由:/parent/preferences
// ISSUE-033P4 localStorage 降级
"use client";
import { PreferenceForm } from "@/components/PreferenceForm";
export default function PreferencesPage() {
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl"></h1>
<PreferenceForm />
</div>
);
}

View File

@@ -0,0 +1,84 @@
// 学情页面
// 依据02-architecture-design.md §3.1 路由结构
// 路由:/parent/weakness
"use client";
import { useChildWeakness } from "@/hooks/useChildWeakness";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { formatPercent, cn } from "@/lib/utils";
export default function WeaknessPage() {
const { currentChild } = useChildSwitcher();
const { weaknesses, loading, error } = useChildWeakness();
if (loading) {
return <span className="skeleton h-64 w-full rounded" />;
}
if (error) {
return (
<div className="rounded border border-danger p-4 text-sm text-danger">
{error.message}
</div>
);
}
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild?.name}</h1>
<div className="space-y-3">
{weaknesses.length === 0 ? (
<p className="text-sm text-ink-muted"></p>
) : (
weaknesses.map((wp) => {
const mastery = formatPercent(wp.masteryLevel);
const isWeak = wp.masteryLevel < 0.6;
return (
<div
key={wp.id}
className="rounded border border-rule bg-paper-elevated p-4"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-serif text-base">
{wp.knowledgePoint}
</h3>
<p className="mt-1 text-xs text-ink-muted">{wp.subject}</p>
</div>
<div className="text-right">
<p
className={cn(
"font-mono text-lg",
isWeak ? "text-danger" : "text-success",
)}
>
{mastery}
</p>
<p className="text-xs text-ink-muted"></p>
</div>
</div>
{/* 掌握度进度条 */}
<div className="mt-3 h-2 overflow-hidden rounded bg-rule">
<div
className={cn(
"h-full rounded",
isWeak ? "bg-danger" : "bg-success",
)}
style={{ width: `${wp.masteryLevel * 100}%` }}
/>
</div>
{wp.recommendation && (
<p className="mt-3 border-t border-rule pt-2 text-sm mark-left">
{wp.recommendation}
</p>
)}
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
// 客户端 ProvidersQueryClient + Urql + MSW 初始化
// 依据02-architecture-design.md §4.3 状态管理分层、§8 MSW
// - NEXT_PUBLIC_API_MOCKING=enabled 时启动 MSW worker
// - QueryClient 单例useState 保持稳定)
// - Urql client 单例
"use client";
import { useState, useEffect, type ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { Provider as UrqlProvider } from "urql";
import { createQueryClient } from "@/lib/query-client";
import { getGraphQLClient } from "@/lib/graphql-client";
const isMockingEnabled = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
const [queryClient] = useState(() => createQueryClient());
const [urqlClient] = useState(() => getGraphQLClient());
const [mswReady, setMswReady] = useState(!isMockingEnabled);
// MSW 初始化(仅浏览器 + mock 开启时)
useEffect(() => {
if (!isMockingEnabled || typeof window === "undefined") return;
let active = true;
(async () => {
try {
const { worker } = await import("@/test/mocks/browser");
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
if (active) setMswReady(true);
} catch (err) {
// MSW 启动失败不阻塞应用
console.warn("[MSW] 启动失败,将使用真实 API", err);
if (active) setMswReady(true);
}
})();
return () => {
active = false;
};
}, []);
// MSW 未就绪时显示加载态(避免 mock 数据未注入时闪烁)
if (!mswReady) {
return (
<div
role="status"
aria-live="polite"
className="flex min-h-screen items-center justify-center"
>
<span className="skeleton h-8 w-48 rounded" />
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
<UrqlProvider value={urqlClient}>{children}</UrqlProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,126 @@
// AppShell应用外壳独立模式
// 依据02-architecture-design.md §6 应用外壳、F10 MF 暴露 AppShell 整体
// - MF 启用时由 Shell(teacher-portal) 提供外壳,本组件仅独立模式使用
// - 布局:顶部头部 + 左侧导航 + 中间内容区
// - 头部含 ChildSwitcher + 用户菜单
"use client";
import { type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChildSwitcher } from "./ChildSwitcher";
import { getUser, logout } from "@/lib/auth";
import { PERMISSIONS } from "@/lib/permissions";
import { usePermission } from "@/hooks/usePermission";
import { cn } from "@/lib/utils";
interface NavItem {
label: string;
href: string;
permission: string;
}
const NAV_ITEMS: NavItem[] = [
{
label: "仪表盘",
href: "/parent/dashboard",
permission: PERMISSIONS.DASHBOARD_VIEW,
},
{
label: "成绩",
href: "/parent/grades",
permission: PERMISSIONS.CHILD_GRADE_VIEW,
},
{
label: "考勤",
href: "/parent/attendance",
permission: PERMISSIONS.CHILD_ATTENDANCE_VIEW,
},
{
label: "作业",
href: "/parent/homework",
permission: PERMISSIONS.CHILD_HOMEWORK_VIEW,
},
{
label: "学情",
href: "/parent/weakness",
permission: PERMISSIONS.CHILD_WEAKNESS_VIEW,
},
{
label: "通知",
href: "/parent/notifications",
permission: PERMISSIONS.NOTIFICATION_VIEW,
},
{
label: "偏好",
href: "/parent/preferences",
permission: PERMISSIONS.NOTIFICATION_PREFERENCE_UPDATE,
},
];
export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const user = getUser();
const { hasPermission } = usePermission();
return (
<div className="flex min-h-screen flex-col">
{/* 头部 */}
<header className="flex items-center justify-between border-b border-rule px-6 py-3">
<div className="flex items-center gap-6">
<Link href="/parent/dashboard" className="font-serif text-lg">
Edu
</Link>
<ChildSwitcher />
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-ink-muted">{user?.name}</span>
<button
type="button"
onClick={logout}
className="text-sm text-ink-muted hover:text-danger"
>
退
</button>
</div>
</header>
<div className="flex flex-1">
{/* 左侧导航 */}
<nav aria-label="主导航" className="w-48 border-r border-rule py-4">
<ul className="space-y-1 px-2">
{NAV_ITEMS.filter((item) =>
hasPermission(item.permission as never),
).map((item) => {
const isActive = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"block rounded px-3 py-2 text-sm transition-colors",
isActive
? "bg-accent-soft font-medium text-ink"
: "text-ink-muted hover:bg-paper-elevated hover:text-ink",
)}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
{/* 主内容区 */}
<main id="main-content" className="flex-1 overflow-x-hidden px-6 py-6">
{children}
</main>
</div>
</div>
);
}
export default AppShell;

View File

@@ -0,0 +1,143 @@
// AttendanceCalendar月度考勤日历
// 依据02-architecture-design.md §15.5 AttendanceCalendar 设计
// - 月历视图,每天用颜色点标记考勤状态
// - present=success / late=warning / absent=danger / leave=ink-subtle
"use client";
import { useMemo } from "react";
import type { AttendanceRecord } from "@/types";
import { cn } from "@/lib/utils";
interface AttendanceCalendarProps {
records: AttendanceRecord[];
}
const STATUS_STYLES: Record<AttendanceRecord["status"], string> = {
present: "bg-success",
late: "bg-warning",
absent: "bg-danger",
leave: "bg-ink-subtle",
};
const STATUS_LABELS: Record<AttendanceRecord["status"], string> = {
present: "出勤",
late: "迟到",
absent: "缺勤",
leave: "请假",
};
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
export function AttendanceCalendar({ records }: AttendanceCalendarProps) {
const { year, month, days, recordMap } = useMemo(() => {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const firstDay = new Date(y, m, 1);
const lastDay = new Date(y, m + 1, 0);
const startWeekday = firstDay.getDay();
const totalDays = lastDay.getDate();
const dayCells: (number | null)[] = [];
for (let i = 0; i < startWeekday; i++) dayCells.push(null);
for (let d = 1; d <= totalDays; d++) dayCells.push(d);
while (dayCells.length % 7 !== 0) dayCells.push(null);
const map = new Map<string, AttendanceRecord>();
for (const r of records) {
map.set(r.date, r);
}
return { year: y, month: m, days: dayCells, recordMap: map };
}, [records]);
// 统计
const stats = useMemo(() => {
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
for (const r of records) {
counts[r.status]++;
}
return counts;
}, [records]);
return (
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="flex items-center justify-between">
<h3 className="font-serif text-base">
{year}{month + 1}
</h3>
<div className="flex gap-3 text-xs">
{(Object.keys(STATUS_LABELS) as AttendanceRecord["status"][]).map(
(s) => (
<span key={s} className="flex items-center gap-1">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
STATUS_STYLES[s],
)}
/>
{STATUS_LABELS[s]}
</span>
),
)}
</div>
</div>
{/* 日历网格 */}
<div className="mt-4">
<div className="grid grid-cols-7 gap-1 text-center text-xs text-ink-muted">
{WEEKDAYS.map((w) => (
<div key={w} className="py-1">
{w}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{days.map((day, idx) => {
if (day === null) {
return <div key={idx} className="aspect-square" />;
}
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const record = recordMap.get(dateStr);
return (
<div
key={idx}
className="flex aspect-square flex-col items-center justify-center rounded text-sm"
title={record ? STATUS_LABELS[record.status] : undefined}
>
<span className="text-xs">{day}</span>
{record && (
<span
className={cn(
"mt-0.5 inline-block h-1.5 w-1.5 rounded-full",
STATUS_STYLES[record.status],
)}
/>
)}
</div>
);
})}
</div>
</div>
{/* 统计 */}
<div className="mt-4 flex gap-4 border-t border-rule pt-3 text-sm">
<span className="text-ink-muted">
<span className="font-mono text-success">{stats.present}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono text-warning">{stats.late}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono text-danger">{stats.absent}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono">{stats.leave}</span>
</span>
</div>
</div>
);
}
export default AttendanceCalendar;

View File

@@ -0,0 +1,72 @@
// ChildGradeChart子女成绩图表recharts
// 依据02-architecture-design.md §15.6 成绩图表设计
// - 柱状图:学生分数 vs 班级平均
// - 响应式容器
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import type { GradeDataPoint } from "@/types";
interface ChildGradeChartProps {
grades: GradeDataPoint[];
}
export function ChildGradeChart({ grades }: ChildGradeChartProps) {
const data = grades.map((g) => ({
name: g.subject,
学生分数: g.studentScore,
班级平均: g.classAverage ?? 0,
}));
return (
<div className="rounded border border-rule bg-paper-elevated p-4">
<h3 className="font-serif text-base"></h3>
<div className="mt-4 h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 8, right: 8, bottom: 8, left: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--rule))" />
<XAxis
dataKey="name"
tick={{ fill: "hsl(var(--ink-muted))", fontSize: 12 }}
/>
<YAxis tick={{ fill: "hsl(var(--ink-muted))", fontSize: 12 }} />
<Tooltip
contentStyle={{
background: "hsl(var(--paper-elevated))",
border: "1px solid hsl(var(--rule))",
borderRadius: "0.5rem",
}}
labelStyle={{ color: "hsl(var(--ink))" }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar
dataKey="学生分数"
fill="hsl(var(--accent))"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="班级平均"
fill="hsl(var(--ink-subtle))"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
export default ChildGradeChart;

View File

@@ -0,0 +1,119 @@
// ChildSummaryCard子女概览卡片
// 依据02-architecture-design.md §15.4 ChildSummaryCard 设计
// - 平均分 / 班级排名 / 出勤率 / 待完成作业
// - 成绩趋势指示
// - 即将到来的事件
"use client";
import type { ChildSummary } from "@/types";
import { formatPercent, formatNumber, formatDate } from "@/lib/utils";
import { cn } from "@/lib/utils";
interface ChildSummaryCardProps {
summary: ChildSummary;
}
const TREND_LABELS = {
up: { text: "上升", color: "text-success" },
down: { text: "下降", color: "text-danger" },
stable: { text: "稳定", color: "text-ink-muted" },
} as const;
const DEFAULT_TREND = { text: "稳定", color: "text-ink-muted" };
export function ChildSummaryCard({ summary }: ChildSummaryCardProps) {
const trend = TREND_LABELS[summary.recentGradeTrend] ?? DEFAULT_TREND;
return (
<div className="space-y-4">
{/* 指标网格 */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Metric label="平均分" value={formatNumber(summary.avgScore)} />
<Metric
label="班级排名"
value={`${summary.classRank} / ${summary.classSize}`}
/>
<Metric label="出勤率" value={formatPercent(summary.attendanceRate)} />
<Metric
label="待完成作业"
value={formatNumber(summary.pendingHomeworkCount)}
highlight={summary.pendingHomeworkCount > 0}
/>
</div>
{/* 成绩趋势 */}
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="flex items-center justify-between">
<h3 className="font-serif text-base"></h3>
<span className={cn("text-sm", trend.color)}>{trend.text}</span>
</div>
<ul className="mt-3 space-y-2">
{summary.recentScores.slice(0, 5).map((score) => (
<li
key={score.examId}
className="flex items-center justify-between border-b border-rule pb-2 text-sm last:border-b-0 last:pb-0"
>
<div>
<span className="font-medium">{score.subject}</span>
<span className="ml-2 text-ink-muted">{score.examName}</span>
</div>
<div className="flex items-center gap-3">
<span className="font-mono">{score.studentScore}</span>
{score.classAverage !== undefined && (
<span className="text-xs text-ink-muted">
{score.classAverage}
</span>
)}
</div>
</li>
))}
</ul>
</div>
{/* 即将到来事件 */}
{summary.upcomingEvents && summary.upcomingEvents.length > 0 && (
<div className="rounded border border-rule bg-paper-elevated p-4">
<h3 className="font-serif text-base"></h3>
<ul className="mt-3 space-y-2">
{summary.upcomingEvents.map((event) => (
<li
key={event.id}
className="flex items-center justify-between text-sm"
>
<span className="mark-left">{event.title}</span>
<span className="text-ink-muted">
{formatDate(event.dueDate)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
function Metric({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
className={cn(
"rounded border border-rule bg-paper-elevated p-4",
highlight && "border-warning",
)}
>
<p className="text-xs text-ink-muted">{label}</p>
<p className="mt-1 font-serif text-2xl">{value}</p>
</div>
);
}
export default ChildSummaryCard;

View File

@@ -0,0 +1,170 @@
// ChildSwitcher 组件单测
// 依据02-architecture-design.md §15.1 ChildSwitcher 设计、§13 测试策略
// 覆盖:加载态 / 无子女空态 / 单子女显示 / 多子女下拉切换 / 键盘可访问性
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChildSwitcher } from "./ChildSwitcher";
import type { ChildInfo } from "@/types";
// mock useChildSwitcher hook
vi.mock("@/hooks/useChildSwitcher", () => ({
useChildSwitcher: vi.fn(),
}));
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
const mockChildren: ChildInfo[] = [
{
id: "student-001",
name: "张小明",
grade: "grade.7",
schoolName: "实验中学",
classId: "class-7-1",
className: "初一(1)班",
},
{
id: "student-002",
name: "张小红",
grade: "grade.5",
schoolName: "实验小学",
classId: "class-5-2",
className: "五年级(2)班",
},
];
const mockUseChildSwitcher = vi.mocked(useChildSwitcher);
beforeEach(() => {
mockUseChildSwitcher.mockReset();
});
describe("ChildSwitcher 加载态", () => {
it("loading 时显示骨架屏", () => {
mockUseChildSwitcher.mockReturnValue({
children: [],
currentChild: null,
currentChildId: null,
switchChild: vi.fn(),
loading: true,
error: undefined,
hasMultipleChildren: false,
});
render(<ChildSwitcher />);
expect(screen.getByLabelText("加载子女列表")).toBeInTheDocument();
});
});
describe("ChildSwitcher 无子女", () => {
it("空列表时显示未绑定提示", () => {
mockUseChildSwitcher.mockReturnValue({
children: [],
currentChild: null,
currentChildId: null,
switchChild: vi.fn(),
loading: false,
error: undefined,
hasMultipleChildren: false,
});
render(<ChildSwitcher />);
expect(screen.getByLabelText("未绑定子女")).toBeInTheDocument();
});
});
describe("ChildSwitcher 单子女", () => {
it("仅显示子女名称(无下拉)", () => {
mockUseChildSwitcher.mockReturnValue({
children: [mockChildren[0]!],
currentChild: mockChildren[0]!,
currentChildId: "student-001",
switchChild: vi.fn(),
loading: false,
error: undefined,
hasMultipleChildren: false,
});
render(<ChildSwitcher />);
expect(screen.getByLabelText("当前子女")).toHaveTextContent("张小明");
// 不应有下拉按钮
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
describe("ChildSwitcher 多子女", () => {
beforeEach(() => {
mockUseChildSwitcher.mockReturnValue({
children: mockChildren,
currentChild: mockChildren[0]!,
currentChildId: "student-001",
switchChild: vi.fn(),
loading: false,
error: undefined,
hasMultipleChildren: true,
});
});
it("显示当前子女名称和切换按钮", () => {
render(<ChildSwitcher />);
const button = screen.getByRole("button", { name: "选择子女" });
expect(button).toBeInTheDocument();
expect(screen.getByText("张小明")).toBeInTheDocument();
});
it("点击按钮展开子女列表", async () => {
const user = userEvent.setup();
render(<ChildSwitcher />);
const button = screen.getByRole("button", { name: "选择子女" });
await user.click(button);
expect(
screen.getByRole("listbox", { name: "子女列表" }),
).toBeInTheDocument();
expect(screen.getByText("张小红")).toBeInTheDocument();
});
it("点击子女项触发 switchChild 并关闭列表", async () => {
const user = userEvent.setup();
const switchChild = vi.fn();
mockUseChildSwitcher.mockReturnValue({
children: mockChildren,
currentChild: mockChildren[0]!,
currentChildId: "student-001",
switchChild,
loading: false,
error: undefined,
hasMultipleChildren: true,
});
render(<ChildSwitcher />);
await user.click(screen.getByRole("button", { name: "选择子女" }));
await user.click(screen.getByText("张小红"));
expect(switchChild).toHaveBeenCalledWith("student-002");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("Escape 键关闭列表", async () => {
const user = userEvent.setup();
render(<ChildSwitcher />);
const button = screen.getByRole("button", { name: "选择子女" });
await user.click(button);
expect(screen.getByRole("listbox")).toBeInTheDocument();
fireEvent.keyDown(button, { key: "Escape" });
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("aria-expanded 反映展开状态", async () => {
const user = userEvent.setup();
render(<ChildSwitcher />);
const button = screen.getByRole("button", { name: "选择子女" });
expect(button).toHaveAttribute("aria-expanded", "false");
await user.click(button);
expect(button).toHaveAttribute("aria-expanded", "true");
});
it("当前选中项有 aria-selected=true", async () => {
const user = userEvent.setup();
render(<ChildSwitcher />);
await user.click(screen.getByRole("button", { name: "选择子女" }));
const options = screen.getAllByRole("option");
expect(options[0]).toHaveAttribute("aria-selected", "true");
expect(options[1]).toHaveAttribute("aria-selected", "false");
});
});

View File

@@ -0,0 +1,128 @@
// ChildSwitcher子女切换组件MF 暴露 ./ChildSwitcher
// 依据02-architecture-design.md §15.1 ChildSwitcher 设计、F10 MF 暴露粒度
// - 多子女:下拉选择器
// - 单子女:仅显示子女名称(不可切换)
// - 无子女:显示空状态提示
// - ISSUE-009纯前端切换不调后端
"use client";
import { useState, useRef, useEffect, type KeyboardEvent } from "react";
// 修正onKeyDown 绑定到 <button>,需用 HTMLButtonElement
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { cn } from "@/lib/utils";
export function ChildSwitcher() {
const { children, currentChild, currentChildId, switchChild, loading } =
useChildSwitcher();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 点击外部关闭
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
if (loading) {
return (
<span className="skeleton h-8 w-32 rounded" aria-label="加载子女列表" />
);
}
if (children.length === 0) {
return (
<span className="text-sm text-ink-muted" aria-label="未绑定子女">
</span>
);
}
// 单子女:仅显示
if (children.length === 1) {
return (
<span className="font-serif text-base" aria-label="当前子女">
{children[0]?.name}
</span>
);
}
// 多子女:下拉选择器
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen((v) => !v);
}
if (e.key === "Escape") setOpen(false);
}
return (
<div ref={ref} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label="选择子女"
onClick={() => setOpen((v) => !v)}
onKeyDown={handleKeyDown}
className="flex items-center gap-2 rounded border border-rule bg-paper px-3 py-1.5 text-sm transition-colors hover:bg-paper-elevated"
>
<span className="font-serif text-base">{currentChild?.name}</span>
<svg
className={cn("h-4 w-4 transition-transform", open && "rotate-180")}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
{open && (
<ul
role="listbox"
aria-label="子女列表"
className="absolute right-0 z-50 mt-1 min-w-48 rounded border border-rule bg-paper-elevated py-1 shadow-lg"
>
{children.map((child) => (
<li
key={child.id}
role="option"
aria-selected={child.id === currentChildId}
>
<button
type="button"
onClick={() => {
switchChild(child.id);
setOpen(false);
}}
className={cn(
"block w-full px-4 py-2 text-left text-sm transition-colors hover:bg-accent-soft",
child.id === currentChildId && "bg-accent-soft font-medium",
)}
>
<span className="font-serif">{child.name}</span>
{child.className && (
<span className="ml-2 text-xs text-ink-muted">
{child.className}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
);
}
export default ChildSwitcher;

View File

@@ -0,0 +1,35 @@
// EmptyChildState无子女绑定的空状态
// 依据02-architecture-design.md §15.3 EmptyChildState 设计
// - 家长未绑定子女时显示
// - 提示联系学校绑定
"use client";
export function EmptyChildState() {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="mb-4">
<svg
className="h-12 w-12 text-ink-subtle"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.479m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
</div>
<h2 className="font-serif text-xl"></h2>
<p className="mt-2 text-sm text-ink-muted">
</p>
</div>
);
}
export default EmptyChildState;

View File

@@ -0,0 +1,50 @@
// MultiChildTabBar多子女水平标签栏
// 依据02-architecture-design.md §15.2 MultiChildTabBar 设计
// - 当 hasMultipleChildren 时在页面顶部显示标签栏
// - 当前选中子女高亮
// - ISSUE-009纯前端切换
"use client";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { cn } from "@/lib/utils";
export function MultiChildTabBar() {
const { children, currentChildId, switchChild, hasMultipleChildren } =
useChildSwitcher();
if (!hasMultipleChildren) return null;
return (
<nav
role="tablist"
aria-label="选择子女"
className="flex gap-1 border-b border-rule"
>
{children
.filter((c) => !c.isArchived)
.map((child) => {
const isActive = child.id === currentChildId;
return (
<button
key={child.id}
role="tab"
aria-selected={isActive}
type="button"
onClick={() => switchChild(child.id)}
className={cn(
"border-b-2 px-4 py-2 text-sm transition-colors",
isActive
? "border-accent font-medium text-ink"
: "border-transparent text-ink-muted hover:text-ink",
)}
>
<span className="font-serif">{child.name}</span>
</button>
);
})}
</nav>
);
}
export default MultiChildTabBar;

View File

@@ -0,0 +1,104 @@
// NotificationFeed 集成测试MSW + urql
// 依据02-architecture-design.md §13 测试策略、§8 MSW 模拟未就绪上游
// 覆盖GraphQL 查询渲染 / 筛选全部未读 / 标记已读 / 全部已读 mutation
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import { NotificationFeed } from "./NotificationFeed";
import { server } from "@/test/mocks/server";
// 创建测试用 urql client无 authMSW 拦截 fetch
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderWithProviders(ui: React.ReactElement) {
const client = createTestClient();
return render(<UrqlProvider value={client}>{ui}</UrqlProvider>);
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("NotificationFeed 集成测试", () => {
it("加载完成后渲染通知列表", async () => {
renderWithProviders(<NotificationFeed />);
// 等待通知加载完成mock 返回 3 条)
await waitFor(() => {
expect(screen.getByText("成绩发布")).toBeInTheDocument();
});
expect(screen.getByText("作业布置")).toBeInTheDocument();
expect(screen.getByText("学校公告")).toBeInTheDocument();
});
it("显示未读数量", async () => {
renderWithProviders(<NotificationFeed />);
await waitFor(() => {
expect(screen.getByText(/未读/)).toBeInTheDocument();
});
// mock 有 2 条未读
expect(screen.getByText(/未读.*2/)).toBeInTheDocument();
});
it("切换到未读筛选", async () => {
const user = userEvent.setup();
renderWithProviders(<NotificationFeed />);
await waitFor(() => {
expect(screen.getByText("成绩发布")).toBeInTheDocument();
});
// 点击"未读"按钮(精确匹配,避免匹配通知项的 aria-label="未读"
const filterButtons = screen.getAllByRole("button");
const unreadButton = filterButtons.find(
(b) =>
b.textContent?.includes("未读") && !b.textContent.includes("成绩发布"),
)!;
await user.click(unreadButton);
// 未读筛选下仍有 2 条mock 固定返回,忽略 unreadOnly 参数)
await waitFor(() => {
expect(screen.getByText("成绩发布")).toBeInTheDocument();
expect(screen.getByText("作业布置")).toBeInTheDocument();
});
});
it("点击全部标为已读触发 mutation", async () => {
const user = userEvent.setup();
renderWithProviders(<NotificationFeed />);
await waitFor(() => {
expect(screen.getByText("成绩发布")).toBeInTheDocument();
});
const markAllButton = screen.getByRole("button", { name: "全部标为已读" });
await user.click(markAllButton);
// mutation 不应抛错mock 返回成功)
// 无需额外断言,只要不报错即通过
});
it("空通知列表显示空状态", async () => {
server.resetHandlers(
graphql.query("MyNotifications", () =>
HttpResponse.json({ data: { myNotifications: [] } }),
),
);
renderWithProviders(<NotificationFeed />);
await waitFor(() => {
expect(screen.getByText("暂无通知")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,133 @@
// NotificationFeed通知列表组件
// 依据02-architecture-design.md §15.7 通知列表设计
// - 通知列表 + 标记已读 + 全部已读
// - 支持未读筛选
"use client";
import { useState } from "react";
import { useMyNotifications } from "@/hooks/useMyNotifications";
import { formatRelativeTime, cn } from "@/lib/utils";
import type { NotificationItem } from "@/types";
const EVENT_TYPE_LABELS: Record<NotificationItem["eventType"], string> = {
grade_recorded: "成绩发布",
homework_graded: "作业评分",
homework_assigned: "作业布置",
exam_published: "考试发布",
attendance_alert: "考勤提醒",
school_announcement: "学校公告",
teacher_message: "老师消息",
fee_reminder: "缴费提醒",
event_invitation: "活动邀请",
};
export function NotificationFeed() {
const [unreadOnly, setUnreadOnly] = useState(false);
const { notifications, loading, markAsRead, markAllAsRead } =
useMyNotifications(unreadOnly);
const unreadCount = notifications.filter((n) => !n.read).length;
async function handleMarkAll() {
await markAllAsRead();
}
async function handleClick(n: NotificationItem) {
if (!n.read) {
await markAsRead(n.id);
}
}
if (loading) {
return <span className="skeleton h-64 w-full rounded" />;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex gap-2">
<button
type="button"
onClick={() => setUnreadOnly(false)}
className={cn(
"rounded px-3 py-1 text-sm",
!unreadOnly
? "bg-accent text-paper"
: "border border-rule text-ink-muted",
)}
>
</button>
<button
type="button"
onClick={() => setUnreadOnly(true)}
className={cn(
"rounded px-3 py-1 text-sm",
unreadOnly
? "bg-accent text-paper"
: "border border-rule text-ink-muted",
)}
>
{unreadCount > 0 && ` (${unreadCount})`}
</button>
</div>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAll}
className="text-sm text-accent hover:underline"
>
</button>
)}
</div>
<ul className="space-y-2">
{notifications.length === 0 ? (
<li className="py-8 text-center text-sm text-ink-muted"></li>
) : (
notifications.map((n) => (
<li
key={n.id}
className={cn(
"rounded border p-3 transition-colors",
n.read
? "border-rule bg-paper"
: "border-accent bg-accent-soft",
)}
>
<button
type="button"
onClick={() => handleClick(n)}
className="block w-full text-left"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-rule px-1.5 py-0.5 text-xs text-ink-muted">
{EVENT_TYPE_LABELS[n.eventType]}
</span>
{!n.read && (
<span
className="h-2 w-2 rounded-full bg-accent"
aria-label="未读"
/>
)}
</div>
<p className="mt-1 font-serif text-sm">{n.title}</p>
<p className="mt-0.5 text-xs text-ink-muted">
{formatRelativeTime(n.createdAt)}
</p>
</div>
</div>
</button>
</li>
))
)}
</ul>
</div>
);
}
export default NotificationFeed;

View File

@@ -0,0 +1,61 @@
// ParentDashboard仪表盘复合组件
// 依据02-architecture-design.md §15 仪表盘设计
// - 组合 ChildSummaryCard + AttendanceCalendar
// - 处理加载/错误状态
// - 依赖 currentChildId切换子女时重新加载
"use client";
import { useChildSummary } from "@/hooks/useChildSummary";
import { useChildAttendance } from "@/hooks/useChildAttendance";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { ChildSummaryCard } from "./ChildSummaryCard";
import { AttendanceCalendar } from "./AttendanceCalendar";
export function ParentDashboard() {
const { currentChild } = useChildSwitcher();
const {
summary,
loading: summaryLoading,
error: summaryError,
} = useChildSummary();
const { attendance, loading: attLoading } = useChildAttendance();
if (summaryLoading && !summary) {
return (
<div className="space-y-4">
<span className="skeleton h-8 w-full rounded" />
<span className="skeleton h-32 w-full rounded" />
<span className="skeleton h-48 w-full rounded" />
</div>
);
}
if (summaryError) {
return (
<div className="rounded border border-danger p-4 text-sm text-danger">
{summaryError.message}
</div>
);
}
if (!summary || !currentChild) {
return null;
}
return (
<div className="space-y-6">
<h1 className="font-serif text-2xl">{currentChild.name}</h1>
<ChildSummaryCard summary={summary} />
{attLoading ? (
<span className="skeleton h-64 w-full rounded" />
) : (
<AttendanceCalendar records={attendance} />
)}
</div>
);
}
export default ParentDashboard;

View File

@@ -0,0 +1,155 @@
// PreferenceForm通知偏好表单
// 依据02-architecture-design.md §14 通知偏好数据模型、§15.8 PreferenceForm 设计
// - ISSUE-033P4 localStorage 降级,更新立即写入 localStorage
// - 三维矩阵:子女 × 事件类型 × 渠道
// - 切换子女标签查看不同子女的偏好
"use client";
import { useState } from "react";
import { useNotificationPreferences } from "@/hooks/useNotificationPreferences";
import { useChildSwitcher } from "@/hooks/useChildSwitcher";
import { getUser } from "@/lib/auth";
import {
NOTIFICATION_EVENT_TYPES,
NOTIFICATION_CHANNELS,
} from "@/lib/schemas/notification-preferences";
import type { NotificationEventType, NotificationChannel } from "@/types";
import { cn } from "@/lib/utils";
const EVENT_LABELS: Record<NotificationEventType, string> = {
grade_recorded: "成绩发布",
homework_graded: "作业评分",
homework_assigned: "作业布置",
exam_published: "考试发布",
attendance_alert: "考勤提醒",
school_announcement: "学校公告",
teacher_message: "老师消息",
fee_reminder: "缴费提醒",
event_invitation: "活动邀请",
};
const CHANNEL_LABELS: Record<NotificationChannel, string> = {
in_app: "应用内",
push: "推送",
sms: "短信",
email: "邮件",
wechat: "微信",
};
export function PreferenceForm() {
const user = getUser();
const parentId = user?.id ?? "parent-001";
const { preferences, loading, updatePreference } =
useNotificationPreferences(parentId);
const { children, currentChildId } = useChildSwitcher();
const [activeChildId, setActiveChildId] = useState<string>(
currentChildId ?? "*",
);
if (loading || !preferences) {
return <span className="skeleton h-64 w-full rounded" />;
}
// 获取当前激活子女的偏好("*" 表示全局默认)
const scope =
activeChildId === "*"
? preferences.defaults
: (preferences.preferences[activeChildId] ?? preferences.defaults);
return (
<div className="space-y-4">
{/* 子女切换标签 */}
<div className="flex gap-1 border-b border-rule">
<button
type="button"
onClick={() => setActiveChildId("*")}
className={cn(
"border-b-2 px-4 py-2 text-sm",
activeChildId === "*"
? "border-accent font-medium"
: "border-transparent text-ink-muted",
)}
>
</button>
{children.map((child) => (
<button
key={child.id}
type="button"
onClick={() => setActiveChildId(child.id)}
className={cn(
"border-b-2 px-4 py-2 text-sm",
activeChildId === child.id
? "border-accent font-medium"
: "border-transparent text-ink-muted",
)}
>
<span className="font-serif">{child.name}</span>
</button>
))}
</div>
{/* 偏好矩阵表格 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-rule">
<th className="py-2 text-left text-xs text-ink-muted"></th>
{NOTIFICATION_CHANNELS.map((ch) => (
<th
key={ch}
className="py-2 text-center text-xs text-ink-muted"
>
{CHANNEL_LABELS[ch]}
</th>
))}
</tr>
</thead>
<tbody>
{NOTIFICATION_EVENT_TYPES.map((eventType) => {
const pref = scope[eventType] ?? {};
return (
<tr
key={eventType}
className="border-b border-rule last:border-b-0"
>
<td className="py-2 font-medium">
{EVENT_LABELS[eventType]}
</td>
{NOTIFICATION_CHANNELS.map((channel) => {
const checked = pref[channel] ?? false;
return (
<td key={channel} className="py-2 text-center">
<input
type="checkbox"
checked={checked}
aria-label={`${EVENT_LABELS[eventType]} - ${CHANNEL_LABELS[channel]}`}
onChange={(e) =>
updatePreference(
activeChildId,
eventType,
channel,
e.target.checked,
)
}
className="h-4 w-4 rounded border-rule accent-accent"
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-xs text-ink-muted">
P4 localStorage
</p>
</div>
);
}
export default PreferenceForm;

View File

@@ -0,0 +1,41 @@
// useChildAttendance获取子女考勤记录
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useMemo } from "react";
import { useQuery } from "urql";
import { CHILD_ATTENDANCE } from "@/lib/graphql/operations";
import type { AttendanceRecord } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildAttendanceResponse {
childAttendance: AttendanceRecord[];
}
function getCurrentMonthRange(): { startDate: string; endDate: string } {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
startDate: start.toISOString().slice(0, 10),
endDate: end.toISOString().slice(0, 10),
};
}
export function useChildAttendance() {
const currentChildId = useChildStore((s) => s.currentChildId);
const { startDate, endDate } = useMemo(getCurrentMonthRange, []);
const [result] = useQuery<ChildAttendanceResponse>({
query: CHILD_ATTENDANCE,
variables: { childId: currentChildId, startDate, endDate },
pause: !currentChildId,
});
return {
attendance: result.data?.childAttendance ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,29 @@
// useChildGrades获取子女成绩列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery } from "urql";
import { CHILD_GRADES } from "@/lib/graphql/operations";
import type { GradeDataPoint } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildGradesResponse {
childGrades: GradeDataPoint[];
}
export function useChildGrades(subject?: string) {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildGradesResponse>({
query: CHILD_GRADES,
variables: { childId: currentChildId, subject: subject ?? null },
pause: !currentChildId,
});
return {
grades: result.data?.childGrades ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,29 @@
// useChildHomework获取子女作业列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery } from "urql";
import { CHILD_HOMEWORK } from "@/lib/graphql/operations";
import type { HomeworkItem } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildHomeworkResponse {
childHomework: HomeworkItem[];
}
export function useChildHomework(status?: string) {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildHomeworkResponse>({
query: CHILD_HOMEWORK,
variables: { childId: currentChildId, status: status ?? null },
pause: !currentChildId,
});
return {
homework: result.data?.childHomework ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,31 @@
// useChildSummary获取子女仪表盘概览
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - 依赖 currentChildId从 Zustand store 读取)
// - 切换子女时自动重新请求
"use client";
import { useQuery } from "urql";
import { CHILD_SUMMARY } from "@/lib/graphql/operations";
import type { ChildSummary } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildSummaryResponse {
childSummary: ChildSummary;
}
export function useChildSummary() {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildSummaryResponse>({
query: CHILD_SUMMARY,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
summary: result.data?.childSummary ?? null,
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,30 @@
// useChildSwitcher子女切换 Hook
// 依据02-architecture-design.md §4.3 状态管理分层、ISSUE-009 纯前端切换
// - 封装 Zustand store提供派生数据currentChild / hasMultipleChildren
// - 切换是纯前端操作不调后端ISSUE-009
// - 自动加载子女列表useMyChildren
"use client";
import { useChildStore } from "@/store/child-store";
import { useMyChildren } from "./useMyChildren";
export function useChildSwitcher() {
const { children, loading, error } = useMyChildren();
const currentChildId = useChildStore((s) => s.currentChildId);
const switchChild = useChildStore((s) => s.switchChild);
const currentChild = children.find((c) => c.id === currentChildId) ?? null;
const hasMultipleChildren = children.filter((c) => !c.isArchived).length > 1;
return {
children,
currentChild,
currentChildId,
switchChild,
loading,
error,
hasMultipleChildren,
};
}

View File

@@ -0,0 +1,29 @@
// useChildWeakness获取子女学情薄弱点
// 依据02-architecture-design.md §4.2 GraphQL 接入
"use client";
import { useQuery } from "urql";
import { CHILD_WEAKNESS } from "@/lib/graphql/operations";
import type { WeaknessPoint } from "@/types";
import { useChildStore } from "@/store/child-store";
interface ChildWeaknessResponse {
childWeakness: WeaknessPoint[];
}
export function useChildWeakness() {
const currentChildId = useChildStore((s) => s.currentChildId);
const [result] = useQuery<ChildWeaknessResponse>({
query: CHILD_WEAKNESS,
variables: { childId: currentChildId },
pause: !currentChildId,
});
return {
weaknesses: result.data?.childWeakness ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,97 @@
// useCrossTabSync跨标签同步 Hook
// 依据02-architecture-design.md §4.4 跨标签同步
// - BroadcastChannel同源标签间实时同步首选
// - storage 事件降级方案BroadcastChannel 不可用时)
// - 同步内容:子女切换 / 子女解绑 / 通知偏好更新
// - 防回环:消息携带 source接收方忽略自己的消息
"use client";
import { useEffect } from "react";
import { useChildStore } from "@/store/child-store";
import type { SyncMessage } from "@/types";
const CHANNEL_NAME = "parent-sync";
const STORAGE_EVENT_KEY = "parent_sync_event";
const SOURCE_ID = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export function useCrossTabSync() {
useEffect(() => {
// 方案 1BroadcastChannel首选
let bc: BroadcastChannel | null = null;
if ("BroadcastChannel" in window) {
bc = new BroadcastChannel(CHANNEL_NAME);
bc.onmessage = (event: MessageEvent<SyncMessage>) => {
const msg = event.data;
if (!msg || msg.source === SOURCE_ID) return; // 防回环
switch (msg.type) {
case "child-switched":
// 同步子女切换(不广播,避免循环)
useChildStore.setState({ currentChildId: msg.childId });
break;
case "child-unbound":
// 子女解绑:刷新子女列表
// 由各页面自行处理useMyChildren 会重新请求)
break;
case "preferences-updated":
// 通知偏好更新:清除 localStorage 缓存,触发重新加载
localStorage.removeItem("parent_notification_preferences");
break;
}
};
}
// 方案 2storage 事件降级BroadcastChannel 不可用时)
function handleStorageEvent(e: StorageEvent) {
if (e.key !== STORAGE_EVENT_KEY || !e.newValue) return;
try {
const msg = JSON.parse(e.newValue) as SyncMessage;
if (msg.source === SOURCE_ID) return;
switch (msg.type) {
case "child-switched":
useChildStore.setState({ currentChildId: msg.childId });
break;
case "child-unbound":
break;
case "preferences-updated":
localStorage.removeItem("parent_notification_preferences");
break;
}
} catch {
// 忽略解析错误
}
}
window.addEventListener("storage", handleStorageEvent);
return () => {
bc?.close();
window.removeEventListener("storage", handleStorageEvent);
};
}, []);
}
// 导出 source ID 供其他模块广播时使用
export { SOURCE_ID as SYNC_SOURCE_ID };
// 广播跨标签消息(供 child-store / preference hook 使用)
export function broadcastSync(msg: Omit<SyncMessage, "source">) {
const fullMsg: SyncMessage = { ...msg, source: SOURCE_ID } as SyncMessage;
// BroadcastChannel
if ("BroadcastChannel" in window) {
const bc = new BroadcastChannel(CHANNEL_NAME);
bc.postMessage(fullMsg);
bc.close();
}
// storage 事件降级
try {
localStorage.setItem(STORAGE_EVENT_KEY, JSON.stringify(fullMsg));
// 立即清除storage 事件只在值变化时触发)
setTimeout(() => localStorage.removeItem(STORAGE_EVENT_KEY), 0);
} catch {
// 忽略
}
}

View File

@@ -0,0 +1,39 @@
// useMyChildren获取家长绑定子女列表
// 依据02-architecture-design.md §4.2 GraphQL 接入
// - ISSUE-010iam GetChildrenByParent P0 阻塞MSW mock 返回 student-001 + student-002
// - 加载后写入 Zustand storesetChildren触发默认选中
// - 单次加载,不轮询(子女列表变更由 WebSocket ChildBound/ChildUnbound 事件通知 P5
"use client";
import { useEffect } from "react";
import { useQuery } from "urql";
import { MY_CHILDREN } from "@/lib/graphql/operations";
import { useChildStore } from "@/store/child-store";
import type { ChildInfo } from "@/types";
interface MyChildrenResponse {
myChildren: ChildInfo[];
}
export function useMyChildren() {
const setChildren = useChildStore((s) => s.setChildren);
const setLoading = useChildStore((s) => s.setLoading);
const [result] = useQuery<MyChildrenResponse>({
query: MY_CHILDREN,
});
useEffect(() => {
setLoading(result.fetching);
if (result.data?.myChildren) {
setChildren(result.data.myChildren);
}
}, [result.data, result.fetching, setChildren, setLoading]);
return {
children: result.data?.myChildren ?? [],
loading: result.fetching,
error: result.error,
};
}

View File

@@ -0,0 +1,51 @@
// useMyNotifications获取通知列表 + 标记已读
// 依据02-architecture-design.md §4.2 GraphQL 接入、§14 通知偏好
"use client";
import { useQuery, useMutation } from "urql";
import {
MY_NOTIFICATIONS,
MARK_AS_READ,
MARK_ALL_AS_READ,
} from "@/lib/graphql/operations";
import type { NotificationItem } from "@/types";
interface MyNotificationsResponse {
myNotifications: NotificationItem[];
}
interface MarkAsReadResponse {
markAsRead: { id: string; read: boolean };
}
interface MarkAllAsReadResponse {
markAllAsRead: { count: number };
}
export function useMyNotifications(unreadOnly = false) {
const [result] = useQuery<MyNotificationsResponse>({
query: MY_NOTIFICATIONS,
variables: { unreadOnly, limit: 50 },
});
const [, markAsReadMutation] = useMutation<MarkAsReadResponse>(MARK_AS_READ);
const [, markAllAsReadMutation] =
useMutation<MarkAllAsReadResponse>(MARK_ALL_AS_READ);
async function markAsRead(notificationId: string) {
return markAsReadMutation({ notificationId });
}
async function markAllAsRead() {
return markAllAsReadMutation({});
}
return {
notifications: result.data?.myNotifications ?? [],
loading: result.fetching,
error: result.error,
markAsRead,
markAllAsRead,
};
}

View File

@@ -0,0 +1,108 @@
// useNotificationPreferences通知偏好P4 localStorage 降级)
// 依据02-architecture-design.md §14 通知偏好数据模型、ISSUE-033 裁决
// - P4 阶段localStorage 为主存储GraphQL 查询提供初始默认值
// - 更新时:先写 localStorage再尝试 GraphQL mutation失败不阻塞
// - P5+ 阶段:切换到后端为主存储
"use client";
import { useEffect, useState, useCallback } from "react";
import { useQuery, useMutation } from "urql";
import {
MY_NOTIFICATION_PREFERENCES,
UPDATE_NOTIFICATION_PREFERENCES,
} from "@/lib/graphql/operations";
import type {
NotificationPreferences,
NotificationEventType,
NotificationChannel,
} from "@/types";
import { getLocalStorage, setLocalStorage } from "@/lib/utils";
const PREFS_KEY = "parent_notification_preferences";
interface MyNotificationPreferencesResponse {
myNotificationPreferences: NotificationPreferences;
}
interface UpdatePreferencesResponse {
updateNotificationPreferences: { parentId: string; updatedAt: string };
}
export function useNotificationPreferences(parentId: string) {
const [localPrefs, setLocalPrefs] = useState<NotificationPreferences | null>(
null,
);
// GraphQL 查询(提供初始默认值)
const [queryResult] = useQuery<MyNotificationPreferencesResponse>({
query: MY_NOTIFICATION_PREFERENCES,
});
const [, updateMutation] = useMutation<UpdatePreferencesResponse>(
UPDATE_NOTIFICATION_PREFERENCES,
);
// 加载:优先 localStorage无则用 GraphQL 返回值
useEffect(() => {
const stored = getLocalStorage(PREFS_KEY);
if (stored) {
try {
setLocalPrefs(JSON.parse(stored) as NotificationPreferences);
return;
} catch {
// fallthrough
}
}
if (queryResult.data?.myNotificationPreferences) {
setLocalPrefs(queryResult.data.myNotificationPreferences);
}
}, [queryResult.data]);
// 更新单个偏好ISSUE-033先写 localStorage
const updatePreference = useCallback(
(
childId: string,
eventType: NotificationEventType,
channel: NotificationChannel,
enabled: boolean,
) => {
setLocalPrefs((prev) => {
if (!prev) return prev;
const childPrefs = prev.preferences[childId] ?? {};
const next: NotificationPreferences = {
...prev,
preferences: {
...prev.preferences,
[childId]: {
...childPrefs,
[eventType]: {
...childPrefs[eventType],
[channel]: enabled,
},
},
},
updatedAt: new Date().toISOString(),
};
setLocalStorage(PREFS_KEY, JSON.stringify(next));
// 异步同步到后端(失败不阻塞)
updateMutation({
parentId,
preferences: next.preferences,
defaults: next.defaults,
}).catch(() => {
// P4 localStorage 降级:后端失败不影响本地
});
return next;
});
},
[parentId, updateMutation],
);
return {
preferences: localPrefs,
loading: !localPrefs && queryResult.fetching,
error: queryResult.error,
updatePreference,
};
}

View File

@@ -0,0 +1,35 @@
// 权限 Hook
// 依据project_rules §3.1(前端禁止 role === "xxx" 硬编码,统一用 hasPermission
// 从 localStorage 读取用户 permissions提供 hasPermission 检查
import { useMemo } from "react";
import { getUser } from "@/lib/auth";
import type { Permission } from "@/lib/permissions";
export function usePermission() {
const user = getUser();
const permissions = useMemo(() => {
return new Set(user?.permissions ?? []);
}, [user]);
function hasPermission(permission: Permission): boolean {
return permissions.has(permission);
}
function hasAnyPermission(...required: Permission[]): boolean {
return required.some((p) => permissions.has(p));
}
function hasAllPermissions(...required: Permission[]): boolean {
return required.every((p) => permissions.has(p));
}
return {
permissions: user?.permissions ?? [],
hasPermission,
hasAnyPermission,
hasAllPermissions,
isAuthenticated: user !== null,
};
}

View File

@@ -0,0 +1,70 @@
// useRealtimeNotifications实时通知 HookP5
// 依据02-architecture-design.md §5 实时推送
// - WebSocket 接收 NotificationRequested 事件
// - 收到新通知时更新 urql 缓存(触发 NotificationFeed 重新渲染)
// - 收到 GradeRecorded / SchoolAnnouncement 事件时触发相应页面刷新
"use client";
import { useCallback } from "react";
import { useWebSocket } from "./useWebSocket";
import type { WebSocketEvent } from "@/types";
export function useRealtimeNotifications() {
const handleEvent = useCallback((event: WebSocketEvent) => {
switch (event.type) {
case "NotificationRequested":
// 新通知:触发 NotificationFeed 刷新
// urql 会自动重新请求staleTime 到期或手动 invalidate
// 这里通过 dispatchEvent 通知 NotificationFeed 组件
window.dispatchEvent(
new CustomEvent("realtime-notification", {
detail: event.notification,
}),
);
break;
case "GradeRecorded":
// 成绩发布:触发成绩页面刷新
window.dispatchEvent(
new CustomEvent("realtime-grade", {
detail: {
childId: event.childId,
examId: event.examId,
},
}),
);
break;
case "SchoolAnnouncement":
// 学校公告:显示全局通知
window.dispatchEvent(
new CustomEvent("realtime-announcement", {
detail: { title: event.title, body: event.body },
}),
);
break;
case "ChildBound":
// 子女绑定:刷新子女列表
window.dispatchEvent(new CustomEvent("realtime-child-bound"));
break;
case "ChildUnbound":
// 子女解绑:刷新子女列表
window.dispatchEvent(
new CustomEvent("realtime-child-unbound", {
detail: { childId: event.childId },
}),
);
break;
}
}, []);
const { status } = useWebSocket({
onEvent: handleEvent,
enabled: true,
});
return { status };
}

View File

@@ -0,0 +1,168 @@
// useWebSocketpush-gateway WebSocket 连接
// 依据02-architecture-design.md §5 实时推送P5
// - 连接 push-gateway WebSocketNEXT_PUBLIC_PUSH_GATEWAY_WS_URL
// - 自动重连(指数退避,最大 30s
// - WebSocket 不可用时降级为 HTTP 轮询60s
// - 事件处理NotificationRequested / GradeRecorded / SchoolAnnouncement / ChildBound / ChildUnbound
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { getToken } from "@/lib/auth";
import { getBackoffDelay } from "@/lib/utils";
import type { WebSocketEvent } from "@/types";
const WS_URL = process.env.NEXT_PUBLIC_PUSH_GATEWAY_WS_URL || "";
const POLL_INTERVAL = 60_000; // 60s 轮询降级
const MAX_RECONNECT_ATTEMPTS = 10;
type ConnectionStatus = "connecting" | "connected" | "disconnected" | "polling";
interface UseWebSocketOptions {
onEvent?: (event: WebSocketEvent) => void;
enabled?: boolean;
}
export function useWebSocket({
onEvent,
enabled = true,
}: UseWebSocketOptions = {}) {
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttempts = useRef(0);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const onEventRef = useRef(onEvent);
// 保持 onEvent 引用最新
useEffect(() => {
onEventRef.current = onEvent;
}, [onEvent]);
const handleEvent = useCallback((event: WebSocketEvent) => {
onEventRef.current?.(event);
}, []);
// HTTP 轮询降级
const startPolling = useCallback(() => {
if (pollTimer.current) return;
setStatus("polling");
// 立即拉取一次
void pollNotifications(handleEvent);
pollTimer.current = setInterval(() => {
void pollNotifications(handleEvent);
}, POLL_INTERVAL);
}, [handleEvent]);
const stopPolling = useCallback(() => {
if (pollTimer.current) {
clearInterval(pollTimer.current);
pollTimer.current = null;
}
}, []);
// WebSocket 连接
const connect = useCallback(() => {
if (!enabled || !WS_URL || typeof window === "undefined") {
startPolling();
return;
}
const token = getToken();
if (!token) {
setStatus("disconnected");
return;
}
setStatus("connecting");
try {
const wsUrl = `${WS_URL}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
reconnectAttempts.current = 0;
setStatus("connected");
stopPolling();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as WebSocketEvent;
handleEvent(data);
} catch {
// 忽略无效消息
}
};
ws.onerror = () => {
// 错误不立即断开,等 onclose 处理
};
ws.onclose = () => {
setStatus("disconnected");
wsRef.current = null;
// 重连或降级
if (reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS) {
const delay = getBackoffDelay(reconnectAttempts.current);
reconnectAttempts.current++;
reconnectTimer.current = setTimeout(() => {
connect();
}, delay);
} else {
// 超过重连次数,降级为轮询
startPolling();
}
};
} catch {
// WebSocket 创建失败,降级为轮询
startPolling();
}
}, [enabled, stopPolling, startPolling, handleEvent]);
useEffect(() => {
connect();
return () => {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
}
stopPolling();
wsRef.current?.close();
wsRef.current = null;
};
}, [connect, stopPolling]);
return {
status,
reconnect: connect,
};
}
// HTTP 轮询:拉取最新通知
async function pollNotifications(
onEvent: (event: WebSocketEvent) => void,
): Promise<void> {
try {
const res = await fetch("/api/v1/parent/notifications?since=true", {
headers: {
Authorization: `Bearer ${localStorage.getItem("parent_access_token") ?? ""}`,
},
});
if (!res.ok) return;
const json = await res.json();
// 将新通知转为 WebSocketEvent 格式
if (json.data?.notifications) {
for (const notif of json.data.notifications) {
onEvent({
type: "NotificationRequested",
notification: notif,
});
}
}
} catch {
// 轮询失败静默忽略
}
}

View File

@@ -0,0 +1,182 @@
// 认证工具单测(含 MSW 集成)
// 依据02-architecture-design.md §13 测试策略、F12 localStorage token、ISSUE-004 REST 登录
// 覆盖token 存储 / isAuthenticated / login REST / refreshAccessToken 竞态防护 / clearAuth
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
vi,
} from "vitest";
import { server } from "@/test/mocks/server";
import {
getToken,
getRefreshToken,
setTokens,
clearAuth,
getUser,
setUser,
isAuthenticated,
login,
refreshAccessToken,
getAuthHeaders,
} from "./auth";
import { mockParent } from "@/test/mocks/fixtures";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("token 存储", () => {
it("setTokens 写入 access/refresh/expiresAt", () => {
setTokens("access-123", "refresh-456", 3600);
expect(getToken()).toBe("access-123");
expect(getRefreshToken()).toBe("refresh-456");
});
it("未设置 token 时返回 null", () => {
expect(getToken()).toBeNull();
expect(getRefreshToken()).toBeNull();
});
});
describe("getUser / setUser", () => {
it("setUser 后 getUser 返回相同对象", () => {
setUser(mockParent);
const user = getUser();
expect(user).not.toBeNull();
expect(user?.id).toBe(mockParent.id);
expect(user?.email).toBe(mockParent.email);
});
it("getUser 解析无效 JSON 返回 null", () => {
localStorage.setItem("parent_user_info", "{invalid json");
expect(getUser()).toBeNull();
});
it("getUser 未设置时返回 null", () => {
expect(getUser()).toBeNull();
});
});
describe("isAuthenticated", () => {
it("无 token 返回 false", () => {
expect(isAuthenticated()).toBe(false);
});
it("有未过期 token 返回 true", () => {
setTokens("access", "refresh", 3600);
expect(isAuthenticated()).toBe(true);
});
it("有过期 token 返回 false", () => {
setTokens("access", "refresh", -1); // 已过期
expect(isAuthenticated()).toBe(false);
});
});
describe("clearAuth", () => {
it("清除所有认证信息", () => {
setTokens("access", "refresh", 3600);
setUser(mockParent);
clearAuth();
expect(getToken()).toBeNull();
expect(getRefreshToken()).toBeNull();
expect(getUser()).toBeNull();
});
});
describe("loginRESTISSUE-004", () => {
it("正确凭据返回用户和 token", async () => {
const result = await login("parent@example.com", "password");
expect(result.user.email).toBe("parent@example.com");
expect(result.tokens.accessToken).toBeTruthy();
expect(result.tokens.refreshToken).toBeTruthy();
// 写入了 localStorage
expect(getToken()).toBe(result.tokens.accessToken);
});
it("错误凭据抛出异常", async () => {
await expect(login("wrong@example.com", "wrong")).rejects.toThrow(
/邮箱或密码错误/,
);
});
});
describe("refreshAccessToken竞态防护", () => {
it("有效 refresh token 返回新 access token", async () => {
setTokens("access", "mock-refresh-token-parent-001", 3600);
const newToken = await refreshAccessToken();
expect(newToken).toBe("mock-access-token-parent-001-renewed");
expect(getToken()).toBe("mock-access-token-parent-001-renewed");
});
it("无 refresh token 返回 null", async () => {
clearAuth();
const result = await refreshAccessToken();
expect(result).toBeNull();
});
it("无效 refresh token 返回 null", async () => {
setTokens("access", "invalid-refresh", 3600);
const result = await refreshAccessToken();
expect(result).toBeNull();
});
it("并发请求复用同一 refresh仅触发一次 fetch", async () => {
setTokens("access", "mock-refresh-token-parent-001", 3600);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const p1 = refreshAccessToken();
const p2 = refreshAccessToken();
const [t1, t2] = await Promise.all([p1, p2]);
expect(t1).toBe(t2);
// 竞态防护:两个并发调用只触发一次 refresh fetch
const refreshCalls = fetchSpy.mock.calls.filter(([url]) =>
String(url).includes("/api/v1/iam/refresh"),
);
expect(refreshCalls).toHaveLength(1);
fetchSpy.mockRestore();
});
});
describe("getAuthHeaders", () => {
it("无 token 返回空对象", () => {
clearAuth();
expect(getAuthHeaders()).toEqual({});
});
it("有 token 返回 Bearer header", () => {
setTokens("my-token", "refresh", 3600);
expect(getAuthHeaders()).toEqual({ Authorization: "Bearer my-token" });
});
});
describe("logout", () => {
it("清除认证信息", async () => {
setTokens("access", "refresh", 3600);
setUser(mockParent);
const { logout } = await import("./auth");
// mock window.location.href 赋值jsdom 不允许直接 spy
const originalHref = window.location.href;
let assignedHref: string | null = null;
Object.defineProperty(window, "location", {
value: {
...window.location,
set href(v: string) {
assignedHref = v;
},
get href() {
return originalHref;
},
},
configurable: true,
});
logout();
expect(getToken()).toBeNull();
expect(getUser()).toBeNull();
expect(assignedHref).toBe("/login");
});
});

View File

@@ -0,0 +1,192 @@
// 认证工具token 存储localStorageF12 裁决)+ 登录RESTISSUE-004
// 依据02-architecture-design.md §4.1、F12 localStorage token、ISSUE-004 登录端点
// parent-portal 唯一走 REST 的端点POST /api/v1/iam/login
import type { ActionState, LoginResponse, UserSession } from "@/types";
const TOKEN_KEY = "parent_access_token";
const REFRESH_TOKEN_KEY = "parent_refresh_token";
const USER_KEY = "parent_user_info";
const TOKEN_EXPIRES_KEY = "parent_token_expires_at";
/**
* 获取 access tokenSSR 安全)
*/
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
/**
* 获取 refresh token
*/
export function getRefreshToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
/**
* 设置 token含过期时间
*/
export function setTokens(
accessToken: string,
refreshToken: string,
expiresIn: number,
): void {
if (typeof window === "undefined") return;
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
const expiresAt = Date.now() + expiresIn * 1000;
localStorage.setItem(TOKEN_EXPIRES_KEY, expiresAt.toString());
}
/**
* 清除所有认证信息
*/
export function clearAuth(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_EXPIRES_KEY);
}
/**
* 获取当前用户信息
*/
export function getUser(): UserSession | null {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(USER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as UserSession;
} catch {
return null;
}
}
/**
* 设置当前用户信息
*/
export function setUser(user: UserSession): void {
if (typeof window === "undefined") return;
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
/**
* 是否已认证token 未过期)
*/
export function isAuthenticated(): boolean {
if (typeof window === "undefined") return false;
const token = getToken();
if (!token) return false;
const expiresAt = localStorage.getItem(TOKEN_EXPIRES_KEY);
if (!expiresAt) return true; // 无过期时间视为永久
return Date.now() < parseInt(expiresAt, 10);
}
/**
* 登录RESTISSUE-004 唯一走 REST 的端点)
* 调用 POST /api/v1/iam/login
*/
export async function login(
email: string,
password: string,
): Promise<{
user: UserSession;
tokens: { accessToken: string; refreshToken: string; expiresIn: number };
}> {
const res = await fetch("/api/v1/iam/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
body: JSON.stringify({ email, password }),
});
const json: ActionState<LoginResponse> = await res.json();
if (!json.success) {
throw new Error(json.error.message);
}
const { user, tokens } = json.data;
setTokens(tokens.accessToken, tokens.refreshToken, tokens.expiresIn);
setUser(user);
return { user, tokens };
}
/**
* 登出
*/
export function logout(): void {
clearAuth();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
}
/**
* Token 刷新(竞态防护:全局单例 promise 复用)
*/
let refreshPromise: Promise<string | null> | null = null;
export async function refreshAccessToken(): Promise<string | null> {
// 复用进行中的 refresh 请求(避免多请求同时 401 触发多次 refresh
if (refreshPromise) return refreshPromise;
const refreshToken = getRefreshToken();
if (!refreshToken) {
logout();
return null;
}
refreshPromise = (async () => {
try {
const res = await fetch("/api/v1/iam/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
logout();
return null;
}
const json: ActionState<{ tokens: LoginResponse["tokens"] }> =
await res.json();
if (!json.success) {
logout();
return null;
}
const {
accessToken,
refreshToken: newRefreshToken,
expiresIn,
} = json.data.tokens;
setTokens(accessToken, newRefreshToken, expiresIn);
return accessToken;
} catch {
logout();
return null;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
/**
* 获取 Authorization header供 GraphQL client 使用)
*/
export function getAuthHeaders(): Record<string, string> {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}

View File

@@ -0,0 +1,72 @@
// GraphQL client (urql)
// 依据02-architecture-design.md §4.2 GraphQL 接入、F9 裁决(首次即 GraphQL
// 端点POST /api/v1/parent/graphql经 api-gateway 代理到 parent-bff
// 认证Authorization: Bearer <token>F12 localStorage token
// 刷新401 时触发 refreshAccessToken重试原请求
import { Client, fetchExchange, cacheExchange } from "urql";
import { getAuthHeaders, refreshAccessToken, getToken } from "./auth";
const GRAPHQL_ENDPOINT =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || "/api/v1/parent/graphql";
/**
* 自定义 fetch注入 Authorization header + 401 刷新重试
*/
async function authedFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const authHeaders = getAuthHeaders();
const headers = new Headers(init?.headers || {});
for (const [k, v] of Object.entries(authHeaders)) {
headers.set(k, v);
}
headers.set("X-Requested-With", "XMLHttpRequest");
let response = await fetch(input, { ...init, headers });
// 401 → 刷新 token 后重试一次
if (response.status === 401) {
const newToken = await refreshAccessToken();
if (newToken) {
headers.set("Authorization", `Bearer ${newToken}`);
response = await fetch(input, { ...init, headers });
}
}
return response;
}
/**
* 创建 urql client单例
*/
export function createGraphQLClient(): Client {
return new Client({
url: GRAPHQL_ENDPOINT,
fetch: authedFetch as unknown as typeof fetch,
exchanges: [cacheExchange, fetchExchange],
fetchOptions: (): RequestInit => {
const token = getToken();
const headers: Record<string, string> = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return { headers };
},
});
}
// 全局单例SSR 安全:仅客户端创建)
let client: Client | null = null;
export function getGraphQLClient(): Client {
if (typeof window === "undefined") {
// SSR每次创建新实例避免请求间状态泄漏
return createGraphQLClient();
}
if (!client) {
client = createGraphQLClient();
}
return client;
}

View File

@@ -0,0 +1,210 @@
// GraphQL operations 文档
// 依据02-architecture-design.md §4.2 GraphQL 接入、F9 裁决(首次即 GraphQL
// 端点POST /api/v1/parent/graphql
// 命名:查询 PascalCaseCurrentUser / MyChildren / ChildSummary ...
//
// 所有查询/变更与 parent-bff 契约对齐:
// - 查询currentUser / myChildren / childSummary / childGrades / childAttendance /
// childHomework / childWeakness / childTrend / myNotifications / myNotificationPreferences
// - 变更markAsRead / markAllAsRead / updateNotificationPreferences
import { gql } from "urql";
// ===== 查询 =====
export const CURRENT_USER = gql`
query CurrentUser {
currentUser {
id
email
name
roles
permissions
dataScope
schoolId
}
}
`;
export const MY_CHILDREN = gql`
query MyChildren {
myChildren {
id
name
avatar
grade
schoolName
classId
className
isArchived
}
}
`;
export const CHILD_SUMMARY = gql`
query ChildSummary($childId: ID!) {
childSummary(childId: $childId) {
childId
avgScore
classRank
classSize
attendanceRate
pendingHomeworkCount
recentGradeTrend
recentScores {
examId
examName
examDate
subject
studentScore
classAverage
classMax
classMin
gradeLevel
}
upcomingEvents {
id
type
title
dueDate
}
}
}
`;
export const CHILD_GRADES = gql`
query ChildGrades($childId: ID!, $subject: String, $limit: Int) {
childGrades(childId: $childId, subject: $subject, limit: $limit) {
examId
examName
examDate
subject
studentScore
classAverage
classMax
classMin
gradeLevel
}
}
`;
export const CHILD_ATTENDANCE = gql`
query ChildAttendance($childId: ID!, $startDate: String!, $endDate: String!) {
childAttendance(
childId: $childId
startDate: $startDate
endDate: $endDate
) {
id
date
status
note
}
}
`;
export const CHILD_HOMEWORK = gql`
query ChildHomework($childId: ID!, $status: String) {
childHomework(childId: $childId, status: $status) {
id
title
subject
className
assignedDate
dueDate
status
score
maxScore
feedback
}
}
`;
export const CHILD_WEAKNESS = gql`
query ChildWeakness($childId: ID!) {
childWeakness(childId: $childId) {
id
knowledgePoint
masteryLevel
subject
recommendation
}
}
`;
export const CHILD_TREND = gql`
query ChildTrend($childId: ID!, $period: TrendPeriod!) {
childTrend(childId: $childId, period: $period) {
childId
period
dataPoints {
date
score
subject
}
}
}
`;
export const MY_NOTIFICATIONS = gql`
query MyNotifications($unreadOnly: Boolean, $limit: Int) {
myNotifications(unreadOnly: $unreadOnly, limit: $limit) {
id
childId
eventType
title
body
read
createdAt
actionUrl
pinned
}
}
`;
export const MY_NOTIFICATION_PREFERENCES = gql`
query MyNotificationPreferences {
myNotificationPreferences {
parentId
preferences
defaults
updatedAt
}
}
`;
// ===== 变更 =====
export const MARK_AS_READ = gql`
mutation MarkAsRead($notificationId: ID!) {
markAsRead(notificationId: $notificationId) {
id
read
}
}
`;
export const MARK_ALL_AS_READ = gql`
mutation MarkAllAsRead {
markAllAsRead {
count
}
}
`;
export const UPDATE_NOTIFICATION_PREFERENCES = gql`
mutation UpdateNotificationPreferences(
$parentId: ID!
$preferences: JSON!
$defaults: JSON!
) {
updateNotificationPreferences(
parentId: $parentId
preferences: $preferences
defaults: $defaults
) {
parentId
updatedAt
}
}
`;

View File

@@ -0,0 +1,107 @@
// i18n 国际化P6-4
// 依据02-architecture-design.md §17 国际化、F4 裁决i18n key 命名 error.<service>.<code_snake>
// - 支持 zh-CN默认+ en-US
// - 错误码 keyerror.<service>.<code_snake>F4
// - 简单翻译表(后续可迁移到 next-intl
export type Locale = "zh-CN" | "en-US";
const DEFAULT_LOCALE: Locale = "zh-CN";
const translations: Record<Locale, Record<string, string>> = {
"zh-CN": {
// 通用
"common.loading": "加载中...",
"common.error": "加载失败",
"common.retry": "重试",
"common.save": "保存",
"common.cancel": "取消",
"common.confirm": "确认",
// 导航
"nav.dashboard": "仪表盘",
"nav.grades": "成绩",
"nav.attendance": "考勤",
"nav.homework": "作业",
"nav.weakness": "学情",
"nav.notifications": "通知",
"nav.preferences": "偏好",
// 子女切换
"child.switcher.empty": "未绑定子女",
"child.switcher.select": "选择子女",
// 错误码F4 命名error.<service>.<code_snake>
"error.iam.invalid_credentials": "邮箱或密码错误",
"error.iam.invalid_refresh": "refresh token 无效",
"error.iam.token_expired": "token 已过期",
"error.parent.child_not_found": "子女不存在",
"error.parent.permission_denied": "无权限访问",
"error.parent.network_error": "网络错误",
"error.parent.graphql_error": "数据加载失败",
// 通知事件
"notification.grade_recorded": "成绩发布",
"notification.homework_graded": "作业评分",
"notification.homework_assigned": "作业布置",
"notification.exam_published": "考试发布",
"notification.attendance_alert": "考勤提醒",
"notification.school_announcement": "学校公告",
"notification.teacher_message": "老师消息",
"notification.fee_reminder": "缴费提醒",
"notification.event_invitation": "活动邀请",
},
"en-US": {
"common.loading": "Loading...",
"common.error": "Failed to load",
"common.retry": "Retry",
"common.save": "Save",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"nav.dashboard": "Dashboard",
"nav.grades": "Grades",
"nav.attendance": "Attendance",
"nav.homework": "Homework",
"nav.weakness": "Analysis",
"nav.notifications": "Notifications",
"nav.preferences": "Preferences",
"child.switcher.empty": "No child bound",
"child.switcher.select": "Select child",
"error.iam.invalid_credentials": "Invalid email or password",
"error.iam.invalid_refresh": "Invalid refresh token",
"error.iam.token_expired": "Token expired",
"error.parent.child_not_found": "Child not found",
"error.parent.permission_denied": "Permission denied",
"error.parent.network_error": "Network error",
"error.parent.graphql_error": "Failed to load data",
"notification.grade_recorded": "Grade Published",
"notification.homework_graded": "Homework Graded",
"notification.homework_assigned": "Homework Assigned",
"notification.exam_published": "Exam Published",
"notification.attendance_alert": "Attendance Alert",
"notification.school_announcement": "School Announcement",
"notification.teacher_message": "Teacher Message",
"notification.fee_reminder": "Fee Reminder",
"notification.event_invitation": "Event Invitation",
},
};
export function getLocale(): Locale {
if (typeof navigator !== "undefined") {
const lang = navigator.language;
if (lang.startsWith("en")) return "en-US";
}
return DEFAULT_LOCALE;
}
export function t(key: string, locale: Locale = getLocale()): string {
return (
translations[locale]?.[key] ?? translations[DEFAULT_LOCALE]?.[key] ?? key
);
}
export { translations };

View File

@@ -0,0 +1,26 @@
// 权限点常量
// 依据F7 裁决(权限点命名 <RESOURCE>_<ACTION>[_<SCOPE>]
// 前端禁止硬编码 role === "xxx",统一使用 usePermission().hasPermission()project_rules §3.1
export const PERMISSIONS = {
// 仪表盘
DASHBOARD_VIEW: "DASHBOARD_VIEW",
// 子女成绩
CHILD_GRADE_VIEW: "CHILD_GRADE_VIEW",
// 子女考勤
CHILD_ATTENDANCE_VIEW: "CHILD_ATTENDANCE_VIEW",
// 子女作业
CHILD_HOMEWORK_VIEW: "CHILD_HOMEWORK_VIEW",
// 子女学情
CHILD_WEAKNESS_VIEW: "CHILD_WEAKNESS_VIEW",
// 通知
NOTIFICATION_VIEW: "NOTIFICATION_VIEW",
NOTIFICATION_PREFERENCE_UPDATE: "NOTIFICATION_PREFERENCE_UPDATE",
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];

View File

@@ -0,0 +1,28 @@
// TanStack Query client服务端数据缓存
// 依据02-architecture-design.md §4.3 状态管理分层Server Cache 层)
// 与 urql 互补urql 管 GraphQLTanStack Query 管 REST/复合查询
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
// 重试策略仅网络错误重试401/403/404 不重试
retry: (failureCount, error: unknown) => {
if (error instanceof Error && error.message.includes("401")) {
return false;
}
return failureCount < 2;
},
staleTime: 30 * 1000, // 30s 内不重新请求
gcTime: 5 * 60 * 1000, // 5min 后回收
refetchOnWindowFocus: false,
refetchOnReconnect: "always",
},
mutations: {
retry: false,
},
},
});
}

View File

@@ -0,0 +1,57 @@
// 通知偏好 Zod schema
// 依据02-architecture-design.md §14 通知偏好数据模型、project_rules §3.8 输入验证
// - 三维矩阵children[eventType][channel] = enabled
// - 用于表单校验 + localStorage 序列化校验
import { z } from "zod";
const NOTIFICATION_EVENT_TYPES = [
"grade_recorded",
"homework_graded",
"homework_assigned",
"exam_published",
"attendance_alert",
"school_announcement",
"teacher_message",
"fee_reminder",
"event_invitation",
] as const;
const NOTIFICATION_CHANNELS = [
"in_app",
"push",
"sms",
"email",
"wechat",
] as const;
const channelPrefSchema = z
.object({
in_app: z.boolean().optional(),
push: z.boolean().optional(),
sms: z.boolean().optional(),
email: z.boolean().optional(),
wechat: z.boolean().optional(),
})
.partial();
const eventTypePrefSchema = z.record(
z.enum(NOTIFICATION_EVENT_TYPES),
channelPrefSchema,
);
export const notificationPreferencesSchema = z.object({
parentId: z.string(),
preferences: z.record(z.string(), eventTypePrefSchema),
defaults: eventTypePrefSchema,
updatedAt: z.string(),
});
export type NotificationPreferencesInput = z.input<
typeof notificationPreferencesSchema
>;
export type NotificationPreferencesOutput = z.output<
typeof notificationPreferencesSchema
>;
export { NOTIFICATION_EVENT_TYPES, NOTIFICATION_CHANNELS };

View File

@@ -0,0 +1,275 @@
// 工具函数单测
// 依据02-architecture-design.md §13 测试策略、project_rules §3.4 TypeScript 规则
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
cn,
formatDate,
formatDateTime,
formatRelativeTime,
formatNumber,
formatPercent,
generateId,
scoreToGrade,
gradeToChinese,
getLocalStorage,
setLocalStorage,
removeLocalStorage,
debounce,
throttle,
getBackoffDelay,
generateTraceId,
} from "./utils";
describe("cn", () => {
it("合并多个类名", () => {
expect(cn("foo", "bar")).toBe("foo bar");
});
it("支持条件类名", () => {
const isHidden = false;
const isVisible = true;
expect(cn("base", isHidden && "hidden", isVisible && "visible")).toBe(
"base visible",
);
});
it("合并 Tailwind 冲突类(后者优先)", () => {
expect(cn("px-2", "px-4")).toBe("px-4");
});
});
describe("formatDate", () => {
it("格式化 ISO 字符串zh-CN", () => {
const result = formatDate("2026-07-10T00:00:00Z", "zh-CN");
expect(result).toContain("2026");
expect(result).toContain("7");
expect(result).toContain("10");
});
it("格式化 Date 对象", () => {
const d = new Date("2026-07-10T00:00:00Z");
const result = formatDate(d, "en-US");
expect(result).toContain("2026");
});
it("默认 locale 为 zh-CN", () => {
const result = formatDate("2026-07-10T00:00:00Z");
expect(result).toContain("2026");
});
});
describe("formatDateTime", () => {
it("包含日期和时间", () => {
const result = formatDateTime("2026-07-10T09:30:00Z", "zh-CN");
expect(result).toContain("2026");
});
});
describe("formatRelativeTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-10T12:00:00Z"));
});
it("秒级(刚刚)", () => {
const result = formatRelativeTime("2026-07-10T11:59:55Z", "zh-CN");
expect(result).toMatch(/秒|second/);
});
it("分钟级", () => {
const result = formatRelativeTime("2026-07-10T11:30:00Z", "zh-CN");
expect(result).toMatch(/分|minute/);
});
it("小时级", () => {
const result = formatRelativeTime("2026-07-10T09:00:00Z", "zh-CN");
expect(result).toMatch(/小时|hour/);
});
it("天级", () => {
const result = formatRelativeTime("2026-07-08T09:00:00Z", "zh-CN");
expect(result).toMatch(/天|day/);
});
});
describe("formatNumber", () => {
it("格式化整数zh-CN 千分位)", () => {
expect(formatNumber(1234567, "zh-CN")).toBe("1,234,567");
});
it("格式化小数", () => {
const result = formatNumber(1234.56, "en-US");
expect(result).toBe("1,234.56");
});
});
describe("formatPercent", () => {
it("格式化百分比0-1 → 0-100%", () => {
const result = formatPercent(0.96, "zh-CN");
expect(result).toContain("96");
expect(result).toContain("%");
});
it("保留指定位数", () => {
const result = formatPercent(0.9633, "en-US", 2);
expect(result).toBe("96.33%");
});
});
describe("generateId", () => {
it("返回字符串", () => {
expect(typeof generateId()).toBe("string");
});
it("每次调用返回不同值", () => {
expect(generateId()).not.toBe(generateId());
});
});
describe("scoreToGrade", () => {
it("A 等级(>=90%", () => {
expect(scoreToGrade(95, 100)).toBe("A");
expect(scoreToGrade(90, 100)).toBe("A");
});
it("B 等级80-89%", () => {
expect(scoreToGrade(89, 100)).toBe("B");
expect(scoreToGrade(80, 100)).toBe("B");
});
it("C 等级70-79%", () => {
expect(scoreToGrade(75, 100)).toBe("C");
expect(scoreToGrade(70, 100)).toBe("C");
});
it("D 等级60-69%", () => {
expect(scoreToGrade(65, 100)).toBe("D");
expect(scoreToGrade(60, 100)).toBe("D");
});
it("F 等级(<60%", () => {
expect(scoreToGrade(59, 100)).toBe("F");
expect(scoreToGrade(0, 100)).toBe("F");
});
it("支持非 100 满分", () => {
expect(scoreToGrade(45, 50)).toBe("A"); // 90%
expect(scoreToGrade(29, 50)).toBe("F"); // 58%
});
});
describe("gradeToChinese", () => {
it("A → 优秀", () => {
expect(gradeToChinese("A")).toBe("优秀");
});
it("B → 良好", () => {
expect(gradeToChinese("B")).toBe("良好");
});
it("C → 及格", () => {
expect(gradeToChinese("C")).toBe("及格");
});
it("D → 及格", () => {
expect(gradeToChinese("D")).toBe("及格");
});
it("F → 不及格", () => {
expect(gradeToChinese("F")).toBe("不及格");
});
it("未知等级原样返回", () => {
expect(gradeToChinese("X")).toBe("X");
});
});
describe("localStorage 工具SSR 安全)", () => {
it("setLocalStorage / getLocalStorage", () => {
expect(setLocalStorage("foo", "bar")).toBe(true);
expect(getLocalStorage("foo")).toBe("bar");
});
it("removeLocalStorage", () => {
setLocalStorage("foo", "bar");
removeLocalStorage("foo");
expect(getLocalStorage("foo")).toBeNull();
});
it("未设置的 key 返回 null", () => {
expect(getLocalStorage("not-exist")).toBeNull();
});
});
describe("debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("延迟执行", () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
it("多次调用只执行最后一次", () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced("a");
debounced("b");
debounced("c");
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("c");
});
});
describe("throttle", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-10T00:00:00Z"));
});
it("首次立即执行", () => {
const fn = vi.fn();
const throttled = throttle(fn, 100);
throttled();
expect(fn).toHaveBeenCalledTimes(1);
});
it("节流窗口内只执行一次", () => {
const fn = vi.fn();
const throttled = throttle(fn, 100);
throttled();
throttled();
throttled();
expect(fn).toHaveBeenCalledTimes(1);
});
});
describe("getBackoffDelay", () => {
it("指数退避", () => {
expect(getBackoffDelay(0, 1000)).toBe(1000);
expect(getBackoffDelay(1, 1000)).toBe(2000);
expect(getBackoffDelay(2, 1000)).toBe(4000);
expect(getBackoffDelay(3, 1000)).toBe(8000);
});
it("上限 30 秒", () => {
expect(getBackoffDelay(10, 1000)).toBe(30000);
});
it("默认 baseDelay 1000", () => {
expect(getBackoffDelay(2)).toBe(4000);
});
});
describe("generateTraceId", () => {
it("返回 parent- 前缀的字符串", () => {
const id = generateTraceId();
expect(id).toMatch(/^parent-/);
});
it("每次调用返回不同值", () => {
expect(generateTraceId()).not.toBe(generateTraceId());
});
});

View File

@@ -0,0 +1,207 @@
// 工具函数
// 依据project_rules §3.4 TypeScript 规则、§3.9 Tailwind 规范
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* 类名合并工具project_rules §3.9
* 支持条件类名,合并 Tailwind 类
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* 格式化日期i18n02 §17.4 国际化格式)
*/
export function formatDate(
date: string | Date,
locale: string = "zh-CN",
): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(d);
}
/**
* 格式化日期时间
*/
export function formatDateTime(
date: string | Date,
locale: string = "zh-CN",
): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(d);
}
/**
* 格式化相对时间(如"3小时前"
*/
export function formatRelativeTime(
date: string | Date,
locale: string = "zh-CN",
): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = Date.now();
const diff = now - d.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (days > 0) return rtf.format(-days, "day");
if (hours > 0) return rtf.format(-hours, "hour");
if (minutes > 0) return rtf.format(-minutes, "minute");
return rtf.format(-seconds, "second");
}
/**
* 格式化数字02 §17.4
*/
export function formatNumber(value: number, locale: string = "zh-CN"): string {
return new Intl.NumberFormat(locale).format(value);
}
/**
* 格式化百分比
*/
export function formatPercent(
value: number,
locale: string = "zh-CN",
digits: number = 1,
): string {
return new Intl.NumberFormat(locale, {
style: "percent",
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value);
}
/**
* 生成唯一 ID用于客户端临时标识
*/
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
/**
* 成绩等级映射02 §17.4
*/
export function scoreToGrade(score: number, maxScore: number = 100): string {
const percentage = (score / maxScore) * 100;
if (percentage >= 90) return "A";
if (percentage >= 80) return "B";
if (percentage >= 70) return "C";
if (percentage >= 60) return "D";
return "F";
}
/**
* 成绩等级中文映射
*/
export function gradeToChinese(grade: string): string {
const map: Record<string, string> = {
A: "优秀",
B: "良好",
C: "及格",
D: "及格",
F: "不及格",
};
return map[grade] ?? grade;
}
/**
* 安全获取 localStorageSSR 安全)
*/
export function getLocalStorage(key: string): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
/**
* 安全设置 localStorage
*/
export function setLocalStorage(key: string, value: string): boolean {
if (typeof window === "undefined") return false;
try {
window.localStorage.setItem(key, value);
return true;
} catch {
return false;
}
}
/**
* 安全删除 localStorage
*/
export function removeLocalStorage(key: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(key);
} catch {
// 忽略
}
}
/**
* 防抖
*/
export function debounce<T extends (...args: never[]) => void>(
fn: T,
delay: number,
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
/**
* 节流
*/
export function throttle<T extends (...args: never[]) => void>(
fn: T,
interval: number,
): (...args: Parameters<T>) => void {
let lastTime = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn(...args);
}
};
}
/**
* 指数退避延迟
*/
export function getBackoffDelay(
attempt: number,
baseDelay: number = 1000,
): number {
return Math.min(baseDelay * Math.pow(2, attempt), 30000);
}
/**
* 生成 trace_id
*/
export function generateTraceId(): string {
return `parent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

View File

@@ -0,0 +1,60 @@
// Web Vitals 上报P6-1
// 依据02-architecture-design.md §12 可观测性
// - 采集 CLS / LCP / FID / FCP / TTFP / INP
// - 上报到 /api/v1/parent/web-vitals经 api-gateway → data-ana
// - 开发环境输出到 console
"use client";
interface WebVitalMetric {
name: string;
value: number;
rating: "good" | "needs-improvement" | "poor";
delta: number;
id: string;
}
const REPORT_URL = "/api/v1/parent/web-vitals";
export async function reportWebVitals(metric: WebVitalMetric): Promise<void> {
if (process.env.NODE_ENV === "development") {
// 开发环境输出到 console
console.debug(
`[Web Vitals] ${metric.name}: ${metric.value} (${metric.rating})`,
);
}
// 生产环境上报
if (process.env.NODE_ENV === "production") {
try {
const body = {
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
page: window.location.pathname,
ts: Date.now(),
};
// 使用 sendBeacon 优先(页面卸载时不丢失)
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(body)], {
type: "application/json",
});
navigator.sendBeacon(REPORT_URL, blob);
} else {
fetch(REPORT_URL, {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
keepalive: true,
}).catch(() => {
// 上报失败静默忽略
});
}
} catch {
// 忽略
}
}
}

View File

@@ -0,0 +1,58 @@
// Next.js middlewareCSP 安全头 + 认证守卫
// 依据02-architecture-design.md §13 安全硬化P6-6、project_rules §4 安全规范
// - CSP限制脚本/样式/图片/连接来源
// - 认证守卫:/parent/* 路由检查 tokenSSR 阶段重定向)
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_ROUTES = ["/parent"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const response = NextResponse.next();
// CSP 安全头
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' ws: wss:",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
].join("; ");
response.headers.set("Content-Security-Policy", csp);
response.headers.set("X-Frame-Options", "SAMEORIGIN");
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()",
);
// 认证守卫SSR 阶段)
if (PROTECTED_ROUTES.some((route) => pathname.startsWith(route))) {
const token = request.cookies.get("parent_access_token")?.value;
// 注意F12 裁决使用 localStorage tokenSSR 阶段无法读取
// 此处仅做 cookie 兜底检查,客户端 parent/layout.tsx 做完整守卫
if (!token) {
// 检查是否有 Authorization headerMF 模式由 Shell 注入)
const authHeader = request.headers.get("authorization");
if (!authHeader) {
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
}
}
return response;
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|mockServiceWorker.js|manifest.json|icon-).*)",
],
};

View File

@@ -0,0 +1,135 @@
// Zustand child-store 单测
// 依据02-architecture-design.md §4.3 状态管理分层、ISSUE-009 纯前端切换
// 覆盖setChildren 默认选中 / switchChild 不调后端 / localStorage 持久化 / reset
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useChildStore } from "./child-store";
import type { ChildInfo } from "@/types";
const mockChildren: ChildInfo[] = [
{
id: "student-001",
name: "张小明",
grade: "grade.7",
schoolName: "实验中学",
classId: "class-7-1",
className: "初一(1)班",
},
{
id: "student-002",
name: "张小红",
grade: "grade.5",
schoolName: "实验小学",
classId: "class-5-2",
className: "五年级(2)班",
},
];
beforeEach(() => {
localStorage.clear();
// 重置 store 到初始状态
useChildStore.setState({
children: [],
currentChildId: null,
isLoading: false,
});
});
describe("setChildren", () => {
it("设置子女列表", () => {
useChildStore.getState().setChildren(mockChildren);
expect(useChildStore.getState().children).toHaveLength(2);
});
it("无 currentChildId 时默认选第一个活跃子女", () => {
useChildStore.getState().setChildren(mockChildren);
expect(useChildStore.getState().currentChildId).toBe("student-001");
});
it("已存在的 currentChildId 保持不变", () => {
localStorage.setItem("parent_current_child_id", "student-002");
useChildStore.setState({ currentChildId: "student-002" });
useChildStore.getState().setChildren(mockChildren);
expect(useChildStore.getState().currentChildId).toBe("student-002");
});
it("currentChildId 不在列表中时重选第一个", () => {
useChildStore.setState({ currentChildId: "student-999" });
useChildStore.getState().setChildren(mockChildren);
expect(useChildStore.getState().currentChildId).toBe("student-001");
});
it("跳过已归档子女作为默认选择", () => {
const withArchived: ChildInfo[] = [
{ ...mockChildren[0]!, isArchived: true },
mockChildren[1]!,
];
useChildStore.getState().setChildren(withArchived);
expect(useChildStore.getState().currentChildId).toBe("student-002");
});
it("默认选中后写入 localStorage", () => {
useChildStore.getState().setChildren(mockChildren);
expect(localStorage.getItem("parent_current_child_id")).toBe("student-001");
});
it("空列表时 currentChildId 为 null", () => {
useChildStore.getState().setChildren([]);
expect(useChildStore.getState().currentChildId).toBeNull();
});
});
describe("switchChildISSUE-009纯前端切换", () => {
beforeEach(() => {
useChildStore.getState().setChildren(mockChildren);
});
it("切换到列表中存在的子女", () => {
useChildStore.getState().switchChild("student-002");
expect(useChildStore.getState().currentChildId).toBe("student-002");
});
it("切换不存在的子女被忽略", () => {
useChildStore.getState().switchChild("student-999");
expect(useChildStore.getState().currentChildId).toBe("student-001");
});
it("切换后写入 localStorage", () => {
useChildStore.getState().switchChild("student-002");
expect(localStorage.getItem("parent_current_child_id")).toBe("student-002");
});
it("切换不触发后端请求(无 fetch 调用)", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
useChildStore.getState().switchChild("student-002");
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
describe("setLoading", () => {
it("设置 loading 状态", () => {
useChildStore.getState().setLoading(true);
expect(useChildStore.getState().isLoading).toBe(true);
useChildStore.getState().setLoading(false);
expect(useChildStore.getState().isLoading).toBe(false);
});
});
describe("reset", () => {
it("清空所有状态", () => {
useChildStore.getState().setChildren(mockChildren);
useChildStore.getState().switchChild("student-002");
useChildStore.getState().setLoading(true);
useChildStore.getState().reset();
expect(useChildStore.getState().children).toEqual([]);
expect(useChildStore.getState().currentChildId).toBeNull();
expect(useChildStore.getState().isLoading).toBe(false);
});
it("清空 localStorage 中的 current_child_id", () => {
useChildStore.getState().setChildren(mockChildren);
useChildStore.getState().reset();
expect(localStorage.getItem("parent_current_child_id")).toBeNull();
});
});

View File

@@ -0,0 +1,90 @@
// Zustand store子女切换状态
// 依据02-architecture-design.md §4.3 状态管理分层Client Business 层)
// - ISSUE-009 裁决switchChild 纯前端状态,不调后端
// - 多子女切换currentChildId 持久化到 localStorage
// - 跨标签同步:通过 BroadcastChannel 通知P4-8 实现 hook此处仅广播事件
import { create } from "zustand";
import type { ChildInfo } from "@/types";
const CURRENT_CHILD_KEY = "parent_current_child_id";
// BroadcastChannel跨标签同步ISSUE-009 + P4-8
let bc: BroadcastChannel | null = null;
if (typeof window !== "undefined" && "BroadcastChannel" in window) {
bc = new BroadcastChannel("parent-sync");
}
interface ChildStoreState {
children: ChildInfo[];
currentChildId: string | null;
isLoading: boolean;
setChildren: (children: ChildInfo[]) => void;
switchChild: (childId: string) => void;
setLoading: (loading: boolean) => void;
reset: () => void;
}
function loadCurrentChildId(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(CURRENT_CHILD_KEY);
}
function persistCurrentChildId(childId: string): void {
if (typeof window === "undefined") return;
localStorage.setItem(CURRENT_CHILD_KEY, childId);
}
export const useChildStore = create<ChildStoreState>((set, get) => ({
children: [],
currentChildId: loadCurrentChildId(),
isLoading: false,
setChildren: (children) => {
const currentId = get().currentChildId;
// 若当前未选中或已选中的不在列表中,默认选第一个(非归档)
const activeChildren = children.filter((c) => !c.isArchived);
let nextCurrentId = currentId;
if (!nextCurrentId || !children.some((c) => c.id === nextCurrentId)) {
nextCurrentId = activeChildren[0]?.id ?? children[0]?.id ?? null;
if (nextCurrentId) {
persistCurrentChildId(nextCurrentId);
}
}
set({ children, currentChildId: nextCurrentId });
},
// ISSUE-009纯前端切换不调后端
switchChild: (childId) => {
const exists = get().children.some((c) => c.id === childId);
if (!exists) return;
persistCurrentChildId(childId);
set({ currentChildId: childId });
// 广播跨标签同步事件P4-8
bc?.postMessage({
type: "child-switched",
childId,
ts: Date.now(),
source: "unknown",
});
},
setLoading: (isLoading) => set({ isLoading }),
reset: () => {
if (typeof window !== "undefined") {
localStorage.removeItem(CURRENT_CHILD_KEY);
}
set({ children: [], currentChildId: null, isLoading: false });
},
}));
// 监听跨标签同步P4-8接收其他标签的切换事件
if (bc && typeof window !== "undefined") {
bc.onmessage = (event) => {
if (event.data?.type === "child-switched") {
useChildStore.setState({ currentChildId: event.data.childId });
}
};
}

View File

@@ -0,0 +1,9 @@
// MSW browser worker浏览器环境用于开发
// 依据02-architecture-design.md §8 MSW
// 启用条件NEXT_PUBLIC_API_MOCKING=enabled
// 需先执行 `npx msw init public/` 生成 mockServiceWorker.js
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);

View File

@@ -0,0 +1,322 @@
// MSW mock fixtures模拟 parent-bff GraphQL + iam login 响应
// 依据:
// - ISSUE-010 iam GetChildrenByParent P0 阻塞 → 用 mockstudent-001 + student-002
// - 02-architecture-design.md §8 MSW 模拟未就绪上游
// - F9 GraphQL所有查询走 GraphQL
// - ISSUE-004 登录端点走 REST
import type {
UserSession,
ChildInfo,
ChildSummary,
GradeDataPoint,
AttendanceRecord,
HomeworkItem,
WeaknessPoint,
LearningTrend,
NotificationItem,
NotificationPreferences,
LoginResponse,
} from "@/types";
// ===== 用户 =====
export const mockParent: UserSession = {
id: "parent-001",
email: "parent@example.com",
name: "张父",
roles: ["parent"],
permissions: [
"DASHBOARD_VIEW",
"CHILD_GRADE_VIEW",
"CHILD_ATTENDANCE_VIEW",
"CHILD_HOMEWORK_VIEW",
"CHILD_WEAKNESS_VIEW",
"NOTIFICATION_VIEW",
"NOTIFICATION_PREFERENCE_UPDATE",
],
dataScope: "CHILDREN",
schoolId: "school-001",
};
// ===== 子女列表ISSUE-010iam P0 阻塞,用 mock=====
export const mockChildren: ChildInfo[] = [
{
id: "student-001",
name: "张小明",
grade: "grade.7",
schoolName: "实验中学",
classId: "class-7-1",
className: "初一(1)班",
},
{
id: "student-002",
name: "张小红",
grade: "grade.5",
schoolName: "实验小学",
classId: "class-5-2",
className: "五年级(2)班",
},
];
// ===== 成绩数据点 =====
export const mockGradeDataPoints: GradeDataPoint[] = [
{
examId: "exam-001",
examName: "期中数学测验",
examDate: "2026-03-15T09:00:00Z",
subject: "数学",
studentScore: 92,
classAverage: 78,
classMax: 100,
classMin: 45,
gradeLevel: "A",
},
{
examId: "exam-002",
examName: "期中语文测验",
examDate: "2026-03-16T09:00:00Z",
subject: "语文",
studentScore: 85,
classAverage: 75,
classMax: 95,
classMin: 52,
gradeLevel: "B",
},
{
examId: "exam-003",
examName: "期中英语测验",
examDate: "2026-03-17T09:00:00Z",
subject: "英语",
studentScore: 88,
classAverage: 72,
classMax: 98,
classMin: 40,
gradeLevel: "B",
},
{
examId: "exam-004",
examName: "月考物理",
examDate: "2026-04-10T09:00:00Z",
subject: "物理",
studentScore: 95,
classAverage: 80,
classMax: 100,
classMin: 50,
gradeLevel: "A",
},
];
// ===== 子女概览 =====
export const mockChildSummary: ChildSummary = {
childId: "student-001",
avgScore: 90,
classRank: 5,
classSize: 45,
attendanceRate: 0.96,
pendingHomeworkCount: 3,
recentGradeTrend: "up",
recentScores: mockGradeDataPoints,
upcomingEvents: [
{
id: "event-001",
type: "exam",
title: "期末数学考试",
dueDate: "2026-07-20T09:00:00Z",
},
{
id: "event-002",
type: "homework",
title: "物理实验报告",
dueDate: "2026-07-12T23:59:59Z",
},
],
};
// ===== 考勤记录 =====
export const mockAttendance: AttendanceRecord[] = (() => {
const records: AttendanceRecord[] = [];
const baseDate = new Date("2026-06-01");
for (let i = 0; i < 22; i++) {
const d = new Date(baseDate);
d.setDate(d.getDate() + i);
// 周末跳过
const day = d.getDay();
if (day === 0 || day === 6) continue;
let status: AttendanceRecord["status"] = "present";
if (i === 5) status = "late";
if (i === 12) status = "leave";
records.push({
id: `att-${i}`,
date: d.toISOString().slice(0, 10),
status,
});
}
return records;
})();
// ===== 作业列表 =====
export const mockHomework: HomeworkItem[] = [
{
id: "hw-001",
title: "数学第三章习题",
subject: "数学",
className: "初一(1)班",
assignedDate: "2026-07-01T09:00:00Z",
dueDate: "2026-07-10T23:59:59Z",
status: "in_progress",
maxScore: 100,
},
{
id: "hw-002",
title: "语文古诗文背诵",
subject: "语文",
className: "初一(1)班",
assignedDate: "2026-06-28T09:00:00Z",
dueDate: "2026-07-08T23:59:59Z",
status: "submitted",
maxScore: 50,
},
{
id: "hw-003",
title: "英语听力练习",
subject: "英语",
className: "初一(1)班",
assignedDate: "2026-07-05T09:00:00Z",
dueDate: "2026-07-12T23:59:59Z",
status: "not_started",
maxScore: 30,
},
{
id: "hw-004",
title: "物理实验报告",
subject: "物理",
className: "初一(1)班",
assignedDate: "2026-06-20T09:00:00Z",
dueDate: "2026-06-25T23:59:59Z",
status: "graded",
score: 28,
maxScore: 30,
feedback: "实验步骤完整,数据分析准确。",
},
];
// ===== 学情薄弱点 =====
export const mockWeakness: WeaknessPoint[] = [
{
id: "wp-001",
knowledgePoint: "一元二次方程",
masteryLevel: 0.45,
subject: "数学",
recommendation: "建议复习配方法,完成针对性练习 20 道",
},
{
id: "wp-002",
knowledgePoint: "文言文虚词",
masteryLevel: 0.6,
subject: "语文",
recommendation: "建议背诵常见虚词用法表",
},
{
id: "wp-003",
knowledgePoint: "现在完成时",
masteryLevel: 0.72,
subject: "英语",
},
];
// ===== 学习趋势 =====
export const mockLearningTrend: LearningTrend = {
childId: "student-001",
period: "month",
dataPoints: [
{ date: "2026-03", score: 82, subject: "数学" },
{ date: "2026-04", score: 88, subject: "数学" },
{ date: "2026-05", score: 92, subject: "数学" },
{ date: "2026-06", score: 90, subject: "数学" },
],
};
// ===== 通知列表 =====
export const mockNotifications: NotificationItem[] = [
{
id: "notif-001",
childId: "student-001",
eventType: "grade_recorded",
title: "grade_recorded.title",
body: "grade_recorded.body",
read: false,
createdAt: new Date(Date.now() - 3600000).toISOString(),
actionUrl: "/parent/grades?childId=student-001",
pinned: true,
},
{
id: "notif-002",
childId: "student-001",
eventType: "homework_assigned",
title: "homework_assigned.title",
body: "homework_assigned.body",
read: false,
createdAt: new Date(Date.now() - 7200000).toISOString(),
actionUrl: "/parent/homework?childId=student-001",
},
{
id: "notif-003",
childId: null,
eventType: "school_announcement",
title: "school_announcement.title",
body: "school_announcement.body",
read: true,
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
// ===== 通知偏好默认值ISSUE-033P4 localStorage 降级)=====
export const mockNotificationPreferences: NotificationPreferences = {
parentId: "parent-001",
preferences: {
"student-001": {
grade_recorded: { in_app: true, push: true },
homework_graded: { in_app: true, push: true },
homework_assigned: { in_app: true, push: true },
exam_published: { in_app: true, push: true, sms: false },
attendance_alert: { in_app: true, push: true, sms: true },
school_announcement: { in_app: true },
teacher_message: { in_app: true, push: true },
fee_reminder: { in_app: true, push: true, sms: true },
event_invitation: { in_app: true, push: true },
},
"student-002": {
grade_recorded: { in_app: true, push: true },
homework_graded: { in_app: true, push: true },
homework_assigned: { in_app: true, push: true },
exam_published: { in_app: true, push: true },
attendance_alert: { in_app: true, push: true, sms: true },
school_announcement: { in_app: true },
teacher_message: { in_app: true, push: true },
fee_reminder: { in_app: true, push: true, sms: true },
event_invitation: { in_app: true, push: true },
},
},
defaults: {
grade_recorded: { in_app: true, push: true },
homework_graded: { in_app: true, push: true },
homework_assigned: { in_app: true, push: true },
exam_published: { in_app: true, push: true },
attendance_alert: { in_app: true, push: true, sms: true },
school_announcement: { in_app: true },
teacher_message: { in_app: true, push: true },
fee_reminder: { in_app: true, push: true, sms: true },
event_invitation: { in_app: true, push: true },
},
updatedAt: new Date().toISOString(),
};
// ===== 登录响应ISSUE-004REST 登录)=====
export const mockLoginResponse: LoginResponse = {
user: mockParent,
tokens: {
accessToken: "mock-access-token-parent-001",
refreshToken: "mock-refresh-token-parent-001",
expiresIn: 3600,
},
};

View File

@@ -0,0 +1,188 @@
// MSW handlersparent-bff GraphQL + iam REST mock
// 依据:
// - 02-architecture-design.md §8 MSW 模拟未就绪上游
// - F9 GraphQL查询走 GraphQL
// - ISSUE-004 登录走 REST
// - ISSUE-010 iam GetChildrenByParent P0 阻塞 → myChildren 返回 mock
// - ISSUE-033 通知偏好 P4 localStorage 降级 → updateNotificationPreferences 仍 mock 200
import { http, HttpResponse, graphql } from "msw";
import type { ActionState, LoginResponse } from "@/types";
import {
mockParent,
mockChildren,
mockChildSummary,
mockGradeDataPoints,
mockAttendance,
mockHomework,
mockWeakness,
mockLearningTrend,
mockNotifications,
mockNotificationPreferences,
mockLoginResponse,
} from "./fixtures";
// ===== REST handlersiam login/refreshISSUE-004=====
const restHandlers = [
http.post("/api/v1/iam/login", async ({ request }) => {
const body = (await request.json()) as { email: string; password: string };
// mock任意 parent@example.com / password 通过
if (body.email === "parent@example.com" && body.password === "password") {
const res: ActionState<LoginResponse> = {
success: true,
data: mockLoginResponse,
};
return HttpResponse.json(res);
}
const err: ActionState<never> = {
success: false,
error: {
code: "IAM_INVALID_CREDENTIALS",
message: "邮箱或密码错误",
},
};
return HttpResponse.json(err, { status: 401 });
}),
http.post("/api/v1/iam/refresh", async ({ request }) => {
const body = (await request.json()) as { refreshToken: string };
if (body.refreshToken === "mock-refresh-token-parent-001") {
const res: ActionState<{ tokens: LoginResponse["tokens"] }> = {
success: true,
data: {
tokens: {
accessToken: "mock-access-token-parent-001-renewed",
refreshToken: "mock-refresh-token-parent-001-renewed",
expiresIn: 3600,
},
},
};
return HttpResponse.json(res);
}
return HttpResponse.json(
{
success: false,
error: { code: "IAM_INVALID_REFRESH", message: "refresh token 无效" },
},
{ status: 401 },
);
}),
];
// ===== GraphQL handlersparent-bffF9=====
const graphqlHandlers = [
graphql.query("CurrentUser", () => {
return HttpResponse.json({
data: {
currentUser: mockParent,
},
});
}),
graphql.query("MyChildren", () => {
return HttpResponse.json({
data: {
myChildren: mockChildren,
},
});
}),
graphql.query("ChildSummary", ({ variables }) => {
const childId = variables.childId as string;
return HttpResponse.json({
data: {
childSummary: { ...mockChildSummary, childId },
},
});
}),
graphql.query("ChildGrades", () => {
return HttpResponse.json({
data: {
childGrades: mockGradeDataPoints,
},
});
}),
graphql.query("ChildAttendance", () => {
return HttpResponse.json({
data: {
childAttendance: mockAttendance,
},
});
}),
graphql.query("ChildHomework", () => {
return HttpResponse.json({
data: {
childHomework: mockHomework,
},
});
}),
graphql.query("ChildWeakness", () => {
return HttpResponse.json({
data: {
childWeakness: mockWeakness,
},
});
}),
graphql.query("ChildTrend", ({ variables }) => {
return HttpResponse.json({
data: {
childTrend: {
...mockLearningTrend,
childId: variables.childId as string,
},
},
});
}),
graphql.query("MyNotifications", () => {
return HttpResponse.json({
data: {
myNotifications: mockNotifications,
},
});
}),
graphql.query("MyNotificationPreferences", () => {
return HttpResponse.json({
data: {
myNotificationPreferences: mockNotificationPreferences,
},
});
}),
graphql.mutation("MarkAsRead", ({ variables }) => {
const notificationId = variables.notificationId as string;
return HttpResponse.json({
data: {
markAsRead: { id: notificationId, read: true },
},
});
}),
graphql.mutation("MarkAllAsRead", () => {
return HttpResponse.json({
data: {
markAllAsRead: { count: mockNotifications.length },
},
});
}),
// ISSUE-033P4 localStorage 降级,但仍 mock 返回 200前端优先用 localStorage
graphql.mutation("UpdateNotificationPreferences", ({ variables }) => {
return HttpResponse.json({
data: {
updateNotificationPreferences: {
parentId: (variables as { parentId: string }).parentId,
updatedAt: new Date().toISOString(),
},
},
});
}),
];
export const handlers = [...restHandlers, ...graphqlHandlers];

View File

@@ -0,0 +1,7 @@
// MSW serverNode 环境,用于 Vitest 测试)
// 依据02-architecture-design.md §8 MSW、vitest.config.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);

View File

@@ -0,0 +1,57 @@
// Vitest 测试环境初始化
// 依据vitest.config.ts setupFiles
import { beforeEach } from "vitest";
import "@testing-library/jest-dom/vitest";
// jsdom 环境补充matchMediarecharts 等库依赖)
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string): MediaQueryList => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
// jsdom 环境补充IntersectionObserver
if (!window.IntersectionObserver) {
class MockIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
}
// jsdom 环境补充ResizeObserverrecharts 依赖)
if (!window.ResizeObserver) {
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
Object.defineProperty(window, "ResizeObserver", {
writable: true,
value: MockResizeObserver,
});
}
// 清理 localStorage
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});

View File

@@ -0,0 +1,247 @@
// parent-portal 类型定义
// 依据02-architecture-design.md §2 领域模型、§14 通知偏好数据模型、§15 详细组件设计
// ===== 会话状态Session=====
export interface UserSession {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
dataScope: DataScope;
schoolId?: string;
}
export type DataScope =
| "ALL"
| "SCHOOL"
| "GRADE"
| "CLASS"
| "SUBJECT"
| "CHILDREN" // 家长特有:仅自己绑定的子女
| "SELF";
// ===== 视口Viewport=====
export interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: string;
requiredPermission: string | null;
scope: "parent";
}
// ===== 子女信息ChildInfo=====
export interface ChildInfo {
id: string;
name: string;
avatar?: string;
grade: string; // i18n key
schoolName: string;
classId: string;
className?: string;
isArchived?: boolean; // 已归档(转学/离校)
}
// ===== 子女仪表盘概览childSummary=====
export interface ChildSummary {
childId: string;
avgScore: number;
classRank: number;
classSize: number;
attendanceRate: number; // 0-1
pendingHomeworkCount: number;
recentGradeTrend: "up" | "down" | "stable";
recentScores: GradeDataPoint[];
upcomingEvents?: UpcomingEvent[];
}
export interface UpcomingEvent {
id: string;
type: "exam" | "homework" | "activity";
title: string;
dueDate: string; // ISO 8601
}
// ===== 成绩Grade=====
export interface GradeDataPoint {
examId: string;
examName: string;
examDate: string; // ISO 8601
subject: string;
studentScore: number;
classAverage?: number;
classMax?: number;
classMin?: number;
gradeLevel: "A" | "B" | "C" | "D" | "F";
}
// ===== 考勤Attendance=====
export interface AttendanceRecord {
id: string;
date: string; // ISO 8601 date
status: "present" | "absent" | "late" | "leave";
note?: string;
}
// ===== 作业Homework=====
export interface HomeworkItem {
id: string;
title: string;
subject: string;
className: string;
assignedDate: string; // ISO 8601
dueDate: string; // ISO 8601
status: "not_started" | "in_progress" | "submitted" | "graded";
score?: number;
maxScore: number;
feedback?: string;
}
// ===== 学情分析 =====
export interface WeaknessPoint {
id: string;
knowledgePoint: string;
masteryLevel: number; // 0-1
subject: string;
recommendation?: string;
}
export interface LearningTrend {
childId: string;
period: "week" | "month" | "semester";
dataPoints: TrendDataPoint[];
}
export interface TrendDataPoint {
date: string;
score: number;
subject?: string;
}
// ===== 通知 =====
export interface NotificationItem {
id: string;
childId: string | null;
eventType: NotificationEventType;
title: string; // i18n key
body: string; // i18n key + 参数
read: boolean;
createdAt: string; // ISO 8601
actionUrl?: string;
pinned?: boolean;
}
// ===== 通知偏好02 §14 通知偏好数据模型)=====
export type NotificationEventType =
| "grade_recorded"
| "homework_graded"
| "homework_assigned"
| "exam_published"
| "attendance_alert"
| "school_announcement"
| "teacher_message"
| "fee_reminder"
| "event_invitation";
export type NotificationChannel =
"in_app" | "push" | "sms" | "email" | "wechat";
export interface NotificationPreferences {
parentId: string;
// 三维矩阵children[eventType][channel] = enabled
// Partial 内层:允许部分 eventType 未设置(新增子女时不必预填全部事件)
preferences: Record<
string, // childId"*" 表示全部子女)
Partial<
Record<
NotificationEventType,
Partial<Record<NotificationChannel, boolean>>
>
>
>;
// 全局默认
defaults: Record<
NotificationEventType,
Partial<Record<NotificationChannel, boolean>>
>;
updatedAt: string; // ISO 8601
}
export interface AvailableChannels {
in_app: boolean;
push: boolean;
sms: boolean;
email: boolean;
wechat: boolean;
}
// ===== API 错误 =====
export interface ApiError {
code: string;
message: string;
details?: unknown;
traceId?: string;
}
// ===== ActionState统一响应契约=====
export type ActionState<T> =
{ success: true; data: T } | { success: false; error: ApiError };
// ===== 登录响应 =====
export interface LoginResponse {
user: UserSession;
tokens: {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
}
// ===== WebSocket 推送事件P5=====
export type WebSocketEvent =
| {
type: "NotificationRequested";
notification: NotificationItem;
}
| {
type: "GradeRecorded";
childId: string;
examId: string;
examName: string;
score: number;
}
| {
type: "SchoolAnnouncement";
title: string;
body: string;
}
| {
type: "ChildUnbound";
childId: string;
}
| {
type: "ChildBound";
child: ChildInfo;
};
// ===== 跨标签同步消息 =====
export type SyncMessage =
| {
type: "child-switched";
childId: string;
ts: number;
source: string;
}
| {
type: "child-unbound";
childId: string;
ts: number;
source: string;
}
| {
type: "preferences-updated";
ts: number;
source: string;
};

View File

@@ -0,0 +1,62 @@
/** @type {import('tailwindcss').Config} */
// parent-portal Tailwind 配置
// 复用 teacher-portal 设计令牌(与 Shell 共享)
// 依据02-architecture-design.md §9 设计令牌三层(复用 packages/ui-tokens
// project_rules §3.10 设计令牌规范(禁止 #hex / 禁止硬编码字体 / 禁止任意值)
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
paper: "hsl(var(--paper))",
ink: {
DEFAULT: "hsl(var(--ink))",
muted: "hsl(var(--ink-muted))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
soft: "hsl(var(--accent-soft))",
},
rule: "hsl(var(--rule))",
success: "hsl(var(--success))",
warning: "hsl(var(--warning))",
danger: "hsl(var(--danger))",
},
fontFamily: {
serif: ["var(--font-serif)"],
sans: ["var(--font-sans)"],
mono: ["var(--font-mono)"],
},
fontSize: {
xs: ["var(--font-size-1)", "var(--line-height-tight)"],
sm: ["var(--font-size-2)", "var(--line-height-normal)"],
base: ["var(--font-size-3)", "var(--line-height-normal)"],
lg: ["var(--font-size-4)", "var(--line-height-normal)"],
xl: ["var(--font-size-5)", "var(--line-height-tight)"],
"2xl": ["var(--font-size-6)", "var(--line-height-tight)"],
"3xl": ["var(--font-size-7)", "var(--line-height-tight)"],
},
spacing: {
1: "var(--space-1)",
2: "var(--space-2)",
3: "var(--space-3)",
4: "var(--space-4)",
5: "var(--space-5)",
6: "var(--space-6)",
8: "var(--space-8)",
10: "var(--space-10)",
12: "var(--space-12)",
16: "var(--space-16)",
},
borderRadius: {
DEFAULT: "var(--radius-md)",
sm: "var(--radius-sm)",
md: "var(--radius-md)",
lg: "var(--radius-lg)",
full: "9999px",
},
},
},
plugins: [],
};

View File

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

View File

@@ -0,0 +1,42 @@
// Vitest 配置
// 依据02-architecture-design.md §13 测试策略分层
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: [
"src/**/*.{test,spec}.{ts,tsx}",
"src/**/__tests__/**/*.{ts,tsx}",
],
exclude: ["node_modules", ".next", "e2e"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.d.ts",
"src/test/**",
"src/mocks/**",
"src/types/**",
"src/**/*.config.{ts,tsx}",
],
thresholds: {
statements: 85,
branches: 75,
functions: 85,
lines: 85,
},
},
},
});

View File

@@ -434,24 +434,36 @@
> parent-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / ErrorBoundary / 设计令牌 / A11y 工具集,本节仅记录 parent-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。
| 场景 | 技术/规则 |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P4 端口分配 | parent-portal = **4002**004 §1.2 强制 4 端 4000-4003ai07 初稿误用 3002 与 iam 冲突ai15 修订) |
| P4 MF Remote 角色 | next.config.js 配 `@module-federation/nextjs-mf` 暴露 `./ChildSwitcher` 等家长端特有组件,复用 Shell 提供的 `AppShell`/`usePermission`/`ApiClient`/`RequirePermission` |
| P4 DataScope CHILDREN | 家长仅能查看自己绑定子女的数据004 §5.4 L0 SELF 变体),前端通过 ApiClient 透传 userId + childId下游服务按 DataScope 过滤 |
| P4 多子女切换 ChildSwitcher | Zustand slice`selectedChildId`+ 切换后 `queryClient.invalidateQueries({ queryKey: ['child', prevId] })`;多子女 Tab≤3或下拉≥4空态/单子女/多子女三态分支 |
| P4 多子女数量边界 | 0 子女EmptyChildState 引导绑定)→ 1 子女(隐藏切换器)→ 2-3Tab Bar→ 4-10下拉→ >10搜索 + 列表);超出 10 触发性能预警 |
| P4 通知偏好三维矩阵 | `Record<childId, Record<eventType, Record<channel, boolean>>>`react-hook-form + Zod 校验;默认值矩阵(成绩发布=邮件+短信、作业批改=邮件、教师沟通=短信、学校通知=邮件);渠道可用性校验 |
| P4 跨标签同步 | BroadcastChannel API`parent-portal-sync`+ localStorage 持久化 + LWWLast-Write-Wins冲突解决同步 selectedChildId / 通知偏好 / 已读状态 |
| P4 隐私合规COPPA/FERPA/PIPL | 未成年人数据最小化采集;通知偏好默认 opt-in数据保留 7 年后自动归档;前端不缓存敏感字段到 localStorage仅 childId + 已读位图) |
| P4 i18n 5 语言含 RTL | next-intl 支持 zh-CN / en-US / zh-TW / ja-JP / ar-SAar-SA 用 `dir="rtl"` + Tailwind `rtl:` 变体;翻译文件按域分 namespaceparent/grades/notifications/preferences |
| P4 PWA + Service Worker | manifest.jsonname/short_name/icons/theme_color+ Service Worker 缓存策略App Shell precache + API stale-while-revalidate + 离线兜底页P5+ 支持离线查看子女成绩缓存 |
| P4 API 契约版本管理 | 主版本 URL `/api/v1/*` → `/api/v2/*` + 子版本响应头 `X-API-Version` + Deprecation/Sunset 头埋点 + 低带宽场景 `X-Fields` 字段裁剪GraphQL 风格BFF REST 透传) |
| P4 性能预算 | Bundle ≤ 80KB JS / 20KB CSS / 200KB 总recharts 懒加载MF shared 单例避免 React 重复打包dynamic import 非首屏组件;预加载子女数据 |
| P4 监控与降级 | 10 项前端指标MF 加载失败率/ApiClient 错误率/Web Vitals/切换延迟等)+ 9 项降级策略MF 失败回退独立页/BFF 5xx 降级缓存/WS 重试指数退避等) |
| P4 模块演化与解耦 | 拆分触发条件Bundle > 120KB / 团队 > 3 人 / 独立部署需求);拆分后 MF 配置从 Shell 暴露状态管理迁移路径Zustand → Server Component + Server ActionsMF 升级路径MF 2.0 → 3.0 |
| P4 iam 家长-学生关联 P0 阻塞 | iam 缺 `GetChildrenByParent` proto RPC + `GET /iam/children` REST + `iam_student_guardians` 表ai02 补全前 parent-portal 仅能假设单子女硬编码 childId 开发调试 |
| P4 BFF 错误码前缀 | **BFF_PARENT\_=parent-bff**coord final §B5统一 `BFF_XXX_` 风格);前端按 `BFF_PARENT_*` 前缀路由 i18n key与 BFF_TEACHER_/BFF_STUDENT_ 对齐 |
| 场景 | 技术/规则 |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P4 端口分配 | parent-portal = **4002**004 §1.2 强制 4 端 4000-4003ai07 初稿误用 3002 与 iam 冲突ai15 修订) |
| P4 MF Remote 角色 | next.config.js 配 `@module-federation/nextjs-mf` 暴露 `./ChildSwitcher` 等家长端特有组件,复用 Shell 提供的 `AppShell`/`usePermission`/`ApiClient`/`RequirePermission` |
| P4 DataScope CHILDREN | 家长仅能查看自己绑定子女的数据004 §5.4 L0 SELF 变体),前端通过 ApiClient 透传 userId + childId下游服务按 DataScope 过滤 |
| P4 多子女切换 ChildSwitcher | Zustand slice`selectedChildId`+ 切换后 `queryClient.invalidateQueries({ queryKey: ['child', prevId] })`;多子女 Tab≤3或下拉≥4空态/单子女/多子女三态分支 |
| P4 多子女数量边界 | 0 子女EmptyChildState 引导绑定)→ 1 子女(隐藏切换器)→ 2-3Tab Bar→ 4-10下拉→ >10搜索 + 列表);超出 10 触发性能预警 |
| P4 通知偏好三维矩阵 | `Record<childId, Record<eventType, Record<channel, boolean>>>`react-hook-form + Zod 校验;默认值矩阵(成绩发布=邮件+短信、作业批改=邮件、教师沟通=短信、学校通知=邮件);渠道可用性校验 |
| P4 跨标签同步 | BroadcastChannel API`parent-portal-sync`+ localStorage 持久化 + LWWLast-Write-Wins冲突解决同步 selectedChildId / 通知偏好 / 已读状态 |
| P4 隐私合规COPPA/FERPA/PIPL | 未成年人数据最小化采集;通知偏好默认 opt-in数据保留 7 年后自动归档;前端不缓存敏感字段到 localStorage仅 childId + 已读位图) |
| P4 i18n 5 语言含 RTL | next-intl 支持 zh-CN / en-US / zh-TW / ja-JP / ar-SAar-SA 用 `dir="rtl"` + Tailwind `rtl:` 变体;翻译文件按域分 namespaceparent/grades/notifications/preferences |
| P4 PWA + Service Worker | manifest.jsonname/short_name/icons/theme_color+ Service Worker 缓存策略App Shell precache + API stale-while-revalidate + 离线兜底页P5+ 支持离线查看子女成绩缓存 |
| P4 API 契约版本管理 | 主版本 URL `/api/v1/*` → `/api/v2/*` + 子版本响应头 `X-API-Version` + Deprecation/Sunset 头埋点 + 低带宽场景 `X-Fields` 字段裁剪GraphQL 风格BFF REST 透传) |
| P4 性能预算 | Bundle ≤ 80KB JS / 20KB CSS / 200KB 总recharts 懒加载MF shared 单例避免 React 重复打包dynamic import 非首屏组件;预加载子女数据 |
| P4 监控与降级 | 10 项前端指标MF 加载失败率/ApiClient 错误率/Web Vitals/切换延迟等)+ 9 项降级策略MF 失败回退独立页/BFF 5xx 降级缓存/WS 重试指数退避等) |
| P4 模块演化与解耦 | 拆分触发条件Bundle > 120KB / 团队 > 3 人 / 独立部署需求);拆分后 MF 配置从 Shell 暴露状态管理迁移路径Zustand → Server Component + Server ActionsMF 升级路径MF 2.0 → 3.0 |
| P4 iam 家长-学生关联 P0 阻塞 | iam 缺 `GetChildrenByParent` proto RPC + `GET /iam/children` REST + `iam_student_guardians` 表ai02 补全前 parent-portal 仅能假设单子女硬编码 childId 开发调试 |
| P4 BFF 错误码前缀 | **BFF_PARENT\_=parent-bff**coord final §B5统一 `BFF_XXX_` 风格);前端按 `BFF_PARENT_*` 前缀路由 i18n key与 BFF_TEACHER_/BFF_STUDENT_ 对齐 |
| P4 认证 token 存储 | localStorageF12 裁决P4 用 localStorage非 cookierefreshAccessToken 竞态防护用 inflight Promise 复用,并发请求只触发一次 refresh fetch |
| P4 switchChild 纯前端 | ISSUE-009 裁决:切换子女不调后端,仅 Zustand slice + localStorage 持久化 selectedChildId切换后由前端 invalidateQueries 触发数据刷新 |
| P4 通知偏好 localStorage 降级 | ISSUE-033 裁决P4 localStorage 为准,更新先写本地再尝试 GraphQL mutation失败不阻塞P5+ 切换到后端为主存储 |
| P4 上游 mock 策略 | MSW 拦截 parent-bff GraphQL + iam RESTiam GetChildrenByParent P0 阻塞用 mock fixturesstudent-001 + student-002 双子女MSW server 在 providers 启动 |
| TS noUncheckedIndexedAccess | Record 索引访问返回 `T \| undefined`;内层 Record 改 `Partial<Record<...>>` 或空对象回退(`?? {}`)避免 undefined 传播 |
| urql fetchOptions headers 类型 | 返回类型须用 `Record<string, string>` 累积再展开,不可用条件对象(`{...(token?{Authorization}:{})}`)否则 TS 推断不兼容 |
| Vitest Promise 身份比较 | `expect(p1).toBe(p2)` 对 Promise 不可靠async 包装产生新 Promise改为验证行为副作用如 fetch 调用次数 === 1 证明竞态防护生效) |
| jsdom window.location.href | 设置触发 "Not implemented: navigation" stderr 警告非错误测试仍通过mock 用 `Object.defineProperty(window, "location", {...})` 替代 `vi.spyOn` |
| MSW 响应过快观察加载态 | MSW 默认同步响应,加载态测试看不到 "加载中";用 `server.use()` + `delay(ms)` 延迟响应再断言按钮文本 |
| Testing Library getByRole 误匹配 | `getByRole("button", { name: /未读/ })` 会匹配通知项 aria-label="未读";改用 `getAllByRole("button")` + find 文本内容过滤精确选中筛选按钮 |
| ESLint flat config 测试文件 | 放宽规则需含 `*.test.tsx`/`*.spec.tsx`/`__tests__/**` 模式,否则 tsx 测试文件按严格规则报错 |
| Dockerfile 多阶段构建 | G1 裁决首次即多阶段buildernode:20-alpine 跑 build+ runnernode:20-alpine 跑 server端口 4002HEALTHCHECK 指向 `/api/health`,非 root 用户 nextjs |
### 2.14 parent-bffTS/NestJSP4

View File

@@ -48,7 +48,14 @@ module.exports = tseslint.config(
// 测试文件放宽规则
{
files: ['**/*.test.ts', '**/*.spec.ts', '**/test/**'],
files: [
'**/*.test.ts',
'**/*.test.tsx',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/test/**',
'**/__tests__/**',
],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',

3265
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff