Files
TripLog/templates/nodes/show.php
T
laoyang 44ffee6c92 节点表单:时间框整块可点、起点终点一键导航、可直接传多张附件
三件事都是「界面上明明该有、但用起来不通」的地方:

1) 时间输入框只能点右侧那个小日历图标才弹选择器
   原生行为如此(而且各浏览器还不一致),用户会以为输入框是只读的。
   在全局脚本里给 datetime-local / date 挂 click → showPicker(),
   等于把整块输入区都当成那个图标;选择器已在显示时再调是空操作,
   不支持的浏览器静默跳过。顺带给可点光标。
   写在 layout.php 里,所以签到时间等其它时间框一并生效。

2) 起点/终点的精确位置此前只存在页面上,没有落库
   高德选点回调里拿到的 lng/lat 现在随节点一起存库,节点详情页和
   表单页都生成高德导航链接,点一下就能直接导航过去(手机端会尝试
   唤起高德 App)。链接只给 to 不给 from,让移动端自动用当前位置当起点。
   ⚠️ 高德 URI API 的坐标顺序是「经度,纬度」,写反了不报错、只会安静地
   导到地球另一边,所以单独写了单元测试守住顺序。
   手工改地名时会清掉旧坐标,退化成关键字搜索,避免「名字改了位置没改」。

3) 节点处不能上传图片附件
   节点表单改成 multipart,加拖放区 + 多选 + 本地预览 + 逐个移除,
   编辑态可勾选删除已有附件(逐条校验归属,防止改 id 删到别人家的文件)。
   上传逻辑从 AttachmentController 抽到 src/Uploads.php,两处共用一份:
   MIME 白名单、大小上限、孤儿回滚各抄一份迟早走偏。
   请求体超过 post_max_size 时 PHP 会静默丢掉 $_POST 与 $_FILES,紧随的
   CSRF 校验必然失败、报「请求已过期」——这是最难排查的一类问题。
   现在按 CONTENT_LENGTH 提前识别并给出人话提示,且该守卫必须排在
   csrf_check() 之前(只读长度、不改状态,所以顺序上是安全的)。

已验证:本地 php -l 全量、模板渲染 255 项、amap_nav_url 单测 18 项、
真实浏览器交互 47 项(时间框三处点击都弹、多选/移除后 input.files 真被
改写、超限被前端拦下、导航链接随选点刷新、勾选删除变红、三视口无溢出)、
打包 57 文件、远端 e2e 267 项,全部通过;部署后双 PHP 渲染逐字节一致。
2026-09-23 07:53:20 +08:00

