Files
FamilyBook/app/Home.php
T
laoyang 7138314dc7 v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:

1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
   → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
     活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
     节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
     (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)

2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
     旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
   → 活动**不再存**这三样,全部**读时算出来**:
     · Trip::derived()      最早开始 / 最晚结束 / 城市去重
     · Trip::cities()       按最早出现时间排(先后顺序本身是信息,不是字母序)
     · Trip::attachDerived() 列表页批量补(避免 N+1)
     · Trip::activitySpan()  没有旅程时说「还没有旅程」,不用「日期待补」
     create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
     列表排序/搜索、首页「最近去了」改走子查询。

3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
   「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
   第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
   于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
   → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
     app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
     三处(头像 / 题图 / 图书封面)全挂上。

4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
   → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
     不说清用户会满页找"怎么没地方填时间")。
     备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。

顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
  php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
  于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。

验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
2026-10-05 10:58:17 +08:00

474 lines
21 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
/**
* 首页 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;
}
/**
* 我在哪儿(v24 用户 2026-10-03 第 1 条)。
*
* 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
* 所以地点从"管理员在系统设置里填经纬度"改成"**每个人**在个人设置里填城市"。
*
* 三级回落:
* ① 个人设置里直接填的经纬度(镇 / 村 / 海外,内置表没有的地方)
* ② 个人设置里填的城市名 → App\Geo 的**内置表**(197 个地级市,不联网)
* ③ 个人设置还没配 → 回落到**旧的家庭级坐标**(settings.weather_lat/lon)
* ④ 都没有 → null,首页那一块不显示
*
* ⚠️ 第 ③ 级是**迁移的善意**,不是冗余:天气原来配在系统设置里(管理员填一次),
* 改成个人设置之后,如果只认 user_settings,那么所有人升级完当天首页的
* 天气就凭空消失了 —— 用户会以为"改坏了"。留着这条回落,
* 他自己去个人设置填上城市的那一刻才真正接管(顺序上个人值优先)。
*
* ⚠️ 顺序是"个人经纬度优先于个人城市名":用户亲手填的坐标比城市名更精确,
* 也更明确地表了态 —— 既填了城市又填了坐标,当然是按坐标来。
*
* @param int $familyId 用来取旧的家庭级兜底坐标(0 = 不做兜底)
* @return array{lat:string,lon:string,place:string}|null
*/
public static function weatherLocation(int $personId, int $familyId = 0): ?array
{
$lat = trim((string)UserSetting::get($personId, 'weather_lat', ''));
$lon = trim((string)UserSetting::get($personId, 'weather_lon', ''));
$city = trim((string)UserSetting::get($personId, 'weather_city', ''));
if ($lat !== '' && $lon !== '' && is_numeric($lat) && is_numeric($lon)) {
// 手填坐标时地名就用他填的城市名(没填就只说"我所在的地方")
return ['lat' => $lat, 'lon' => $lon, 'place' => $city !== '' ? $city : '我所在的地方'];
}
if ($city !== '') {
$g = Geo::lookup($city);
if ($g !== null) {
return $g;
}
// 城市名认不出,但用户确实配了别的(见下)→ 继续往下回落,别直接返回 null
}
if ($familyId > 0) {
$flat = trim((string)Setting::get($familyId, 'weather_lat', ''));
$flon = trim((string)Setting::get($familyId, 'weather_lon', ''));
if ($flat !== '' && $flon !== '' && is_numeric($flat) && is_numeric($flon)) {
return [
'lat' => $flat,
'lon' => $flon,
'place' => trim((string)Setting::get($familyId, 'weather_place', '')),
];
}
}
return null;
}
/**
* 今天的天气。没配地点 / 请求失败 / 超时 → null(首页整块不显示)。
*
* 用 Open-Meteo:**不需要申请 key**、允许非商业使用、有中文描述。
* 之所以不选和风/心知:都要申请 key,而"配好 key 才能看见天气"这件事
* 会让用户以为功能没做(配置项藏在哪他未必找得到)。
*
* ⚠️ 只按经纬度请求,不传城市名 —— 城市名 → 坐标的事在 Geo 里离线做了
* (见 Geo 的类注释:在线地理编码对中国地级市实测命中率很差)。
* 这里拿到的 lat/lon 已经是确定的坐标,不再猜。
*
* @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。
// ⚠️ 缓存键里带上坐标:v24 起地点是**每人一份**的(一家人可能分处两地),
// 只用家庭 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)
. '&current=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();
// ⚠️ 用户第 15 批 第 2 条:活动自己没有时间了 —— "最近去过哪"要按
// **各段旅程里最晚的那个时间**排,不能再看 e.starts_at(那列已经不用了,
// 恒为 NULL,等于每次取到的都是"最后插入的那条",看起来像随机)。
$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 GREATEST(
COALESCE((SELECT MAX(n.starts_at) FROM trip_nodes n
WHERE n.event_id = e.id AND n.stage IN ('plan','actual')), '1000-01-01'),
'1000-01-01') 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' => ['证件 / 资料 / 附件'],
];
// ---- 任务 ----
// ⚠️ v24 第 14 批 用户第 2 条:**按状态算,不看板算**。
// 看板是"这件事属于哪摊事",状态是"做到哪一步";首页关心的是后者,
// 所以这里用 Task::byStatus()(跨看板)—— 用 board() 只会数到默认看板里的。
$doing = count(Task::byStatus($familyId, $me, 'doing'));
$todo = count(Task::byStatus($familyId, $me, 'todo'));
$late = 0;
foreach (array_merge(Task::byStatus($familyId, $me, 'doing'),
Task::byStatus($familyId, $me, 'todo')) 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) . ' 本账(净资产)' : '先建一本家庭账'],
];
// ---- 读书(v24 第 14 批 用户第 5 条)----
$bs = Book::stats($familyId);
$reading = (int)($bs['status']['reading'] ?? 0);
$doneN = (int)($bs['status']['done'] ?? 0);
$cards['books'] = [
'name' => '读书',
'icon' => self::icon('books'),
'color' => Module::color('books', $familyId),
'href' => url('books'),
'big' => $bs['total'] > 0 ? ($reading > 0 ? $reading . ' 本在读' : $doneN . ' 本读过') : '空书架',
'lines' => [$bs['total'] > 0
? ($doneN . ' 本读完 · ' . (int)$bs['notes'] . ' 条笔记')
: '传一本进来,或先记下想读的书'],
];
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"/>',
// 读书:一本摊开的书(两页 + 书脊)
'books' => '<path d="M2.2 4.2A1.6 1.6 0 0 1 3.8 2.6h3.4c.9 0 1.8.4 1.8 1.3v10.2c0-.9-.9-1.3-1.8-1.3H3.8a1.6 1.6 0 0 1-1.6-1.6z"/><path d="M15.8 4.2a1.6 1.6 0 0 0-1.6-1.6h-3.4c-.9 0-1.8.4-1.8 1.3v10.2c0-.9.9-1.3 1.8-1.3h3.4a1.6 1.6 0 0 0 1.6-1.6z"/>',
];
$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>';
}
}