首页改成「旅行地图 / 活动列表」两个标签页(批 16)

原来首页是一屏卡片网格,一次旅行一张卡,看不出「去过哪儿」。改成:

- **默认「🗺️ 旅行地图」**:一张大地图(`clamp(520px, 78vh, 920px)`),
  **一次旅行一枚图标**(有封面放封面、没有放 📍,下面挂一行活动名),
  点图标直接进那次旅行的详情页。位置 = 那次旅行所有坐标点的**重心**
  (执行节点优先、计划节点兜底,取消的不算)。
- **另一个标签「📋 活动列表」**:每行一条 —— 左边封面,右边
  标题 + 简述 + **主要参与人**(头像/名字,超过 5 位显示「等 N 人」)+
  去了几个地方(地名去重)+ 经历了几天(自然日)+ 几个节点。
- 只有一次旅行(或几次都在同城)时 `setFitView` 会一路放大到街道级 ——
  那是导航视角不是"旅行足迹"视角,用 `MAX_ZOOM = 11` 压住。
- 一次旅行都没落坐标的家庭:不出标签条,退化成原来的单页列表。
- 图标下面挂了活动名,整块是 56×64 **矩形**,判重叠就按矩形;
  详情页那种纯圆标才按中心距 —— 形状不同判定不同,混用会误判。

数据由 `ActivityController::collectStats()` 用几条 IN 查询**一次聚合**
(不做逐活动的 N+1):节点明细 / 参与人(按参与节点数降序)/ 时间跨度 / 地点去重。

