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) . '¤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(); // ⚠️ 用户第 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' => '', 'kin' => '', 'movies' => '', 'trips' => '', 'calendar' => '', 'resources' => '', 'tasks' => '', 'days' => '', 'ledger' => '', // 读书:一本摊开的书(两页 + 书脊) 'books' => '', ]; $d = $paths[$key] ?? ''; if ($d === '') { return ''; } return ''; } }