Files
TripLog/templates/activities/show.php
T
laoyang c4c3b17cf7 修「光秃秃地图」回归 + 详情页改标签页布局(批 12)
回归修复(用户反馈:地图上什么都不显示):
- 批 11 重构时把 {lng,lat} 对象直接传给了 AMap.Marker.position ——
  AMap 只认 [lng, lat] 数组/LngLat,构造器抛 Invalid Object,
  forEach 当场中断,整张地图一个覆盖物都不剩(光秃秃地图)。
- 现在 position 一律归一化为数组(中点/单点三分支),并新增
  _page11.php + _map12.js 真 AMap 无头浏览器用例锁死(覆盖双坐标/
  单坐标/只有地名三种节点形态 + 无 JS 报错 + 标记数量断言),
  已接入 _chain3.py 全链(步骤 10b)。

标签页布局(用户需求):
- 活动详情页拆成两个标签:「🗺️ 行程地图」(默认)与「📋 计划与实际」;
- 地图页独占整页,高度加大到 clamp(480px, 72vh, 860px)(手机 62vh);
- 节点页 = 原 时间轴(计划/实际两列)+ 评论区,默认隐藏;
- 切回地图标签时重新 setFitView(隐藏期间容器尺寸无意义);
- 活动没有可上图的执行节点时不渲染标签条,退化为原单页布局。

已验证:本地全链 12 步全绿(渲染检查 341 项 + 真 AMap 浏览器验证
11 项);远端 e2e 341 项 0 失败;部署静态断言/双 PHP 渲染/内容断言
全过后提交。
2026-09-24 12:04:31 +08:00

