Files
Edu/apps/parent-portal/src/app/login/page.tsx
SpecialX 5661938cc0 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
2026-07-10 17:40:27 +08:00

103 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 登录页(独立模式 / 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>
);
}