Files
Edu/apps/parent-portal/src/components/AttendanceCalendar.tsx
SpecialX 96a936e154 feat(parent-portal): docker 构建修复 + 禁用 mock + 记录下游待办
Dockerfile 改用 standalone 模式

next.config.js 添加 output standalone

修复 cssnano-simple 构建失败:globals.css 注释含 */ 被误解析

tailwind.config.js 覆盖 boxShadow 为 rgba 格式 + 禁用 ringWidth

组件修复:替换 / 语法为 inline style 或自定义类

otel.ts 添加 webpackIgnore 跳过 optionalDependencies 静态分析

.env.example 默认禁用 mock

新增 .dockerignore 和 docs/nextstep.md

删除 postcss.config.js

验证:Docker 镜像构建成功,容器 healthy,typecheck + lint 零错误
2026-07-13 17:21:39 +08:00

206 lines
6.3 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.
// AttendanceCalendar月度考勤日历
// 依据02-architecture-design.md §15.5 AttendanceCalendar 设计
// - 月历视图,每天用颜色点标记考勤状态
// - present=success / late=warning / absent=danger / leave=ink-subtle
// - 支持月份导航(可选 month + onMonthChange props
"use client";
import { useMemo } from "react";
import type { AttendanceRecord } from "@/types";
import { cn } from "@/lib/utils";
interface AttendanceCalendarProps {
records: AttendanceRecord[];
/** YYYY-MM 格式,未传则使用当前月 */
month?: string;
/** 月份切换回调,传入则显示导航按钮 */
onMonthChange?: (month: string) => void;
}
const STATUS_STYLES: Record<AttendanceRecord["status"], string> = {
present: "bg-success",
late: "bg-warning",
absent: "bg-danger",
leave: "bg-ink-subtle",
};
const STATUS_LABELS: Record<AttendanceRecord["status"], string> = {
present: "出勤",
late: "迟到",
absent: "缺勤",
leave: "请假",
};
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
function parseMonth(month: string): { year: number; month: number } {
const [yearStr, monthStr] = month.split("-");
return {
year: parseInt(yearStr ?? "0", 10),
month: parseInt(monthStr ?? "1", 10) - 1,
};
}
function formatMonth(year: number, month: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}`;
}
function shiftMonth(month: string, delta: number): string {
const { year, month: m } = parseMonth(month);
const date = new Date(year, m + delta, 1);
return formatMonth(date.getFullYear(), date.getMonth());
}
export function AttendanceCalendar({
records,
month,
onMonthChange,
}: AttendanceCalendarProps) {
const {
year,
month: monthLabel,
days,
recordMap,
} = useMemo(() => {
const now = new Date();
const target = month
? parseMonth(month)
: { year: now.getFullYear(), month: now.getMonth() };
const y = target.year;
const m = target.month;
const firstDay = new Date(y, m, 1);
const lastDay = new Date(y, m + 1, 0);
const startWeekday = firstDay.getDay();
const totalDays = lastDay.getDate();
const dayCells: (number | null)[] = [];
for (let i = 0; i < startWeekday; i++) dayCells.push(null);
for (let d = 1; d <= totalDays; d++) dayCells.push(d);
while (dayCells.length % 7 !== 0) dayCells.push(null);
const map = new Map<string, AttendanceRecord>();
for (const r of records) {
map.set(r.date, r);
}
return { year: y, month: m, days: dayCells, recordMap: map };
}, [records, month]);
// 统计
const stats = useMemo(() => {
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
for (const r of records) {
counts[r.status]++;
}
return counts;
}, [records]);
return (
<div className="rounded border border-rule bg-paper-elevated p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{onMonthChange && (
<button
type="button"
onClick={() =>
onMonthChange(shiftMonth(formatMonth(year, monthLabel), -1))
}
className="rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
aria-label="上一月"
>
</button>
)}
<h3 className="font-serif text-base">
{year}{monthLabel + 1}
</h3>
{onMonthChange && (
<button
type="button"
onClick={() =>
onMonthChange(shiftMonth(formatMonth(year, monthLabel), 1))
}
className="rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
aria-label="下一月"
>
</button>
)}
</div>
<div className="flex gap-3 text-xs">
{(Object.keys(STATUS_LABELS) as AttendanceRecord["status"][]).map(
(s) => (
<span key={s} className="flex items-center gap-1">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
STATUS_STYLES[s],
)}
/>
{STATUS_LABELS[s]}
</span>
),
)}
</div>
</div>
{/* 日历网格 */}
<div className="mt-4">
<div className="grid grid-cols-7 gap-1 text-center text-xs text-ink-muted">
{WEEKDAYS.map((w) => (
<div key={w} className="py-1">
{w}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{days.map((day, idx) => {
if (day === null) {
return <div key={idx} style={{ aspectRatio: "1 / 1" }} />;
}
const dateStr = `${year}-${String(monthLabel + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const record = recordMap.get(dateStr);
return (
<div
key={idx}
className="flex flex-col items-center justify-center rounded text-sm"
style={{ aspectRatio: "1 / 1" }}
title={record ? STATUS_LABELS[record.status] : undefined}
>
<span className="text-xs">{day}</span>
{record && (
<span
className={cn(
"mt-0.5 inline-block h-1.5 w-1.5 rounded-full",
STATUS_STYLES[record.status],
)}
/>
)}
</div>
);
})}
</div>
</div>
{/* 统计 */}
<div className="mt-4 flex gap-4 border-t border-rule pt-3 text-sm">
<span className="text-ink-muted">
<span className="font-mono text-success">{stats.present}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono text-warning">{stats.late}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono text-danger">{stats.absent}</span>
</span>
<span className="text-ink-muted">
<span className="font-mono">{stats.leave}</span>
</span>
</div>
</div>
);
}
export default AttendanceCalendar;