用户 2026-10-03 提的四条:
① 家谱卡片重做
· 去掉「⇄ 姻亲」按钮,改成**每个人**卡片右下角都有「以 TA 为中心」
(点了以他重画一张家谱,涵盖原来的姻亲场景)。
· 头像外框改**长方形**(96×72,宽度贴合头像),object-fit: contain 不裁切,
姓名/称呼在头像下方。
· 修「点右下角按钮误进成员页」:卡片不再整张是 <a>,
只有姓名是链接,右下角是独立的 <button>(物理上分开,不抢点击)。
② 家谱文案与按钮清理
· 去掉副标题与下方图例说明;整块去掉「看全部 / 回到我 / 回到我的家谱」
(顺带消掉"出现两个回到我的家谱")。镜头由库自动 fit,拖拽/滚轮即可。
③ 首页控制台
· 标题下显示个人 slogan;去掉右侧三个统计;「我的资料/我的家谱」换成图标按钮;
去掉与导航条重复的「名册」按钮。
· 去掉「接下来的日子」(生日归到"未来七天"里,不再重复)。
· 新增统计信息块:按时段随机问候 + 今日天气(Open-Meteo,免 key,可留空不显示)
+ 近七天日程/待办/逾期数。
· 新增各模块小结卡(任务/日程/纪念日/观影/旅程/财务/名册/家谱/资源)。
· 个人设置新增「首页上的模块」:可勾选与拖动排序(每人一份,存 user_settings)。
· 保留「往年的今天」。
④ 名册改列表
· 卡片网格 → **竖向列表**:左头像、中间姓名+与我的关系+手机号+生日。
· 修拖动「只能往后不能往前」:落点改为显式记录"插到谁前面/后面",
并排除被拖元素自身。
· 去掉那句拖动提示(能力保留)。
· 搜索新增**姓名首字母**检索(y / yy / yyq 都命中「杨永全」):
新 app/Pinyin.php,intl 优先 + 内置常用字表兜底(本地无 intl 也能测);
纯字母/数字查询词不走 SQL LIKE(拼音与加密手机号在库里都比不了)。
附带上两轮的收尾:
· 菜单点外部自动关闭(全局事件委托,覆盖所有 details.menu)。
· 旅程 is_public:列表与日历只显示公开的;详情页不拦(不丢数据)。
· 重复日程:新 app/Repeat.php(RRULE 解析/生成/展开,含"工作日"),
新表 calendar_event_skips(RFC 的 EXDATE);日历按规则展开;
编辑支持三档「全部 / 这一次及以后 / 仅此一次」。
· 结束时间可添加/去掉,并随开始时间联动。
验证:端到端 772 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 26、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
393 lines
16 KiB
PHP
393 lines
16 KiB
PHP
<?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;
|
||
}
|
||
|
||
/**
|
||
* 今天的天气。没配 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' => '<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>';
|
||
}
|
||
} |