263 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div class="page-header">
<div>
<h1><?= e($activity['title']) ?></h1>
<p class="muted"><?= e($activity['description'] ?: '暂无简介') ?></p>
</div>
<div class="actions" style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'plan']) ?>" class="btn">+ 计划节点</a>
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'reality']) ?>" class="btn-primary">+ 执行节点</a>
<a href="<?= url('activities/edit', ['id' => $activity['id']]) ?>" class="btn">编辑活动</a>
<form method="post" action="<?= url('activities/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除活动「<?= e($activity['title']) ?>」?\n该活动下的所有节点、附件、评论都会一并删除,且不可恢复。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$activity['id'] ?>">
<button type="submit" class="btn btn-danger">删除活动</button>
</form>
</div>
</div>
<?php if (!empty($activity['cover_image'])): ?>
<div class="activity-cover">
<img src="<?= e(media_cover((int)$activity['id'])) ?>" alt="">
</div>
<?php endif; ?>
<?php $expenseSummary = $expenseSummary ?? ['total' => 0.0, 'count' => 0]; ?>
<?php if ((int)$expenseSummary['count'] > 0): ?>
<div class="activity-stats">
<span class="activity-stat">💰 共 <?= (int)$expenseSummary['count'] ?> 笔</span>
<span class="activity-stat">总计 ¥<?= number_format((float)$expenseSummary['total'], 2) ?></span>
</div>
<?php endif; ?>
<?php
/* ── 路线地图(批 10 引入,批 11 调整位置与标记逻辑)──────────────
位置放在封面之下、时间轴之前:行程一长,拉到底才看到地图的体验很差。
按时间顺序把执行节点的起终点连成路线:节点内实线、节点之间虚线;
圆形标记放在**这段行程线的中点**(缺一头就放有的一头)——
有图片附件显示图片,没有显示节点类型 emoji,左上角带序号,
点一下打开该节点详情。
只有地名、没坐标的地点(偏僻地方下拉选不到点位):浏览器端调
高德地理编码(AMap.Geocoder)按地名查坐标,查到就补进路线重画;
查不到就保持缺省,不硬造位置。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']);
$sname = trim((string)($s['name'] ?? ''));
$ename = trim((string)($e['name'] ?? ''));
if (!$hasS && !$hasE && $sname === '' && $ename === '') {
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']]),
'sname' => $sname, // 出发地名:没有坐标时交给高德地理编码兜底
'ename' => $ename, // 到达地名:同上
'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 !== []): ?>
<div class="route-tabs" role="tablist" aria-label="行程地图与节点详情切换">
<button type="button" class="route-tab is-active" data-panel="route-panel-map"
aria-selected="true">🗺️ 行程地图</button>
<button type="button" class="route-tab" data-panel="route-panel-nodes"
aria-selected="false">📋 计划与实际</button>
</div>
<div class="route-panel" id="route-panel-map">
<section class="route-map-section">
<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>
</div>
<div class="route-panel" id="route-panel-nodes" hidden>
<?php endif; ?>
<div class="timeline-grid">
<section class="timeline-col">
<h2>📋 计划 (<?= count($planNodes) ?>)</h2>
<?php if (empty($planNodes)): ?>
<p class="muted">暂无计划节点</p>
<?php else: ?>
<?php foreach ($planNodes as $n): ?>
<?php $cardCategory = 'plan'; require __DIR__ . '/_node_card.php'; ?>
<?php endforeach; ?>
<?php endif; ?>
</section>
<section class="timeline-col">
<h2>🎬 实际 (<?= count($realityNodes) ?>)</h2>
<?php if (empty($realityNodes)): ?>
<p class="muted">暂无执行节点</p>
<?php else: ?>
<?php foreach ($realityNodes as $n): ?>
<?php $cardCategory = 'reality'; require __DIR__ . '/_node_card.php'; ?>
<?php endforeach; ?>
<?php endif; ?>
</section>
</div>
<?php
$commentTargetType = \App\Comments::TARGET_ACTIVITY;
$commentTargetId = (int)$activity['id'];
require dirname(__DIR__) . '/comments/_block.php';
?>
<?php if ($routeNodes !== []): ?>
</div>
<script>
(function () {
'use strict';
/* ── 标签页切换:行程地图 / 计划与实际 ──────────────────────
地图标签默认打开(地图是主视图);切回来时重新 setFitView,
因为容器隐藏期间尺寸没有意义。 */
var tabs = Array.prototype.slice.call(document.querySelectorAll('.route-tab'));
var panels = {
'route-panel-map': document.getElementById('route-panel-map'),
'route-panel-nodes': document.getElementById('route-panel-nodes'),
};
var map = null; // 下面初始化后挂进来,供切换标签时重新适配视野
tabs.forEach(function (btn) {
btn.addEventListener('click', function () {
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 (id) {
if (panels[id]) panels[id].hidden = (id !== btn.getAttribute('data-panel'));
});
if (btn.getAttribute('data-panel') === 'route-panel-map' && map) {
map.setFitView(null, false, [50, 50, 50, 50]);
}
});
});
/* ── 路线地图(AMap 2.0,layout.php 已全局加载)────────────── */
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;
map = new AMap.Map(el, { viewMode: '2D', zoom: 5 });
var overlays = []; // 当前画上去的所有覆盖物;地理编码补点后整体重画
/* 全量重画:先清掉旧覆盖物再按当前 route 数据画。
地理编码是异步的,一个点查回来就重画一次,数据是幂等的。 */
function drawRoute() {
overlays.forEach(function (o) { map.remove(o); });
overlays = [];
var inPath = []; // 节点内:出发地 → 到达地(实线)
var hopPath = []; // 节点之间:上一站到达地 → 下一站出发地(虚线)
var prevEnd = null;
route.forEach(function (n, i) {
var s = n.start, en = n.end;
if (s && en) inPath.push([s.lng, s.lat], [en.lng, en.lat]);
if (s && prevEnd) hopPath.push([prevEnd.lng, prevEnd.lat], [s.lng, s.lat]);
/* 圆形标记放这段行程线的中点(起终点都有才取中点),缺一头就放有的一头。
⚠️ position 必须是 [lng, lat] 数组 —— 把 {lng,lat} 对象直接传给
AMap 会抛 Invalid Object,forEach 当场中断、整张地图一个覆盖物
都不剩(批 11 上线后踩的坑,光秃秃地图就是它)。 */
var pos = (s && en)
? [(s.lng + en.lng) / 2, (s.lat + en.lat) / 2]
: (s ? [s.lng, s.lat] : (en ? [en.lng, en.lat] : null));
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,
content: html,
anchor: 'center',
map: map,
});
marker.on('click', function () { window.location.href = n.url; });
overlays.push(marker);
}
/* 到达地小圆点(和出发地不是同一点时才画) */
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; });
overlays.push(dot);
}
prevEnd = en || s;
});
if (inPath.length > 1) {
overlays.push(new AMap.Polyline({
path: inPath, strokeColor: '#3B82F6', strokeWeight: 5,
strokeOpacity: 0.9, lineJoin: 'round', showDir: true, map: map,
}));
}
if (hopPath.length) {
overlays.push(new AMap.Polyline({
path: hopPath, strokeColor: '#94A3B8', strokeWeight: 3,
strokeStyle: 'dashed', strokeOpacity: 0.8, map: map,
}));
}
if (overlays.length) map.setFitView(null, false, [50, 50, 50, 50]);
}
drawRoute();
/* 只有地名、没有坐标的地点:向高德地理编码查坐标(插件按需加载)。
查到就补进数据并重画;查不到(地名太偏/写错)保持缺省,不硬造位置。 */
function geocode(name, cb) {
if (!name) return;
AMap.plugin('AMap.Geocoder', function () {
try {
new AMap.Geocoder({}).getLocation(name, function (status, result) {
if (status === 'complete' && result && result.geocodes && result.geocodes.length
&& result.geocodes[0].location) {
var loc = result.geocodes[0].location;
cb({ lng: Number(loc.getLng()), lat: Number(loc.getLat()) });
}
});
} catch (e) { /* 地理编码服务不可用时保持原状 */ }
});
}
route.forEach(function (n) {
if (!n.start && n.sname) geocode(n.sname, function (p) { n.start = p; drawRoute(); });
if (!n.end && n.ename) geocode(n.ename, function (p) { n.end = p; drawRoute(); });
});
})();
</script>
<?php endif; ?>