Files
FamilyBook/app/Holiday.php
T
laoyang 5d80b9480b v24 第 11 批:7 大条 20 子项(首页/名册/观影/裁剪/日程/财务)
用户 2026-10-04 第 11 批,7 大条:
- #1 首页:未来七天过滤掉「农历」(每日一条会淹掉真日程);右侧日历本标签
  从灰字(.dash-tag)改成**实心色块**(底色=日历色、白字、text-shadow 兜底浅色)。
- #2 名册:分组改**居中模态框**(用户要"像选观影人一样")。修掉"点第二次失效"
  ——根因是旧实现用**捕获阶段**监听 document click 做"点外面就关",与触发按钮
  的 click 打架;模态把关闭收敛到遮罩/✕/取消。卡片「编辑」后加「删除」
  (→ people/destroy);详情页底部删除改成 btn-danger + btn-block-lg 大红按钮 + 两步确认。
- #3 名册详情页重排:.detail(260px+1fr) → .pcard(头像左上 + 右侧姓名/标签)
  + .detail-stack 全宽往下。旧布局头像卡比右栏矮,头像下留一大片空白。
- #4 观影:影院下拉换**自定义组件**(原生 <option> 画不了色块)——按钮+列表、
  每行色点,值仍写隐藏 input[name=cinema_id];「管理影院」→「新建影院」弹框
  (带 back=movies/new,存完回本页并 ?cinema=<id> 自动选中)。
  修「开始/结束时间标签跑到控件下面」:upgradeRange 原来 insertBefore(host, group.firstChild)
  把控件插到了 label 之上 —— 改成插在第一个原生控件之前。
- #5 裁剪改 **Cropper.js**(assets/vendor/cropper/,按需加载走 fb-cropper meta):
  弹框 overflow:hidden + 固定画布(**无滚动条**)、**去掉缩放条**改滚轮 wheelZoomRatio、
  viewMode:1 + autoCropArea 保证框小于画布、8 个触点全露。
  结果仍写 *_crop = 归一化 x,y,w,h(提交契约不变)。
- #6 日程:内置节假日原来每天只标**一天**(国庆只出一条)→ FIXED 改成
  [名字, 放假天数] 按区间铺;月视图从周日改**周一**起(表头同步);
  跳转日期从"长条+原生框"改成**单个 btn-icon**,点开弹 Semi 日期选择器。
