2026-10-03 11:30:48 +08:00
|
|
|
|
<?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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 05:52:40 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 「今天是个什么日子」—— 首页信息面板要的那一串标记(v24 第 24 批 用户第 2 条)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「如果有节气的话,或者有重要的节日的话,在这里显示。
|
|
|
|
|
|
* 比如说今天是这个国庆节啊,今天是端午节,或者是今天是中秋节,
|
|
|
|
|
|
* 或者是今天是芒种,今天是大寒,今天是小寒,今天是什么纪念日,
|
|
|
|
|
|
* 今天是谁的生日等等。显示在这里。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 一次把五类来源合成**一行可读的短语**:
|
|
|
|
|
|
* ① 法定节假日(Holiday::range —— 含"补班",那也要说,否则用户以为白放假)
|
|
|
|
|
|
* ② 二十四节气(Holiday::solarTermRange)
|
|
|
|
|
|
* ③ 传统节日(Holiday::traditionalRange —— 春节/中秋/端午…)
|
|
|
|
|
|
* ④ 纪念日(Anniversary::all —— 含自建与派生)
|
|
|
|
|
|
* ⑤ 生日(同上,Anniversary 已经把生日一起派生出来了)
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么合成"一条字符串"而不是给模板五个数组:
|
|
|
|
|
|
* 面板上这是**一句话的位置**("今天是……"),模板里再拼一遍会把这五类的
|
|
|
|
|
|
* 优先级、去重、连接符全散到视图层 —— 同一件事写两处,下次加一类必漏一处。
|
|
|
|
|
|
* ("生日"与"纪念日"天然会重复:生日本身就是一条派生的纪念日,
|
|
|
|
|
|
* 所以下面按**名字去重**。)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $viewerId 谁在看(纪念日的称呼按他算,如"儿子"而非"杨小全")
|
|
|
|
|
|
* @return array{tags:array<int,array{word:string,kind:string}>, line:string}
|
|
|
|
|
|
* tags 给模板做彩色小标签,line 给纯文本兜底
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function todayMarks(int $familyId, int $viewerId, string $date): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$tags = [];
|
|
|
|
|
|
$seen = [];
|
|
|
|
|
|
$add = static function (string $word, string $kind) use (&$tags, &$seen): void {
|
|
|
|
|
|
$word = trim($word);
|
|
|
|
|
|
// 同一件事别出现两次(生日既是"生日"也是一条派生纪念日)
|
|
|
|
|
|
if ($word === '' || isset($seen[$word])) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
$seen[$word] = true;
|
|
|
|
|
|
$tags[] = ['word' => $word, 'kind' => $kind];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
$y = (int)substr($date, 0, 4);
|
|
|
|
|
|
|
|
|
|
|
|
// ① 法定节假日(只认"今天在放假区间里";补班也报,但换一种措辞)
|
|
|
|
|
|
foreach (\App\Holiday::range($y, $date, $date) as $h) {
|
|
|
|
|
|
$nm = (string)($h['name'] ?? '');
|
|
|
|
|
|
if ($nm === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$add(!empty($h['off']) ? $nm : ($nm . '(补班)'), 'holiday');
|
|
|
|
|
|
}
|
|
|
|
|
|
// ② 二十四节气
|
|
|
|
|
|
foreach (\App\Holiday::solarTermRange($date, $date) as $s) {
|
|
|
|
|
|
$add((string)($s['name'] ?? ''), 'term');
|
|
|
|
|
|
}
|
|
|
|
|
|
// ③ 中国传统节日
|
|
|
|
|
|
foreach (\App\Holiday::traditionalRange($date, $date) as $t) {
|
|
|
|
|
|
$add((string)($t['name'] ?? ''), 'festival');
|
|
|
|
|
|
}
|
|
|
|
|
|
// ④⑤ 纪念日 + 生日(Anniversary::all 已把生日派生进来)
|
|
|
|
|
|
foreach (\App\Anniversary::all($familyId, $viewerId) as $a) {
|
|
|
|
|
|
$repeat = (string)($a['repeat_type'] ?? 'solar');
|
|
|
|
|
|
$m = (int)($a['month'] ?? 0);
|
|
|
|
|
|
$d = (int)($a['day'] ?? 0);
|
|
|
|
|
|
if ($m <= 0 || $d <= 0) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$isToday = false;
|
|
|
|
|
|
if ($repeat === 'lunar') {
|
|
|
|
|
|
// 农历:把"今天的农历月日"拿来比(闰月单列)
|
|
|
|
|
|
$l = \App\Lunar::solarToLunar($y, (int)substr($date, 5, 2), (int)substr($date, 8, 2));
|
|
|
|
|
|
$isToday = $l !== null
|
|
|
|
|
|
&& (int)$l['month'] === $m && (int)$l['day'] === $d
|
|
|
|
|
|
&& (int)($l['is_leap'] ? 1 : 0) === (int)($a['is_leap'] ?? 0);
|
|
|
|
|
|
} elseif ($repeat === 'once') {
|
|
|
|
|
|
// 一次性:年月日全对上才算(这种不是"每年重复")
|
|
|
|
|
|
$born = (string)($a['born'] ?? '');
|
|
|
|
|
|
$isToday = $born !== '' && substr($born, 0, 10) === $date;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$isToday = $m === (int)substr($date, 5, 2) && $d === (int)substr($date, 8, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$isToday) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$title = (string)($a['title'] ?? '');
|
|
|
|
|
|
$add($title, (string)($a['kind'] ?? '') === '生日' ? 'birthday' : 'anniv');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$words = array_map(static fn(array $t): string => $t['word'], $tags);
|
|
|
|
|
|
return [
|
|
|
|
|
|
'tags' => $tags,
|
|
|
|
|
|
'line' => $words ? implode(' · ', $words) : '',
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 11:30:48 +08:00
|
|
|
|
/**
|
2026-10-03 15:01:38 +08:00
|
|
|
|
* 我在哪儿(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(首页整块不显示)。
|
2026-10-03 11:30:48 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 用 Open-Meteo:**不需要申请 key**、允许非商业使用、有中文描述。
|
|
|
|
|
|
* 之所以不选和风/心知:都要申请 key,而"配好 key 才能看见天气"这件事
|
|
|
|
|
|
* 会让用户以为功能没做(配置项藏在哪他未必找得到)。
|
|
|
|
|
|
*
|
2026-10-03 15:01:38 +08:00
|
|
|
|
* ⚠️ 只按经纬度请求,不传城市名 —— 城市名 → 坐标的事在 Geo 里离线做了
|
|
|
|
|
|
* (见 Geo 的类注释:在线地理编码对中国地级市实测命中率很差)。
|
|
|
|
|
|
* 这里拿到的 lat/lon 已经是确定的坐标,不再猜。
|
2026-10-03 11:30:48 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @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。
|
2026-10-03 15:01:38 +08:00
|
|
|
|
// ⚠️ 缓存键里带上坐标:v24 起地点是**每人一份**的(一家人可能分处两地),
|
|
|
|
|
|
// 只用家庭 id 做键会让两口子互相看到对方城市的天气。
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 05:52:40 +08:00
|
|
|
|
$base = getenv('FAMILYBOOK_WEATHER_BASE');
|
|
|
|
|
|
if (!is_string($base) || $base === '') {
|
|
|
|
|
|
$base = 'https://api.open-meteo.com/v1/forecast';
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⚠️ 这个可覆盖的 base 是**为测试留的**(与 Douban 的 FAMILYBOOK_DOUBAN_SEARCH
|
|
|
|
|
|
// 同一套做法):本地/CI 环境取不到 api.open-meteo.com(实测外网直连超时
|
|
|
|
|
|
// 17.8s 后失败),于是"天气那一块"在本地**永远**是空 ——
|
|
|
|
|
|
// 断言只能靠 mock 才验得了。生产环境不设这个变量,走真实地址。
|
|
|
|
|
|
$url = $base
|
2026-10-03 11:30:48 +08:00
|
|
|
|
. '?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon)
|
2026-10-07 05:52:40 +08:00
|
|
|
|
. '¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m,is_day'
|
|
|
|
|
|
. '&daily=temperature_2m_max,temperature_2m_min&forecast_days=1'
|
|
|
|
|
|
. '&timezone=Asia%2FShanghai';
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$body = self::httpGet($url);
|
|
|
|
|
|
if ($body === null) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
$j = json_decode($body, true);
|
|
|
|
|
|
if (!is_array($j) || !isset($j['current'])) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-10-07 05:52:40 +08:00
|
|
|
|
// v24 第 24 批 用户第 2 条:面板上要显示**温度、风力**(用户原话)。
|
|
|
|
|
|
// 风向、昼夜、今日高低温都顺手取回来 —— 它们只在 current/daily 里各加一个字段,
|
|
|
|
|
|
// 不多打一次接口;以后要在面板上加"最高 20° 最低 12°"直接就有。
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$data = [
|
|
|
|
|
|
'temp' => (string)round((float)($j['current']['temperature_2m'] ?? 0)),
|
|
|
|
|
|
'code' => (int)($j['current']['weather_code'] ?? -1),
|
2026-10-07 05:52:40 +08:00
|
|
|
|
'wind' => round((float)($j['current']['wind_speed_10m'] ?? 0), 1),
|
|
|
|
|
|
'wdir' => (int)($j['current']['wind_direction_10m'] ?? 0),
|
|
|
|
|
|
'day' => (int)($j['current']['is_day'] ?? 1),
|
|
|
|
|
|
'tmax' => isset($j['daily']['temperature_2m_max'][0])
|
|
|
|
|
|
? round((float)$j['daily']['temperature_2m_max'][0]) : null,
|
|
|
|
|
|
'tmin' => isset($j['daily']['temperature_2m_min'][0])
|
|
|
|
|
|
? round((float)$j['daily']['temperature_2m_min'][0]) : null,
|
2026-10-03 11:30:48 +08:00
|
|
|
|
];
|
|
|
|
|
|
// 缓存下来(存在 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;
|
|
|
|
|
|
}
|
2026-10-07 05:52:40 +08:00
|
|
|
|
[$text, $icon, $tone] = self::wmo((int)($data['code'] ?? -1), (int)($data['day'] ?? 1));
|
|
|
|
|
|
$wind = (float)($data['wind'] ?? -1);
|
|
|
|
|
|
$wdir = (int)($data['wdir'] ?? -1);
|
2026-10-03 11:30:48 +08:00
|
|
|
|
return [
|
|
|
|
|
|
'temp' => (string)$data['temp'] . '°C',
|
|
|
|
|
|
'text' => $text,
|
|
|
|
|
|
'icon' => $icon,
|
2026-10-07 05:52:40 +08:00
|
|
|
|
// v24 第 24 批:面板要的是**彩色图标**(用户原话),
|
|
|
|
|
|
// 所以除了符号本身,再把"该用哪一档颜色"一起给出来 ——
|
|
|
|
|
|
// 颜色由**后端**决定(晴=暖黄、雨=蓝、雪=青…),前端只管贴上去;
|
|
|
|
|
|
// 让 CSS 去猜天气码等于把同一份判断抄两遍。
|
|
|
|
|
|
'tone' => $tone,
|
|
|
|
|
|
'wind' => $wind >= 0 ? (string)round($wind) . ' km/h' : '',
|
|
|
|
|
|
// 风向文字(八向)。用户没点名要,但"风"这件事光看风速不够 ——
|
|
|
|
|
|
// 顺手给出来,面板上放在风力旁边即可(没数据时是空串,不占位)。
|
|
|
|
|
|
'wdir' => $wdir >= 0 ? self::windDir($wdir) : '',
|
|
|
|
|
|
'tmax' => isset($data['tmax']) && $data['tmax'] !== null ? (string)round((float)$data['tmax']) . '°' : '',
|
|
|
|
|
|
'tmin' => isset($data['tmin']) && $data['tmin'] !== null ? (string)round((float)$data['tmin']) . '°' : '',
|
2026-10-03 11:30:48 +08:00
|
|
|
|
'place' => trim($place),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 05:52:40 +08:00
|
|
|
|
/** 风向角度 → 八向中文(数据缺失时调用方不会调它) */
|
|
|
|
|
|
private static function windDir(int $deg): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$dirs = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
|
|
|
|
|
|
$i = (int)round((($deg % 360) + 360) % 360 / 45) % 8;
|
|
|
|
|
|
return $dirs[$i] . '风';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 11:30:48 +08:00
|
|
|
|
/**
|
2026-10-07 05:52:40 +08:00
|
|
|
|
* WMO 天气代码 → [中文描述, 一个符号, 配色档]。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 符号用**普通字符**(不是 emoji):emoji 在部分旧系统上会变豆腐块,
|
|
|
|
|
|
* 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。
|
|
|
|
|
|
* v24 第 24 批 用户第 2 条要"彩色图标"—— 那就用**颜色**把它变彩色,
|
|
|
|
|
|
* 而不是换成 emoji(换 emoji 会把豆腐块的风险直接引进来)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int $day 1=白天 0=夜间(晴天夜间给月亮,不要白天也挂太阳)
|
|
|
|
|
|
* @return array{0:string,1:string,2:string} [描述, 符号, 配色档(sunny/cloud/rain/snow/storm/fog)]
|
2026-10-03 11:30:48 +08:00
|
|
|
|
*/
|
2026-10-07 05:52:40 +08:00
|
|
|
|
private static function wmo(int $c, int $day = 1): array
|
2026-10-03 11:30:48 +08:00
|
|
|
|
{
|
|
|
|
|
|
if ($c === 0) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return $day === 1 ? ['晴', '☀', 'sunny'] : ['晴', '☾', 'sunny'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (in_array($c, [1, 2], true)) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['多云', '⛅', 'cloud'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($c === 3) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['阴', '☁', 'cloud'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (in_array($c, [45, 48], true)) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['有雾', '≋', 'fog'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($c >= 51 && $c <= 57) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['毛毛雨', '☂', 'rain'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($c >= 61 && $c <= 67) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['下雨', '☂', 'rain'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($c >= 71 && $c <= 77) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['下雪', '❄', 'snow'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($c >= 80 && $c <= 82) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['阵雨', '☂', 'rain'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($c >= 85 && $c <= 86) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['阵雪', '❄', 'snow'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
if ($c >= 95) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['雷雨', '⚡', 'storm'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
2026-10-07 05:52:40 +08:00
|
|
|
|
return ['天气', '·', 'cloud'];
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 极简 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();
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// ⚠️ 用户第 15 批 第 2 条:活动自己没有时间了 —— "最近去过哪"要按
|
|
|
|
|
|
// **各段旅程里最晚的那个时间**排,不能再看 e.starts_at(那列已经不用了,
|
|
|
|
|
|
// 恒为 NULL,等于每次取到的都是"最后插入的那条",看起来像随机)。
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$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
|
2026-10-05 10:58:17 +08:00
|
|
|
|
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"
|
2026-10-03 11:30:48 +08:00
|
|
|
|
);
|
|
|
|
|
|
$st->execute([$familyId]);
|
|
|
|
|
|
$lastTrip = (string)($st->fetchColumn() ?: '');
|
|
|
|
|
|
$cards['trips'] = [
|
|
|
|
|
|
'name' => '旅程',
|
|
|
|
|
|
'icon' => self::icon('trips'),
|
|
|
|
|
|
'color' => Module::color('trips', $familyId),
|
|
|
|
|
|
'href' => url('trips'),
|
2026-10-05 10:58:17 +08:00
|
|
|
|
'big' => $tripN . ' 次',
|
|
|
|
|
|
'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有活动记录'],
|
2026-10-03 11:30:48 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 日程 ----
|
|
|
|
|
|
$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' => ['证件 / 资料 / 附件'],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 任务 ----
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// ⚠️ v24 第 14 批 用户第 2 条:**按状态算,不看板算**。
|
|
|
|
|
|
// 看板是"这件事属于哪摊事",状态是"做到哪一步";首页关心的是后者,
|
|
|
|
|
|
// 所以这里用 Task::byStatus()(跨看板)—— 用 board() 只会数到默认看板里的。
|
|
|
|
|
|
$doing = count(Task::byStatus($familyId, $me, 'doing'));
|
|
|
|
|
|
$todo = count(Task::byStatus($familyId, $me, 'todo'));
|
2026-10-03 11:30:48 +08:00
|
|
|
|
$late = 0;
|
2026-10-05 10:58:17 +08:00
|
|
|
|
foreach (array_merge(Task::byStatus($familyId, $me, 'doing'),
|
|
|
|
|
|
Task::byStatus($familyId, $me, 'todo')) as $t) {
|
|
|
|
|
|
if (Task::isOverdue($t['due_at'] ?? null)) {
|
|
|
|
|
|
$late++;
|
2026-10-03 11:30:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$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) . ' 本账(净资产)' : '先建一本家庭账'],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// ---- 读书(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'] . ' 条笔记')
|
|
|
|
|
|
: '传一本进来,或先记下想读的书'],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-03 11:30:48 +08:00
|
|
|
|
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"/>',
|
2026-10-05 10:58:17 +08:00
|
|
|
|
// 读书:一本摊开的书(两页 + 书脊)
|
|
|
|
|
|
'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"/>',
|
2026-10-03 11:30:48 +08:00
|
|
|
|
];
|
|
|
|
|
|
$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>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|