甘特图是项目管理系统的核心组件,而假期和非工作时间的展示直接影响用户对排期的判断。如果只在渲染层面做遮罩,而不在类型层面约束假期数据的结构,后续接入不同数据源时很容易出现字段缺失、日期格式混乱等问题。本文从类型设计的角度出发,讲清楚如何用TypeScript定义一套支持甘特图非工作时间遮罩层的自定义假期数据源类型。

一、先拆解甘特图遮罩层需要哪些数据
在动手写类型之前,需要先明确遮罩层的渲染逻辑。甘特图的时间轴通常分为三级:总视图(月)、中视图(周或日)、细视图(小时)。非工作时间遮罩可以出现在任意一级,比如日视图下晚上的时间段要涂灰,月视图下国庆长假整块都要覆盖。
由此可以提炼出两类数据:一类是周期性非工作时间,比如每个周六周日、每天18点之后到次日9点之前,这类数据适合用规则描述;另一类是离散的假期事件,比如法定节假日、公司周年庆、调休补班日,这类数据适合用明确的日期区间描述。两类数据分开建模,比混在一个结构里要清晰得多。
还要注意一个特殊场景:调休。比如国庆放假七天,但前后两个周六周日要补班,这两天虽然是周末,却是工作时间,不应该被涂灰。所以离散事件需要能表达"覆盖默认规则"的能力。
二、定义基础类型结构
先定义最基础的日期区间类型,这是所有假期事件的骨架。日期统一用ISO 8601格式的字符串表示,避免时区歧义:
// 基础日期类型,ISO格式字符串,如 "2024-10-01"
type ISODateString = string;
// 时间点类型,如 "09:00"
type TimeString = string;
// 日期区间,含头含尾
interface DateRange {
start: ISODateString;
end: ISODateString;
}
// 一天内的时段区间,用于小时级遮罩
interface TimeRange {
start: TimeString;
end: TimeString;
}接下来定义假期事件类型。关键字段是type,用来区分节假日和调休补班:
// 假期事件类别
enum HolidayEventType {
/** 法定节假日或公司假期 */
Holiday = 'holiday',
/** 调休补班日,覆盖默认休息规则 */
Workday = 'workday',
/** 自定义特殊时段,如半天假 */
Custom = 'custom',
}
// 单个假期事件
interface HolidayEvent {
/** 事件唯一标识 */
id: string;
/** 事件类别 */
type: HolidayEventType;
/** 生效日期范围 */
range: DateRange;
/** 显示名称,如 "国庆节" */
label?: string;
/** 遮罩层颜色,可选,不填则用默认色 */
maskColor?: string;
/** 是否整天使非工作日 */
fullDay: boolean;
/** 非整天时生效的具体时段 */
timeRanges?: TimeRange[];
}这里有一个设计取舍:timeRanges设计成可选数组,是因为半天假这类场景下一天内可能有多个非工作时段。而fullDay用必填的布尔值而不是靠timeRanges是否存在来判断,是为了让语义更明确,避免渲染逻辑里到处写空值判断。
三、周期性规则与完整数据源类型
光有离散事件还不够,周期规则需要单独定义。用位掩码或数字数组都可以,这里用数字数组更直观,0代表周日,6代表周六:
// 每周哪些天是休息日,[0, 6] 表示周六周日
type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
// 每日工作时间配置
interface DailyWorkHours {
/** 上班时间,如 "09:00" */
workStart: TimeString;
/** 下班时间,如 "18:00" */
workEnd: TimeString;
/** 午休时段,可选 */
lunchBreak?: TimeRange;
}
// 周期性非工作时间规则
interface RecurringRule {
/** 每周休息日 */
restWeekdays: Weekday[];
/** 每日工作时间,默认 09:00-18:00 */
workHours: DailyWorkHours;
}
// 完整的假期数据源
interface HolidayDataSource {
/** 周期规则 */
recurring: RecurringRule;
/** 离散事件列表 */
events: HolidayEvent[];
/** 数据源版本,用于缓存失效 */
version: string;
}这个结构的好处是职责分离。recurring描述常态,events描述例外,渲染遮罩时先按常态铺底,再用例外事件打补丁。调休补班日在events中以HolidayEventType.Workday类型存在,渲染时对相应日期取消默认的周末遮罩。
如果业务上还要区分不同地区或不同团队的日历,可以进一步用泛型扩展:
// 支持自定义元数据的泛型数据源
interface TypedHolidayDataSource<T = Record<string, unknown>>
extends HolidayDataSource {
/** 附加元数据,如部门ID、地区代码 */
meta?: T;
}
// 使用示例:带地区信息的数据源
type RegionalSource = TypedHolidayDataSource<{ region: 'CN' | 'US' }>;四、类型守卫与数据校验
类型定义只是编译期的保障,数据源往往来自后端接口,运行时还需要校验。写一个类型守卫函数,既能在运行时过滤非法数据,又能让TypeScript正确收窄类型:
// 校验是否为合法的ISO日期字符串
function isISODateString(value: unknown): value is ISODateString {
return (
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
!Number.isNaN(Date.parse(value))
);
}
// 校验单个假期事件
function isHolidayEvent(value: unknown): value is HolidayEvent {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === 'string' &&
Object.values(HolidayEventType).includes(v.type as HolidayEventType) &&
typeof v.range === 'object' &&
isISODateString((v.range as DateRange).start) &&
isISODateString((v.range as DateRange).end) &&
typeof v.fullDay === 'boolean'
);
}
// 校验整个数据源
function isHolidayDataSource(value: unknown): value is HolidayDataSource {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return (
Array.isArray((v.recurring as RecurringRule)?.restWeekdays) &&
Array.isArray(v.events) &&
(v.events as unknown[]).every(isHolidayEvent)
);
}在接口层使用时,先走校验再消费数据,非法数据直接走降级逻辑:
async function loadHolidaySource(): Promise<HolidayDataSource> {
const res = await fetch('/api/holidays');
const raw: unknown = await res.json();
if (!isHolidayDataSource(raw)) {
// 降级:返回只有默认周末规则的空事件源
return {
recurring: { restWeekdays: [0, 6], workHours: { workStart: '09:00', workEnd: '18:00' } },
events: [],
version: 'fallback',
};
}
return raw;
}五、与甘特图组件对接的类型适配
主流甘特图库(如dhtmlxGantt、frappe-gantt、自研组件)对非工作时间的数据格式要求各不相同。这时可以写一层适配函数,把统一的HolidayDataSource转换成目标组件需要的格式,而不是让业务直接依赖组件的类型。以一个需要平铺日期数组遮罩的组件为例:
// 目标组件要求的遮罩片段类型
interface GanttMaskSegment {
date: ISODateString;
segments: TimeRange[];
color: string;
label: string;
}
// 适配函数:把数据源转成组件需要的遮罩片段
function toMaskSegments(
source: HolidayDataSource,
viewRange: DateRange
): GanttMaskSegment[] {
const result: GanttMaskSegment[] = [];
const cursor = new Date(viewRange.start);
const end = new Date(viewRange.end);
while (cursor <= end) {
const iso = cursor.toISOString().slice(0, 10);
const override = source.events.find(
(e) => iso >= e.range.start && iso <= e.range.end
);
if (override?.type === HolidayEventType.Workday) {
// 补班日,不生成遮罩
} else if (override) {
result.push({
date: iso,
segments: override.fullDay
? [{ start: '00:00', end: '23:59' }]
: override.timeRanges ?? [],
color: override.maskColor ?? '#e8e8e8',
label: override.label ?? '',
});
} else if (source.recurring.restWeekdays.includes(cursor.getDay())) {
// 默认周末遮罩
result.push({
date: iso,
segments: [{ start: '00:00', end: '23:59' }],
color: '#f0f0f0',
label: '周末',
});
}
cursor.setDate(cursor.getDate() + 1);
}
return result;
}适配层的价值在于隔离变化。组件升级或者更换时,只需要重写适配函数,业务侧的数据类型定义和存储结构完全不用动。反过来,如果各业务模块直接按照组件要求的格式存数据,一旦换组件就要做全量数据迁移,代价大得多。
六、几点实践建议
第一,日期字段统一用ISO字符串而不是时间戳,可读性和序列化兼容性都更好,跨时区也更安全。第二,遮罩颜色之类的展示属性尽量放在数据源里作为可选字段,让视觉配置可以按事件粒度覆盖,但渲染时一定要有默认值兜底。第三,调休逻辑务必在类型层面区分开Holiday和Workday两种事件,这是实际项目中最容易出错的点,漏掉补班日会导致排期计算整体偏移。第四,数据源加上version字段并配合接口的缓存策略,法定节假日每年调整时只需更新数据,不用发版改代码。
按照这套类型体系实现后,假期数据从接口到渲染的整条链路都有明确的类型约束,编译期就能拦住大部分结构错误,甘特图的遮罩层也能稳定适配各种复杂的假期规则。
TypeScript甘特图数据源类型修改时间:2026-09-08 05:56:36