diff --git a/assets/style.css b/assets/style.css index ee32538..68db85b 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1322,3 +1322,70 @@ video.video-thumb.video-thumb-mini { } /* 联想选中后回填的详细地址(让人看见记下来的是什么) */ .loc-detail { margin-top: 6px; font-size: 12.5px; word-break: break-all; } + +/* ── 22. 首页:旅行地图 + 活动列表两个标签页(批 16)───────────── */ +/* 首页的地图是主视图,比详情页那张再高一点 */ +.home-map { height: clamp(520px, 78vh, 920px); } + +/* 一次旅行一枚图标:圆形(有封面放封面,没有放 📍)+ 下面一行活动名 */ +.home-marker-wrap { display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: pointer; } +.home-marker { + width: 44px; height: 44px; border-radius: 50%; + border: 3px solid #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, .35); + background: var(--surface); overflow: hidden; + display: flex; align-items: center; justify-content: center; font-size: 22px; +} +.home-marker img { width: 100%; height: 100%; object-fit: cover; display: block; } +.home-marker-emoji { line-height: 1; } +.home-marker-label { + max-width: 92px; padding: 1px 7px; border-radius: 999px; + background: rgba(255, 255, 255, .94); color: var(--text); + font-size: 11.5px; line-height: 17px; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; + box-shadow: 0 1px 4px rgba(0, 0, 0, .22); +} + +/* 列表:每行一条 —— 左边封面、右边标题/简述/参与人/摘要 */ +.act-list { display: flex; flex-direction: column; gap: 12px; } +.act-row { + display: grid; grid-template-columns: 176px 1fr; gap: 16px; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 12px; + transition: box-shadow .15s, border-color .15s; +} +.act-row:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); } +.act-row-cover { + position: relative; aspect-ratio: 4 / 3; border-radius: var(--radius-sm, 8px); + overflow: hidden; background: var(--bg-soft); +} +.act-row-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.act-row-cover-ph { + width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; + font-size: 26px; color: var(--text-mute); +} +.act-row-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.act-row-body h3 { margin: 0; font-size: 16px; } +.act-row-desc { + margin: 0; font-size: 13.5px; line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; +} +.act-row-members { display: flex; flex-wrap: wrap; gap: 6px; } +.act-member { + display: inline-flex; align-items: center; gap: 5px; + padding: 2px 9px 2px 2px; border-radius: 999px; background: var(--bg-soft); + font-size: 12.5px; color: var(--text-soft); max-width: 150px; +} +.act-member img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; display: block; } +.act-member-initial { + width: 20px; height: 20px; border-radius: 50%; background: var(--accent); color: #fff; + font-size: 11px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; +} +.act-member-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.act-member-more { font-size: 12.5px; color: var(--text-mute); align-self: center; } +.act-row-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; } + +@media (max-width: 560px) { + .act-row { grid-template-columns: 104px 1fr; gap: 12px; padding: 10px; } + .home-map { height: clamp(420px, 62vh, 620px); } +} diff --git a/src/Controllers/ActivityController.php b/src/Controllers/ActivityController.php index e0beee2..a11efdc 100644 --- a/src/Controllers/ActivityController.php +++ b/src/Controllers/ActivityController.php @@ -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 $ids + * @return 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', [ diff --git a/templates/activities/index.php b/templates/activities/index.php index 558a74b..329225d 100644 --- a/templates/activities/index.php +++ b/templates/activities/index.php @@ -1,3 +1,23 @@ + (int)$a['id'], + 'title' => (string)$a['title'], + 'place' => (string)($st['place_main'] ?? ''), + 'lat' => round((float)$st['lat'], 6), + 'lng' => round((float)$st['lng'], 6), + 'img' => !empty($a['cover_image']) ? media_cover((int)$a['id']) : '', + 'url' => url('activities/show', ['id' => $a['id']]), + ]; +} +?> @@ -8,23 +28,224 @@ 创建第一个活动 -
+ + +
+ + +
+ +
+
+

每一枚图标是一次旅行(落在那次旅行去过的地方的重心),点图标就进那次旅行的详情。

+
+ +