Files
TripLog/templates/activities/show.php
T
laoyang 331fadfb48 地图与正文同宽 / 详情页封面改列表行样式 / 封面拖拽上传(批 21)
① 带地图的页面(首页旅行地图、详情页行程地图)原本铺满整个视口
  (批 18 的负 margin 出血),用户反馈「上面正文固定宽、下面地图铺满屏」
  看着奇怪 → 改回与正文版心同宽,bleed() 已删、CSS 去边框覆盖已删。

② 活动详情页顶部整幅 16:9 大封面太大 → 改用活动列表同款「封面行」
  (.act-row-static:小封面 + 参与人 + 统计),数据复用 collectStats
  同一聚合口径;.activity-cover 样式删除。

③ 创建/编辑活动封面支持直接把图片拖进上传区:drop→DataTransfer 塞给
  原有 file input 并触发 change,类型/大小校验与裁剪流程原样复用;
  拖入悬停给高亮反馈。

验证:本地全链(含 _map12/_map16 同宽与封面行断言、5 档视口)全绿;
部署哨兵同步改向(批 17/18 锚定旧实现的断言全部反向或改口径)。
2026-09-24 22:13:29 +08:00

469 lines
24 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
/* 批 21:顶部不再放整幅 16:9 大封面(太大),改用活动列表同款的「封面行」——
左边小封面、右边参与人与统计(去了几个地方 / 几天 / 几个节点)。
数据与列表同一口径(ActivityController::collectStats);没传 stats 时只渲染封面。 */
$actSt = $actStats[(int)$activity['id']] ?? null;
$placeCount = (int)($actSt['place_count'] ?? 0);
$placeMain = (string)($actSt['place_main'] ?? '');
$days = (int)($actSt['days'] ?? 0);
$spanText = (string)($actSt['span_text'] ?? '');
$nodeCount = (int)($actSt['node_count'] ?? 0);
$membersTop = array_slice($actSt['members'] ?? [], 0, 5);
$memberCount = (int)($actSt['member_count'] ?? 0);
?>
<?php if (!empty($activity['cover_image']) || $actSt): ?>
<div class="act-row act-row-static">
<div class="act-row-cover">
<?php if (!empty($activity['cover_image'])): ?>
<img src="<?= e(media_cover((int)$activity['id'])) ?>" alt="">
<?php else: ?>
<div class="act-row-cover-ph">📸</div>
<?php endif; ?>
</div>
<?php if ($actSt): ?>
<div class="act-row-body">
<?php if ($membersTop): ?>
<div class="act-row-members">
<?php foreach ($membersTop 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($membersTop)): ?>
<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>
<?php endif; ?>
</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 ?? [];
$modeIcons = $modeIcons ?? []; // 批 19:出行方式→图标映射,由控制器注入
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);
// 批 19:标记图标优先用「出行方式」的图标;没选出行方式时回退到节点类型图标
//(赶路节点没选方式 → 仍是 🚗;选了飞机/火车/游船等 → 显示对应交通工具图标,不再一律汽车)。
$rnModeType = (string)($rn['travel_mode_type'] ?? '');
$rnIcon = ($rnModeType !== '' && isset($modeIcons[$rnModeType]))
? $modeIcons[$rnModeType]
: (string)($rn['node_type_icon'] ?: '📍');
$routeNodes[] = [
'id' => (int)$rn['id'],
'title' => (string)($rn['title'] ?: '(无标题节点)'),
'icon' => $rnIcon,
'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;
/* 批 21:地图不再铺满整个视口 —— 用户反馈「上面正文固定宽、下面地图铺满屏」
看着奇怪,改回与正文版心同宽(批 18 的负 margin 出血逻辑 bleed() 已删)。 */
map = new AMap.Map(el, { viewMode: '2D', zoom: 5 });
/* 挂到 window 供自动化测试(真浏览器量标记位置)与线上排查用;页面卸载即释放 */
window.__routeMap = map;
var overlays = []; // 当前画上去的所有覆盖物;地理编码补点后整体重画
/* ── 防重合(批 15 改法,批 20 收紧)─────────────────────────
只处理「经纬度完全相同」的重合,且只挪图标、不挪线、不改数据:
① 往返(A→B 与 B→A 是同一段):两段线重合(本就该重合),但两枚图标会
叠在同一中点 → 沿这条线**前后排开**,看上去就是同一条路上跑了两趟。
② 坐标不同、只是屏幕上挨着的密集节点(缩小时尤甚):**不再让开**,
一律留在真实经纬度上。批 20 之前按屏幕像素距离判碰撞、把挨着的标记
各甩 56px(低 zoom 下等于数公里),导致缩小时图标明显偏离真实位置。
判定从「屏幕像素中心距」改为「posKey 经纬度字符串是否相同」:只有
完全相同的坐标才偏移,否则零偏移。位移方向取线段屏幕方向,太短则退法线、再水平。
错开只改「怎么画」,不改数据 —— inPath / prevEnd 仍用原始坐标。 */
var MARK_W = 46; // 碰撞判定:圆标直径 40 + 6px 间隙(按中心距判,见下面的排布器)
var STEP = MARK_W + 10; // 让开一步 = 56px(圆标净间隙 16px,两枚挨着也分得清)
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;
}
/** 一段线在屏幕上的方向:单位向量 + 像素长度。
A→B 与 B→A 用同一个键 → 往返两枚图标朝同一头排队,不会各往一边跑。 */
function makeSegDir() {
var cache = {};
return function (p1, p2) {
var key = segKey(p1, p2);
if (cache[key]) return cache[key];
var d = { x: 1, y: 0, len: 0 }; // 拿不到就退化成水平方向
try {
var a = map.lngLatToContainer(new AMap.LngLat(p1[0], p1[1]));
var b = map.lngLatToContainer(new AMap.LngLat(p2[0], p2[1]));
var vx = b.x - a.x, vy = b.y - a.y;
var len = Math.sqrt(vx * vx + vy * vy);
if (len > 1) d = { x: vx / len, y: vy / len, len: len };
} catch (e) {
/* 地图还没量出尺寸:用水平方向兜底 */
}
cache[key] = d;
return d;
};
}
/** 排布:只有「经纬度完全相同」的标记才偏移(批 20 收紧)。
坐标不同 → 一律零偏移,留在真实位置,不再按屏幕像素甩开。
同坐标重复(如往返中点)沿这段线前后排开,线太短走法线、再水平兜底。 */
function makePlacer() {
var seen = {}; // posKey(realCoord) -> 已在该坐标放置的标记数
return {
place: function (dir, pos) {
var key = posKey(pos);
var n = seen[key] || 0;
if (n === 0) { // 该坐标第一枚:原地放,不偏移
seen[key] = 1;
return { x: 0, y: 0 };
}
/* 同坐标重复:沿这段线让开(图标仍排在这条线上) */
var maxAlong = Math.max(0, dir.len * 0.45 - MARK_W / 2);
var cand = [];
var k, step, sg;
for (k = 1; k <= 4; k++) { // ① 沿线前后交替(首选)
step = Math.ceil(k / 2) * STEP;
sg = (k % 2 === 1) ? 1 : -1;
if (step <= maxAlong) cand.push({ x: dir.x * step * sg, y: dir.y * step * sg });
}
for (k = 1; k <= 4; k++) { // ② 线太短:改走法线
step = Math.ceil(k / 2) * STEP;
sg = (k % 2 === 1) ? 1 : -1;
cand.push({ x: -dir.y * step * sg, y: dir.x * step * sg });
}
for (k = 1; k <= 6; k++) { // ③ 兜底:水平让开
step = Math.ceil(k / 2) * STEP;
sg = (k % 2 === 1) ? 1 : -1;
cand.push({ x: step * sg, y: 0 });
}
/* 第 n 次重复取第 n 个候选(跳过 cand[0]=原地),顺着线铺开;
候选用尽就取最后一个,总比乱飞好 */
var pick = cand.length ? cand[Math.min(n, cand.length - 1)] : { x: 0, y: 0 };
seen[key] = n + 1;
return pick;
},
reset: function () { seen = {}; },
};
}
/* 全量重画:先清掉旧覆盖物再按当前 route 数据画。
地理编码是异步的,一个点查回来就重画一次,数据是幂等的。
fromZoom=true 表示这次是「用户缩放了」触发的重排:只重排标记,
不再 setFitView —— 否则会和缩放事件互相触发,还会把用户手动
缩放好的视野又拉回去。 */
function drawRoute(fromZoom) {
overlays.forEach(function (o) { map.remove(o); });
overlays = [];
var inPath = []; // 节点内:出发地 → 到达地(实线)
var hopPath = []; // 节点之间:上一站到达地 → 下一站出发地(虚线)
var prevEnd = null;
var segDir = makeSegDir(); // 线段在屏幕上的方向:图标沿它排队
var placer = makePlacer(); // 标记排布:仅同坐标才偏移,否则留真实位置(批 20)
var dotSeen = {}; // 到达地红点:同一点位只画一个
route.forEach(function (n, i) {
var s = n.start, en = n.end;
/* 线按**原始坐标**画:往返两段重合是正常的,用户要的是图标不叠,
不是把线劈成两条(批 14 那样左右错开,偏移量太大看着像画错)。 */
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 = markerPos(n);
if (pos) {
/* 只把「经纬度完全相同」的标记(如往返中点)沿各自线段错开;
坐标不同则一律留在真实位置,不再按屏幕像素甩开 —— 否则节点
密集、缩小时多枚标记会被甩出 56px(低 zoom 下=数公里),
图标明显偏离真实坐标(批 20)。 */
var dir = (s && en)
? segDir([s.lng, s.lat], [en.lng, en.lat])
: { x: 1, y: 0, len: 0 };
var off = placer.place(dir, 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 opts = {
position: pos,
content: html,
anchor: 'center',
map: map,
};
/* 同坐标的标记才沿这条线挪开 —— 用像素偏移,谁也不挡谁 */
if (off.x !== 0 || off.y !== 0) {
opts.offset = new AMap.Pixel(off.x, off.y);
}
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); });
/* 改窗口大小 / 旋转屏幕 / 换分辨率:地图容器跟着版心变,容器一变
就要按新尺寸重新适配视野并重排标记。防抖 200ms。 */
var resizeTimer = null;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
if (!map) return;
map.resize();
drawRoute(); // 重新适配视野 + 按新像素重排标记
}, 200);
});
/* 只有地名、没有坐标的地点:向高德地理编码查坐标(插件按需加载)。
查到就补进数据并重画;查不到(地名太偏/写错)保持缺省,不硬造位置。 */
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; ?>