197 lines
8.1 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.
<?php
/**
* 节点详情:基础信息 + 参与人 + 花销 + 讨论
* 一个节点一页,把挂在这个节点上的所有记录集中起来
*/
use App\Attachments;
use App\Comments;
$statusLabels = [
'planned' => '待执行',
'ongoing' => '进行中',
'completed' => '已完成',
'cancelled' => '已取消',
];
$st = (string)($node['status'] ?? 'planned');
if (!isset($statusLabels[$st])) $st = 'planned';
$sLoc = json_decode_field($node['start_location'] ?? null);
$eLoc = json_decode_field($node['end_location'] ?? null);
/**
* 交通只记一个号码(航班号 / 车次 / 班次)——
* 航班号本身就隐含航空公司,座位号对多人同行又没法记,所以都不收。
*/
$tripFieldLabels = [
'plane' => '航班号',
'train' => '车次',
'bus' => '班次',
'subway' => '线路',
'ship' => '航次',
'taxi' => '备注',
];
$tripLabel = $tripFieldLabels[(string)($node['travel_mode_type'] ?? '')] ?? '车次 / 班次';
$transportParts = [];
if (!empty($node['trip_number'])) {
$transportParts[] = $tripLabel . ' ' . $node['trip_number'];
}
$durationText = '';
if (!empty($node['duration_minutes'])) {
$mins = (int)$node['duration_minutes'];
$durationText = $mins >= 60 ? intdiv($mins, 60) . ' 小时 ' . ($mins % 60) . ' 分' : $mins . ' 分钟';
}
?>
<div class="page-header">
<div>
<h1><?= e(($node['node_type_icon'] ?? '') . ' ' . ($node['title'] ?: '(无标题节点)')) ?></h1>
<p class="muted">
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>"><?= e($node['activity_title']) ?></a>
· <?= $node['node_category'] === 'plan' ? '计划节点' : '执行节点' ?>
· <span class="status-tag status-<?= e($st) ?>"><?= e($statusLabels[$st]) ?></span>
<?php if ($node['node_category'] === 'reality' && !empty($node['original_plan_node_id'])): ?>
· <span class="tag-default">由计划节点生成</span>
<?php endif; ?>
</p>
</div>
<div class="actions" style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="<?= url('nodes/edit', ['id' => $node['id']]) ?>" class="btn">编辑节点</a>
<a href="<?= url('attachments/node', ['id' => $node['id']]) ?>" class="btn">📷 照片 (<?= (int)$attachmentCount ?>)</a>
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
<form method="post" action="<?= url('nodes/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除这个节点?它的附件、参与记录、花销和讨论都会一并删除。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$node['id'] ?>">
<button type="submit" class="btn btn-danger">删除节点</button>
</form>
</div>
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">时间</span>
<span class="detail-value">
<?= date('Y-m-d H:i', strtotime((string)$node['start_time'])) ?>
→
<?= date('Y-m-d H:i', strtotime((string)$node['end_time'])) ?>
<?php if ($durationText !== ''): ?>
<span class="muted">(<?= e($durationText) ?>)</span>
<?php endif; ?>
</span>
</div>
<div class="detail-item">
<span class="detail-label">类型</span>
<span class="detail-value"><?= e($node['node_type_name'] ?? '未分类') ?></span>
</div>
<?php if (!empty($node['travel_mode_label'])): ?>
<div class="detail-item">
<span class="detail-label">出行方式</span>
<span class="detail-value"><?= e($node['travel_mode_label']) ?></span>
</div>
<?php endif; ?>
<?php if (!empty($transportParts)): ?>
<div class="detail-item">
<span class="detail-label">交通</span>
<span class="detail-value"><?= e(implode(' · ', $transportParts)) ?></span>
</div>
<?php endif; ?>
<?php if (!empty($sLoc['name']) || !empty($sLoc['address'])): ?>
<div class="detail-item">
<span class="detail-label">起点</span>
<span class="detail-value">
📍 <?= e($sLoc['name'] ?? '') ?>
<?php if (!empty($sLoc['address'])): ?>
<span class="muted"><?= e($sLoc['address']) ?></span>
<?php endif; ?>
<?php /* 存下来的坐标就是为了一键导航:手机端点开会直接唤起高德 App */ ?>
<?php $sNav = amap_nav_url($sLoc); ?>
<?php if ($sNav !== ''): ?>
<a class="loc-nav-link loc-nav-inline" href="<?= e($sNav) ?>"
target="_blank" rel="noopener">🧭 导航</a>
<?php endif; ?>
</span>
</div>
<?php endif; ?>
<?php if (!empty($eLoc['name']) || !empty($eLoc['address'])): ?>
<div class="detail-item">
<span class="detail-label">终点</span>
<span class="detail-value">
📍 <?= e($eLoc['name'] ?? '') ?>
<?php if (!empty($eLoc['address'])): ?>
<span class="muted"><?= e($eLoc['address']) ?></span>
<?php endif; ?>
<?php $eNav = amap_nav_url($eLoc); ?>
<?php if ($eNav !== ''): ?>
<a class="loc-nav-link loc-nav-inline" href="<?= e($eNav) ?>"
target="_blank" rel="noopener">🧭 导航</a>
<?php endif; ?>
</span>
</div>
<?php endif; ?>
<?php if (!empty($node['description'])): ?>
<div class="detail-item detail-item-wide">
<span class="detail-label">描述</span>
<span class="detail-value"><?= nl2br(e($node['description'])) ?></span>
</div>
<?php endif; ?>
<?php if ($st === 'cancelled' && !empty($node['cancel_reason'])): ?>
<div class="detail-item detail-item-wide">
<span class="detail-label">取消原因</span>
<span class="detail-value node-cancel-reason"><?= e($node['cancel_reason']) ?></span>
</div>
<?php endif; ?>
</div>
<?php
/* 照片墙:地址一律走 media/show 代理,页面里不出现存储后端的真实地址 */
$photoRows = [];
$otherCount = 0;
foreach (($attachments ?? []) as $attRow) {
if (Attachments::isImageRow($attRow)) {
$photoRows[] = $attRow;
} else {
$otherCount++;
}
}
$showPhotos = array_slice($photoRows, 0, 12);
?>
<?php if (!empty($attachments)): ?>
<h2 class="section-title" id="photos">照片与附件(<?= count($attachments) ?>)</h2>
<?php if (!empty($showPhotos)): ?>
<div class="photo-grid photo-grid-sm">
<?php foreach ($showPhotos as $attRow): ?>
<a class="photo-card" href="<?= e(media_att((int)$attRow['id'])) ?>"
target="_blank" rel="noopener" title="<?= e($attRow['file_name']) ?>">
<img src="<?= e(media_att((int)$attRow['id'])) ?>" alt="" loading="lazy">
</a>
<?php endforeach; ?>
<?php if (count($photoRows) > count($showPhotos)): ?>
<a class="photo-more" href="<?= url('attachments/node', ['id' => (int)$node['id']]) ?>#photos">
+<?= count($photoRows) - count($showPhotos) ?><span>张</span>
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="photo-bar">
<a class="btn btn-sm" href="<?= url('attachments/node', ['id' => (int)$node['id']]) ?>">
📷 管理照片(<?= count($attachments) ?>)
</a>
<?php if ($otherCount > 0): ?>
<span class="muted">另有 <?= (int)$otherCount ?> 个非图片附件</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php require __DIR__ . '/_members.php'; ?>
<?php require __DIR__ . '/_expenses.php'; ?>
<?php
$commentTargetType = Comments::TARGET_NODE;
$commentTargetId = (int)$node['id'];
require dirname(__DIR__) . '/comments/_block.php';
?>