Files
Edu/packages/ui-components/src/calendar.tsx
SpecialX 0066afc55e 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 全部通过。
2026-07-14 08:47:18 +08:00

259 lines
7.2 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.
"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;