feat(teacher-portal): v2 下游核查 + push-gateway 接入 + 测试扩展至 87 + ui-components 12/12

v2 核查结论:

- iam/push-gateway/api-gateway 已就绪

- teacher-bff teacher 域仍是 P2 占位(56/61 operations 不可用)

- 5 个 gRPC target 留空走降级模式 B

- 保留 MSW mock,记录 12 项 teacher-bff 待补工作到 nextstep-v2.md

v2 完成工作:

- push-gateway WebSocket 接入(环境变量 + URL query token + Reconnect 协议)

- 单元测试扩展(usePermission 15 + useAuth 9,总计 87/87 passed)

- ui-components 剩余 3 组件(Chart/Calendar/RichTextEditor,12/12)

- 设计令牌完整迁移(无 hsl/hex 字面量)

- i18n 5/55 页面 + 26 模块 key

- 性能优化(size-limit 9 项 + 5 页面懒加载)

- useAuth 迁移阻塞核查(iam 未实现 Set-Cookie,修正 v1 假设)

文档:新增 nextstep-v2.md;workline.md §5.7 添加 v2 工作记录。

验证:typecheck + lint + 87/87 tests + arch:scan 全部通过。
This commit is contained in:
SpecialX
2026-07-14 08:47:18 +08:00
parent ae6fe23be6
commit 0066afc55e
9 changed files with 1642 additions and 79 deletions

View File

@@ -0,0 +1,258 @@
"use client";
/**
* Calendar - 课案日历组件
*
* 维护者ai13teacher-portal
* 关联lesson-plans/calendar 页面复用
*
* 特性:
* - 月视图,支持前后月切换
* - 日期点击回调
* - 日期标记(有课案的日期高亮)
* - 纯 CSS + var(--*) 设计令牌
*/
import { useState, useMemo, useCallback } from "react";
export interface CalendarEvent {
date: string; // ISO date string YYYY-MM-DD
title: string;
type?: "lesson" | "exam" | "meeting";
}
export interface CalendarProps {
/** 初始月份(默认当前月) */
initialDate?: Date;
/** 事件列表 */
events?: CalendarEvent[];
/** 日期点击回调 */
onDateClick?: (date: Date) => void;
/** 月份切换回调 */
onMonthChange?: (date: Date) => void;
}
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
const WEEKDAYS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function formatDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function getEventColor(type: CalendarEvent["type"]): string {
switch (type) {
case "exam":
return "var(--color-danger)";
case "meeting":
return "var(--color-warning)";
case "lesson":
default:
return "var(--color-accent)";
}
}
export function Calendar({
initialDate = new Date(),
events = [],
onDateClick,
onMonthChange,
}: CalendarProps): React.ReactElement {
const [currentDate, setCurrentDate] = useState(initialDate);
const eventsByDate = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const evt of events) {
const existing = map.get(evt.date) || [];
existing.push(evt);
map.set(evt.date, existing);
}
return map;
}, [events]);
const days = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startWeekday = firstDay.getDay();
const daysInMonth = lastDay.getDate();
const cells: Array<{ date: Date | null; isCurrentMonth: boolean }> = [];
// 上月填充
for (let i = 0; i < startWeekday; i++) {
cells.push({ date: null, isCurrentMonth: false });
}
// 当月
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(year, month, d), isCurrentMonth: true });
}
// 下月填充(补齐 6 行 = 42 格)
while (cells.length < 42) {
cells.push({ date: null, isCurrentMonth: false });
}
return cells;
}, [currentDate]);
const handlePrevMonth = useCallback(() => {
const prev = new Date(
currentDate.getFullYear(),
currentDate.getMonth() - 1,
1,
);
setCurrentDate(prev);
onMonthChange?.(prev);
}, [currentDate, onMonthChange]);
const handleNextMonth = useCallback(() => {
const next = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
1,
);
setCurrentDate(next);
onMonthChange?.(next);
}, [currentDate, onMonthChange]);
const today = formatDate(new Date());
return (
<div
className="w-full"
style={{
background: "var(--bg-surface)",
borderRadius: "var(--radius-card)",
padding: "var(--space-md)",
}}
>
{/* 月份导航 */}
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={handlePrevMonth}
className="px-3 py-1 text-sm transition-opacity hover:opacity-70"
style={{
color: "var(--color-ink-muted)",
borderRadius: "var(--radius-button)",
}}
aria-label="上一月"
>
</button>
<h3
className="text-base font-medium"
style={{
color: "var(--color-ink)",
fontFamily: "var(--font-family-serif)",
}}
>
{currentDate.getFullYear()} {currentDate.getMonth() + 1}
</h3>
<button
type="button"
onClick={handleNextMonth}
className="px-3 py-1 text-sm transition-opacity hover:opacity-70"
style={{
color: "var(--color-ink-muted)",
borderRadius: "var(--radius-button)",
}}
aria-label="下一月"
>
</button>
</div>
{/* 星期表头 */}
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS.map((day, i) => (
<div
key={`wd-${i}`}
className="text-center text-xs uppercase tracking-wide py-2"
style={{ color: "var(--color-ink-subtle)" }}
>
{day}
</div>
))}
</div>
{/* 日期网格 */}
<div className="grid grid-cols-7 gap-1">
{days.map((cell, i) => {
if (!cell.date) {
return <div key={`empty-${i}`} className="min-h-[60px]" />;
}
const dateStr = formatDate(cell.date);
const dayEvents = eventsByDate.get(dateStr) || [];
const isToday = dateStr === today;
return (
<button
type="button"
key={`day-${dateStr}`}
onClick={() => onDateClick?.(cell.date!)}
className="min-h-[60px] p-1 text-left transition-colors hover:bg-[var(--bg-subtle)]"
style={{
background: isToday
? "var(--color-accent-subtle)"
: "transparent",
borderRadius: "var(--radius-default)",
border: isToday
? "1px solid var(--color-accent)"
: "1px solid transparent",
cursor: "pointer",
}}
>
<div
className="text-xs mb-1"
style={{
color: isToday
? "var(--color-accent)"
: "var(--color-ink-muted)",
fontWeight: isToday ? "600" : "400",
}}
>
{cell.date.getDate()}
</div>
{/* 事件标记 */}
<div className="space-y-0.5">
{dayEvents.slice(0, 2).map((evt, j) => (
<div
key={`evt-${j}`}
className="text-[10px] truncate px-1 py-0.5"
style={{
background: getEventColor(evt.type),
color: "var(--color-ink-on-accent)",
borderRadius: "2px",
}}
title={evt.title}
>
{evt.title}
</div>
))}
{dayEvents.length > 2 && (
<div
className="text-[10px]"
style={{ color: "var(--color-ink-subtle)" }}
>
+{dayEvents.length - 2}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
);
}
export { WEEKDAYS, WEEKDAYS_EN, formatDate };
export default Calendar;