- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等 - Tailwind v4 + @theme inline,移除 tailwind.config.js - React 19 use() + Suspense 流式渲染,首屏骨架秒出 - 三级错误边界:Route → Section → Widget 层层兜底 - 错误上报:useErrorReport → sendBeacon → /api/log mock 端点 - 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围 - 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99% - notify 统一 Toast 封装,禁止业务直接 import sonner - PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套) 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
252 lines
7.0 KiB
TypeScript
252 lines
7.0 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 rounded-xl border bg-card p-4">
|
||
{/* 月份导航 */}
|
||
<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-muted"
|
||
style={{
|
||
background: isToday
|
||
? "hsl(var(--primary) / 0.1)"
|
||
: "transparent",
|
||
borderRadius: "var(--radius)",
|
||
border: isToday
|
||
? "1px solid hsl(var(--primary))"
|
||
: "1px solid transparent",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<div
|
||
className="text-xs mb-1"
|
||
style={{
|
||
color: isToday
|
||
? "hsl(var(--primary))"
|
||
: "hsl(var(--muted-foreground))",
|
||
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;
|