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 零错误
206 lines
6.3 KiB
TypeScript
206 lines
6.3 KiB
TypeScript
// 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;
|