Files
laoyang 02f5d072a4 家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开
用户 2026-10-03 提的四条:

① 家谱卡片重做
   · 去掉「⇄ 姻亲」按钮,改成**每个人**卡片右下角都有「以 TA 为中心」
     (点了以他重画一张家谱,涵盖原来的姻亲场景)。
   · 头像外框改**长方形**(96×72,宽度贴合头像),object-fit: contain 不裁切,
     姓名/称呼在头像下方。
   · 修「点右下角按钮误进成员页」:卡片不再整张是 <a>,
     只有姓名是链接,右下角是独立的 <button>(物理上分开,不抢点击)。

② 家谱文案与按钮清理
   · 去掉副标题与下方图例说明;整块去掉「看全部 / 回到我 / 回到我的家谱」
     (顺带消掉"出现两个回到我的家谱")。镜头由库自动 fit,拖拽/滚轮即可。

③ 首页控制台
   · 标题下显示个人 slogan;去掉右侧三个统计;「我的资料/我的家谱」换成图标按钮;
     去掉与导航条重复的「名册」按钮。
   · 去掉「接下来的日子」(生日归到"未来七天"里,不再重复)。
   · 新增统计信息块:按时段随机问候 + 今日天气(Open-Meteo,免 key,可留空不显示)
     + 近七天日程/待办/逾期数。
   · 新增各模块小结卡(任务/日程/纪念日/观影/旅程/财务/名册/家谱/资源)。
   · 个人设置新增「首页上的模块」:可勾选与拖动排序(每人一份,存 user_settings)。
   · 保留「往年的今天」。

④ 名册改列表
   · 卡片网格 → **竖向列表**:左头像、中间姓名+与我的关系+手机号+生日。
   · 修拖动「只能往后不能往前」:落点改为显式记录"插到谁前面/后面",
     并排除被拖元素自身。
   · 去掉那句拖动提示(能力保留)。
   · 搜索新增**姓名首字母**检索(y / yy / yyq 都命中「杨永全」):
     新 app/Pinyin.php,intl 优先 + 内置常用字表兜底(本地无 intl 也能测);
     纯字母/数字查询词不走 SQL LIKE(拼音与加密手机号在库里都比不了)。

附带上两轮的收尾:
   · 菜单点外部自动关闭(全局事件委托,覆盖所有 details.menu)。
   · 旅程 is_public:列表与日历只显示公开的;详情页不拦(不丢数据)。
   · 重复日程:新 app/Repeat.php(RRULE 解析/生成/展开,含"工作日"),
     新表 calendar_event_skips(RFC 的 EXDATE);日历按规则展开;
     编辑支持三档「全部 / 这一次及以后 / 仅此一次」。
   · 结束时间可添加/去掉,并随开始时间联动。

验证:端到端 772 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 26、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 11:30:48 +08:00

