首页改成「旅行地图 / 活动列表」两个标签页(批 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:
@@ -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', [
|
||||
|
||||
Reference in New Issue
Block a user