活动详情页新增「行程路线」地图(高德,批 10)

按时间顺序把执行节点(reality)的起终点坐标连成路线:节点内实线
(showDir 方向箭头)、节点之间虚线衔接;每个节点在出发地(没有出发地
就放到达地)放一个圆形标记 —— 有图片附件显示图片,没有就显示节点
类型 emoji,左上角带序号;点击标记/红点打开对应节点详情。到达地与
出发地不同点时另画红色小圆点。

实现要点:
- 数据层 Attachments::firstImageIdsForNodes():一条 GROUP BY + MIN(id)
  批量取各执行节点首张图片附件,防 N+1;
- 模板只收「有坐标的执行节点」,计划节点与无坐标节点不上图;
  路线数据经 data-route 属性以 JSON 交给内联 JS;
- AMap 2.0(layout.php 已全局加载),加载失败时给一句人话兜底不白屏;
- 地图区块位于时间轴之下、评论区之前。

已验证:本地全链 11 步全绿(渲染检查 330 项);远端 e2e 全绿(新增
10 项地图断言);部署静态断言与双 PHP 真实渲染通过后提交。
This commit is contained in:
2026-09-24 11:12:27 +08:00
parent 0e30495f14
commit 69279db1b2
4 changed files with 189 additions and 0 deletions
+124
View File
@@ -54,6 +54,130 @@
</section>
</div>
<?php
/* ── 路线地图(批 10)──────────────────────────────
按时间顺序把执行节点的起终点连成路线:节点内实线、节点之间虚线;
每个节点在起点(没有起点就放终点)放一个圆形标记 ——
有图片附件显示图片,没有显示节点类型 emoji,左上角带序号,
点一下打开该节点详情。AMap 2.0 已由 layout.php 全局加载。 */
$routeNodes = [];
$nodeFirstImage = $nodeFirstImage ?? [];
foreach (($realityNodes ?? []) as $rn) {
$s = json_decode_field($rn['start_location'] ?? null);
$e = json_decode_field($rn['end_location'] ?? null);
$hasS = !empty($s['lng']) && !empty($s['lat']);
$hasE = !empty($e['lng']) && !empty($e['lat']);
if (!$hasS && !$hasE) {
continue; // 起终点一个坐标都没有的节点不上地图
}
$imgId = (int)($nodeFirstImage[(int)$rn['id']] ?? 0);
$routeNodes[] = [
'id' => (int)$rn['id'],
'title' => (string)($rn['title'] ?: '(无标题节点)'),
'icon' => (string)($rn['node_type_icon'] ?: '📍'),
'img' => $imgId > 0 ? media_att($imgId) : '',
'url' => url('nodes/show', ['id' => (int)$rn['id']]),
'start' => $hasS ? ['lng' => (float)$s['lng'], 'lat' => (float)$s['lat']] : null,
'end' => $hasE ? ['lng' => (float)$e['lng'], 'lat' => (float)$e['lat']] : null,
];
}
?>
<?php if ($routeNodes !== []): ?>
<section class="route-map-section">
<h2 class="section-title" id="route-map">🗺️ 行程路线</h2>
<div class="route-map" id="route-map-canvas"
data-route="<?= e(json_encode($routeNodes, JSON_UNESCAPED_UNICODE)) ?>"></div>
<p class="muted route-map-hint">
按时间顺序连出的执行节点路线(节点间虚线衔接),点标记可打开对应节点。
线上圆标 = 节点出发地,<span class="route-legend-end">红点</span> = 到达地。
</p>
</section>
<script>
(function () {
'use strict';
var el = document.getElementById('route-map-canvas');
if (!el) return;
/* AMap 脚本加载失败(罕见)时给一句人话,不白屏 */
if (typeof AMap === 'undefined' || window.__AMAP_ERROR) {
el.innerHTML = '<p class="route-map-fallback">地图脚本没有加载成功,暂时无法显示路线。</p>';
return;
}
var route;
try {
route = JSON.parse(el.getAttribute('data-route') || '[]');
} catch (e) {
return;
}
if (!route.length) return;
var map = new AMap.Map(el, { viewMode: '2D', zoom: 5 });
var inNodePath = []; // 节点内:出发地 → 到达地(实线)
var hopPath = []; // 节点之间:上一站到达地 → 下一站出发地(虚线)
var prevEnd = null;
route.forEach(function (n, i) {
var s = n.start, en = n.end;
/* 节点内实线 */
if (s && en) {
inNodePath.push([s.lng, s.lat], [en.lng, en.lat]);
}
/* 节点间虚线(衔接上一站的到达地) */
if (s && prevEnd) {
hopPath.push([prevEnd.lng, prevEnd.lat], [s.lng, s.lat]);
}
/* 圆形标记:优先放在出发地,只有到达地就放到达地 */
var pos = s || en;
if (pos) {
var html = '<div class="route-marker" title="' + n.title.replace(/"/g, '&quot;') + '">'
+ (n.img
? '<img src="' + n.img + '" alt="">'
: '<span class="route-marker-emoji">' + n.icon + '</span>')
+ '<span class="route-marker-num">' + (i + 1) + '</span>'
+ '</div>';
var marker = new AMap.Marker({
position: [pos.lng, pos.lat],
content: html,
anchor: 'bottom-center',
map: map,
});
marker.on('click', function () { window.location.href = n.url; });
}
/* 到达地小圆点(和出发地不是同一点时才画) */
if (en && (!s || en.lng !== s.lng || en.lat !== s.lat)) {
var dot = new AMap.Marker({
position: [en.lng, en.lat],
content: '<div class="route-end-dot"></div>',
anchor: 'center',
map: map,
});
dot.on('click', function () { window.location.href = n.url; });
}
prevEnd = en || s;
});
if (inNodePath.length > 1) {
map.add(new AMap.Polyline({
path: inNodePath, strokeColor: '#3B82F6', strokeWeight: 5,
strokeOpacity: 0.9, lineJoin: 'round', showDir: true,
}));
}
if (hopPath.length) {
map.add(new AMap.Polyline({
path: hopPath, strokeColor: '#94A3B8', strokeWeight: 3,
strokeStyle: 'dashed', strokeOpacity: 0.8,
}));
}
map.setFitView(null, false, [50, 50, 50, 50]);
})();
</script>
<?php endif; ?>
<?php
$commentTargetType = \App\Comments::TARGET_ACTIVITY;
$commentTargetId = (int)$activity['id'];