Files
TripLog/templates/activities/show.php
T

375 lines
18 KiB
PHP
Raw Normal View History

<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, // 到达地名:同上
'saddr' => trim((string)($s['address'] ?? '')), // 详细地址:地理编码优先用它
'eaddr' => trim((string)($e['address'] ?? '')),
'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 = []; // 当前画上去的所有覆盖物;地理编码补点后整体重画
/* ── 防重合(批 14)────────────────────────────────────────
两类重合,都要治:
① 往返:A→B 与 B→A 中点是同一个、线段也完全重合
→ 线段沿**法线**错开(像双向车道各占一边),标记在屏幕上错开;
② 距离很近的地点(相差几公里):坐标不同,但在"看全国"的缩放级别下
像素位置几乎一致 → 只能按**屏幕像素**判定碰撞再错开。
所以标记一律按像素排布(与缩放级别同步重算),不再只比坐标字符串。
注意:错开只改「怎么画」,不改数据 —— prevEnd 仍用原始坐标。 */
var MARK_W = 46; // 标记直径 + 间隙(CSS 里圆标 40px)
var MARK_H = 44;
function segKey(p1, p2) {
var a = p1[0].toFixed(5) + ',' + p1[1].toFixed(5);
var b = p2[0].toFixed(5) + ',' + p2[1].toFixed(5);
return a < b ? a + '|' + b : b + '|' + a; // A→B 与 B→A 同一键
}
function posKey(p) {
return p[0].toFixed(5) + ',' + p[1].toFixed(5);
}
function markerPos(n) {
var s = n.start, en = n.end;
if (s && en) return [(s.lng + en.lng) / 2, (s.lat + en.lat) / 2];
if (s) return [s.lng, s.lat];
if (en) return [en.lng, en.lat];
return null;
}
/** 同一段重复出现时,沿法线左右交替错开(+1 / -1 / +2 / -2 …) */
function makeSegOffsetter() {
var seen = {};
return function (p1, p2) {
var key = segKey(p1, p2);
var n = seen[key] || 0;
seen[key] = n + 1;
if (n === 0) return [p1, p2];
var dx = p2[0] - p1[0], dy = p2[1] - p1[1];
var len = Math.sqrt(dx * dx + dy * dy) || 1e-9;
var mag = Math.max(len * 0.06, 0.0008); // 长段按比例、短段给下限
var k = (n % 2 === 1) ? (n + 1) / 2 : -(n / 2);
var ox = (-dy / len) * mag * k;
var oy = (dx / len) * mag * k;
return [[p1[0] + ox, p1[1] + oy], [p2[0] + ox, p2[1] + oy]];
};
}
/** 贪心排布:和已放好的标记撞上就左右交替挪开,返回像素偏移 */
function makePlacer() {
var placed = []; // 已占用的屏幕坐标(容器像素)
return {
place: function (base, id) {
var dx = 0, guard = 0, k = 0;
while (guard++ < 30) {
var hit = false;
for (var j = 0; j < placed.length; j++) {
var q = placed[j];
if (Math.abs(base.x + dx - q.x) < MARK_W
&& Math.abs(base.y - q.y) < MARK_H) {
hit = true;
break;
}
}
if (!hit) break;
k++;
dx = (k % 2 === 1) ? Math.ceil(k / 2) * MARK_W : -Math.ceil(k / 2) * MARK_W;
}
placed.push({ x: base.x + dx, y: base.y, id: id });
return dx;
},
reset: function () { placed = []; },
};
}
/* 全量重画:先清掉旧覆盖物再按当前 route 数据画。
地理编码是异步的,一个点查回来就重画一次,数据是幂等的。
fromZoom=true 表示这次是「用户缩放了」触发的重排:只重排标记,
不再 setFitView —— 否则会和缩放事件互相触发,还会把用户手动
缩放好的视野又拉回去。 */
function drawRoute(fromZoom) {
overlays.forEach(function (o) { map.remove(o); });
overlays = [];
var inPath = []; // 节点内:出发地 → 到达地(实线)
var hopPath = []; // 节点之间:上一站到达地 → 下一站出发地(虚线)
var prevEnd = null;
var shiftSeg = makeSegOffsetter();
var placer = makePlacer(); // 标记按屏幕像素排布,撞上就挪开
var dotSeen = {}; // 到达地红点:同一点位只画一个
route.forEach(function (n, i) {
var s = n.start, en = n.end;
if (s && en) {
var seg = shiftSeg([s.lng, s.lat], [en.lng, en.lat]);
inPath.push(seg[0], seg[1]);
}
if (s && prevEnd) {
var hop = shiftSeg([prevEnd.lng, prevEnd.lat], [s.lng, s.lat]);
hopPath.push(hop[0], hop[1]);
}
/* 圆形标记放这段行程线的中点(起终点都有才取中点),缺一头就放有的一头。
⚠️ position 必须是 [lng, lat] 数组 —— 把 {lng,lat} 对象直接传给
AMap 会抛 Invalid Object,forEach 当场中断、整张地图一个覆盖物
都不剩(批 11 上线后踩的坑,光秃秃地图就是它)。 */
var pos = markerPos(n);
if (pos) {
/* 先问地图「这个经纬度现在在屏幕哪个像素上」,再和已放的标记比距离:
坐标不同但屏幕位置挨着(几公里的两个点、或往返的中点)都要挪开 */
var base = null;
try {
base = map.lngLatToContainer(new AMap.LngLat(pos[0], pos[1]));
} catch (e) {
base = null;
}
var dx = base ? placer.place(base, i) : 0;
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 opts = {
position: pos,
content: html,
anchor: 'center',
map: map,
};
/* 撞上了就整体往旁边挪 —— 用像素偏移,谁也不挡谁 */
if (dx !== 0) {
opts.offset = new AMap.Pixel(dx, 0);
}
var marker = new AMap.Marker(opts);
marker.on('click', function () { window.location.href = n.url; });
overlays.push(marker);
}
/* 到达地小圆点(和出发地不是同一点时才画)。
同一点位只画一个:往返行程两端的红点本来就该是同一个。 */
if (en && (!s || en.lng !== s.lng || en.lat !== s.lat)) {
var dk = posKey([en.lng, en.lat]);
if (!dotSeen[dk]) {
dotSeen[dk] = 1;
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 && !fromZoom) map.setFitView(null, false, [50, 50, 50, 50]);
}
drawRoute();
/* 缩放会改变标记之间的屏幕距离:缩小时原本分开的标记可能又叠在一起,
所以缩放结束后按新的像素位置重排一次(不重新适配视野)。 */
map.on('zoomend', function () { drawRoute(true); });
/* 只有地名、没有坐标的地点:向高德地理编码查坐标(插件按需加载)。
查到就补进数据并重画;查不到(地名太偏/写错)保持缺省,不硬造位置。 */
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) {
var qs = n.saddr || n.sname;
if (qs) geocode(qs, function (p) { n.start = p; drawRoute(); });
}
if (!n.end) {
var qe = n.eaddr || n.ename;
if (qe) geocode(qe, function (p) { n.end = p; drawRoute(); });
}
});
})();
</script>
<?php endif; ?>