321 lines
13 KiB
PHP
Raw Permalink 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
/**
* 重复日程(用户 2026-10-03 第 5、6 条)。
*
* 之前只有一列 `rrule` 被写入与导出,**没有任何展开逻辑** ——
* 日历里同一条日程只会出现一次,"重复"实际上没生效。
* 这里补上:把 RRULE 展开成一段范围内的若干次发生。
*
* 用 RFC 5545 的 RRULE 原文存储(不是自定义格式),三个好处:
* ① ics 导入导出原样带过,与别的日历互通;
* ② CalDAV 客户端本来就认它;
* ③ 不用自己发明一套再写转换。
*
* 支持的子集(够覆盖家庭场景,且实现可验证):
* FREQ=DAILY | WEEKLY | MONTHLY | YEARLY
* INTERVAL=n(每 n 天/周/月/年)
* BYDAY=MO,TU,…(WEEKLY 用;"每个工作日"就是 BYDAY=MO,TU,WE,TH,FR)
* COUNT=n(共重复 n 次) / UNTIL=<UTC 时刻>(重复到某天为止)
*
* ⚠️ 展开只按**日期**比对范围:日程的时刻在事件本身(starts_at),
* 重复出来的每一次共用同一个时刻。这是日历的通行做法
* ("每周三 19:00"里的 19:00 属于事件,不属于规则)。
*/
declare(strict_types=1);
namespace App;
final class Repeat
{
/** 界面上的重复周期 → FREQ 片段 */
public const FREQS = [
'' => '不重复',
'daily' => '每天',
'weekly' => '每周',
'monthly' => '每月',
'yearly' => '每年',
'weekday' => '每个工作日(周一至周五)',
];
/** 结束条件 */
public const ENDS = [
'never' => '一直重复',
'count' => '重复指定次数',
'until' => '重复到某天为止',
];
/** 周一..周日(RFC 5545 的 BYDAY 记号;0=周日,与 date('w') 对齐后转换) */
private const WEEKDAY_CODE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
/** 展开的硬上限:防止 COUNT 极大或 UNTIL 很远时把请求拖死 */
private const MAX_OCCURRENCES = 2000;
/**
* 界面字段 → RRULE 原文。返回 '' 表示不重复。
*
* @param array $d freq / rep_end / rep_count / rep_until
*/
public static function build(array $d): string
{
$freq = (string)($d['freq'] ?? '');
if (!isset(self::FREQS[$freq]) || $freq === '') {
return '';
}
$parts = [];
switch ($freq) {
case 'daily':
$parts[] = 'FREQ=DAILY';
break;
case 'weekly':
$parts[] = 'FREQ=WEEKLY';
break;
case 'monthly':
$parts[] = 'FREQ=MONTHLY';
break;
case 'yearly':
$parts[] = 'FREQ=YEARLY';
break;
case 'weekday':
// 工作日 = 每周 + 明确点名周一到周五(不要用 INTERVAL=1 靠"跳过周末"实现,
// 那样节假日与调休都对不上,客户端也不认)
$parts[] = 'FREQ=WEEKLY';
$parts[] = 'BYDAY=MO,TU,WE,TH,FR';
break;
}
$end = (string)($d['rep_end'] ?? 'never');
if ($end === 'count') {
$n = (int)($d['rep_count'] ?? 0);
if ($n >= 1) {
// ⚠️ COUNT 含**首次**:用户填 10 就是"一共 10 次"(含第一次)。
// RFC 也是这么算的,别自作聪明 +1。
$parts[] = 'COUNT=' . min($n, self::MAX_OCCURRENCES);
}
} elseif ($end === 'until') {
$u = trim((string)($d['rep_until'] ?? ''));
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $u, $m) && checkdate((int)$m[2], (int)$m[3], (int)$m[1])) {
// UNTIL 一律写成**当天的 23:59:59 UTC**:这样"重复到 12-31"意味着
// 12-31 这一天也包含在内(写成 00:00:00 会把最后那天砍掉)。
$parts[] = 'UNTIL=' . $m[1] . $m[2] . $m[3] . 'T235959Z';
}
}
return implode(';', $parts);
}
/** RRULE 原文 → 结构化(认不出的键忽略,不报错) */
public static function parse(string $rrule): array
{
$out = ['freq' => '', 'interval' => 1, 'byday' => [], 'count' => 0, 'until' => ''];
$rrule = trim($rrule);
if ($rrule === '') {
return $out;
}
foreach (explode(';', $rrule) as $kv) {
$kv = trim($kv);
if ($kv === '') {
continue;
}
$eq = strpos($kv, '=');
if ($eq === false) {
continue;
}
$k = strtoupper(substr($kv, 0, $eq));
$v = substr($kv, $eq + 1);
switch ($k) {
case 'FREQ':
$f = strtoupper($v);
if ($f === 'WEEKLY' && strtoupper($v) !== '' && strpos(strtoupper($rrule), 'BYDAY=MO,TU,WE,TH,FR') !== false) {
$out['freq'] = 'weekday';
} else {
$out['freq'] = ['DAILY' => 'daily', 'WEEKLY' => 'weekly',
'MONTHLY' => 'monthly', 'YEARLY' => 'yearly'][$f] ?? '';
}
break;
case 'INTERVAL':
$out['interval'] = max(1, (int)$v);
break;
case 'BYDAY':
foreach (explode(',', strtoupper($v)) as $d) {
$d = trim($d);
if ($d !== '') {
$out['byday'][] = $d;
}
}
break;
case 'COUNT':
$out['count'] = max(0, (int)$v);
break;
case 'UNTIL':
// 20261231T235959Z → 2026-12-31
if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $v, $m)) {
$out['until'] = $m[1] . '-' . $m[2] . '-' . $m[3];
}
break;
}
}
return $out;
}
/** RRULE → 一句人话(列表/详情页用) */
public static function describe(string $rrule): string
{
$p = self::parse($rrule);
if ($p['freq'] === '') {
return '';
}
$word = self::FREQS[$p['freq']];
if ($p['interval'] > 1) {
$unit = ['daily' => '天', 'weekly' => '周', 'monthly' => '个月', 'yearly' => '年'][$p['freq']] ?? '';
if ($unit !== '' && $p['freq'] !== 'weekday') {
$word = '每 ' . $p['interval'] . ' ' . $unit;
}
}
if ($p['count'] > 0) {
$word .= ',共 ' . $p['count'] . ' 次';
} elseif ($p['until'] !== '') {
$word .= ',直到 ' . $p['until'];
} else {
$word .= ',一直重复';
}
return $word;
}
/**
* 把一条日程在 [from, to] 内的**每一次发生**列出来。
*
* @param string $dtstart 事件开始时刻 'Y-m-d H:i:s'(重复的锚点)
* @param string $rrule 规则原文;'' 表示不重复
* @param array $skips 被跳过的日期 ['Y-m-d' => true](单个发生被取消/被挪走)
* @return array<int,string> 日期列表('Y-m-d'),升序,**不含**被跳过的
*/
public static function occurrences(string $dtstart, string $rrule, string $from, string $to, array $skips = []): array
{
if ($rrule === '') {
$d = substr($dtstart, 0, 10);
return ($d >= $from && $d <= $to) ? [$d] : [];
}
$p = self::parse($rrule);
if ($p['freq'] === '') {
$d = substr($dtstart, 0, 10);
return ($d >= $from && $d <= $to) ? [$d] : [];
}
$start = substr($dtstart, 0, 10);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start)) {
return [];
}
$count = 0; // 已生成的发生次数(**含**被跳过的,RFC 就是这么数的)
$out = [];
$guard = 0;
$emit = static function (string $d) use (&$out, &$count, $from, $to, $skips): void {
$count++;
if ($d > $to) {
return; // 超出范围就不再关心
}
if ($d >= $from && !isset($skips[$d])) {
$out[] = $d;
}
};
// 是否还可以继续生成:COUNT 到了 / UNTIL 过了 / 超出范围 / 硬上限
$canGo = static function (string $d) use (&$count, $p, $to): bool {
if ($p['count'] > 0 && $count >= $p['count']) {
return false;
}
if ($p['until'] !== '' && $d > $p['until']) {
return false;
}
return $d <= $to; // 已经超过范围末端
};
$interval = max(1, (int)$p['interval']);
switch ($p['freq']) {
case 'daily':
$d = $start;
while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) {
$emit($d);
$d = date('Y-m-d', strtotime($d . ' +' . $interval . ' day'));
}
break;
case 'weekly':
case 'weekday':
// BYDAY 缺省 = 与 DTSTART 同一个星期几
$byday = $p['byday'];
if (!$byday) {
$byday = [self::WEEKDAY_CODE[(int)date('w', strtotime($start))]];
}
$codes = array_flip(self::WEEKDAY_CODE); // 'MO' => 1(与 date('w') 一致)
// 从 DTSTART 所在那一周的周一开始走
$weekStart = date('Y-m-d', strtotime($start . ' -' . ((int)date('w', strtotime($start)) + 6) % 7 . ' day'));
$wk = $weekStart;
while ($guard++ < self::MAX_OCCURRENCES && $canGo($wk)) {
// 一周内按周一→周日顺序
$days = [];
for ($i = 0; $i < 7; $i++) {
$day = date('Y-m-d', strtotime($wk . ' +' . $i . ' day'));
$code = self::WEEKDAY_CODE[(int)date('w', strtotime($day))];
if (in_array($code, $byday, true)) {
$days[] = $day;
}
}
sort($days);
foreach ($days as $day) {
if ($day < $start) {
continue; // DTSTART 之前的不算
}
if (!$canGo($day)) {
break 2;
}
$emit($day);
}
$wk = date('Y-m-d', strtotime($wk . ' +' . ($interval * 7) . ' day'));
}
break;
case 'monthly':
$dom = (int)substr($start, 8, 2);
$y = (int)substr($start, 0, 4);
$m = (int)substr($start, 5, 2);
$d = $start;
while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) {
$emit($d);
// ⚠️ 该月没有这一天(如 31 号遇到只有 30 天的月)→ 按 RFC **跳过该月**,
// 绝不能"顺延到下月 1 号" —— 那样"每月 31 号"会变成一堆 1 号,
// 而且 1 号往往还有别的事,看起来像重复错乱(实测踩到过)。
$m += $interval;
while ($m > 12) {
$m -= 12;
$y++;
}
if (!checkdate($m, $dom, $y)) {
continue; // 这个月没有该日 → 直接看下一个月
}
$d = sprintf('%04d-%02d-%02d', $y, $m, $dom);
}
break;
case 'yearly':
$mm = (int)substr($start, 5, 2);
$dom = (int)substr($start, 8, 2);
$y = (int)substr($start, 0, 4);
$d = $start;
while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) {
$emit($d);
$y += $interval;
// 2/29 遇平年 → 跳过那年(RFC 行为;要"每年 2/28 提醒"请用纪念日模块)
if (!checkdate($mm, $dom, $y)) {
$d = sprintf('%04d-%02d-01', $y, $mm);
continue;
}
$d = sprintf('%04d-%02d-%02d', $y, $mm, $dom);
}
break;
}
sort($out);
return array_values(array_unique($out));
}
}