Files
Edu/apps/parent-portal/src/components/AttendanceWarningBanner.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

129 lines
3.2 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.
// AttendanceWarningBanner考勤异常预警横幅
// 依据:参考项目差距补齐 - 考勤异常预警
// - 缺勤≥3 高危(红色)
// - 迟到≥3 中危(黄色)
// - 出勤率<90% 高危(红色)
// - 可关闭(本地 state不持久化
"use client";
import { useState, useMemo } from "react";
import type { AttendanceRecord } from "@/types";
import { cn, formatPercent } from "@/lib/utils";
interface AttendanceWarningBannerProps {
records: AttendanceRecord[];
}
interface AttendanceStats {
present: number;
late: number;
absent: number;
leave: number;
total: number;
rate: number;
}
function calculateStats(records: AttendanceRecord[]): AttendanceStats {
const counts = { present: 0, late: 0, absent: 0, leave: 0 };
for (const r of records) {
counts[r.status]++;
}
const total = records.length;
const attended = counts.present + counts.late;
const rate = total > 0 ? attended / total : 1;
return { ...counts, total, rate };
}
interface WarningItem {
message: string;
level: "warning" | "danger";
}
export function AttendanceWarningBanner({
records,
}: AttendanceWarningBannerProps): JSX.Element | null {
const [dismissed, setDismissed] = useState(false);
const warnings = useMemo<WarningItem[]>(() => {
const stats = calculateStats(records);
const items: WarningItem[] = [];
if (stats.absent >= 3) {
items.push({
message: `本月缺勤 ${stats.absent}超过预警阈值3 次)`,
level: "danger",
});
}
if (stats.late >= 3) {
items.push({
message: `本月迟到 ${stats.late}超过预警阈值3 次)`,
level: "warning",
});
}
if (stats.total > 0 && stats.rate < 0.9) {
items.push({
message: `本月出勤率偏低(${formatPercent(stats.rate)}),低于 90%`,
level: "danger",
});
}
return items;
}, [records]);
if (dismissed || warnings.length === 0) {
return null;
}
const hasDanger = warnings.some((w) => w.level === "danger");
return (
<div
role="alert"
className={cn(
"rounded border p-4",
hasDanger ? "border-danger" : "border-warning",
)}
style={{
backgroundColor: hasDanger
? "hsl(var(--danger) / 0.1)"
: "hsl(var(--warning) / 0.1)",
}}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-1">
<p className="font-serif text-base">
{hasDanger ? "考勤异常预警" : "考勤提醒"}
</p>
<ul className="space-y-1 text-sm">
{warnings.map((w, idx) => (
<li
key={idx}
className={cn(
w.level === "danger" ? "text-danger" : "text-warning",
)}
>
{w.message}
</li>
))}
</ul>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className="flex-shrink-0 rounded px-2 py-1 text-sm text-ink-muted hover:text-ink"
aria-label="关闭预警"
>
×
</button>
</div>
</div>
);
}
export default AttendanceWarningBanner;
export { calculateStats };