活动详情页新增「行程路线」地图(高德,批 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:
@@ -1242,3 +1242,44 @@ video.video-thumb.video-thumb-mini {
|
|||||||
}
|
}
|
||||||
.lightbox-file-hint { margin: 0; font-size: 13px; color: var(--text-mute); }
|
.lightbox-file-hint { margin: 0; font-size: 13px; color: var(--text-mute); }
|
||||||
.lightbox-file-dl { display: inline-block; }
|
.lightbox-file-dl { display: inline-block; }
|
||||||
|
|
||||||
|
/* ── 20. 活动页路线地图(批 10)──────────────────────────────────────────── */
|
||||||
|
.route-map-section { margin-top: 28px; }
|
||||||
|
.route-map {
|
||||||
|
height: 380px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.route-map-hint { margin-top: 8px; font-size: 12.5px; }
|
||||||
|
.route-map-fallback { padding: 24px; text-align: center; color: var(--text-mute); }
|
||||||
|
/* 节点圆形标记:有图片附件放图片,没有放类型 emoji;左上角序号 */
|
||||||
|
.route-marker {
|
||||||
|
position: relative; width: 40px; height: 40px; border-radius: 50%;
|
||||||
|
border: 3px solid #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, .35);
|
||||||
|
background: var(--surface); cursor: pointer; overflow: visible;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.route-marker img {
|
||||||
|
width: 100%; height: 100%; border-radius: 50%;
|
||||||
|
object-fit: cover; display: block;
|
||||||
|
}
|
||||||
|
.route-marker-emoji { line-height: 1; }
|
||||||
|
.route-marker-num {
|
||||||
|
position: absolute; top: -6px; right: -6px; min-width: 17px; height: 17px;
|
||||||
|
border-radius: 999px; background: #ef4444; color: #fff;
|
||||||
|
font-size: 11px; font-weight: 700; line-height: 17px;
|
||||||
|
text-align: center; padding: 0 3px;
|
||||||
|
}
|
||||||
|
/* 到达地小圆点 */
|
||||||
|
.route-end-dot {
|
||||||
|
width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: #ef4444; border: 2px solid #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
|
||||||
|
}
|
||||||
|
.route-legend-end { color: #ef4444; font-weight: 700; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.route-map { height: 300px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,6 +141,28 @@ final class Attachments
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一批节点的「第一张图片附件」id(活动页路线地图的圆形标记用)。
|
||||||
|
* 一条 GROUP BY 带回全部节点,防 N+1;id 递增即上传顺序的第一张。
|
||||||
|
*
|
||||||
|
* @return array<int, int> node_id => attachment_id
|
||||||
|
*/
|
||||||
|
public static function firstImageIdsForNodes(array $nodeIds): array
|
||||||
|
{
|
||||||
|
if ($nodeIds === []) return [];
|
||||||
|
$ids = array_map('intval', $nodeIds);
|
||||||
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$stmt = Database::get()->prepare("SELECT target_id, MIN(id) AS att_id FROM attachments
|
||||||
|
WHERE target_type = '" . self::TARGET_NODE . "' AND is_image = 1 AND target_id IN ($ph)
|
||||||
|
GROUP BY target_id");
|
||||||
|
$stmt->execute($ids);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$out[(int)$row['target_id']] = (int)$row['att_id'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
/** 历史行没有 storage 列时按本地处理 */
|
/** 历史行没有 storage 列时按本地处理 */
|
||||||
public static function storageOf(array $att): string
|
public static function storageOf(array $att): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -227,6 +227,8 @@ class ActivityController
|
|||||||
'realityNodes' => $realityNodes,
|
'realityNodes' => $realityNodes,
|
||||||
'executedPlanIds' => $executedPlanIds,
|
'executedPlanIds' => $executedPlanIds,
|
||||||
'nodeMembers' => $nodeMembers,
|
'nodeMembers' => $nodeMembers,
|
||||||
|
// 路线地图:每个执行节点第一张图片附件 id(圆形标记用),一条 GROUP BY 防止 N+1
|
||||||
|
'nodeFirstImage' => Attachments::firstImageIdsForNodes(array_column($realityNodes, 'id')),
|
||||||
'expenseSummary' => Expenses::summaryForActivity($id),
|
'expenseSummary' => Expenses::summaryForActivity($id),
|
||||||
'commentTree' => Comments::tree(Comments::TARGET_ACTIVITY, $id),
|
'commentTree' => Comments::tree(Comments::TARGET_ACTIVITY, $id),
|
||||||
'pageTitle' => $activity['title'],
|
'pageTitle' => $activity['title'],
|
||||||
|
|||||||
@@ -54,6 +54,130 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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, '"') + '">'
|
||||||
|
+ (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
|
<?php
|
||||||
$commentTargetType = \App\Comments::TARGET_ACTIVITY;
|
$commentTargetType = \App\Comments::TARGET_ACTIVITY;
|
||||||
$commentTargetId = (int)$activity['id'];
|
$commentTargetId = (int)$activity['id'];
|
||||||
|
|||||||
Reference in New Issue
Block a user