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 全部通过。
259 lines
7.2 KiB
TypeScript
259 lines
7.2 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Calendar - 课案日历组件
|
||
*
|
||
* 维护者:ai13(teacher-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;
|