验证:新增真浏览器用例 `_page16.php` + `_map16.js`(首页 **20 项**),
夹具覆盖「两个重心只差几公里的同城旅行」(防重合回归)+「完全没坐标的活动」
(不上图但列表里要有)。首轮抓到两件事:① 断言拿了被截断的标题做匹配;
② 夹具里只有两个点,`fitView` 把它们拉得很开、根本测不到重合
→ 补一个远在三亚的活动把视野拉回"看全国"。
e2e 新增 6b 节(**注意必须放在「删除活动」之前**,否则活动连带节点、
参与人一起被删,首页变空态、断言全程空转 —— 首轮就是这样红的)。
本地全链 13 步全绿(渲染检查 370 项、首页浏览器 20 项);远端 e2e 365 项
0 失败;部署静态断言 204 条 0 FAIL、双 PHP 渲染逐字节一致、内容断言全过。
This commit is contained in:
2026-09-24 17:03:55 +08:00
parent 064e6b5a5e
commit 388d5dc212
3 changed files with 438 additions and 27 deletions
+137 -14
View File
@@ -22,29 +22,152 @@ class ActivityController
$stmt->execute([$familyId]);
$activities = $stmt->fetchAll();
// 每个活动的节点数:一次 GROUP BY 查完,避免逐个 COUNT 的 N+1
$stats = [];
if (!empty($activities)) {
$ids = array_column($activities, 'id');
$ph = implode(',', array_fill(0, count($ids), '?'));
$s = $db->prepare("SELECT activity_id, COUNT(*) AS cnt
FROM trip_nodes WHERE activity_id IN ($ph) GROUP BY activity_id");
$s->execute($ids);
$counts = [];
foreach ($s->fetchAll() as $row) {
$counts[(int)$row['activity_id']] = (int)$row['cnt'];
}
foreach ($activities as &$a) {
$a['node_count'] = $counts[(int)$a['id']] ?? 0;
}
unset($a);
$stats = $this->collectStats(array_map('intval', array_column($activities, 'id')));
}
view('activities/index', [
'activities' => $activities,
'stats' => $stats,
'pageTitle' => '活动列表',
]);
}
/**
* 首页要的聚合数据,**全部一次查完**(几条 IN 查询,不做逐活动的 N+1):
* - 主要位置(地图图标落哪儿):所有坐标点的重心,执行节点优先、计划节点兜底
* - 去过的几个地方:起终点里的地名去重计数
* - 时间跨度:最早开始 ~ 最晚结束,跨了几个自然日
* - 主要参与人:按参与节点数降序(参与得越多越靠前)
*
* @param array<int, int> $ids
* @return array<int, array>
*/
private function collectStats(array $ids): array
{
if (empty($ids)) return [];
$db = Database::get();
$ph = implode(',', array_fill(0, count($ids), '?'));
$out = [];
foreach ($ids as $id) {
$out[$id] = [
'node_count' => 0,
'place_count' => 0,
'place_main' => '',
'lng' => null,
'lat' => null,
'start' => null,
'end' => null,
'days' => 0,
'span_text' => '',
'members' => [],
'member_count' => 0,
];
}
/* ① 节点:位置 / 时间 / 地点名。取消的节点不计入(它没真的发生)。 */
$s = $db->prepare("SELECT activity_id, node_category, status, start_time, end_time,
start_location, end_location
FROM trip_nodes WHERE activity_id IN ($ph) ORDER BY start_time");
$s->execute($ids);
$coords = []; // activity => ['reality' => [[lng,lat,name],…], 'plan' => …]
$names = []; // activity => [地名 => 1]
foreach ($s->fetchAll() as $n) {
$aid = (int)$n['activity_id'];
if (!isset($out[$aid])) continue;
$st = &$out[$aid];
$st['node_count']++;
if ($n['status'] === 'cancelled') continue;
foreach (['start_location', 'end_location'] as $key) {
$loc = json_decode((string)$n[$key], true);
if (!is_array($loc)) continue;
$name = trim((string)($loc['name'] ?? ''));
if ($name !== '') $names[$aid][$name] = 1;
$lng = isset($loc['lng']) ? (float)$loc['lng'] : 0.0;
$lat = isset($loc['lat']) ? (float)$loc['lat'] : 0.0;
if ($lng == 0.0 && $lat == 0.0) continue; // 只有地名、没坐标
$bucket = ($n['node_category'] === 'reality') ? 'reality' : 'plan';
$coords[$aid][$bucket][] = [$lng, $lat, $name];
}
if (!empty($n['start_time'])
&& ($st['start'] === null || $n['start_time'] < $st['start'])) {
$st['start'] = $n['start_time'];
}
if (!empty($n['end_time'])
&& ($st['end'] === null || $n['end_time'] > $st['end'])) {
$st['end'] = $n['end_time'];
}
}
unset($st);
/* ② 重心 = 主要位置;离重心最近那个点的地名当它的名字。
执行节点优先:计划改了、实际没去的地方不该把图标拽过去。 */
foreach ($coords as $aid => $set) {
$pts = !empty($set['reality']) ? $set['reality'] : ($set['plan'] ?? []);
if (!$pts) continue;
$sl = 0.0; $sb = 0.0;
foreach ($pts as $p) { $sl += $p[0]; $sb += $p[1]; }
$cl = $sl / count($pts);
$cb = $sb / count($pts);
$out[$aid]['lng'] = $cl;
$out[$aid]['lat'] = $cb;
$best = ''; $bestD = null;
foreach ($pts as $p) {
if ($p[2] === '') continue;
$d = ($p[0] - $cl) * ($p[0] - $cl) + ($p[1] - $cb) * ($p[1] - $cb);
if ($bestD === null || $d < $bestD) { $bestD = $d; $best = $p[2]; }
}
$out[$aid]['place_main'] = $best;
}
/* ③ 去过的地方数:地名去重(同一个城市写十次也只算一个) */
foreach ($names as $aid => $set) {
$out[$aid]['place_count'] = count($set);
}
/* ④ 时间跨度:按自然日算(同一天出发并返回算 1 天) */
foreach ($out as $aid => $st) {
if ($st['start'] === null) continue;
$from = strtotime($st['start']);
$to = $st['end'] !== null ? strtotime($st['end']) : $from;
if (!$from) continue;
$fromD = strtotime(date('Y-m-d', $from));
$toD = strtotime(date('Y-m-d', $to));
$days = (int)floor(($toD - $fromD) / 86400) + 1;
$out[$aid]['days'] = max(1, $days);
$out[$aid]['span_text'] = ($toD === $fromD)
? date('Y-m-d', $fromD)
: date('Y-m-d', $fromD) . ' ~ ' . date('m-d', $toD);
}
/* ⑤ 参与人:参与节点越多越靠前 —— 「主要参与人」就是排在前面的那几位 */
$s = $db->prepare("SELECT tn.activity_id, nm.user_id, u.name, u.avatar, COUNT(*) AS hits
FROM node_members nm
JOIN trip_nodes tn ON tn.id = nm.trip_node_id
LEFT JOIN users u ON u.id = nm.user_id
WHERE tn.activity_id IN ($ph)
GROUP BY tn.activity_id, nm.user_id, u.name, u.avatar
ORDER BY tn.activity_id, hits DESC, nm.user_id ASC");
$s->execute($ids);
foreach ($s->fetchAll() as $m) {
$aid = (int)$m['activity_id'];
if (!isset($out[$aid])) continue;
$out[$aid]['members'][] = [
'id' => (int)$m['user_id'],
'name' => (string)($m['name'] ?? ''),
'avatar' => media_avatar($m['avatar'] ?? null),
];
}
foreach ($out as $aid => $st) {
$out[$aid]['member_count'] = count($st['members']);
}
return $out;
}
public function create(): void
{
view('activities/form', [