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' => '',
'kin' => '',
'movies' => '',
'trips' => '',
'calendar' => '',
'resources' => '',
'tasks' => '',
'days' => '',
'ledger' => '',
];
$d = $paths[$key] ?? '';
if ($d === '') {
return '';
}
return '';
}
}