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:
258
packages/ui-components/src/calendar.tsx
Normal file
258
packages/ui-components/src/calendar.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"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;
|
||||
Reference in New Issue
Block a user