Files
FamilyBook/app/Home.php
T
laoyang e0562b8627 v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)
用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00

449 lines
19 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();
$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>';
}
}