首页改成「旅行地图 / 活动列表」两个标签页(批 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
+67
View File
@@ -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); }
}
+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', [
+233 -12
View File
@@ -1,3 +1,23 @@
<?php
/* 首页两个视图:默认「旅行地图」(一次旅行一枚图标,点进去就是那次旅行),
另一个标签是「活动列表」(每行一条:封面 + 标题简述 / 主要参与人 / 去了几个地方 /
经历了多长时间)。数据由 ActivityController::collectStats() 一次聚合好。 */
$stats = $stats ?? [];
$spots = [];
foreach ($activities as $a) {
$st = $stats[(int)$a['id']] ?? null;
if (!$st || $st['lat'] === null || $st['lng'] === null) continue;
$spots[] = [
'id' => (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']]),
];
}
?>
<div class="page-header">
<h1>活动列表</h1>
</div>
@@ -8,23 +28,224 @@
<a href="<?= url('activities/create') ?>" class="btn-primary">创建第一个活动</a>
</div>
<?php else: ?>
<div class="card-grid">
<?php foreach ($activities as $a): ?>
<a href="<?= url('activities/show', ['id' => $a['id']]) ?>" class="card">
<?php if (!empty($a['cover_image'])): ?>
<img src="<?= e(media_cover((int)$a['id'])) ?>" alt="" class="card-cover">
<?php else: ?>
<div class="card-cover card-cover-placeholder">📸</div>
<?php if ($spots): ?>
<div class="route-tabs" role="tablist">
<button type="button" class="route-tab is-active" data-panel="home-panel-map"
role="tab" aria-selected="true">🗺️ 旅行地图</button>
<button type="button" class="route-tab" data-panel="home-panel-list"
role="tab" aria-selected="false">📋 活动列表</button>
</div>
<div class="route-panel" id="home-panel-map">
<div class="route-map home-map" id="home-map-canvas"
data-spots="<?= e(json_encode($spots, JSON_UNESCAPED_UNICODE)) ?>"></div>
<p class="route-map-hint">每一枚图标是一次旅行(落在那次旅行去过的地方的重心),点图标就进那次旅行的详情。</p>
</div>
<div class="route-panel" id="home-panel-list" hidden>
<?php endif; ?>
<div class="card-body">
<div class="act-list">
<?php foreach ($activities as $a): ?>
<?php
$st = $stats[(int)$a['id']] ?? null;
$placeCount = (int)($st['place_count'] ?? 0);
$placeMain = (string)($st['place_main'] ?? '');
$nodeCount = (int)($st['node_count'] ?? 0);
$days = (int)($st['days'] ?? 0);
$spanText = (string)($st['span_text'] ?? '');
$memberCount = (int)($st['member_count'] ?? 0);
$members = array_slice($st['members'] ?? [], 0, 5);
?>
<a href="<?= url('activities/show', ['id' => $a['id']]) ?>" class="act-row">
<div class="act-row-cover">
<?php if (!empty($a['cover_image'])): ?>
<img src="<?= e(media_cover((int)$a['id'])) ?>" alt="">
<?php else: ?>
<div class="act-row-cover-ph">📸</div>
<?php endif; ?>
</div>
<div class="act-row-body">
<h3><?= e($a['title']) ?></h3>
<p class="muted"><?= e($a['description'] ?: '暂无简介') ?></p>
<div class="meta">
<span><?= (int)$a['node_count'] ?> 个节点</span>
<span><?= date('Y-m-d', strtotime($a['created_at'])) ?></span>
<p class="muted act-row-desc"><?= e($a['description'] ?: '暂无简介') ?></p>
<?php if ($members): ?>
<div class="act-row-members">
<?php foreach ($members as $m): ?>
<span class="act-member">
<?php if (!empty($m['avatar'])): ?>
<img src="<?= e($m['avatar']) ?>" alt="">
<?php else: ?>
<span class="act-member-initial"><?= e(mb_substr((string)$m['name'], 0, 1)) ?></span>
<?php endif; ?>
<span class="act-member-name"><?= e($m['name']) ?></span>
</span>
<?php endforeach; ?>
<?php if ($memberCount > count($members)): ?>
<span class="act-member-more">等 <?= $memberCount ?> 人</span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="meta act-row-meta">
<?php if ($placeCount > 0): ?>
<span>📍 <?= $placeCount ?> 个地方<?= $placeMain !== '' ? '(' . e($placeMain) . ')' : '' ?></span>
<?php endif; ?>
<?php if ($days > 0): ?>
<span>🕒 <?= $days ?> 天<?= $spanText !== '' ? '(' . e($spanText) . ')' : '' ?></span>
<?php endif; ?>
<span>🧳 <?= $nodeCount ?> 个节点</span>
</div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php if ($spots): ?>
</div>
<script>
(function () {
'use strict';
/* ── 标签页:地图默认打开;切回地图时重新适配视野并重排图标 ── */
var map = null;
var tabs = Array.prototype.slice.call(document.querySelectorAll('.route-tab'));
var panels = {
'home-panel-map': document.getElementById('home-panel-map'),
'home-panel-list': document.getElementById('home-panel-list'),
};
tabs.forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-panel');
if (!panels[id]) return;
tabs.forEach(function (b) {
var on = (b === btn);
b.classList.toggle('is-active', on);
b.setAttribute('aria-selected', on ? 'true' : 'false');
});
Object.keys(panels).forEach(function (k) {
if (panels[k]) panels[k].hidden = (k !== id);
});
if (id === 'home-panel-map' && map) {
fitView();
relayout();
}
});
});
var el = document.getElementById('home-map-canvas');
if (!el) return;
if (typeof AMap === 'undefined' || window.__AMAP_ERROR) {
el.innerHTML = '<p class="route-map-fallback">地图脚本没有加载成功,暂时无法显示旅行地图。</p>';
return;
}
var spots;
try {
spots = JSON.parse(el.getAttribute('data-spots') || '[]');
} catch (e) {
return;
}
if (!spots.length) return;
/* 图标下面还挂了一行活动名,整块是 56×64 的**矩形**,所以这里按矩形判重叠。
(详情页那两处是纯圆标,才按中心距判 —— 形状不同判定就不同,别混用。) */
var MARK_W = 56, MARK_H = 64, STEP = MARK_W + 12;
function shortTitle(t) {
return (t && t.length > 8) ? t.slice(0, 8) + '…' : (t || '');
}
function esc(s) {
return String(s === null || s === undefined ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
map = new AMap.Map(el, { viewMode: '2D', zoom: 5 });
window.__homeMap = map;
var markers = [];
spots.forEach(function (s) {
var inner = s.img
? '<img src="' + esc(s.img) + '" alt="">'
: '<span class="home-marker-emoji">📍</span>';
var tip = s.place ? s.title + '(' + s.place + ')' : s.title;
var html = '<div class="home-marker-wrap" title="' + esc(tip) + '">'
+ '<div class="home-marker">' + inner + '</div>'
+ '<div class="home-marker-label">' + esc(shortTitle(s.title)) + '</div>'
+ '</div>';
/* ⚠️ position 必须是 [lng, lat] 数组:传 {lng,lat} 裸对象会让 AMap 抛错,
整个绘制循环当场中断(批 11 的「光秃秃地图」就是它)。 */
var mk = new AMap.Marker({
position: [s.lng, s.lat],
content: html,
anchor: 'center',
map: map,
});
mk.on('click', function () { window.location.href = s.url; });
markers.push(mk);
});
/* 适配视野,但**别放到街道级**:只有一次旅行(或几次都在同城)时
setFitView 会一路放大到 18 级,那是导航视角不是"旅行足迹"视角。
上限定在 11 级(能看清一座城市及其周边)。setZoom 会触发 zoomend → 自动重排。 */
var MAX_ZOOM = 11;
function fitView() {
if (!markers.length) return;
map.setFitView(null, false, [40, 40, 40, 40]);
if (map.getZoom() > MAX_ZOOM) map.setZoom(MAX_ZOOM);
}
fitView();
/* 图标重合(同一个城市去过好几次)时让开:没有线段可沿,就先左右、
再上下,按**屏幕像素**判定(缩放到"看全国"时两个城市会叠在一起)。 */
function place(base, placed) {
var cand = [{ x: 0, y: 0 }];
var k, m, sg;
for (k = 1; k <= 6; k++) {
m = Math.ceil(k / 2) * STEP; sg = (k % 2 === 1) ? 1 : -1;
cand.push({ x: m * sg, y: 0 });
}
for (k = 1; k <= 4; k++) {
m = Math.ceil(k / 2) * Math.round(MARK_H * 0.9); sg = (k % 2 === 1) ? 1 : -1;
cand.push({ x: 0, y: m * sg });
}
var hit = function (x, y) {
for (var j = 0; j < placed.length; j++) {
var q = placed[j];
if (Math.abs(x - q.x) < MARK_W && Math.abs(y - q.y) < MARK_H) return true;
}
return false;
};
var pick = null;
for (var i = 0; i < cand.length; i++) {
if (!hit(base.x + cand[i].x, base.y + cand[i].y)) { pick = cand[i]; break; }
}
if (!pick) pick = cand[0];
placed.push({ x: base.x + pick.x, y: base.y + pick.y });
return pick;
}
/* 缩放会改变图标之间的屏幕距离,所以每次缩放结束都要重排一次。
只改 offset,不重建标记 —— 重建会把点击事件也一起丢掉。 */
function relayout() {
var placed = [];
markers.forEach(function (mk) {
var base = null;
try {
base = map.lngLatToContainer(mk.getPosition());
} catch (e) {
base = null;
}
if (!base) return;
var off = place(base, placed);
mk.setOffset(new AMap.Pixel(off.x, off.y));
});
}
relayout();
map.on('zoomend', relayout);
})();
</script>
<?php endif; ?>
<?php endif; ?>