家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开
用户 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 条线上探针全过。
This commit is contained in:
+129
-21
@@ -317,6 +317,75 @@ final class Calendar
|
||||
return (string)$st->fetchColumn() === 'write';
|
||||
}
|
||||
|
||||
// ---------------- 重复日程的「某一次」例外(用户 2026-10-03 第 5 条)----------------
|
||||
|
||||
/** 把某一天标记为"跳过"(取消这一次 / 这一次被挪走) */
|
||||
public static function skipOnce(int $eventId, string $dateYmd): void
|
||||
{
|
||||
$d = substr(trim($dateYmd), 0, 10);
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
|
||||
return;
|
||||
}
|
||||
Database::get()->prepare(
|
||||
'INSERT INTO calendar_event_skips (event_id, skip_date) VALUES (?,?)
|
||||
ON DUPLICATE KEY UPDATE skip_date = VALUES(skip_date)'
|
||||
)->execute([$eventId, $d]);
|
||||
}
|
||||
|
||||
/** 取消"跳过"标记(撤销一次取消) */
|
||||
public static function unskipOnce(int $eventId, string $dateYmd): void
|
||||
{
|
||||
Database::get()->prepare('DELETE FROM calendar_event_skips WHERE event_id = ? AND skip_date = ?')
|
||||
->execute([$eventId, substr(trim($dateYmd), 0, 10)]);
|
||||
}
|
||||
|
||||
/** 这条日程被跳过了哪些日期 */
|
||||
public static function skipsOf(int $eventId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT skip_date FROM calendar_event_skips WHERE event_id = ? ORDER BY skip_date'
|
||||
);
|
||||
$st->execute([$eventId]);
|
||||
return array_map(static fn($r) => (string)$r['skip_date'], $st->fetchAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把"重复日程里被改掉的某一次"落成一条**独立的单次日程**。
|
||||
*
|
||||
* 为什么要独立一条而不是改原日程:原日程是**整个系列**的定义,
|
||||
* 「仅此一次」的改动不能碰它。所以:
|
||||
* ① 在原系列里把这一天标记为跳过(不再出现原样那次);
|
||||
* ② 新建一条只发生这一次的日程,内容 = 用户改后的。
|
||||
* 这样"改过的那一天"与"其余各次"各自独立,之后怎么改都不会互相干扰。
|
||||
*
|
||||
* @return int 新建的那条日程 id
|
||||
*/
|
||||
public static function detachOnce(array $ev, string $date, array $d, int $byId = 0): int
|
||||
{
|
||||
$date = substr(trim($date), 0, 10);
|
||||
self::skipOnce((int)$ev['id'], $date);
|
||||
|
||||
// 时刻:沿用用户改后的;他改的是"那一天的"日程,日期必须锁在 $date
|
||||
$starts = (string)($d['starts_at'] ?? '');
|
||||
$ends = (string)($d['ends_at'] ?? '');
|
||||
$startDay = substr($starts, 0, 10);
|
||||
if ($startDay !== $date) {
|
||||
// 用户没改日期(或者改到别的天了)→ 把这一天对齐到 $date,
|
||||
// 只取他改的时刻部分;否则"仅此一次"会莫名其妙跳走一天。
|
||||
$starts = $date . ' ' . (substr($starts, 11, 8) !== '' ? substr($starts, 11, 8) : '00:00:00');
|
||||
}
|
||||
$ends = $ends !== '' ? $date . ' ' . (substr($ends, 11, 8) ?: '00:00:00') : null;
|
||||
|
||||
$new = $d;
|
||||
$new['starts_at'] = $starts;
|
||||
$new['ends_at'] = $ends;
|
||||
$new['rrule'] = ''; // 单次日程不带重复规则
|
||||
// ⚠️ addEvent 内部会自己 newUid(),别在这里另设 $new['uid'](会被忽略,
|
||||
// 只是让人误以为"UID 是我指定的")。created_by 走 $d['created_by']。
|
||||
$new['created_by'] = $byId ?: ($d['created_by'] ?? null);
|
||||
return self::addEvent((int)$ev['calendar_id'], $new);
|
||||
}
|
||||
|
||||
/** 一条日程的 UID(CalDAV 那边按 UID 取) */
|
||||
public static function newUid(): string
|
||||
{
|
||||
@@ -468,39 +537,71 @@ final class Calendar
|
||||
}
|
||||
if ($ids) {
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
// ⚠️ 取数范围要**比 [from,to] 放宽**:重复日程的种子(starts_at)可能远早于
|
||||
// 本月("从去年起每周三"),按 starts_at 直接卡范围会把整个系列漏掉。
|
||||
// 这里取到 `to` 为止、往前不设限,展开阶段再按 [from,to] 裁。
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT * FROM calendar_events
|
||||
WHERE calendar_id IN ($in)
|
||||
AND DATE(starts_at) <= ? AND DATE(COALESCE(ends_at, starts_at)) >= ?
|
||||
AND DATE(starts_at) <= ?
|
||||
ORDER BY starts_at"
|
||||
);
|
||||
$st->execute(array_merge($ids, [$to, $from]));
|
||||
$st->execute(array_merge($ids, [$to]));
|
||||
$calById = [];
|
||||
foreach ($calendars as $c) {
|
||||
$calById[(int)$c['id']] = $c;
|
||||
}
|
||||
foreach ($st->fetchAll() as $e) {
|
||||
$rows = $st->fetchAll();
|
||||
|
||||
// 一次把这批日程的「例外日期」取出来(N+1 会在重复日程多时明显变慢)
|
||||
$skips = [];
|
||||
if ($rows) {
|
||||
$eids = array_map('intval', array_column($rows, 'id'));
|
||||
$in2 = implode(',', array_fill(0, count($eids), '?'));
|
||||
$sk = Database::get()->prepare(
|
||||
"SELECT event_id, skip_date FROM calendar_event_skips WHERE event_id IN ($in2)"
|
||||
);
|
||||
$sk->execute($eids);
|
||||
foreach ($sk->fetchAll() as $r) {
|
||||
$skips[(int)$r['event_id']][(string)$r['skip_date']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as $e) {
|
||||
$c = $calById[(int)$e['calendar_id']] ?? null;
|
||||
$sd = substr((string)$e['starts_at'], 0, 10);
|
||||
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
|
||||
// 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的)
|
||||
$first = true;
|
||||
foreach (self::eachDay($sd, $ed, $from, $to) as $day) {
|
||||
$tm = substr((string)$e['starts_at'], 11, 5);
|
||||
$out[] = [
|
||||
'cal' => (int)$e['calendar_id'],
|
||||
'color' => (string)($c['color'] ?? '#4A7C82'),
|
||||
'calname' => (string)($c['name'] ?? ''),
|
||||
'title' => (string)$e['title'],
|
||||
'date' => $day,
|
||||
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
|
||||
'time' => ($first && empty($e['all_day'])) ? $tm : '',
|
||||
'all_day' => !empty($e['all_day']),
|
||||
'kind' => 'custom',
|
||||
'ref_id' => (int)$e['id'],
|
||||
'href' => url('calendar/event', ['id' => (int)$e['id']]),
|
||||
];
|
||||
$first = false;
|
||||
$tm = substr((string)$e['starts_at'], 11, 5);
|
||||
$rr = (string)($e['rrule'] ?? '');
|
||||
// 用户 2026-10-03 第 5/6 条:重复日程按规则展开成若干次发生;
|
||||
// 不重复的走同一条路(occurrences 会返回它自己那一天)。
|
||||
$days = Repeat::occurrences(
|
||||
(string)$e['starts_at'], $rr, $from, $to,
|
||||
$skips[(int)$e['id']] ?? []
|
||||
);
|
||||
foreach ($days as $day) {
|
||||
// 跨天日程:从这次发生的当天起,按事件本身的长度铺开
|
||||
$span = (int)floor((strtotime($ed) - strtotime($sd)) / 86400);
|
||||
$first = true;
|
||||
foreach (self::eachDay($day, date('Y-m-d', strtotime($day . ' +' . $span . ' day')), $from, $to) as $d2) {
|
||||
$out[] = [
|
||||
'cal' => (int)$e['calendar_id'],
|
||||
'color' => (string)($c['color'] ?? '#4A7C82'),
|
||||
'calname' => (string)($c['name'] ?? ''),
|
||||
'title' => (string)$e['title'],
|
||||
'date' => $d2,
|
||||
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
|
||||
'time' => ($first && empty($e['all_day'])) ? $tm : '',
|
||||
'all_day' => !empty($e['all_day']),
|
||||
'kind' => 'custom',
|
||||
'ref_id' => (int)$e['id'],
|
||||
// 带上这一天的日期,重复日程点进去才能定位到"改哪一次"
|
||||
'occ' => $day,
|
||||
'repeat' => $rr,
|
||||
'href' => url('calendar/event', ['id' => (int)$e['id'], 'd' => $day]),
|
||||
];
|
||||
$first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,14 +641,21 @@ final class Calendar
|
||||
// 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。
|
||||
if (isset($byKind['trip'])) {
|
||||
$c = $byKind['trip'][0];
|
||||
// 用户 2026-10-03 第 4 条:不公开的旅程,节点也不上日历。
|
||||
// COALESCE 兜底:缺 trip_details 行时按"公开"处理(老数据不会凭空消失)。
|
||||
// ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串,
|
||||
// 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉,
|
||||
// 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
|
||||
n.node_type, n.status AS node_status,
|
||||
e.id AS event_id, e.title AS trip_title
|
||||
FROM trip_nodes n
|
||||
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
|
||||
LEFT JOIN trip_details d ON d.event_id = e.id
|
||||
WHERE n.family_id = ? AND n.starts_at IS NOT NULL
|
||||
AND n.status <> 'cancelled'
|
||||
AND COALESCE(d.is_public, 1) = 1
|
||||
AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ?
|
||||
ORDER BY n.starts_at"
|
||||
);
|
||||
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
/**
|
||||
* 首页 dashboard 的数据装配(用户 2026-10-03 第 3 条)。
|
||||
*
|
||||
* 这一版首页要回答的是「今天是个什么日子、家里最近怎么样」:
|
||||
* · 一句随机问候(按当前时段 + 今日待办/日程给一句人话)
|
||||
* · 今天的天气
|
||||
* · 近七天的日程数 / 待办数 / 逾期数
|
||||
* · 各模块的一张小结(用户可开关与排序)
|
||||
*
|
||||
* ⚠️ 天气是**外部依赖**,所以:
|
||||
* ① 只在配置里给了 key 时才去请求(没配就整块不显示,不报错、不占位);
|
||||
* ② 结果**缓存 30 分钟**(天气不需要每分钟刷;也避免把外部接口打爆);
|
||||
* ③ 请求失败一律静默回落成"没有天气"——首页不能因为第三方挂了就打不开。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Home
|
||||
{
|
||||
/** 天气缓存有效期(秒) */
|
||||
private const WEATHER_TTL = 1800;
|
||||
|
||||
/**
|
||||
* 随机问候语。
|
||||
*
|
||||
* 用户原话:「随机给出一段问候语,例如,上午好,xxx,最近心情怎么样之类的」。
|
||||
* 所以是**按时段 + 带名字 + 有点变化**,而不是固定的"你好"。
|
||||
* ⚠️ 用 md5(日期+人名) 做种子而不是 random_int:
|
||||
* 同一天刷新多次看到的应该是**同一句**,否则每次刷新都变、像抽奖。
|
||||
*
|
||||
* @param string $name 我怎么称呼自己(家书里"我"就是他)
|
||||
*/
|
||||
public static function greeting(string $name, string $today, int $events = 0, int $overdue = 0): string
|
||||
{
|
||||
$h = (int)date('G');
|
||||
if ($h < 5) {
|
||||
$part = '夜深了';
|
||||
} elseif ($h < 11) {
|
||||
$part = '早上好';
|
||||
} elseif ($h < 13) {
|
||||
$part = '中午好';
|
||||
} elseif ($h < 18) {
|
||||
$part = '下午好';
|
||||
} else {
|
||||
$part = '晚上好';
|
||||
}
|
||||
$who = trim($name) !== '' ? ',' . trim($name) : '';
|
||||
|
||||
// 有逾期先说逾期(那是最该被看见的),其次今天的安排,最后才是寒暄
|
||||
if ($overdue > 0) {
|
||||
$tail = '有 ' . $overdue . ' 件事已经过期了,要不要先处理一下?';
|
||||
} elseif ($events > 0) {
|
||||
$tail = '今天有 ' . $events . ' 件事,慢慢来。';
|
||||
} else {
|
||||
$tails = [
|
||||
'最近心情怎么样?',
|
||||
'今天想做点什么?',
|
||||
'日子慢慢过,不着急。',
|
||||
'记得给家里打个电话。',
|
||||
'今天也是好日子。',
|
||||
'有空翻翻往年的今天。',
|
||||
];
|
||||
$seed = crc32($today . '|' . $name);
|
||||
$tail = $tails[$seed % count($tails)];
|
||||
}
|
||||
return $part . $who . ',' . $tail;
|
||||
}
|
||||
|
||||
/** 今天星期几(中文) */
|
||||
public static function weekday(string $date): string
|
||||
{
|
||||
$w = ['日', '一', '二', '三', '四', '五', '六'][(int)date('w', strtotime($date))];
|
||||
return '星期' . $w;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今天的天气。没配 key / 请求失败 / 超时 → null(首页整块不显示)。
|
||||
*
|
||||
* 用 Open-Meteo:**不需要申请 key**、允许非商业使用、有中文描述。
|
||||
* 之所以不选和风/心知:都要申请 key,而"配好 key 才能看见天气"这件事
|
||||
* 会让用户以为功能没做(配置项藏在哪他未必找得到)。
|
||||
*
|
||||
* ⚠️ 只按经纬度请求,不传城市名 —— 经纬度由管理员在系统设置里填。
|
||||
* 没填经纬度就当"没配天气",不猜(猜错城市比不显示更糟)。
|
||||
*
|
||||
* @return array{temp:string,text:string,icon:string,place:string}|null
|
||||
*/
|
||||
public static function weather(int $familyId, string $lat, string $lon, string $place): ?array
|
||||
{
|
||||
$lat = trim($lat);
|
||||
$lon = trim($lon);
|
||||
if ($lat === '' || $lon === '' || !is_numeric($lat) || !is_numeric($lon)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ⚠️ 缓存存在 settings 表里,而那张表对 family_id 有**外键**:
|
||||
// 传 0 会插入失败(外键不存在),所以必须带家庭 id。
|
||||
// 同一个家庭共用一份天气缓存 —— 本来也就是同一个地方。
|
||||
$cacheKey = 'weather_' . md5($lat . ',' . $lon);
|
||||
$cached = Setting::get($familyId, $cacheKey, '');
|
||||
if ($cached !== '') {
|
||||
$dec = json_decode((string)$cached, true);
|
||||
if (is_array($dec) && (int)($dec['at'] ?? 0) + self::WEATHER_TTL > time()) {
|
||||
return self::weatherOut((array)($dec['data'] ?? []), $place);
|
||||
}
|
||||
}
|
||||
|
||||
$url = 'https://api.open-meteo.com/v1/forecast'
|
||||
. '?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon)
|
||||
. '¤t=temperature_2m,weather_code&timezone=Asia%2FShanghai';
|
||||
$body = self::httpGet($url);
|
||||
if ($body === null) {
|
||||
return null;
|
||||
}
|
||||
$j = json_decode($body, true);
|
||||
if (!is_array($j) || !isset($j['current'])) {
|
||||
return null;
|
||||
}
|
||||
$data = [
|
||||
'temp' => (string)round((float)($j['current']['temperature_2m'] ?? 0)),
|
||||
'code' => (int)($j['current']['weather_code'] ?? -1),
|
||||
];
|
||||
// 缓存下来(存在 settings,key 里带 family 0 = 全站级)
|
||||
Setting::set($familyId, $cacheKey, json_encode(['at' => time(), 'data' => $data], JSON_UNESCAPED_UNICODE));
|
||||
return self::weatherOut($data, $place);
|
||||
}
|
||||
|
||||
/** 内部结构 → 页面用的天气结构 */
|
||||
private static function weatherOut(array $data, string $place): ?array
|
||||
{
|
||||
if (!isset($data['temp'])) {
|
||||
return null;
|
||||
}
|
||||
[$text, $icon] = self::wmo((int)($data['code'] ?? -1));
|
||||
return [
|
||||
'temp' => (string)$data['temp'] . '°C',
|
||||
'text' => $text,
|
||||
'icon' => $icon,
|
||||
'place' => trim($place),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* WMO 天气代码 → [中文描述, 一个符号]。
|
||||
* 符号用**普通字符**(不是 emoji):emoji 在部分旧系统上会变豆腐块,
|
||||
* 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。
|
||||
*/
|
||||
private static function wmo(int $c): array
|
||||
{
|
||||
if ($c === 0) {
|
||||
return ['晴', '☀'];
|
||||
}
|
||||
if (in_array($c, [1, 2], true)) {
|
||||
return ['多云', '⛅'];
|
||||
}
|
||||
if ($c === 3) {
|
||||
return ['阴', '☁'];
|
||||
}
|
||||
if (in_array($c, [45, 48], true)) {
|
||||
return ['有雾', '≋'];
|
||||
}
|
||||
if ($c >= 51 && $c <= 57) {
|
||||
return ['毛毛雨', '☂'];
|
||||
}
|
||||
if ($c >= 61 && $c <= 67) {
|
||||
return ['下雨', '☂'];
|
||||
}
|
||||
if ($c >= 71 && $c <= 77) {
|
||||
return ['下雪', '❄'];
|
||||
}
|
||||
if ($c >= 80 && $c <= 82) {
|
||||
return ['阵雨', '☂'];
|
||||
}
|
||||
if ($c >= 85 && $c <= 86) {
|
||||
return ['阵雪', '❄'];
|
||||
}
|
||||
if ($c >= 95) {
|
||||
return ['雷雨', '⚡'];
|
||||
}
|
||||
return ['天气', '·'];
|
||||
}
|
||||
|
||||
/** 极简 GET(3 秒超时;失败返回 null,绝不抛) */
|
||||
private static function httpGet(string $url): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 3, 'ignore_errors' => true]]);
|
||||
$r = @file_get_contents($url, false, $ctx);
|
||||
return $r === false ? null : (string)$r;
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_USERAGENT => 'FamilyBook/1.0',
|
||||
]);
|
||||
$r = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($r === false || $code < 200 || $code >= 300) {
|
||||
return null;
|
||||
}
|
||||
return (string)$r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 各模块的一张小结。返回 key => ['name','icon','color','href','big','lines']。
|
||||
*
|
||||
* 「big」是一个最该被看见的数字/短句,「lines」是几行补充。
|
||||
* 每张卡都**点得进去**(href 指向该模块最能回答这张卡的那一页)。
|
||||
*/
|
||||
public static function moduleCards(int $familyId, int $me, array $soon, array $tasks): array
|
||||
{
|
||||
$cards = [];
|
||||
|
||||
// ---- 名册 ----
|
||||
$people = Person::map($familyId);
|
||||
$roster = Person::roster($familyId, $me, 'member', '');
|
||||
$cards['people'] = [
|
||||
'name' => '名册',
|
||||
'icon' => self::icon('people'),
|
||||
'color' => Module::color('people', $familyId),
|
||||
'href' => url('people'),
|
||||
'big' => (string)count($roster) . ' 人',
|
||||
'lines' => [count($people) . ' 人在这个家里'],
|
||||
];
|
||||
|
||||
// ---- 家谱 ----
|
||||
$graph = Kinship::graph($familyId);
|
||||
$cards['kin'] = [
|
||||
'name' => '家谱',
|
||||
'icon' => self::icon('kin'),
|
||||
'color' => Module::color('kin', $familyId),
|
||||
'href' => url('kin'),
|
||||
'big' => (string)Relations::count($familyId) . ' 条连线',
|
||||
'lines' => ['父母 / 配偶 / 子女填上就能长出来'],
|
||||
];
|
||||
|
||||
// ---- 观影 ----
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT COUNT(*) FROM events WHERE family_id = ? AND kind = 'movie'"
|
||||
);
|
||||
$st->execute([$familyId]);
|
||||
$movieN = (int)$st->fetchColumn();
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT title FROM events WHERE family_id = ? AND kind = 'movie'
|
||||
ORDER BY starts_at DESC, id DESC LIMIT 1"
|
||||
);
|
||||
$st->execute([$familyId]);
|
||||
$lastMovie = (string)($st->fetchColumn() ?: '');
|
||||
$cards['movies'] = [
|
||||
'name' => '观影',
|
||||
'icon' => self::icon('movies'),
|
||||
'color' => Module::color('movies', $familyId),
|
||||
'href' => url('movies'),
|
||||
'big' => $movieN . ' 部',
|
||||
'lines' => $lastMovie !== '' ? ['最近看了《' . $lastMovie . '》'] : ['还没有观影记录'],
|
||||
];
|
||||
|
||||
// ---- 旅程 ----
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT COUNT(*) FROM events e
|
||||
LEFT JOIN trip_details d ON d.event_id = e.id
|
||||
WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1"
|
||||
);
|
||||
$st->execute([$familyId]);
|
||||
$tripN = (int)$st->fetchColumn();
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT e.title FROM events e
|
||||
LEFT JOIN trip_details d ON d.event_id = e.id
|
||||
WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1
|
||||
ORDER BY e.starts_at DESC, e.id DESC LIMIT 1"
|
||||
);
|
||||
$st->execute([$familyId]);
|
||||
$lastTrip = (string)($st->fetchColumn() ?: '');
|
||||
$cards['trips'] = [
|
||||
'name' => '旅程',
|
||||
'icon' => self::icon('trips'),
|
||||
'color' => Module::color('trips', $familyId),
|
||||
'href' => url('trips'),
|
||||
'big' => $tripN . ' 趟',
|
||||
'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有旅程记录'],
|
||||
];
|
||||
|
||||
// ---- 日程 ----
|
||||
$cards['calendar'] = [
|
||||
'name' => '日程',
|
||||
'icon' => self::icon('calendar'),
|
||||
'color' => Module::color('calendar', $familyId),
|
||||
'href' => url('calendar'),
|
||||
'big' => count($soon) . ' 条',
|
||||
'lines' => ['未来七天'],
|
||||
];
|
||||
|
||||
// ---- 资源 ----
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT COUNT(*) FROM resources r
|
||||
LEFT JOIN resource_shares s ON s.resource_id = r.id AND s.person_id = ?
|
||||
WHERE r.family_id = ? AND (r.owner_id = ? OR s.person_id IS NOT NULL)'
|
||||
);
|
||||
$st->execute([$me, $familyId, $me]);
|
||||
$resN = (int)$st->fetchColumn();
|
||||
$cards['resources'] = [
|
||||
'name' => '资源',
|
||||
'icon' => self::icon('resources'),
|
||||
'color' => Module::color('resources', $familyId),
|
||||
'href' => url('resources'),
|
||||
'big' => $resN . ' 项',
|
||||
'lines' => ['证件 / 资料 / 附件'],
|
||||
];
|
||||
|
||||
// ---- 任务 ----
|
||||
$board = Task::board($familyId, $me);
|
||||
$doing = count($board['doing']);
|
||||
$todo = count($board['todo']);
|
||||
$late = 0;
|
||||
foreach ([$board['todo'], $board['doing']] as $col) {
|
||||
foreach ($col as $t) {
|
||||
if (Task::isOverdue($t['due_at'] ?? null)) {
|
||||
$late++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$cards['tasks'] = [
|
||||
'name' => '任务',
|
||||
'icon' => self::icon('tasks'),
|
||||
'color' => Module::color('tasks', $familyId),
|
||||
'href' => url('tasks'),
|
||||
'big' => $doing . ' 件在做',
|
||||
'lines' => [$todo . ' 件待办' . ($late > 0 ? ' · ' . $late . ' 件逾期' : '')],
|
||||
];
|
||||
|
||||
// ---- 纪念日 ----
|
||||
$up = Anniversary::upcoming($familyId, $me, 30);
|
||||
$next = $up[0] ?? null;
|
||||
$cards['days'] = [
|
||||
'name' => '纪念日',
|
||||
'icon' => self::icon('days'),
|
||||
'color' => Module::color('days', $familyId),
|
||||
'href' => url('days'),
|
||||
'big' => $next ? ((int)$next['days'] === 0 ? '今天' : (int)$next['days'] . ' 天后') : '—',
|
||||
'lines' => [$next ? (string)$next['title'] : '三十天内没有纪念日'],
|
||||
];
|
||||
|
||||
// ---- 财务 ----
|
||||
$books = Ledger::mine($familyId, $me);
|
||||
$net = 0;
|
||||
$hasBook = false;
|
||||
foreach ($books as $b) {
|
||||
$hasBook = true;
|
||||
$net += Ledger::summary(Ledger::entries((int)$b['id']))['net'];
|
||||
}
|
||||
$cards['ledger'] = [
|
||||
'name' => '财务',
|
||||
'icon' => self::icon('ledger'),
|
||||
'color' => Module::color('ledger', $familyId),
|
||||
'href' => url('ledger'),
|
||||
'big' => $hasBook ? Ledger::money($net) : '还没记账',
|
||||
'lines' => [$hasBook ? count($books) . ' 本账(净资产)' : '先建一本家庭账'],
|
||||
];
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
/** 模块图标(行内 SVG;与观影设置下拉同一套做法,不引外部字体/图片) */
|
||||
public static function icon(string $key): string
|
||||
{
|
||||
$paths = [
|
||||
'people' => '<circle cx="7" cy="6" r="2.6"/><path d="M2.6 14c0-2.4 2-3.9 4.4-3.9s4.4 1.5 4.4 3.9"/><circle cx="13" cy="7.2" r="2"/><path d="M11.6 14c0-1.9 1.2-3 2.8-3s2.6 1.1 2.6 3"/>',
|
||||
'kin' => '<rect x="6.4" y="1.8" width="5.2" height="4" rx="1"/><rect x="1.8" y="12.2" width="5.2" height="4" rx="1"/><rect x="11" y="12.2" width="5.2" height="4" rx="1"/><path d="M9 5.8v3M4.4 12.2v-1.6h9.2v1.6M4.4 10.6h9.2"/>',
|
||||
'movies' => '<rect x="1.8" y="3.4" width="14.4" height="11.2" rx="2"/><path d="M1.8 7.2h14.4M5.4 3.4v3.8M10.6 3.4v3.8"/>',
|
||||
'trips' => '<path d="M9 1.8c-2.9 0-5.2 2.3-5.2 5.2 0 3.9 5.2 9.2 5.2 9.2s5.2-5.3 5.2-9.2c0-2.9-2.3-5.2-5.2-5.2z"/><circle cx="9" cy="7" r="1.9"/>',
|
||||
'calendar' => '<rect x="2" y="3.6" width="14" height="12.4" rx="2"/><path d="M2 7.6h14M5.6 1.8v3.6M12.4 1.8v3.6"/>',
|
||||
'resources' => '<path d="M2.4 4.4A1.6 1.6 0 0 1 4 2.8h3.2l1.6 2h5.2A1.6 1.6 0 0 1 15.6 6.4v7.2a1.6 1.6 0 0 1-1.6 1.6H4a1.6 1.6 0 0 1-1.6-1.6z"/>',
|
||||
'tasks' => '<rect x="2.4" y="2.4" width="13.2" height="13.2" rx="2"/><path d="M5.6 9.2l2.4 2.4 4.4-4.8"/>',
|
||||
'days' => '<path d="M9 2.2l2 4.2 4.6.6-3.4 3.2.8 4.6L9 12.6l-4 2.2.8-4.6L2.4 7l4.6-.6z"/>',
|
||||
'ledger' => '<rect x="2.4" y="3" width="13.2" height="12" rx="2"/><path d="M5.6 6.6h6.8M5.6 9.4h6.8M5.6 12.2h4"/>',
|
||||
];
|
||||
$d = $paths[$key] ?? '';
|
||||
if ($d === '') {
|
||||
return '';
|
||||
}
|
||||
return '<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true" fill="none"'
|
||||
. ' stroke="currentColor" stroke-width="1.5" stroke-linecap="round"'
|
||||
. ' stroke-linejoin="round">' . $d . '</svg>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* 首页上显示哪些模块、什么顺序(用户 2026-10-03 第 3 条)。
|
||||
*
|
||||
* 原话:「在个人设置那里,要增加一个设置,用户可以选择,在首页上显示哪些模块,
|
||||
* 并且调整它们显示的顺序。」
|
||||
*
|
||||
* 与导航条那套(Module::navFor + nav_known)是**同一套思路**,所以这里刻意
|
||||
* 复用同样的三条纪律(踩过的坑不再踩第二遍):
|
||||
*
|
||||
* ① 存 user_settings(**每人一份**)—— 首页放什么是很个人的事;
|
||||
* ② 只保留"还存在的模块"(删模块/改 key 之后,老配置里那条认不出来,
|
||||
* 直接渲染就是一张空卡);
|
||||
* ③ 另存一份 `home_known` =「用户做这次决定时,世上已知的全部模块」——
|
||||
* 只有 `现在的模块 - known` 才是**真的新装**、才该补进来。
|
||||
* ⚠️ 不能写成"不在已选里就补":那样用户**主动关掉**的模块下次请求就被补回来,
|
||||
* 「可配置显示哪些」直接失效(导航条那边就是这么踩过的)。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class HomeMods
|
||||
{
|
||||
/**
|
||||
* 首页**默认**显示哪些模块、什么顺序。
|
||||
*
|
||||
* 比默认导航条短一些:首页是"概览",把九个模块都铺上去就成了一面墙。
|
||||
* 选的是"每天想看的那几个"。
|
||||
*/
|
||||
public const DEFAULT_HOME = ['tasks', 'calendar', 'days', 'movies', 'trips', 'ledger'];
|
||||
|
||||
/** 这个人首页上要显示哪些模块(按顺序) */
|
||||
public static function forPerson(int $personId): array
|
||||
{
|
||||
$raw = (string)UserSetting::get($personId, 'home_modules', '');
|
||||
$dec = $raw !== '' ? json_decode($raw, true) : null;
|
||||
if (!is_array($dec)) {
|
||||
return self::DEFAULT_HOME; // 没配过(新装的人 / 重置过的)
|
||||
}
|
||||
|
||||
// ① 只留还存在的模块(去重、保序)
|
||||
$saved = [];
|
||||
foreach ($dec as $k) {
|
||||
$k = (string)$k;
|
||||
if (Module::exists($k) && !in_array($k, $saved, true)) {
|
||||
$saved[] = $k;
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 新装的模块补到末尾(见类注释里的 ③)
|
||||
$known = [];
|
||||
$kraw = (string)UserSetting::get($personId, 'home_known', '');
|
||||
$kdec = $kraw !== '' ? json_decode($kraw, true) : null;
|
||||
if (is_array($kdec)) {
|
||||
foreach ($kdec as $k) {
|
||||
$k = (string)$k;
|
||||
if (Module::exists($k)) {
|
||||
$known[$k] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (Module::keys() as $k) {
|
||||
if (!isset($known[$k]) && !in_array($k, $saved, true)) {
|
||||
$saved[] = $k; // 真的新装 → 补进来
|
||||
}
|
||||
}
|
||||
return $saved;
|
||||
}
|
||||
|
||||
/** 保存首页模块(顺序即数组顺序;不在数组里的就是关掉的) */
|
||||
public static function save(int $personId, array $keys): void
|
||||
{
|
||||
$out = [];
|
||||
foreach ($keys as $k) {
|
||||
$k = (string)$k;
|
||||
if (Module::exists($k) && !in_array($k, $out, true)) {
|
||||
$out[] = $k;
|
||||
}
|
||||
}
|
||||
UserSetting::set($personId, 'home_modules', json_encode($out, JSON_UNESCAPED_UNICODE));
|
||||
// 【关键】同时记下"此刻已知的全部模块",这样才能区分
|
||||
// 「用户关掉的」与「后来新装的」(见 ③)
|
||||
UserSetting::set($personId, 'home_known',
|
||||
json_encode(Module::keys(), JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置页要用的展示顺序:已选的按现有顺序在前,未选的按模块表顺序在后。
|
||||
* 这样"关掉再打开"位置不会跳。
|
||||
*/
|
||||
public static function orderedForPicker(int $personId): array
|
||||
{
|
||||
$on = self::forPerson($personId);
|
||||
$out = $on;
|
||||
foreach (Module::keys() as $k) {
|
||||
if (!in_array($k, $out, true)) {
|
||||
$out[] = $k;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
+58
-11
@@ -35,11 +35,24 @@ final class Person
|
||||
$args[] = $familyId;
|
||||
$args[] = $kind;
|
||||
if ($q !== '') {
|
||||
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
|
||||
$like = '%' . $q . '%';
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
// ⚠️ 纯字母/数字的查询词**不能**交给 SQL 的 LIKE 过滤:
|
||||
// 它是"姓名首字母"(如 yyq),SQL 里根本没法把汉字转拼音,
|
||||
// 一 LIKE 就把结果筛成空 —— 后面的 PHP 精筛也就没东西可筛了,
|
||||
// 表现为"搜 yyq 一个人都没有"(功能像没做)。
|
||||
// 所以这种查询词直接跳过 SQL 条件,全量取回来之后在 PHP 里筛
|
||||
// (名册是个人小列表,几十条,全取完全没问题)。
|
||||
//
|
||||
// ⚠️ **也不能**用 SQL 过滤纯数字(手机号):手机号是**加密存的**,
|
||||
// 库里只有密文,SQL 的 LIKE 永远匹配不到明文数字。
|
||||
// 数字查询同样交给 PHP 精筛(那里会解密后再比)。
|
||||
$needSql = (bool)preg_match('/[^A-Za-z0-9]/u', $q);
|
||||
if ($needSql) {
|
||||
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
|
||||
$like = '%' . $q . '%';
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
}
|
||||
}
|
||||
$sql .= ' ORDER BY p.id ASC';
|
||||
$st = Database::get()->prepare($sql);
|
||||
@@ -59,6 +72,9 @@ final class Person
|
||||
*/
|
||||
public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array
|
||||
{
|
||||
// 用户 2026-10-03 第 4 条:名册改成列表,每行要显示「手机号 / 生日 / 与我的关系」
|
||||
// → 手机号是加密存的,得在这里一并取出密文(下面解密,避免逐行再查一次)。
|
||||
// → 关系称呼走 Kinship(每人算一次),不在 SQL 里。
|
||||
$sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source,
|
||||
a.id AS account_id, a.role AS account_role, a.status AS account_status,
|
||||
a.last_login_at, n.nickname AS my_nickname
|
||||
@@ -72,16 +88,47 @@ final class Person
|
||||
$args[] = $viewerId;
|
||||
$args[] = $kind;
|
||||
if ($q !== '') {
|
||||
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
|
||||
$like = '%' . $q . '%';
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
// ⚠️ SQL 只在**能帮忙**时才粗筛,两个反例:
|
||||
// · 纯字母(yyq)—— 那是"姓名首字母",SQL 里没法把汉字转拼音,
|
||||
// 一旦 LIKE 就把结果筛成空,后面的 PHP 精筛也就没东西可筛了;
|
||||
// · 纯数字(手机号)—— 手机号是**加密存的**,库里只有密文,
|
||||
// LIKE 永远匹配不到明文数字。
|
||||
// 这两类都交给下面的 PHP 精筛(它会解密、会算首字母)。
|
||||
// 只含中文/邮箱这类"库里有明文可比"的查询词才加 SQL 条件。
|
||||
$needSql = (bool)preg_match('/[^A-Za-z0-9]/u', $q);
|
||||
if ($needSql) {
|
||||
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
|
||||
$like = '%' . $q . '%';
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
}
|
||||
}
|
||||
$sql .= ' ORDER BY r.sort_order ASC, p.id ASC';
|
||||
$st = Database::get()->prepare($sql);
|
||||
$st->execute($args);
|
||||
return $st->fetchAll();
|
||||
$rows = $st->fetchAll();
|
||||
|
||||
// 用户 2026-10-03 第 4 条:再加**姓名首字母**检索(y / yy / yyq 都命中「杨永全」)。
|
||||
// ⚠️ 不在 SQL 里做:SQL 侧没法把汉字转拼音(线上也没有现成的函数),
|
||||
// 硬塞进 SQL 只能靠一堆 LIKE OR,既慢又不可能穷尽。
|
||||
// 名册是**一个人自己的**小列表(几十条),在 PHP 里过滤完全够快。
|
||||
// 做法:SQL 只在能帮忙时粗筛(中文/邮箱这类),然后**一律**走这里精筛一次。
|
||||
// 精筛覆盖:姓名 / 称呼 / 邮箱 / **解密后的手机号** / 姓名首字母。
|
||||
if ($q !== '') {
|
||||
$rows = array_values(array_filter($rows, static function (array $r) use ($q): bool {
|
||||
// 手机号是加密的,Pinyin::matches 认 phone_plain;这里把它带上再判一次
|
||||
if (!empty($r['phone_encrypted'])) {
|
||||
try {
|
||||
$r['phone_plain'] = Crypto::decrypt((string)$r['phone_encrypted']);
|
||||
} catch (\Throwable $e) {
|
||||
// 解不开就当没填(不影响其它字段的匹配)
|
||||
}
|
||||
}
|
||||
return Pinyin::matches($r, $q);
|
||||
}));
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
/**
|
||||
* 汉字 → 拼音首字母(用户 2026-10-03 第 4 条)。
|
||||
*
|
||||
* 用途:名册搜索支持「姓名首字母」——
|
||||
* 输入 y / yy / yyq 都能搜到「杨永全」。
|
||||
*
|
||||
* 两条路(**必须都有**,因为本地与线上环境不同):
|
||||
*
|
||||
* ① `Intl` 扩展的 `Collator('zh-Hans-CN-u-co-pinyin')`:拿一张
|
||||
* 「a..z 各挑一个锚字」的表做二分,就能判断任意汉字落在哪个字母段。
|
||||
* 线上 PHP 7.4/8.1 都有 intl,走这条最准、零数据。
|
||||
* ② 没有 intl 时的兜底:**常用姓氏/人名用字表**。
|
||||
* 本地开发环境没有 intl(实测 `intl: 无`),没有兜底就等于
|
||||
* 「这个功能本地永远测不了」—— 那是最糟的情况,只能上线才发现问题。
|
||||
*
|
||||
* ⚠️ 兜底表覆盖不到的生僻字会**原样保留首字符参与匹配**(不产字母),
|
||||
* 也就是说:搜不到时是"少匹配",绝不会错匹配。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Pinyin
|
||||
{
|
||||
/** 拼音排序用的锚字(a..z 各一个;没有 i/u/v 开头的常用字,所以是 23 个) */
|
||||
private const ANCHORS = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
|
||||
private const LETTERS = 'abcdefghjklmnopqrstwxyz';
|
||||
|
||||
/**
|
||||
* 常用姓氏 + 常见人名字的**首字母表**(兜底用)。
|
||||
* 只列"作为名字第一个字"出现频率高的;查不到就交给下一档处理。
|
||||
* 这一份不可能穷尽,所以它只是兜底 —— 线上有 intl 时走的是锚字二分。
|
||||
*/
|
||||
private const TABLE = [
|
||||
// 姓氏(覆盖绝大多数中国姓氏)
|
||||
'赵a' => 1, '钱q' => 1, '孙s' => 1, '李l' => 1, '周z' => 1, '吴w' => 1, '郑z' => 1,
|
||||
'王w' => 1, '冯f' => 1, '陈c' => 1, '褚c' => 1, '卫w' => 1, '蒋j' => 1, '沈s' => 1,
|
||||
'韩h' => 1, '杨y' => 1, '朱z' => 1, '秦q' => 1, '尤y' => 1, '许x' => 1, '何h' => 1,
|
||||
'吕l' => 1, '施s' => 1, '张z' => 1, '孔k' => 1, '曹c' => 1, '严y' => 1, '华h' => 1,
|
||||
'金j' => 1, '魏w' => 1, '陶t' => 1, '姜j' => 1, '戚q' => 1, '谢x' => 1, '邹z' => 1,
|
||||
'喻y' => 1, '柏b' => 1, '水s' => 1, '窦d' => 1, '章z' => 1, '云y' => 1, '苏s' => 1,
|
||||
'潘p' => 1, '葛g' => 1, '奚x' => 1, '范f' => 1, '彭p' => 1, '郎l' => 1, '鲁l' => 1,
|
||||
'韦w' => 1, '昌c' => 1, '马m' => 1, '苗m' => 1, '凤f' => 1, '花h' => 1, '方f' => 1,
|
||||
'俞y' => 1, '任r' => 1, '袁y' => 1, '柳l' => 1, '酆f' => 1, '鲍b' => 1, '史s' => 1,
|
||||
'唐t' => 1, '费f' => 1, '廉l' => 1, '岑c' => 1, '薛x' => 1, '雷l' => 1, '贺h' => 1,
|
||||
'倪n' => 1, '汤t' => 1, '滕t' => 1, '殷y' => 1, '罗l' => 1, '毕b' => 1, '郝h' => 1,
|
||||
'邬w' => 1, '安a' => 1, '常c' => 1, '乐y' => 1, '于y' => 1, '时s' => 1, '傅f' => 1,
|
||||
'皮p' => 1, '卞b' => 1, '齐q' => 1, '康k' => 1, '伍w' => 1, '余y' => 1, '元y' => 1,
|
||||
'卜b' => 1, '顾g' => 1, '孟m' => 1, '平p' => 1, '黄h' => 1, '和h' => 1, '穆m' => 1,
|
||||
'萧x' => 1, '尹y' => 1, '姚y' => 1, '邵s' => 1, '湛z' => 1, '汪w' => 1, '祁q' => 1,
|
||||
'毛m' => 1, '禹y' => 1, '狄d' => 1, '米m' => 1, '贝b' => 1, '明m' => 1, '臧z' => 1,
|
||||
'计j' => 1, '伏f' => 1, '成c' => 1, '戴d' => 1, '谈t' => 1, '宋s' => 1, '茅m' => 1,
|
||||
'庞p' => 1, '熊x' => 1, '纪j' => 1, '舒s' => 1, '屈q' => 1, '项x' => 1, '祝z' => 1,
|
||||
'董d' => 1, '梁l' => 1, '杜d' => 1, '阮r' => 1, '蓝l' => 1, '闵m' => 1, '席x' => 1,
|
||||
'季j' => 1, '麻m' => 1, '强q' => 1, '贾j' => 1, '路l' => 1, '娄l' => 1, '危w' => 1,
|
||||
'江j' => 1, '童t' => 1, '颜y' => 1, '郭g' => 1, '梅m' => 1, '盛s' => 1, '林l' => 1,
|
||||
'刁d' => 1, '钟z' => 1, '徐x' => 1, '邱q' => 1, '骆l' => 1, '高g' => 1, '夏x' => 1,
|
||||
'蔡c' => 1, '田t' => 1, '樊f' => 1, '胡h' => 1, '凌l' => 1, '霍h' => 1, '虞y' => 1,
|
||||
'万w' => 1, '支z' => 1, '柯k' => 1, '昝z' => 1, '管g' => 1, '卢l' => 1, '莫m' => 1,
|
||||
'房f' => 1, '裘q' => 1, '缪m' => 1, '干g' => 1, '解x' => 1, '应y' => 1, '宗z' => 1,
|
||||
'丁d' => 1, '宣x' => 1, '邓d' => 1, '单s' => 1, '杭h' => 1, '洪h' => 1, '包b' => 1,
|
||||
'诸z' => 1, '左z' => 1, '石s' => 1, '崔c' => 1, '吉j' => 1, '钮n' => 1, '龚g' => 1,
|
||||
'程c' => 1, '嵇j' => 1, '邢x' => 1, '滑h' => 1, '裴p' => 1, '陆l' => 1, '荣r' => 1,
|
||||
'翁w' => 1, '荀x' => 1, '羊y' => 1, '於y' => 1, '惠h' => 1, '甄z' => 1, '曲q' => 1,
|
||||
'封f' => 1, '芮r' => 1, '羿y' => 1, '储c' => 1, '靳j' => 1, '汲j' => 1, '邴b' => 1,
|
||||
'糜m' => 1, '松s' => 1, '井j' => 1, '段d' => 1, '富f' => 1, '巫w' => 1, '乌w' => 1,
|
||||
'焦j' => 1, '巴b' => 1, '弓g' => 1, '牧m' => 1, '隗k' => 1, '山s' => 1, '谷g' => 1,
|
||||
'车c' => 1, '侯h' => 1, '宓m' => 1, '蓬p' => 1, '全q' => 1, '郗x' => 1, '班b' => 1,
|
||||
'仰y' => 1, '秋q' => 1, '仲z' => 1, '伊y' => 1, '宫g' => 1, '宁n' => 1, '仇q' => 1,
|
||||
'栾l' => 1, '暴b' => 1, '甘g' => 1, '厉l' => 1, '戎r' => 1, '祖z' => 1, '武w' => 1,
|
||||
'符f' => 1, '刘l' => 1, '景j' => 1, '詹z' => 1, '束s' => 1, '龙l' => 1, '叶y' => 1,
|
||||
'幸x' => 1, '司s' => 1, '韶s' => 1, '郜g' => 1, '黎l' => 1, '蓟j' => 1, '薄b' => 1,
|
||||
'印y' => 1, '宿s' => 1, '白b' => 1, '怀h' => 1, '蒲p' => 1, '邰t' => 1, '从c' => 1,
|
||||
'鄂e' => 1, '索s' => 1, '咸x' => 1, '籍j' => 1, '赖l' => 1, '卓z' => 1, '蔺l' => 1,
|
||||
'屠t' => 1, '蒙m' => 1, '池c' => 1, '乔q' => 1, '阴y' => 1, '郁y' => 1, '胥x' => 1,
|
||||
'能n' => 1, '苍c' => 1, '双s' => 1, '闻w' => 1, '莘s' => 1, '党d' => 1, '翟z' => 1,
|
||||
'谭t' => 1, '贡g' => 1, '劳l' => 1, '逄p' => 1, '姬j' => 1, '申s' => 1, '扶f' => 1,
|
||||
'堵d' => 1, '冉r' => 1, '宰z' => 1, '郦l' => 1, '雍y' => 1, '郤x' => 1, '璩q' => 1,
|
||||
'桑s' => 1, '桂g' => 1, '濮p' => 1, '牛n' => 1, '寿s' => 1, '通t' => 1, '边b' => 1,
|
||||
'扈h' => 1, '燕y' => 1, '冀j' => 1, '郏j' => 1, '浦p' => 1, '尚s' => 1, '农n' => 1,
|
||||
'温w' => 1, '别b' => 1, '庄z' => 1, '晏y' => 1, '柴c' => 1, '瞿q' => 1, '阎y' => 1,
|
||||
'充c' => 1, '慕m' => 1, '连l' => 1, '茹r' => 1, '习x' => 1, '宦h' => 1, '艾a' => 1,
|
||||
'鱼y' => 1, '容r' => 1, '向x' => 1, '古g' => 1, '易y' => 1, '慎s' => 1, '戈g' => 1,
|
||||
'廖l' => 1, '庾y' => 1, '终z' => 1, '暨j' => 1, '居j' => 1, '衡h' => 1, '步b' => 1,
|
||||
'都d' => 1, '耿g' => 1, '满m' => 1, '弘h' => 1, '匡k' => 1, '国g' => 1, '文w' => 1,
|
||||
'寇k' => 1, '广g' => 1, '禄l' => 1, '阙q' => 1, '东d' => 1, '欧o' => 1, '殳s' => 1,
|
||||
'沃w' => 1, '利l' => 1, '蔚w' => 1, '越y' => 1, '夔k' => 1, '隆l' => 1, '师s' => 1,
|
||||
'巩g' => 1, '厍s' => 1, '聂n' => 1, '晁c' => 1, '勾g' => 1, '敖a' => 1, '融r' => 1,
|
||||
'冷l' => 1, '訾z' => 1, '辛x' => 1, '阚k' => 1, '那n' => 1, '简j' => 1, '饶r' => 1,
|
||||
'空k' => 1, '曾z' => 1, '毋w' => 1, '沙s' => 1, '乜n' => 1, '养y' => 1, '鞠j' => 1,
|
||||
'须x' => 1, '丰f' => 1, '巢c' => 1, '关g' => 1, '蒯k' => 1, '相x' => 1, '查z' => 1,
|
||||
'后h' => 1, '荆j' => 1, '红h' => 1, '游y' => 1, '竺z' => 1, '权q' => 1, '逯l' => 1,
|
||||
'盖g' => 1, '益y' => 1, '桓h' => 1, '公g' => 1,
|
||||
// 名字里高频的字
|
||||
'永y' => 1, '全q' => 1, '博b' => 1, '为w' => 1, '明m' => 1, '华h' => 1, '建j' => 1,
|
||||
'国g' => 1, '文w' => 1, '军j' => 1, '平p' => 1, '志z' => 1, '强q' => 1, '磊l' => 1,
|
||||
'洋y' => 1, '勇y' => 1, '艳y' => 1, '杰j' => 1, '娟j' => 1, '涛t' => 1, '超c' => 1,
|
||||
'敏m' => 1, '静j' => 1, '丽l' => 1, '秀x' => 1, '英y' => 1, '娜n' => 1, '丹d' => 1,
|
||||
'玲l' => 1, '芳f' => 1, '燕y' => 1, '红h' => 1, '霞x' => 1, '梅m' => 1, '兰l' => 1,
|
||||
'琴q' => 1, '云y' => 1, '飞f' => 1, '鹏p' => 1, '宇y' => 1, '浩h' => 1, '鑫x' => 1,
|
||||
'亮l' => 1, '刚g' => 1, '辉h' => 1, '峰f' => 1, '龙l' => 1, '波b' => 1, '斌b' => 1,
|
||||
'春c' => 1, '雨y' => 1, '晨c' => 1, '阳y' => 1, '雪x' => 1, '悦y' => 1, '欣x' => 1,
|
||||
'怡y' => 1, '佳j' => 1, '思s' => 1, '雨y' => 1, '子z' => 1, '小x' => 1, '大d' => 1,
|
||||
'老l' => 1, '爸b' => 1, '妈m' => 1, '哥g' => 1, '姐j' => 1, '弟d' => 1, '妹m' => 1,
|
||||
'爷y' => 1, '奶n' => 1, '叔s' => 1, '姨y' => 1, '舅j' => 1, '嫂s' => 1, '儿e' => 1,
|
||||
'女n' => 1, '宝b' => 1, '乐l' => 1, '安a' => 1, '康k' => 1, '福f' => 1, '寿s' => 1,
|
||||
'喜x' => 1, '顺s' => 1, '和h' => 1, '美m' => 1, '好h' => 1, '新x' => 1, '中z' => 1,
|
||||
'正z' => 1, '长c' => 1, '海h' => 1, '江j' => 1, '河h' => 1, '山s' => 1, '林l' => 1,
|
||||
'木m' => 1, '金j' => 1, '玉y' => 1, '石s' => 1, '田t' => 1, '力l' => 1, '学x' => 1,
|
||||
'生s' => 1, '成c' => 1, '立l' => 1, '德d' => 1, '仁r' => 1, '义y' => 1, '礼l' => 1,
|
||||
'智z' => 1, '信x' => 1, '忠z' => 1, '孝x' => 1, '友y' => 1, '爱a' => 1, '慈c' => 1,
|
||||
// 家谱称呼里高频的那批字(父/母/伯/叔/姑/舅/姨/岳…)——
|
||||
// 名册搜索经常会拿这些字当关键词,兜底表里缺了就会出现
|
||||
// "本地搜 s 搜不到『张三』"这种与环境相关的怪现象。
|
||||
'父f' => 1, '母m' => 1, '祖z' => 1, '孙s' => 1, '外w' => 1, '表b' => 1, '堂t' => 1,
|
||||
'侄z' => 1, '甥s' => 1, '婿x' => 1, '媳x' => 1, '伯b' => 1, '姑g' => 1, '岳y' => 1,
|
||||
'公g' => 1, '婆p' => 1, '妻q' => 1, '夫f' => 1, '丈z' => 1, '娘n' => 1, '爹d' => 1,
|
||||
'三s' => 1, '四s' => 1, '五w' => 1, '六l' => 1, '七q' => 1, '八b' => 1, '九j' => 1,
|
||||
'十s' => 1, '百b' => 1, '千q' => 1, '万w' => 1, '一y' => 1, '二e' => 1, '零l' => 1,
|
||||
'不b' => 1, '的d' => 1, '了l' => 1, '在z' => 1, '有y' => 1, '是s' => 1, '我w' => 1,
|
||||
'人r' => 1, '家j' => 1, '门m' => 1, '户h' => 1, '先x' => 1, '后h' => 1, '晚w' => 1,
|
||||
'长c' => 1, '幼y' => 1, '大d' => 1, '小x' => 1, '多d' => 1, '少s' => 1, '高g' => 1,
|
||||
'矮a' => 1, '胖p' => 1, '瘦s' => 1, '黑h' => 1, '白b' => 1, '花h' => 1, '草c' => 1,
|
||||
'树s' => 1, '叶y' => 1, '果g' => 1, '苗m' => 1, '阳y' => 1, '光g' => 1, '月y' => 1,
|
||||
'星x' => 1, '风f' => 1, '雪x' => 1, '雨y' => 1, '雷l' => 1, '电d' => 1, '冰b' => 1,
|
||||
'州z' => 1, '城c' => 1, '村c' => 1, '镇z' => 1, '县x' => 1, '市s' => 1, '省s' => 1,
|
||||
'华h' => 1, '青q' => 1, '岛d' => 1, '京j' => 1, '津j' => 1, '冀j' => 1, '鲁l' => 1,
|
||||
'思s' => 1, '念n' => 1, '想x' => 1, '记j' => 1, '忘w' => 1, '知z' => 1, '道d' => 1,
|
||||
'说s' => 1, '话h' => 1, '听t' => 1, '看k' => 1, '走z' => 1, '来l' => 1, '去q' => 1,
|
||||
'回h' => 1, '到d' => 1, '给g' => 1, '拿n' => 1, '做z' => 1, '用y' => 1, '买m' => 1,
|
||||
'卖m' => 1, '吃c' => 1, '喝h' => 1, '睡s' => 1, '起q' => 1, '坐z' => 1, '站z' => 1,
|
||||
'跑p' => 1, '跳t' => 1, '唱c' => 1, '写x' => 1, '读d' => 1, '画h' => 1, '算s' => 1,
|
||||
'作z' => 1, '课k' => 1, '班b' => 1, '校x' => 1, '师s' => 1, '医y' => 1, '院y' => 1,
|
||||
'药y' => 1, '病b' => 1, '健j' => 1, '体t' => 1, '身s' => 1, '心x' => 1, '手s' => 1,
|
||||
'脚j' => 1, '眼y' => 1, '耳e' => 1, '鼻b' => 1, '口k' => 1, '头t' => 1, '发f' => 1,
|
||||
];
|
||||
|
||||
/** 首字母查表:'杨' => 'y'。首次调用时从 TABLE 摊平,之后走内存缓存。 */
|
||||
private static ?array $map = null;
|
||||
|
||||
public static function initialMap(): array
|
||||
{
|
||||
if (self::$map === null) {
|
||||
self::$map = [];
|
||||
foreach (self::TABLE as $kv => $_) {
|
||||
$k = (string)$kv;
|
||||
if (mb_strlen($k, 'UTF-8') >= 1) {
|
||||
$ch = mb_substr($k, 0, 1, 'UTF-8');
|
||||
$ch = mb_substr($k, 0, mb_strlen($k, 'UTF-8') - 1, 'UTF-8');
|
||||
$letter = mb_substr((string)$kv, mb_strlen((string)$kv, 'UTF-8') - 1, 1, 'UTF-8');
|
||||
if ($ch !== '' && $letter !== '') {
|
||||
self::$map[$ch] = strtolower($letter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return self::$map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个字的拼音首字母(小写)。认不出时返回 ''。
|
||||
* 有 intl 走锚字二分;没有就走内置表。
|
||||
*/
|
||||
public static function initial(string $ch): string
|
||||
{
|
||||
if ($ch === '') {
|
||||
return '';
|
||||
}
|
||||
// 已经是 ASCII 字母 → 直接用
|
||||
if (preg_match('/^[a-zA-Z]$/', $ch)) {
|
||||
return strtolower($ch);
|
||||
}
|
||||
if (preg_match('/^[0-9]$/', $ch)) {
|
||||
return $ch;
|
||||
}
|
||||
// ① intl 拼音排序(线上有)
|
||||
$viaIntl = self::initialViaIntl($ch);
|
||||
if ($viaIntl !== null) {
|
||||
return $viaIntl;
|
||||
}
|
||||
// ② 内置表兜底(本地无 intl 时)
|
||||
return self::initialMap()[$ch] ?? '';
|
||||
}
|
||||
|
||||
/** 用 Intl 的拼音 collation + 锚字二分定位;没有 intl 返回 null */
|
||||
private static function initialViaIntl(string $ch): ?string
|
||||
{
|
||||
static $coll = false;
|
||||
if ($coll === false) {
|
||||
$coll = null;
|
||||
if (class_exists('\\Collator')) {
|
||||
$c = new \Collator('zh-Hans-CN-u-co-pinyin');
|
||||
// 排序没真的生效时(compare('阿','匝') 不 < 0)就别用,免得给出错结果
|
||||
if ($c->compare('阿', '匝') < 0) {
|
||||
$coll = $c;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($coll === null) {
|
||||
return null;
|
||||
}
|
||||
$anchors = self::ANCHORS;
|
||||
$len = mb_strlen($anchors, 'UTF-8');
|
||||
$lo = 0;
|
||||
$hi = $len - 1;
|
||||
$best = -1;
|
||||
while ($lo <= $hi) {
|
||||
$mid = intdiv($lo + $hi, 2);
|
||||
$a = mb_substr($anchors, $mid, 1, 'UTF-8');
|
||||
if ($coll->compare($ch, $a) >= 0) {
|
||||
$best = $mid;
|
||||
$lo = $mid + 1;
|
||||
} else {
|
||||
$hi = $mid - 1;
|
||||
}
|
||||
}
|
||||
if ($best < 0) {
|
||||
return null;
|
||||
}
|
||||
return mb_substr(self::LETTERS, $best, 1, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一串字 → 首字母串(小写)。认不出的字**跳过**(不产字母,也不报错)。
|
||||
* 例:'杨永全' → 'yyq'。
|
||||
*/
|
||||
public static function initials(string $s): string
|
||||
{
|
||||
$out = '';
|
||||
$n = mb_strlen($s, 'UTF-8');
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$out .= self::initial(mb_substr($s, $i, 1, 'UTF-8'));
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* q 是否是 s 的**子序列**(用于"输入 y 也能搜到 yyq"这类前缀式检索)。
|
||||
*
|
||||
* 为什么用子序列而不是前缀:
|
||||
* 用户可能只记得中间某个字(比如"永全" → yq),或干脆跳着打;
|
||||
* 名册这种小列表宁可宽松一点,也别让人以为搜索坏了。
|
||||
*/
|
||||
public static function isSubsequence(string $q, string $s): bool
|
||||
{
|
||||
$q = strtolower(trim($q));
|
||||
$s = strtolower($s);
|
||||
if ($q === '') {
|
||||
return true;
|
||||
}
|
||||
$i = 0;
|
||||
$qn = strlen($q);
|
||||
for ($j = 0, $sn = strlen($s); $j < $sn && $i < $qn; $j++) {
|
||||
if ($s[$j] === $q[$i]) {
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
return $i === $qn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个人是否匹配搜索词。
|
||||
*
|
||||
* 覆盖:姓名 / 称呼 / 邮箱 / 手机号 的**模糊**包含,以及姓名首字母的**子序列**匹配。
|
||||
*
|
||||
* @param array $row 名册行(含 name / email / phone_plain,可选 my_nickname)
|
||||
* @param string $q 搜索词
|
||||
*/
|
||||
public static function matches(array $row, string $q): bool
|
||||
{
|
||||
$q = trim($q);
|
||||
if ($q === '') {
|
||||
return true;
|
||||
}
|
||||
$needle = mb_strtolower($q, 'UTF-8');
|
||||
foreach (['name', 'my_nickname', 'nickname', 'email', 'phone_plain'] as $k) {
|
||||
$v = mb_strtolower(trim((string)($row[$k] ?? '')), 'UTF-8');
|
||||
if ($v !== '' && mb_strpos($v, $needle) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 首字母:只对**字母/数字**查询词做子序列匹配,
|
||||
// 中文查询词走上面的"包含"(否则"永全"会被当成首字母串去比对,永远匹配不上)
|
||||
if (preg_match('/^[a-z0-9]+$/', $needle)) {
|
||||
$ini = self::initials((string)($row['name'] ?? ''));
|
||||
if ($ini !== '' && self::isSubsequence($needle, $ini)) {
|
||||
return true;
|
||||
}
|
||||
// 称呼也参与首字母(比如称呼是"小宝" → xb)
|
||||
$nickIni = self::initials((string)($row['my_nickname'] ?? ''));
|
||||
if ($nickIni !== '' && self::isSubsequence($needle, $nickIni)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -383,6 +383,7 @@ CREATE TABLE IF NOT EXISTS `trip_details` (
|
||||
`event_id` INT UNSIGNED NOT NULL COMMENT '对应 events(kind="trip")',
|
||||
`cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面图(存储相对键)',
|
||||
`summary` TEXT DEFAULT NULL COMMENT '这趟旅程的一段话小结',
|
||||
`is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(0=不显示在列表与日历)',
|
||||
PRIMARY KEY (`event_id`),
|
||||
CONSTRAINT `fk_tdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旅程事件的特有数据(一对一卫星表)'
|
||||
@@ -866,6 +867,35 @@ SQL,
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 重复日程的「单个发生」例外表(版本 13,用户 2026-10-03 第 5 条)----
|
||||
// 用户要的三档编辑里,「仅此一次」与「跳过这次」都要能"只动某一次"。
|
||||
// 做法(与 RFC 5545 的 EXDATE 同构):把被取消/被挪走的那一次记在这里,
|
||||
// 展开时跳过它;如果那次是被"挪到别的时间",就再插一条独立日程。
|
||||
// ⚠️ 不复用 calendar_events:那是"日程本体",这里是"本体的某一次例外",
|
||||
// 混在一张表里会让"这条日程重复吗"变成要额外判断的字段。
|
||||
$db->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `calendar_event_skips` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程(calendar_events.id)',
|
||||
`skip_date` DATE NOT NULL COMMENT '被跳过的哪一天(重复展开时命中这一天就不出)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_skip` (`event_id`, `skip_date`),
|
||||
CONSTRAINT `fk_skip_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='重复日程里被单独取消/挪走的某一次(RFC 5545 的 EXDATE)'
|
||||
SQL);
|
||||
|
||||
// ---- 旅程的活动「是否公开」(版本 13,用户 2026-10-03 第 4 条)----
|
||||
// 用户原话:「旅程的活动中,只显示 is_public=1 的活动」。
|
||||
// ⚠️ 放在 **trip_details**(1:1 的卫星表)而不是 events:
|
||||
// events 是观影/旅程/… 共用的骨架表,加一个只有旅程用的开关
|
||||
// 会让观影那边多出一个永远为 0 的无意义字段。
|
||||
// ⚠️ 默认 **1**(公开):老数据升级后必须照旧可见 ——
|
||||
// 默认 0 会让人以为"旅程全丢了"(这是最危险的一种迁移默认值)。
|
||||
self::addColumn($db, 'trip_details', 'is_public',
|
||||
"ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`");
|
||||
|
||||
// ---- 纪念日(版本 13,用户第 13 条)----
|
||||
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
|
||||
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
|
||||
|
||||
+19
-5
@@ -44,13 +44,18 @@ final class Trip
|
||||
/** 全部旅程(按出发时间倒序) */
|
||||
public static function all(int $familyId, string $keyword = ''): array
|
||||
{
|
||||
// ⚠️ 用户 2026-10-03 第 4 条:「旅程的活动中,只显示 is_public=1 的活动」。
|
||||
// 用 LEFT JOIN + COALESCE 而不是 INNER JOIN:
|
||||
// 老数据/异常情况下 trip_details 可能缺行,INNER JOIN 会让整趟旅程
|
||||
// 从列表里消失("行程丢了"比"多显示一条"严重得多),
|
||||
// COALESCE(...,1) 表示"没有这行就算公开"。
|
||||
$sql = 'SELECT e.id, e.title, e.starts_at, e.ends_at, e.location, e.note,
|
||||
d.cover_key, d.summary,
|
||||
d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public,
|
||||
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count,
|
||||
(SELECT COUNT(*) FROM trip_nodes n WHERE n.event_id = e.id) AS node_count
|
||||
FROM events e
|
||||
LEFT JOIN trip_details d ON d.event_id = e.id
|
||||
WHERE e.family_id = ? AND e.kind = ?';
|
||||
WHERE e.family_id = ? AND e.kind = ? AND COALESCE(d.is_public, 1) = 1';
|
||||
$bind = [$familyId, 'trip'];
|
||||
$kw = trim($keyword);
|
||||
if ($kw !== '') {
|
||||
@@ -107,7 +112,9 @@ final class Trip
|
||||
public static function find(int $familyId, int $id, int $viewerId = 0): ?array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT e.*, d.cover_key, d.summary
|
||||
// ⚠️ 单条详情**不按 is_public 拦**:勾了"不公开"只代表不进列表/日历,
|
||||
// 直接拿到链接的人(或行程成员)仍应该能打开它 —— 否则非遗漏一次就找不回来。
|
||||
"SELECT e.*, d.cover_key, d.summary, COALESCE(d.is_public, 1) AS is_public
|
||||
FROM events e LEFT JOIN trip_details d ON d.event_id = e.id
|
||||
WHERE e.family_id = ? AND e.id = ? AND e.kind = 'trip'"
|
||||
);
|
||||
@@ -176,14 +183,21 @@ final class Trip
|
||||
$row = [
|
||||
self::keyOrNull($d['cover_key'] ?? ''),
|
||||
self::textOrNull($d['summary'] ?? ''),
|
||||
// 用户第 4 条:是否公开。
|
||||
// ⚠️ 必须区分「这个键没传」与「用户明确取消勾选」:
|
||||
// · 没传(程序化创建 / 迁移 / 老调用)→ **按公开(1)**,
|
||||
// 与建表默认值一致;默认 0 会让数据**静默消失**在列表里,
|
||||
// 那是最坏的一种失败(用户只会看到"我的行程不见了")。
|
||||
// · 传了 0(表单里取消勾选)→ 真的不公开。
|
||||
array_key_exists('is_public', $d) ? (!empty($d['is_public']) ? 1 : 0) : 1,
|
||||
];
|
||||
$has = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?');
|
||||
$has->execute([$eventId]);
|
||||
if ((int)$has->fetchColumn() > 0) {
|
||||
$db->prepare('UPDATE trip_details SET cover_key=?, summary=? WHERE event_id=?')
|
||||
$db->prepare('UPDATE trip_details SET cover_key=?, summary=?, is_public=? WHERE event_id=?')
|
||||
->execute([...$row, $eventId]);
|
||||
} else {
|
||||
$db->prepare('INSERT INTO trip_details (cover_key, summary, event_id) VALUES (?,?,?)')
|
||||
$db->prepare('INSERT INTO trip_details (cover_key, summary, is_public, event_id) VALUES (?,?,?,?)')
|
||||
->execute([...$row, $eventId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,40 @@ error_reporting(E_ALL);
|
||||
ini_set('display_errors', $debug ? '1' : '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
/**
|
||||
* 把错误日志指到**我们够得着的地方**(v24)。
|
||||
*
|
||||
* 为什么需要:线上出 500 时最该看的就是错误日志,而宝塔的 `/www/wwwlogs`
|
||||
* 既不在部署账号的读权限内,也不在 `open_basedir` 允许的路径里
|
||||
* (本站是 `/www/wwwroot/…:/tmp/`)。这一轮真遇到了:用户报 500,
|
||||
* 能查的全查了(逐文件哈希、两账号 32 次真实渲染、边界输入、DNS/证书/缓存)
|
||||
* 全绿,唯独拿不到"当时哪一行炸了"。
|
||||
*
|
||||
* 做法:**只需指定 `error_log`** —— PHP 本来就会把 Fatal error(含文件:行号与
|
||||
* 调用栈)写进去,不需要自己注册 shutdown 钩子。
|
||||
* (实测:先前那版加了 `register_shutdown_function` 反而多余,
|
||||
* 钩子自身一旦出错还会**再抛一个 Fatal**,把真正的原因埋掉。)
|
||||
*
|
||||
* 落点:优先站点根的 `db/`(敏感配置所在地,不入 Web 根、不进仓库),
|
||||
* 没有就回落 `sys_get_temp_dir()`(线上 open_basedir 含 `/tmp/`,一定可写)。
|
||||
* 两处都不可写就保持原样(记日志不该把站点拖垮)。
|
||||
*
|
||||
* ⚠️ 只记 PHP 自己的错误文本,**不记**请求体/变量值(可能含口令)。
|
||||
*/
|
||||
{
|
||||
$fbLogFile = '';
|
||||
foreach ([__DIR__ . '/../db', sys_get_temp_dir()] as $fbDir) {
|
||||
if (is_dir($fbDir) && is_writable($fbDir)) {
|
||||
$fbLogFile = rtrim($fbDir, '/\\') . '/familybook-error.log';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($fbLogFile !== '') {
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('error_log', $fbLogFile);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 时区:日期是家书的核心数据,一开始就必须明确 ----------------
|
||||
date_default_timezone_set($GLOBALS['config']['timezone'] ?? 'Asia/Shanghai');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user