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:
36
apps/parent-portal/.env.example
Normal file
36
apps/parent-portal/.env.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# parent-portal 环境变量示例
|
||||
# 复制为 .env.local 使用
|
||||
|
||||
# ===== API 网关 =====
|
||||
# api-gateway 地址(dev :8080)
|
||||
API_GATEWAY_URL=http://localhost:8080
|
||||
|
||||
# ===== 微前端(MF)=====
|
||||
# MF 开关:enabled 启用 MF Remote,disabled 作为独立应用运行
|
||||
# 依据:ARB-002 MF Shell 暴露清单
|
||||
NEXT_PUBLIC_MF_ENABLED=disabled
|
||||
|
||||
# ===== GraphQL(F9 裁决)=====
|
||||
# parent-bff GraphQL 端点(经 api-gateway 代理)
|
||||
# 批次 3(P4)parent-bff 就绪前用 MSW mock
|
||||
NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/parent/graphql
|
||||
|
||||
# ===== Mock =====
|
||||
# MSW mock 开关:enabled 启用 mock,disabled 调真实后端
|
||||
# 依据: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
|
||||
52
apps/parent-portal/Dockerfile
Normal file
52
apps/parent-portal/Dockerfile
Normal 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 裁决(首次即多阶段)
|
||||
# 端口:4002(4 端 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"]
|
||||
@@ -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 **首次实现即用 GraphQL(urql)**,不再使用 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/hooks,coord 维护 shared-ts/contracts。
|
||||
> 6. **F10 MF 暴露粒度**:暴露 AppShell 整体。
|
||||
> 7. **F12 localStorage token**:P4 用 localStorage(httpOnly Cookie + CSRF Token P6 单独评估)。
|
||||
> 8. **ISSUE-033-ai15 通知偏好**:P4 阶段通知偏好设置页面 UI + 表单校验,偏好暂存前端 localStorage(降级方案),不调后端 API;P5 阶段 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
5
apps/parent-portal/next-env.d.ts
vendored
Normal 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.
|
||||
87
apps/parent-portal/next.config.js
Normal file
87
apps/parent-portal/next.config.js
Normal 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 GraphQL(urql)
|
||||
|
||||
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;
|
||||
55
apps/parent-portal/package.json
Normal file
55
apps/parent-portal/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@edu/parent-portal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Edu 家长端微前端 Remote(MF)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
6
apps/parent-portal/postcss.config.js
Normal file
6
apps/parent-portal/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
25
apps/parent-portal/public/manifest.json
Normal file
25
apps/parent-portal/public/manifest.json
Normal 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"]
|
||||
}
|
||||
275
apps/parent-portal/public/mockServiceWorker.js
Normal file
275
apps/parent-portal/public/mockServiceWorker.js
Normal 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
|
||||
}
|
||||
18
apps/parent-portal/src/app/api/health/route.ts
Normal file
18
apps/parent-portal/src/app/api/health/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
38
apps/parent-portal/src/app/api/ready/route.ts
Normal file
38
apps/parent-portal/src/app/api/ready/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
203
apps/parent-portal/src/app/globals.css
Normal file
203
apps/parent-portal/src/app/globals.css
Normal 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: Npx,project_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 Semantic(light)===== */
|
||||
--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 Semantic(dark)===== */
|
||||
--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;
|
||||
}
|
||||
}
|
||||
49
apps/parent-portal/src/app/layout.tsx
Normal file
49
apps/parent-portal/src/app/layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// RootLayout:字体 + 全局样式 + Providers
|
||||
// 依据:02-architecture-design.md §9 设计令牌三层、project_rules §3.10 设计令牌规范
|
||||
// - 字体:Inter(sans)/ Fraunces(serif)/ JetBrains Mono(mono),经 next/font 加载
|
||||
// - 全局样式:globals.css(设计令牌 + 基础样式 + a11y)
|
||||
// - Providers:QueryClient + 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>
|
||||
);
|
||||
}
|
||||
93
apps/parent-portal/src/app/login/login.test.tsx
Normal file
93
apps/parent-portal/src/app/login/login.test.tsx
Normal 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(/登录中/);
|
||||
});
|
||||
});
|
||||
102
apps/parent-portal/src/app/login/page.tsx
Normal file
102
apps/parent-portal/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/parent-portal/src/app/page.tsx
Normal file
11
apps/parent-portal/src/app/page.tsx
Normal 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");
|
||||
}
|
||||
33
apps/parent-portal/src/app/parent/attendance/page.tsx
Normal file
33
apps/parent-portal/src/app/parent/attendance/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/parent-portal/src/app/parent/dashboard/page.tsx
Normal file
9
apps/parent-portal/src/app/parent/dashboard/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// 仪表盘页面
|
||||
// 依据:02-architecture-design.md §3.1 路由结构
|
||||
// 路由:/parent/dashboard
|
||||
|
||||
import { ParentDashboard } from "@/components/ParentDashboard";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <ParentDashboard />;
|
||||
}
|
||||
85
apps/parent-portal/src/app/parent/grades/page.tsx
Normal file
85
apps/parent-portal/src/app/parent/grades/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/parent-portal/src/app/parent/homework/page.tsx
Normal file
120
apps/parent-portal/src/app/parent/homework/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/parent-portal/src/app/parent/layout.tsx
Normal file
51
apps/parent-portal/src/app/parent/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/parent-portal/src/app/parent/notifications/page.tsx
Normal file
17
apps/parent-portal/src/app/parent/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/parent-portal/src/app/parent/preferences/page.tsx
Normal file
17
apps/parent-portal/src/app/parent/preferences/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
// 通知偏好页面
|
||||
// 依据:02-architecture-design.md §3.1 路由结构、§14 通知偏好
|
||||
// 路由:/parent/preferences
|
||||
// ISSUE-033:P4 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>
|
||||
);
|
||||
}
|
||||
84
apps/parent-portal/src/app/parent/weakness/page.tsx
Normal file
84
apps/parent-portal/src/app/parent/weakness/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/parent-portal/src/app/providers.tsx
Normal file
69
apps/parent-portal/src/app/providers.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// 客户端 Providers:QueryClient + 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>
|
||||
);
|
||||
}
|
||||
126
apps/parent-portal/src/components/AppShell.tsx
Normal file
126
apps/parent-portal/src/components/AppShell.tsx
Normal 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;
|
||||
143
apps/parent-portal/src/components/AttendanceCalendar.tsx
Normal file
143
apps/parent-portal/src/components/AttendanceCalendar.tsx
Normal 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;
|
||||
72
apps/parent-portal/src/components/ChildGradeChart.tsx
Normal file
72
apps/parent-portal/src/components/ChildGradeChart.tsx
Normal 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;
|
||||
119
apps/parent-portal/src/components/ChildSummaryCard.tsx
Normal file
119
apps/parent-portal/src/components/ChildSummaryCard.tsx
Normal 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;
|
||||
170
apps/parent-portal/src/components/ChildSwitcher.test.tsx
Normal file
170
apps/parent-portal/src/components/ChildSwitcher.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
128
apps/parent-portal/src/components/ChildSwitcher.tsx
Normal file
128
apps/parent-portal/src/components/ChildSwitcher.tsx
Normal 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;
|
||||
35
apps/parent-portal/src/components/EmptyChildState.tsx
Normal file
35
apps/parent-portal/src/components/EmptyChildState.tsx
Normal 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;
|
||||
50
apps/parent-portal/src/components/MultiChildTabBar.tsx
Normal file
50
apps/parent-portal/src/components/MultiChildTabBar.tsx
Normal 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;
|
||||
104
apps/parent-portal/src/components/NotificationFeed.test.tsx
Normal file
104
apps/parent-portal/src/components/NotificationFeed.test.tsx
Normal 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(无 auth,MSW 拦截 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
133
apps/parent-portal/src/components/NotificationFeed.tsx
Normal file
133
apps/parent-portal/src/components/NotificationFeed.tsx
Normal 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;
|
||||
61
apps/parent-portal/src/components/ParentDashboard.tsx
Normal file
61
apps/parent-portal/src/components/ParentDashboard.tsx
Normal 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;
|
||||
155
apps/parent-portal/src/components/PreferenceForm.tsx
Normal file
155
apps/parent-portal/src/components/PreferenceForm.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
// PreferenceForm:通知偏好表单
|
||||
// 依据:02-architecture-design.md §14 通知偏好数据模型、§15.8 PreferenceForm 设计
|
||||
// - ISSUE-033:P4 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;
|
||||
41
apps/parent-portal/src/hooks/useChildAttendance.ts
Normal file
41
apps/parent-portal/src/hooks/useChildAttendance.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
29
apps/parent-portal/src/hooks/useChildGrades.ts
Normal file
29
apps/parent-portal/src/hooks/useChildGrades.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
29
apps/parent-portal/src/hooks/useChildHomework.ts
Normal file
29
apps/parent-portal/src/hooks/useChildHomework.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
apps/parent-portal/src/hooks/useChildSummary.ts
Normal file
31
apps/parent-portal/src/hooks/useChildSummary.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
apps/parent-portal/src/hooks/useChildSwitcher.ts
Normal file
30
apps/parent-portal/src/hooks/useChildSwitcher.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
29
apps/parent-portal/src/hooks/useChildWeakness.ts
Normal file
29
apps/parent-portal/src/hooks/useChildWeakness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
97
apps/parent-portal/src/hooks/useCrossTabSync.ts
Normal file
97
apps/parent-portal/src/hooks/useCrossTabSync.ts
Normal 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(() => {
|
||||
// 方案 1:BroadcastChannel(首选)
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 方案 2:storage 事件降级(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 {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
39
apps/parent-portal/src/hooks/useMyChildren.ts
Normal file
39
apps/parent-portal/src/hooks/useMyChildren.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// useMyChildren:获取家长绑定子女列表
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入
|
||||
// - ISSUE-010:iam GetChildrenByParent P0 阻塞,MSW mock 返回 student-001 + student-002
|
||||
// - 加载后写入 Zustand store(setChildren),触发默认选中
|
||||
// - 单次加载,不轮询(子女列表变更由 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,
|
||||
};
|
||||
}
|
||||
51
apps/parent-portal/src/hooks/useMyNotifications.ts
Normal file
51
apps/parent-portal/src/hooks/useMyNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
108
apps/parent-portal/src/hooks/useNotificationPreferences.ts
Normal file
108
apps/parent-portal/src/hooks/useNotificationPreferences.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
35
apps/parent-portal/src/hooks/usePermission.ts
Normal file
35
apps/parent-portal/src/hooks/usePermission.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
70
apps/parent-portal/src/hooks/useRealtimeNotifications.ts
Normal file
70
apps/parent-portal/src/hooks/useRealtimeNotifications.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// useRealtimeNotifications:实时通知 Hook(P5)
|
||||
// 依据: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 };
|
||||
}
|
||||
168
apps/parent-portal/src/hooks/useWebSocket.ts
Normal file
168
apps/parent-portal/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// useWebSocket:push-gateway WebSocket 连接
|
||||
// 依据:02-architecture-design.md §5 实时推送(P5)
|
||||
// - 连接 push-gateway WebSocket(NEXT_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 {
|
||||
// 轮询失败静默忽略
|
||||
}
|
||||
}
|
||||
182
apps/parent-portal/src/lib/auth.test.ts
Normal file
182
apps/parent-portal/src/lib/auth.test.ts
Normal 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("login(REST,ISSUE-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");
|
||||
});
|
||||
});
|
||||
192
apps/parent-portal/src/lib/auth.ts
Normal file
192
apps/parent-portal/src/lib/auth.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// 认证工具:token 存储(localStorage,F12 裁决)+ 登录(REST,ISSUE-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 token(SSR 安全)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录(REST,ISSUE-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}` } : {};
|
||||
}
|
||||
72
apps/parent-portal/src/lib/graphql-client.ts
Normal file
72
apps/parent-portal/src/lib/graphql-client.ts
Normal 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;
|
||||
}
|
||||
210
apps/parent-portal/src/lib/graphql/operations.ts
Normal file
210
apps/parent-portal/src/lib/graphql/operations.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// GraphQL operations 文档
|
||||
// 依据:02-architecture-design.md §4.2 GraphQL 接入、F9 裁决(首次即 GraphQL)
|
||||
// 端点:POST /api/v1/parent/graphql
|
||||
// 命名:查询 PascalCase(CurrentUser / 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
107
apps/parent-portal/src/lib/i18n.ts
Normal file
107
apps/parent-portal/src/lib/i18n.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// i18n 国际化(P6-4)
|
||||
// 依据:02-architecture-design.md §17 国际化、F4 裁决(i18n key 命名 error.<service>.<code_snake>)
|
||||
// - 支持 zh-CN(默认)+ en-US
|
||||
// - 错误码 key:error.<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 };
|
||||
26
apps/parent-portal/src/lib/permissions.ts
Normal file
26
apps/parent-portal/src/lib/permissions.ts
Normal 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];
|
||||
28
apps/parent-portal/src/lib/query-client.ts
Normal file
28
apps/parent-portal/src/lib/query-client.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// TanStack Query client(服务端数据缓存)
|
||||
// 依据:02-architecture-design.md §4.3 状态管理分层(Server Cache 层)
|
||||
// 与 urql 互补:urql 管 GraphQL,TanStack 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
275
apps/parent-portal/src/lib/utils.test.ts
Normal file
275
apps/parent-portal/src/lib/utils.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
207
apps/parent-portal/src/lib/utils.ts
Normal file
207
apps/parent-portal/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期(i18n,02 §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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取 localStorage(SSR 安全)
|
||||
*/
|
||||
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)}`;
|
||||
}
|
||||
60
apps/parent-portal/src/lib/web-vitals.ts
Normal file
60
apps/parent-portal/src/lib/web-vitals.ts
Normal 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 {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
58
apps/parent-portal/src/middleware.ts
Normal file
58
apps/parent-portal/src/middleware.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Next.js middleware:CSP 安全头 + 认证守卫
|
||||
// 依据:02-architecture-design.md §13 安全硬化(P6-6)、project_rules §4 安全规范
|
||||
// - CSP:限制脚本/样式/图片/连接来源
|
||||
// - 认证守卫:/parent/* 路由检查 token(SSR 阶段重定向)
|
||||
|
||||
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 token,SSR 阶段无法读取
|
||||
// 此处仅做 cookie 兜底检查,客户端 parent/layout.tsx 做完整守卫
|
||||
if (!token) {
|
||||
// 检查是否有 Authorization header(MF 模式由 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-).*)",
|
||||
],
|
||||
};
|
||||
135
apps/parent-portal/src/store/child-store.test.ts
Normal file
135
apps/parent-portal/src/store/child-store.test.ts
Normal 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("switchChild(ISSUE-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();
|
||||
});
|
||||
});
|
||||
90
apps/parent-portal/src/store/child-store.ts
Normal file
90
apps/parent-portal/src/store/child-store.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
9
apps/parent-portal/src/test/mocks/browser.ts
Normal file
9
apps/parent-portal/src/test/mocks/browser.ts
Normal 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);
|
||||
322
apps/parent-portal/src/test/mocks/fixtures.ts
Normal file
322
apps/parent-portal/src/test/mocks/fixtures.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
// MSW mock fixtures:模拟 parent-bff GraphQL + iam login 响应
|
||||
// 依据:
|
||||
// - ISSUE-010 iam GetChildrenByParent P0 阻塞 → 用 mock(student-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-010:iam 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-033:P4 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-004:REST 登录)=====
|
||||
export const mockLoginResponse: LoginResponse = {
|
||||
user: mockParent,
|
||||
tokens: {
|
||||
accessToken: "mock-access-token-parent-001",
|
||||
refreshToken: "mock-refresh-token-parent-001",
|
||||
expiresIn: 3600,
|
||||
},
|
||||
};
|
||||
188
apps/parent-portal/src/test/mocks/handlers.ts
Normal file
188
apps/parent-portal/src/test/mocks/handlers.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// MSW handlers:parent-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 handlers(iam login/refresh,ISSUE-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 handlers(parent-bff,F9)=====
|
||||
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-033:P4 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];
|
||||
7
apps/parent-portal/src/test/mocks/server.ts
Normal file
7
apps/parent-portal/src/test/mocks/server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// MSW server(Node 环境,用于 Vitest 测试)
|
||||
// 依据:02-architecture-design.md §8 MSW、vitest.config.ts
|
||||
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
57
apps/parent-portal/src/test/setup.ts
Normal file
57
apps/parent-portal/src/test/setup.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Vitest 测试环境初始化
|
||||
// 依据:vitest.config.ts setupFiles
|
||||
|
||||
import { beforeEach } from "vitest";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// jsdom 环境补充:matchMedia(recharts 等库依赖)
|
||||
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 环境补充:ResizeObserver(recharts 依赖)
|
||||
if (!window.ResizeObserver) {
|
||||
class MockResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
Object.defineProperty(window, "ResizeObserver", {
|
||||
writable: true,
|
||||
value: MockResizeObserver,
|
||||
});
|
||||
}
|
||||
|
||||
// 清理 localStorage
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
247
apps/parent-portal/src/types/index.ts
Normal file
247
apps/parent-portal/src/types/index.ts
Normal 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;
|
||||
};
|
||||
62
apps/parent-portal/tailwind.config.js
Normal file
62
apps/parent-portal/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
32
apps/parent-portal/tsconfig.json
Normal file
32
apps/parent-portal/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
42
apps/parent-portal/vitest.config.ts
Normal file
42
apps/parent-portal/vitest.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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-4003;ai07 初稿误用 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-3(Tab Bar)→ 4-10(下拉)→ >10(搜索 + 列表);超出 10 触发性能预警 |
|
||||
| P4 通知偏好三维矩阵 | `Record<childId, Record<eventType, Record<channel, boolean>>>`;react-hook-form + Zod 校验;默认值矩阵(成绩发布=邮件+短信、作业批改=邮件、教师沟通=短信、学校通知=邮件);渠道可用性校验 |
|
||||
| P4 跨标签同步 | BroadcastChannel API(`parent-portal-sync`)+ localStorage 持久化 + LWW(Last-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-SA;ar-SA 用 `dir="rtl"` + Tailwind `rtl:` 变体;翻译文件按域分 namespace(parent/grades/notifications/preferences) |
|
||||
| P4 PWA + Service Worker | manifest.json(name/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 Actions);MF 升级路径(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-4003;ai07 初稿误用 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-3(Tab Bar)→ 4-10(下拉)→ >10(搜索 + 列表);超出 10 触发性能预警 |
|
||||
| P4 通知偏好三维矩阵 | `Record<childId, Record<eventType, Record<channel, boolean>>>`;react-hook-form + Zod 校验;默认值矩阵(成绩发布=邮件+短信、作业批改=邮件、教师沟通=短信、学校通知=邮件);渠道可用性校验 |
|
||||
| P4 跨标签同步 | BroadcastChannel API(`parent-portal-sync`)+ localStorage 持久化 + LWW(Last-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-SA;ar-SA 用 `dir="rtl"` + Tailwind `rtl:` 变体;翻译文件按域分 namespace(parent/grades/notifications/preferences) |
|
||||
| P4 PWA + Service Worker | manifest.json(name/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 Actions);MF 升级路径(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 存储 | localStorage(F12 裁决,P4 用 localStorage,非 cookie);refreshAccessToken 竞态防护用 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 REST;iam GetChildrenByParent P0 阻塞用 mock fixtures(student-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 裁决首次即多阶段:builder(node:20-alpine 跑 build)+ runner(node:20-alpine 跑 server),端口 4002,HEALTHCHECK 指向 `/api/health`,非 root 用户 nextjs |
|
||||
|
||||
### 2.14 parent-bff(TS/NestJS,P4)
|
||||
|
||||
|
||||
@@ -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
3265
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user