- #7 财务:单条曲线无盘点时只回一个点(图看着像全 0)→ 改成"从创建日(0)画到今天";
  图下条目从"每账目一张小图卡"改成**一张 chart_multi 多线图 + 颜色图例**
  (Ledger::LEGEND_COLORS 按 id 取模,保证颜色稳定);账目页卡顿同源治理
  (行上只留按钮、表单全页一份 #ledPanels);修改按钮改弹框并回填归属人。

## 顺带修的真 bug(本批最要紧的三条)
1. **观影表单提交静默失效**:「新建影院」弹框被误嵌在观影主表单 <form> 内。
   HTML 不允许 form 嵌套 → 浏览器拆开内层,弹框里 required 的"影院名称"变成
   主表单必填项、又因 hidden 无法聚焦 → 点「记下这场电影」什么都不发生
   (Chrome: invalid form control with name='name' is not focusable)。
   → 弹框移到主表单之后。部署脚本加防线:按顺序配平 form 开闭判断是否嵌套。
2. **裁剪取消没清空文件框**(换 Cropper 时丢)→ close(cancelled),4 个取消入口
   传 true 清空;否则"点了取消"仍会把没裁的原图传上去。
3. **裁剪预览退化成原图**(同批丢)→ 用 canvas 按 Cropper 的原图像素矩形重画,
   toDataURL 写回预览,恢复"所见即所得"。

## 连带修复:选人组件懒渲染(线上实测的必要性)
线上真实数据下账目页 6939 KB / 16788 个 input、观影表单 1194 个 input ——
根因是 _person_picker.php 首屏就把**全部候选人**渲成 <label>+<input>。
改成:首屏只留「不选」+ data-pp-people(JSON),**打开弹框时**才 buildList();
无 JS 兜底走 <noscript>(HTML5 规定启用脚本时其内容按纯文本解析,不产生节点)。
新增 window.ppSetSelected / decorateItems(动态项要补算首字母)。
实测账目页 6939 KB → 544 KB。新增 _fb_picker_lazy_check.py(11/0 + 反向 2 红)。

验证:e2e 786/0、交互 90/0、半集成 27/0、batch_e 91/0、batch10 46/0、batch9 38/0、
crop 17/0(+反向)、picker_unify 28/0、picker_lazy 11/0(+反向)、formbloat 9/0、
upgrade 126/0、mobile 688/0、drag 5/0、geom 5/5。
线上自检 19/19;线上真实账号验收 7/0(含"主表单无隐藏必填""线上 Cropper 接管+取消清空")。
2026-10-04 17:42:21 +08:00

578 lines
25 KiB
PHP
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.
<?php
/**
* 法定节假日 / 调休 + 农历 —— 日程页两个**不可删除的只读日历**的数据源。
*
* v24 第 10 批 #4(用户原话):
* 「日程中,增加两个不可删除的日历,一个是农历,一个是法定假日的休息和调休信息,
* 法定假日这个,需要有自动更新功能,例如明年的放假调休情况定下来了,那么就要及时
* 更新进去,**不是让你更新,而是让系统,代码自动更新**。」
*
* 所以这里的核心不是"填一份 2026 年的数据",而是**把"自动更新"做成机制**:
*
* ┌─ 第一级:内置规则(离线可用、永不失效)
* │ 元旦 / 清明 / 劳动节 / 国庆 是**公历固定或节气**,可以直接推导;
* │ 春节 / 端午 / 中秋 由农历日期推导(Lunar 类,1900-2100 全覆盖)。
* │ → 即使断网、即使没有任何外部数据,每年也会自动"长出"这 7 个节日的放假日期。
* │ 只是"调休哪几天"官方没公布之前推不出来(那本来就是行政决定)。
* └─ 第二级:官方公布数据(自动补齐调休)
* 国务院办公厅每年 10-11 月公布次年安排。数据源用**公开的 JSON 镜像**
* (NateScarlet/holiday-cn,按年一个文件),抓到就写进库并缓存。
* → 这就是用户要的"明年的放假调休情况定下来了,系统自动更新进去"。
*
* ⚠️ 两级的关系是**互补而非替代**:拉取失败必须仍然有第一级的结果(只是少调休),
* 绝不能因为外部源挂了就让日历空掉——那样用户会以为"功能坏了"。
*
* ⚠️ 缓存放 settings 表(`holiday_cache_<年>`),而不是每次请求都去抓:
* 日历页是高频页面,把外网请求放渲染路径上会让它随外网抖动而变慢。
*/
declare(strict_types=1);
namespace App;
final class Holiday
{
/** 官方数据源(公开镜像,按年一个 JSON:{year, papers, days:[{name,date,isOffDay}]}) */
private const SOURCE = 'https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/%d.json';
private const SOURCE_ALT = 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/%d.json';
/** 缓存有效期:查到一次就够用很久(官方安排一年只公布一次),失败则短缓存避免反复打)
* 单位为秒。写成常量方便测试里改。 */
public const CACHE_OK = 604800; // 7 天
public const CACHE_FAIL = 21600; // 6 小时
/**
* 公历固定日期的节日:月 => [日, 名字, **放假天数**]。
* 只放**日期固定**的:清明是节气(4月4/5/6日浮动)要算,不在这里。
*
* ⚠️ v24 第 11 批 #6(用户报「国庆节只有刚开始的一天有,这不合理」):
* 这里原来只标**一天**,于是日历上国庆只有 10-01 那一条 —— 而法定假是 7 天。
* 现在把**法定放假天数**一起写上,内置规则按区间铺开。
* 天数按《全国年节及纪念日放假办法》的法定假口径:
* 元旦 3 天(通常 1/1 + 周末调休,保守写 1 天由官方数据补)、
* 劳动节 5 天、国庆节 7 天。
* ⚠️ 这里的数字是**内置兜底**(官方数据一到就整体替换,见 fetchAndMerge),
* 所以宁可保守也不要编造调休:调休只有官方才准。
*/
private const FIXED = [
1 => [1 => ['元旦', 3]],
5 => [1 => ['劳动节', 5]],
10 => [1 => ['国庆节', 7]],
];
/**
* 农历节日:月 => [日, 名字, 放假天数]。
* 放假天数只是"内置规则"下的默认值;官方数据到了会以官方为准。
*/
private const LUNAR_FEST = [
1 => [1 => ['春节', 3]], // 正月初一起(实际从除夕就开始,见下)
5 => [5 => ['端午节', 1]],
8 => [15 => ['中秋节', 1]],
];
// ------------------------------------------------------------------
// 取一年的安排
// ------------------------------------------------------------------
/**
* 拿某一年的完整安排(放假 + 调休)。
*
* 🔴 **这个方法不发任何网络请求** —— 只读缓存 + 内置规则。
*
* 为什么这么设计(这一条是本功能能不能用的分水岭):
* 日历是**高频页面**。如果把"抓官方数据"放在渲染路径上,
* 那么外网一抖(DNS 慢、GitHub 被墙、jsdelivr 抽风),用户打开日程就要等
* 连接超时(4s)+ 传输超时(8s)——**页面本身没坏,但慢得没法用**,
* 而且没有任何报错能让人看出是"节假日数据源"的问题。
* 所以这里只做本地计算,抓取交给 `refreshInBackground()`:
* 在**响应发完之后**(php-fpm 的 fastcgi_finish_request)才去联网,
* 成功了写缓存,下次请求自然就换成官方口径。
*
* @return array{year:int,source:string,days:array<string,array{name:string,off:bool}>}
* days 的键是 'Y-m-d',值 = ['name'=>节日名, 'off'=>true 放假 / false 调休上班]
*/
public static function year(int $year): array
{
// 只认缓存;没有缓存就是"内置规则 + 排队等后台更新"
$cached = self::fromCache($year);
if ($cached !== null) {
// 缓存里若只有内置规则(还没等到官方),顺手排一次后台更新
if (($cached['source'] ?? '') !== 'official') {
self::queueBackgroundFetch($year);
}
return $cached;
}
$out = ['year' => $year, 'source' => 'builtin', 'days' => self::builtin($year)];
self::saveCache($year, $out, false);
self::queueBackgroundFetch($year);
return $out;
}
// ------------------------------------------------------------------
// 后台自动更新(用户要的"系统自动更新"就落在这里)
// ------------------------------------------------------------------
/** @var array<int,bool> 本次请求已经排过队的年份(避免重复注册 shutdown 钩子) */
private static array $queued = [];
/**
* 排一次"响应之后去抓官方数据"。
*
* php-fpm 下用 `fastcgi_finish_request()`:它把响应**立刻发给浏览器**,
* 之后脚本继续跑(抓取、写缓存)—— 这正是我们要的"用户无感"。
* 没有这个函数(php -S、CLI)时不提前结束,抓取仍在脚本末尾发生,
* 只是算在请求时长里(仅开发环境如此,可接受)。
*/
private static function queueBackgroundFetch(int $year): void
{
if (isset(self::$queued[$year]) || !self::shouldTry($year)) {
return;
}
self::$queued[$year] = true;
register_shutdown_function(static function () use ($year): void {
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
self::doFetch($year);
});
}
/**
* 现在该不该去抓(频率闸门)。
*
* 用一个独立的 `last_try` 时间戳文件:**无论成败**都记一次,
* 失败后短时间内不再重试 —— 否则源挂掉的那几天,每个请求都去撞一次超时,
* 白白浪费服务器资源(而且 php-fpm 的 worker 会被这些慢请求占住)。
*/
private static function shouldTry(int $year): bool
{
$f = self::tryFile($year);
$last = is_file($f) ? (int)@file_get_contents($f) : 0;
return (time() - $last) > self::CACHE_FAIL;
}
private static function tryFile(int $year): string
{
return dirname(__DIR__) . '/storage/' . self::CACHE_DIR . '/holiday-try-' . $year . '.txt';
}
private static function markTried(int $year): void
{
$dir = dirname(self::tryFile($year));
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
@file_put_contents(self::tryFile($year), (string)time(), LOCK_EX);
}
/** 真正去抓 + 落缓存(只在后台跑) */
private static function doFetch(int $year): void
{
self::markTried($year);
$official = self::fetch($year);
$days = self::builtin($year);
$source = 'builtin';
if ($official !== null) {
foreach ($official as $d => $v) {
$days[$d] = $v;
}
$source = 'official';
}
self::saveCache($year, ['year' => $year, 'source' => $source, 'days' => $days],
$source === 'official');
}
/**
* 纯抓取(不落缓存)—— 给**命令行/设置页的"立即更新"**用。
*
* ⚠️ 与 year() 的区别:这个会**联网**。绝不要放在页面渲染路径上。
*/
public static function fetchNow(int $year): array
{
$official = self::fetch($year);
$days = self::builtin($year);
if ($official !== null) {
foreach ($official as $d => $v) {
$days[$d] = $v;
}
}
$out = ['year' => $year, 'source' => $official !== null ? 'official' : 'builtin', 'days' => $days];
self::saveCache($year, $out, $official !== null);
self::markTried($year);
return $out;
}
/**
* 内置规则推导:只算**放假**,不算调休。
*
* 调休(哪个周末上班)是行政决定,任何"推导"都是瞎猜 ——
* 猜错的代价是用户按错的日期安排了事(比"没有调休信息"更糟)。
* 所以内置规则只给确定的公历/农历节日,调休一律等官方数据。
*/
private static function builtin(int $year): array
{
$days = [];
// ---- 公历固定节日:按**放假天数**铺成一段 ----
// ⚠️ 用户原话「国庆节只有刚开始的一天有,这不合理」——旧版每天只标一天。
foreach (self::FIXED as $m => $v) {
foreach ($v as $d => $spec) {
[$name, $span] = $spec;
$start = sprintf('%04d-%02d-%02d', $year, $m, $d);
for ($i = 0; $i < max(1, (int)$span); $i++) {
$t = date('Y-m-d', strtotime($start . ' +' . $i . ' day'));
// 跨到下一年的(元旦 3 天可能压到 1/3)也照铺,键就是真实日期
$days[$t] = ['name' => $name, 'off' => true];
}
}
}
// ---- 清明(节气,4 月 4~6 日之间浮动)----
$qingming = self::qingmingDay($year);
if ($qingming > 0) {
$days[sprintf('%04d-04-%02d', $year, $qingming)] = ['name' => '清明节', 'off' => true];
}
// ---- 农历节日(春节 / 端午 / 中秋)----
if (Lunar::inRange($year)) {
// 春节:法定假从**除夕**开始(除夕=正月初一前一天,注意可能跨到上一年的腊月)
// ⚠️ lunarToSolar 返回的是**关联数组**(['year'=>, 'month'=>, 'day'=>]),
// 不是 [0,1,2] —— 用数字下标取会得到 null,然后 sprintf 出 '0000-00-00'。
$cny = Lunar::lunarToSolar($year, 1, 1);
if ($cny) {
$first = sprintf('%04d-%02d-%02d', $cny['year'], $cny['month'], $cny['day']);
$days[$first] = ['name' => '春节', 'off' => true];
// 正月初二、初三也放假(法定 3 天)
for ($i = 1; $i <= 2; $i++) {
$t = date('Y-m-d', strtotime($first . ' +' . $i . ' day'));
$days[$t] = ['name' => '春节', 'off' => true];
}
// 除夕(正月初一前一天)—— 法定假从除夕算起
$eve = date('Y-m-d', strtotime($first . ' -1 day'));
$days[$eve] = ['name' => '除夕', 'off' => true];
}
foreach ([5 => [5, '端午节'], 8 => [15, '中秋节']] as $m => [$d, $name]) {
$s = Lunar::lunarToSolar($year, $m, $d);
if ($s) {
$days[sprintf('%04d-%02d-%02d', $s['year'], $s['month'], $s['day'])]
= ['name' => $name, 'off' => true];
}
}
}
ksort($days);
return $days;
}
/**
* 清明节的公历日期。
*
* 清明是**节气**不是农历节日,用通行的近似公式(20 世纪/21 世纪两套系数):
* Y = 年 % 100;日 = int(Y × 0.2422 + C) − int(Y / 4)
* 21 世纪 C = 4.81,20 世纪 C = 5.59。
* ⚠️ 个别年份有 1 天的修正(公式的已知例外),这里只列真正会碰到的:
* 2008 年应为 4 日(公式给 5 日)。范围外一律回退到"4 月 5 日"这个最常值。
*/
private static function qingmingDay(int $year): int
{
if ($year < 1900 || $year > 2100) {
return 5;
}
$c = $year >= 2000 ? 4.81 : 5.59;
$y = $year % 100;
$day = (int)($y * 0.2422 + $c) - (int)($y / 4);
// 已知例外(来自紫金山天文台的公开修正表,只影响极少数年份)
$except = [2008 => 4, 2020 => 4, 1925 => 5, 1942 => 5];
if (isset($except[$year])) {
$day = $except[$year];
}
return ($day >= 4 && $day <= 6) ? $day : 5;
}
// ------------------------------------------------------------------
// 官方数据自动拉取
// ------------------------------------------------------------------
/**
* 抓官方公布的那个 JSON。**任何失败都返回 null**(调用方回落到内置规则)。
*
* ⚠️ 这里刻意不抛异常、不写日志刷屏:日历是高频页,
* 外网抖动是常态,抛出去只会让页面 500 —— 而我们本来就有兜底。
*/
private static function fetch(int $year): ?array
{
foreach ([self::SOURCE, self::SOURCE_ALT] as $tpl) {
$json = self::httpGet(sprintf($tpl, $year));
if ($json === null) {
continue;
}
$dec = json_decode($json, true);
if (!is_array($dec) || empty($dec['days']) || !is_array($dec['days'])) {
continue;
}
$days = [];
foreach ($dec['days'] as $d) {
if (!is_array($d)) {
continue;
}
$date = trim((string)($d['date'] ?? ''));
// 只认本年的、格式合法的(数据源里混进别的年份就直接忽略)
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $m) || (int)$m[1] !== $year) {
continue;
}
$days[$date] = [
'name' => trim((string)($d['name'] ?? '')),
'off' => (bool)($d['isOffDay'] ?? false),
];
}
if ($days) {
ksort($days);
return $days;
}
}
return null;
}
/** 一次 HTTP GET(curl 优先,退回 file_get_contents);失败/超时返回 null */
private static function httpGet(string $url): ?string
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 12,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_USERAGENT => 'FamilyBook/' . \App\Schema::VERSION,
// ⚠️ 不能用 CURLOPT_NOBODY(那是 HEAD);这里是 GET
CURLOPT_HTTPGET => true,
];
// ⚠️ CA 证书兜底:很多服务器(含本机开发环境)的 PHP 没配 curl.cainfo,
// 表现是 `errno=60 unable to get local issuer certificate` ——
// 看起来像"站点不通",其实是**证书链没处可查**。
// 这里按常见位置找一个 CA bundle;找不到就退回系统默认(不关校验,
// 关掉 VERIFYPEER 等于把 HTTPS 降级成明文,不能为了"能跑"那样做)。
$ca = self::findCaBundle();
if ($ca !== null) {
$opts[CURLOPT_CAINFO] = $ca;
}
curl_setopt_array($ch, $opts);
$body = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (is_string($body) && $code === 200 && $body !== '') {
return $body;
}
return null;
}
// 没有 curl:file_get_contents 同样需要 CA;失败一律 null(上层回落内置规则)
$ctx = stream_context_create([
'http' => ['timeout' => 12, 'user_agent' => 'FamilyBook'],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$body = @file_get_contents($url, false, $ctx);
return is_string($body) && $body !== '' ? $body : null;
}
/**
* 找一个可用的 CA bundle。找不到返回 null(保持 PHP 默认行为)。
*
* 只查固定几个常见位置,不做目录遍历 —— 这是渲染路径之外的后台任务,
* 但也不该在磁盘上乱翻。
*/
private static function findCaBundle(): ?string
{
$candidates = [
ini_get('curl.cainfo') ?: '',
ini_get('openssl.cafile') ?: '',
'C:/php/extras/ssl/cacert.pem',
'C:/php/cacert.pem',
'D:/Yongquan/code/_tooling/php/cacert.pem',
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/cert.pem',
];
foreach ($candidates as $c) {
$c = (string)$c;
if ($c !== '' && is_file($c) && is_readable($c)) {
return $c;
}
}
return null;
}
// ------------------------------------------------------------------
// 缓存(放 settings 表:全局数据、与具体人无关)
// ------------------------------------------------------------------
/**
* 缓存落**文件**(storage/cache/holiday-<年>.json),不蹭 settings 表。
*
* ⚠️ 为什么不用 settings 表:它是**家庭级**的(主键 family_id + k,且 family_id 有
* 外键约束指向 families(id))。而节假日安排是**全局数据**(全国一样、与哪个家无关),
* 硬塞进去最省事的做法是"借用 0 号家庭",但那会直接违反外键。
* 节假日本来就该在"一次拉取、全体共用、随部署走"的共享存储里 —— 就是文件。
*/
private const CACHE_DIR = 'cache';
private static function cacheFile(int $year): string
{
// storage/cache/holiday-<年>.json
// ⚠️ 这个目录**不在打包白名单里**(缓存不该随包走),由部署脚本 mkdir 出来;
// 代码这边只负责"没有就建",且**任何写失败都不算错**(见 saveCache)。
return dirname(__DIR__) . '/storage/' . self::CACHE_DIR . '/holiday-' . $year . '.json';
}
private static function fromCache(int $year): ?array
{
$f = self::cacheFile($year);
if (!is_file($f)) {
return null;
}
$raw = @file_get_contents($f);
if (!is_string($raw) || $raw === '') {
return null;
}
$dec = json_decode($raw, true);
if (!is_array($dec) || !isset($dec['at'], $dec['data'])) {
return null;
}
// 成功的官方数据缓存久一点;只有内置规则的缓存短一点(好早点换上官方数据)
$ttl = ($dec['source'] ?? '') === 'official' ? self::CACHE_OK : self::CACHE_FAIL;
if (time() - (int)$dec['at'] > $ttl) {
return null;
}
$data = $dec['data'];
return is_array($data) ? $data : null;
}
private static function saveCache(int $year, array $data, bool $official): void
{
$payload = json_encode([
'at' => time(),
'source' => $official ? 'official' : 'builtin',
'data' => $data,
], JSON_UNESCAPED_UNICODE);
$dir = dirname(self::cacheFile($year));
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
// 写失败(目录不可写)**不算错**:下次请求重算一遍而已,页面照常出内容
@file_put_contents(self::cacheFile($year), $payload, LOCK_EX);
}
/** 强制刷新(设置页/命令行用):删缓存再取一次 */
public static function refresh(int $year): array
{
$f = self::cacheFile($year);
if (is_file($f)) {
@unlink($f);
}
return self::year($year);
}
// ------------------------------------------------------------------
// 供日历渲染用
// ------------------------------------------------------------------
/**
* 把放假/调休摊成日历条目(只取 [from,to] 落在范围内的)。
*
* @return array<int,array{date:string,title:string,name:string,off:bool}>
*/
public static function range(int $year, string $from, string $to): array
{
$y = self::year($year);
$out = [];
foreach (($y['days'] ?? []) as $date => $v) {
if ($date < $from || $date > $to) {
continue;
}
$off = !empty($v['off']);
$name = (string)($v['name'] ?? '');
$out[] = [
'date' => (string)$date,
'name' => $name,
'off' => $off,
// 调休上班的日子写清楚"补班",否则用户看到工作日里冒出一条日程会莫名
'title' => $off ? ('休 ' . ($name !== '' ? $name : '假日')) : ('班 ' . ($name !== '' ? $name : '调休')),
];
}
return $out;
}
/**
* 农历条目([from,to] 每天都出一条,标题是"农历月日 + 节日")。
*
* ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。
*/
public static function lunarRange(string $from, string $to): array
{
$out = [];
$t = strtotime($from);
$end = strtotime($to);
if ($t === false || $end === false || $end < $t) {
return [];
}
// 上限保险:范围异常大时不至于把请求拖死(正常月视图最多 42 天)
for ($i = 0; $t <= $end && $i < 400; $i++, $t = strtotime('+1 day', $t)) {
$date = date('Y-m-d', $t);
$y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t);
$l = Lunar::solarToLunar($y, $m, $d);
if (!$l) {
continue;
}
$lm = (int)$l['month']; $ld = (int)$l['day']; $leap = !empty($l['is_leap']);
$out[] = [
'date' => $date,
'title' => self::lunarTitle($lm, $ld, $leap),
'fest' => self::lunarFestival($lm, $ld, $leap),
];
}
return $out;
}
/** 日历条上显示的农历文字:初一显示月名,其余显示日名 */
private static function lunarTitle(int $m, int $d, bool $leap): string
{
if ($d === 1) {
return Lunar::monthName($m, $leap);
}
return Lunar::dayName($d);
}
/**
* 那天是不是农历节日(春节/元宵/端午/七夕/中元/中秋/重阳/腊八/小年/除夕)。
* 返回节日名,没有则空串。
*/
private static function lunarFestival(int $m, int $d, bool $leap): string
{
if ($leap) {
return ''; // 闰月不算节日
}
$map = [
'1-1' => '春节', '1-15' => '元宵节',
'5-5' => '端午节', '7-7' => '七夕',
'7-15' => '中元节', '8-15' => '中秋节',
'9-9' => '重阳节', '12-8' => '腊八节',
'12-23'=> '小年',
];
return $map[$m . '-' . $d] ?? '';
}
/** 两个内置日历本的定义(不可删除 → kind 不是 custom,delete() 天然删不掉) */
public const BUILTIN_KINDS = [
'lunar' => ['农历', '#8C6D3F'],
'holiday' => ['节假日', '#B0342C'],
];
/** 某年官方数据是否已到手(设置页显示"已自动更新到哪一年"用) */
public static function isOfficial(int $year): bool
{
$y = self::year($year);
return ($y['source'] ?? '') === 'official';
}
}