补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊

数据库里早就建好了 comments、node_members + role_tags、expenses +
expense_splits 三组表,但一直没有代码接入。本次把这三块连上业务,
并新增「节点详情页」作为聚合入口。

新增
- src/Access.php          统一归属校验。此前每个控制器各写各的 family_id
                          条件,容易漏;现在 activity()/node()/target() 一处收敛,
                          requireAdmin() 统一管理员判断。
- src/Comments.php        两级平铺:parent_id 只允许指向顶层,写「回复的回复」
                          时规范化回顶层;删顶层会连同回复一起删。
- src/Members.php         参与人 + 角色标签 + 情绪 + 签到。情绪目录 17 项,
                          全部选用 Emoji 11.0(2018)及以前的码位,避免旧系统
                          字体缺字形显示成空白方块。
- src/Expenses.php        花销与分摊。分摊按「分」为单位 intdiv 平均,
                          余数归最后一人,保证 SUM(share_amount) = total_amount。
- templates/nodes/show.php 节点详情页:基本信息 + 参与人 + 花销 + 讨论 + 附件入口。
- 角色标签管理、评论区、参与人区、花销区各自的模板与控制器。

修复
- 越权:往别人活动的节点上插节点、把别家用户加成参与人,都只校验了
  「节点存在」,没校验「用户与节点同家庭」。现统一走 Access。
- 删活动时节点附件文件删不掉:附件在 /uploads/attachments/ 下,却调了
  只认 /uploads/covers/ 的删除函数,判定不通过 → 文件永久残留。改为
  统一的 deleteUploadFile(),两个目录都认。
- 删有引用记录的用户会 500(activities.created_by / attachments.uploader_id /
  comments.author_id / expenses.payer_user_id 这几处没有级联,也没做前置检查)。

其他
- 活动/节点列表补上附件、评论、参与人、花销四个计数(一次 GROUP BY 查完)。
- 首页布局新增一条迁移易踩的规避:新列上的索引必须排在 ALTER TABLE 之后。
- 新增 .gitignore:config.php(含数据库口令)与 uploads/ 下用户上传的
  头像封面不入库,只保留 uploads/.htaccess 那条禁止执行脚本的规则。
- 新增 config.example.php 作为新环境的配置模板。

本批不改数据库结构,不需要任何数据迁移。

本地与线上验证
- php -l 全绿(PHP 8.4 本地 / 线上 7.4.30 与 8.1.7 双版本)。
- 模板渲染检查 180 项全通过。
- 线上端到端 153 项全通过:登录、活动增删改查、节点流转、
  参与人/角色标签/情绪/签到、评论、花销与分摊、权限、级联清理、
  全站页面无 PHP 报错泄漏、跑完基线逐表核对且既有 id 集合逐字未变。
- 顺带写了 trip_sql_column_check.py:拿真实库表结构静态校验全项目 SQL 的
  列名引用。本次就是靠它之外的 e2e 抓到 travel_modes 并没有 line_name
  (该列属于 trip_nodes),一个列名写错导致节点详情页整页 Fatal。
This commit is contained in:
2026-09-22 22:03:08 +08:00
parent 6c1345e202
commit a79956758b
49 changed files with 7059 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* 节点花销面板
* 依赖外部变量:$node、$expenses、$expenseSummary、$familyUsers、
* $categories、$currencies(code=>中文名)、$expenseSymbols(code=>符号)、$currentUserId
*/
$expenses = $expenses ?? [];
$familyUsers = $familyUsers ?? [];
$categories = $categories ?? [];
$currencies = $currencies ?? [];
$expenseSymbols = $expenseSymbols ?? [];
$expenseSummary = $expenseSummary ?? ['budget' => 0.0, 'actual' => 0.0, 'count' => 0, 'diff' => 0.0];
$budget = (float)$expenseSummary['budget'];
$actual = (float)$expenseSummary['actual'];
$diff = (float)$expenseSummary['diff'];
$money = static function (float $v): string {
return number_format($v, 2);
};
?>
<section class="panel" id="expenses">
<h2 class="section-title">💰 花销 (<?= (int)$expenseSummary['count'] ?>)</h2>
<div class="expense-summary">
<div class="expense-stat">
<span class="muted">预算</span>
<strong>¥<?= $money($budget) ?></strong>
</div>
<div class="expense-stat">
<span class="muted">实际</span>
<strong>¥<?= $money($actual) ?></strong>
</div>
<div class="expense-stat">
<span class="muted"><?= $diff > 0 ? '超出预算' : '比预算省' ?></span>
<strong class="<?= $diff > 0 ? 'money-over' : 'money-under' ?>">¥<?= $money(abs($diff)) ?></strong>
</div>
</div>
<?php if (empty($expenses)): ?>
<p class="muted">还没有记账</p>
<?php else: ?>
<div class="expense-list">
<?php foreach ($expenses as $ex):
$sym = $expenseSymbols[$ex['currency']] ?? '';
$splits = [];
foreach (($ex['splits'] ?? []) as $sp) {
$sname = (string)($sp['user_name'] ?? '');
if ($sname === '') $sname = '已注销';
$splits[] = $sname . ' ' . $sym . $money((float)$sp['share_amount']);
}
?>
<div class="expense-row">
<div class="expense-main">
<span class="expense-cat"><?= e($ex['category']) ?></span>
<?php if ((int)$ex['is_budget'] === 1): ?>
<span class="expense-kind expense-kind-budget">预算</span>
<?php else: ?>
<span class="expense-kind expense-kind-actual">实际</span>
<?php endif; ?>
<span class="expense-desc"><?= e($ex['description'] ?: $ex['category']) ?></span>
<span class="expense-amount"><?= e($sym) ?><?= $money((float)$ex['total_amount']) ?></span>
</div>
<div class="expense-sub">
付款:<?= e($ex['payer_name'] ?: '未知') ?>
<?php if (!empty($splits)): ?>
· 分摊:<?= e(implode(' / ', $splits)) ?>
<?php endif; ?>
<form method="post" action="<?= url('expenses/destroy') ?>" style="display:inline"
onsubmit="return confirm('删除这笔花销?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$ex['id'] ?>">
<button type="submit" class="comment-del">删除</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<details class="inline-panel">
<summary>+ 记一笔花销</summary>
<form method="post" action="<?= url('expenses/store') ?>" class="form form-plain">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<div class="form-row two-col">
<div>
<label>金额 *</label>
<input type="text" name="total_amount" required placeholder="例如:268.50">
</div>
<div>
<label>币种</label>
<select name="currency">
<?php foreach ($currencies as $code => $label): ?>
<option value="<?= e($code) ?>"><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-row two-col">
<div>
<label>类别</label>
<select name="category">
<?php foreach ($categories as $cat): ?>
<option value="<?= e($cat) ?>"><?= e($cat) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label>付款人 *</label>
<select name="payer_user_id" required>
<?php foreach ($familyUsers as $u): ?>
<option value="<?= (int)$u['id'] ?>"
<?= (int)$u['id'] === (int)$currentUserId ? ' selected' : '' ?>>
<?= e($u['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-row">
<label>说明</label>
<input type="text" name="description" maxlength="200" placeholder="例如:三人门票">
</div>
<div class="form-row">
<label>性质</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="is_budget" value="0" checked> 实际花费
</label>
<label class="radio-label">
<input type="radio" name="is_budget" value="1"> 预算
</label>
</div>
</div>
<div class="form-row">
<label>分摊人(默认全家平摊,可只勾选部分人)</label>
<div class="check-grid">
<?php foreach ($familyUsers as $u): ?>
<label class="checkbox-label">
<input type="checkbox" name="split_user_ids[]"
value="<?= (int)$u['id'] ?>" checked>
<span><?= e($u['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</details>
</section>
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* 节点参与人面板
* 依赖外部变量:$node、$members、$familyUsers、$roleTags(id=>name)、$emotions(emoji=>中文)
*/
$members = $members ?? [];
$familyUsers = $familyUsers ?? [];
$roleTags = $roleTags ?? [];
$emotions = $emotions ?? [];
$joinedIds = [];
foreach ($members as $m) {
$joinedIds[(int)$m['user_id']] = true;
}
$notJoined = [];
foreach ($familyUsers as $u) {
if (!isset($joinedIds[(int)$u['id']])) $notJoined[] = $u;
}
?>
<section class="panel" id="members">
<h2 class="section-title">👥 参与人 (<?= count($members) ?>)</h2>
<?php if (empty($members)): ?>
<p class="muted">还没有添加参与人</p>
<?php else: ?>
<div class="member-list">
<?php foreach ($members as $m):
$mName = (string)($m['name'] ?? '');
if ($mName === '') $mName = '已注销用户';
?>
<div class="member-row">
<?php if (!empty($m['avatar'])): ?>
<img src="<?= e($m['avatar']) ?>" alt="" class="avatar-sm">
<?php else: ?>
<span class="avatar-sm avatar-placeholder"><?= e(mb_substr($mName, 0, 1)) ?></span>
<?php endif; ?>
<span class="member-name"><?= e($mName) ?></span>
<?php foreach ($m['role_tag_names'] as $tagName): ?>
<span class="member-role"><?= e($tagName) ?></span>
<?php endforeach; ?>
<?php if (!empty($m['emotion'])): ?>
<span class="member-emotion" title="<?= e($emotions[$m['emotion']] ?? '') ?>">
<?= e($m['emotion']) ?><?= e($emotions[$m['emotion']] ?? '') ?>
</span>
<?php endif; ?>
<?php if (!empty($m['check_in_time'])): ?>
<span class="member-checkin">✓ 签到 <?= date('m-d H:i', strtotime((string)$m['check_in_time'])) ?></span>
<?php endif; ?>
<form method="post" action="<?= url('members/destroy') ?>" class="member-del-form"
onsubmit="return confirm('把 <?= e($mName) ?> 从本节点移出?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<input type="hidden" name="user_id" value="<?= (int)$m['user_id'] ?>">
<button type="submit" class="comment-del">移出</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($notJoined)): ?>
<form method="post" action="<?= url('members/addAll') ?>" style="display:inline"
onsubmit="return confirm('把家庭里的 <?= count($familyUsers) ?> 位成员全部加入这个节点?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<button type="submit" class="btn-sm">+ 一键加入全家(<?= count($notJoined) ?> 人待加入)</button>
</form>
<?php endif; ?>
<details class="inline-panel">
<summary>+ 添加 / 修改参与人</summary>
<form method="post" action="<?= url('members/store') ?>" class="form form-plain">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<div class="form-row">
<label>成员 *</label>
<select name="user_id" required>
<option value="">— 请选择 —</option>
<?php foreach ($familyUsers as $u): ?>
<option value="<?= (int)$u['id'] ?>">
<?= e($u['name']) ?><?= isset($joinedIds[(int)$u['id']]) ? '(已加入,保存将覆盖)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php if (!empty($roleTags)): ?>
<div class="form-row">
<label>角色标签(可多选)</label>
<div class="check-grid">
<?php foreach ($roleTags as $tagId => $tagName): ?>
<label class="checkbox-label">
<input type="checkbox" name="role_tag_ids[]" value="<?= (int)$tagId ?>">
<span><?= e($tagName) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<p class="muted" style="font-size:12px;">
还没有角色标签,去
<a href="<?= url('role_tags/index') ?>">角色标签管理</a>
先建几个(例如:司机 / 摄影师 / 记账员)
</p>
<?php endif; ?>
<div class="form-row">
<label>心情(可选)</label>
<div class="emotion-grid">
<label class="emotion-option">
<input type="radio" name="emotion" value="" checked>
<span class="emotion-face">🚫</span>
<span class="emotion-text">不记录</span>
</label>
<?php foreach ($emotions as $emoji => $label): ?>
<label class="emotion-option" title="<?= e($label) ?>">
<input type="radio" name="emotion" value="<?= e($emoji) ?>">
<span class="emotion-face"><?= e($emoji) ?></span>
<span class="emotion-text"><?= e($label) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="form-row">
<label>签到时间(可选,留空表示未签到)</label>
<input type="datetime-local" name="check_in_time">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</details>
</section>
+139
View File
@@ -0,0 +1,139 @@
<div class="page-header">
<div>
<h1>节点附件</h1>
<p class="muted">
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>">
<?= e($node['activity_title']) ?>
</a>
·
<?= e($node['title'] ?: '无标题节点') ?>
·
<?= $node['node_category'] === 'plan' ? '计划' : '执行' ?>
</p>
</div>
<a href="<?= url('activities/show', ['id' => $node['activity_id']]) ?>" class="btn">返回活动</a>
</div>
<form method="post" action="<?= url('attachments/upload') ?>" enctype="multipart/form-data" class="form" style="margin-bottom:24px;">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="node_id" value="<?= (int)$node['id'] ?>">
<div class="form-row">
<label>上传附件</label>
<div class="drop-zone" id="drop-zone">
<input type="file" name="files[]" id="file-input" multiple style="display:none;">
<div class="drop-zone-inner">
<div style="font-size:32px;">📎</div>
<p style="margin:6px 0 0;">点击或拖拽文件到这里</p>
<p class="muted" style="font-size:12px; margin:4px 0 0;">
支持 JPG / PNG / GIF / WebP / PDF / DOC / XLS / MP4 等,单个不超过 20MB
</p>
</div>
</div>
<div id="file-list" class="file-list"></div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" id="btn-submit-upload" style="display:none;">开始上传</button>
</div>
</form>
<h2 style="font-size:16px; margin: 32px 0 12px;">已上传(<?= count($attachments) ?>)</h2>
<?php if (empty($attachments)): ?>
<div class="empty">
<p>暂无附件,上传第一张机票/门票/照片吧</p>
</div>
<?php else: ?>
<div class="attachment-grid">
<?php foreach ($attachments as $att):
$url = $att['file_url'];
$name = $att['file_name'] ?: basename($url);
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$isImage = in_array($ext, ['jpg','jpeg','png','gif','webp','heic'], true);
$filePath = dirname(__DIR__, 2) . $url;
$size = is_file($filePath) ? filesize($filePath) : 0;
$sizeText = $size > 0 ? ($size < 1024 * 1024
? round($size / 1024, 1) . ' KB'
: round($size / 1024 / 1024, 2) . ' MB') : '';
?>
<div class="attachment-card">
<a href="<?= e($url) ?>" target="_blank" class="attachment-link">
<?php if ($isImage): ?>
<img src="<?= e($url) ?>" alt="" class="attachment-thumb">
<?php else: ?>
<div class="attachment-icon">
<?php
$icons = ['pdf'=>'📄','doc'=>'📝','docx'=>'📝','xls'=>'📊','xlsx'=>'📊','mp4'=>'🎬','mov'=>'🎬','zip'=>'🗜️'];
echo $icons[$ext] ?? '📎';
?>
</div>
<?php endif; ?>
</a>
<div class="attachment-meta">
<div class="attachment-name" title="<?= e($name) ?>"><?= e($name) ?></div>
<div class="attachment-sub">
<?= e($att['uploader_name'] ?: '未知') ?>
<?php if ($sizeText): ?> · <?= e($sizeText) ?><?php endif; ?>
</div>
<form method="post" action="<?= url('attachments/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除此附件?');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$att['id'] ?>">
<button type="submit" class="attachment-delete" title="删除">×</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<script>
(function () {
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var fileList = document.getElementById('file-list');
var btnSubmit = document.getElementById('btn-submit-upload');
if (!dropZone || !fileInput) return;
dropZone.addEventListener('click', function () { fileInput.click(); });
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('dragging');
});
dropZone.addEventListener('dragleave', function () {
dropZone.classList.remove('dragging');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('dragging');
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
refreshList();
}
});
fileInput.addEventListener('change', refreshList);
function refreshList() {
fileList.innerHTML = '';
var files = fileInput.files;
if (!files.length) {
btnSubmit.style.display = 'none';
return;
}
for (var i = 0; i < files.length; i++) {
var f = files[i];
var size = f.size < 1024 * 1024
? (f.size / 1024).toFixed(1) + ' KB'
: (f.size / 1024 / 1024).toFixed(2) + ' MB';
var row = document.createElement('div');
row.className = 'file-row';
row.textContent = '📎 ' + f.name + ' (' + size + ')';
fileList.appendChild(row);
}
btnSubmit.style.display = 'inline-block';
}
})();
</script>
+369
View File
@@ -0,0 +1,369 @@
<?php
$isEdit = $isEdit ?? false;
$n = $node; // null=新建;数组=编辑
$curStatus = $n['status'] ?? ($category === 'plan' ? 'planned' : 'completed');
$statusLabels = [
'planned' => '待执行',
'ongoing' => '进行中',
'completed' => '已完成',
'cancelled' => '已取消',
];
// 计划节点不会有"进行中",执行节点默认就是已完成
$statusOptions = $category === 'plan'
? ['planned', 'completed', 'cancelled']
: ['completed', 'ongoing', 'planned', 'cancelled'];
$dtLocal = static function ($value): string {
if (empty($value)) return '';
$ts = strtotime((string)$value);
return $ts ? date('Y-m-d\TH:i', $ts) : '';
};
$sLoc = json_decode_field($n['start_location'] ?? null);
$eLoc = json_decode_field($n['end_location'] ?? null);
?>
<div class="page-header">
<div>
<h1><?= $isEdit ? '编辑' : '新建' ?><?= $category === 'plan' ? '计划' : '执行' ?>节点</h1>
<p class="muted">
活动:
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>"><?= e($activity['title']) ?></a>
<?php if ($isEdit && !empty($n['original_plan_node_id'])): ?>
· <span class="tag-default">由计划节点生成</span>
<?php endif; ?>
</p>
</div>
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>" class="btn">返回活动</a>
</div>
<form method="post" action="<?= url($isEdit ? 'nodes/update' : 'nodes/store') ?>" class="form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="activity_id" value="<?= (int)$activity['id'] ?>">
<input type="hidden" name="node_category" value="<?= e($category) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int)$n['id'] ?>">
<?php endif; ?>
<?php if (!$isEdit && $category === 'reality' && !empty($planNodes)): ?>
<div class="form-row">
<label>关联计划节点(可选)</label>
<select name="original_plan_node_id">
<option value="">— 不关联,此为临时新增 —</option>
<?php foreach ($planNodes as $p): ?>
<option value="<?= (int)$p['id'] ?>">
<?= e($p['title'] ?: '(无标题)') ?> · <?= date('m-d H:i', strtotime($p['start_time'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="form-row">
<label>节点类型 *</label>
<select name="node_type_id" required>
<?php foreach ($nodeTypes as $t): ?>
<option value="<?= (int)$t['id'] ?>"
<?= (int)($n['node_type_id'] ?? 0) === (int)$t['id'] ? ' selected' : '' ?>>
<?= e($t['icon'] . ' ' . $t['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row two-col">
<div>
<label>开始时间 *</label>
<input type="datetime-local" name="start_time" required value="<?= e($dtLocal($n['start_time'] ?? null)) ?>">
</div>
<div>
<label>结束时间 *</label>
<input type="datetime-local" name="end_time" required value="<?= e($dtLocal($n['end_time'] ?? null)) ?>">
</div>
</div>
<div class="form-row">
<label>标题</label>
<input type="text" name="title" maxlength="200" placeholder="例如:出发去奥森"
value="<?= e($n['title'] ?? '') ?>">
</div>
<fieldset class="fieldset">
<legend>起点信息(可选)</legend>
<div class="form-row">
<label>起点名称(输入地址自动补全)</label>
<input type="text" name="start_location_name" id="start-location-input"
placeholder="输入关键词,如「北京南站」「奥森公园」"
autocomplete="off"
value="<?= e($sLoc['name'] ?? '') ?>">
<input type="hidden" name="start_location_address" id="start-location-address" value="<?= e($sLoc['address'] ?? '') ?>">
<input type="hidden" name="start_location_lat" id="start-location-lat" value="<?= e($sLoc['lat'] ?? '') ?>">
<input type="hidden" name="start_location_lng" id="start-location-lng" value="<?= e($sLoc['lng'] ?? '') ?>">
</div>
</fieldset>
<fieldset class="fieldset">
<legend>终点信息(可选)</legend>
<div class="form-row">
<label>终点名称(输入地址自动补全)</label>
<input type="text" name="end_location_name" id="end-location-input"
placeholder="输入关键词,如「国家大剧院」「三里屯」"
autocomplete="off"
value="<?= e($eLoc['name'] ?? '') ?>">
<input type="hidden" name="end_location_address" id="end-location-address" value="<?= e($eLoc['address'] ?? '') ?>">
<input type="hidden" name="end_location_lat" id="end-location-lat" value="<?= e($eLoc['lat'] ?? '') ?>">
<input type="hidden" name="end_location_lng" id="end-location-lng" value="<?= e($eLoc['lng'] ?? '') ?>">
</div>
</fieldset>
<fieldset class="fieldset">
<legend>出行方式(可选)</legend>
<div class="form-row">
<label>选择方式</label>
<select name="travel_mode_id" id="travel-mode-select">
<option value="" data-mode-type="">— 不使用 —</option>
<?php foreach ($travelModes as $tm): ?>
<option value="<?= (int)$tm['id'] ?>" data-mode-type="<?= e($tm['mode_type']) ?>"
<?= (int)($n['travel_mode_id'] ?? 0) === (int)$tm['id'] ? ' selected' : '' ?>>
<?= e(($modeLabels[$tm['mode_type']] ?? $tm['mode_type']) . (!empty($tm['plate_number']) ? ' · ' . $tm['plate_number'] : '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row transport-detail" data-fields="train plane bus subway ship other" id="field-line_name">
<label>线路名称</label>
<input type="text" name="line_name" maxlength="100" placeholder="例如:京沪高铁、地铁10号线"
value="<?= e($n['line_name'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane bus taxi bike ship other" id="field-carrier">
<label>承运公司</label>
<input type="text" name="carrier" maxlength="100" placeholder="例如:中国铁路、中国国航、滴滴"
value="<?= e($n['carrier'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane bus subway ship other" id="field-trip_number">
<label>车次 / 航班号 / 线路号</label>
<input type="text" name="trip_number" maxlength="50" placeholder="例如:G1234、CA1234"
value="<?= e($n['trip_number'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane ship other" id="field-seat_info">
<label>座位号 / 车厢</label>
<input type="text" name="seat_info" maxlength="100" placeholder="例如:5车12A、商务舱3A"
value="<?= e($n['seat_info'] ?? '') ?>">
</div>
<div class="form-row transport-detail" data-fields="train plane ship" id="field-gate">
<label>登机口 / 站台</label>
<input type="text" name="gate" maxlength="20" placeholder="例如:B23、T3航站楼C15"
value="<?= e($n['gate'] ?? '') ?>">
</div>
</fieldset>
<div class="form-row">
<label>详细描述</label>
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($n['description'] ?? '') ?></textarea>
</div>
<?php if ($isEdit): ?>
<fieldset class="fieldset">
<legend>执行状态</legend>
<div class="form-row">
<label>状态</label>
<select name="status">
<?php foreach ($statusOptions as $st): ?>
<option value="<?= e($st) ?>" <?= $curStatus === $st ? ' selected' : '' ?>>
<?= e($statusLabels[$st]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row">
<label>取消原因(状态选「已取消」时填写)</label>
<input type="text" name="cancel_reason" maxlength="500" placeholder="例如:下雨,临时改行程"
value="<?= e($n['cancel_reason'] ?? '') ?>">
</div>
</fieldset>
<?php endif; ?>
<div class="form-actions">
<a href="<?= url('activities/show', ['id' => $activity['id']]) ?>" class="btn">取消</a>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
<script>
(function () {
// ===== 出行方式动态字段 =====
var modeSelect = document.getElementById('travel-mode-select');
if (modeSelect) {
var details = document.querySelectorAll('.transport-detail');
var refresh = function () {
var opt = modeSelect.options[modeSelect.selectedIndex];
var modeType = opt ? (opt.getAttribute('data-mode-type') || '') : '';
details.forEach(function (el) {
var allow = el.getAttribute('data-fields').split(' ');
el.style.display = allow.indexOf(modeType) !== -1 ? '' : 'none';
});
};
modeSelect.addEventListener('change', refresh);
refresh();
}
// ===== 高德地址自动补全(自定义下拉) =====
var maxWait = 5000; // 最多等 5 秒
var waited = 0;
function waitForAmap(callback) {
if (window.__AMAP_LOADED && typeof AMap !== 'undefined' && AMap.AutoComplete) {
callback();
return;
}
if (window.__AMAP_ERROR) {
console.error('[AMap] 脚本加载失败:', window.__AMAP_ERROR);
return;
}
if (waited >= maxWait) {
console.error('[AMap] 等待超时。可能原因:');
console.error(' 1. Key 无效或未绑定域名白名单');
console.error(' 2. 安全密钥不匹配');
console.error(' 3. 网络无法访问 webapi.amap.com');
return;
}
waited += 200;
setTimeout(function () { waitForAmap(callback); }, 200);
}
waitForAmap(function () {
setupAutoComplete('start-location-input', 'start');
setupAutoComplete('end-location-input', 'end');
});
function setupAutoComplete(inputId, prefix) {
var input = document.getElementById(inputId);
if (!input) return;
var addrField = document.getElementById(prefix + '-location-address');
var latField = document.getElementById(prefix + '-location-lat');
var lngField = document.getElementById(prefix + '-location-lng');
var dropdown = document.createElement('div');
dropdown.className = 'amap-dropdown';
dropdown.style.display = 'none';
var wrapper = input.closest('.form-row') || input.parentElement;
wrapper.style.position = 'relative';
wrapper.appendChild(dropdown);
var autoComplete = new AMap.AutoComplete({});
var currentIndex = -1;
var currentResults = [];
function renderDropdown(results) {
currentResults = results || [];
currentIndex = -1;
if (!currentResults.length) {
dropdown.style.display = 'none';
return;
}
dropdown.innerHTML = currentResults.map(function (item, i) {
var name = item.name || '';
var district = item.district || '';
var address = item.address || '';
var sub = [district, address].filter(Boolean).join(' ');
return '<div class="amap-dropdown-item" data-index="' + i + '">' +
'<div class="amap-dropdown-name">' + escapeHtml(name) + '</div>' +
(sub ? '<div class="amap-dropdown-sub">' + escapeHtml(sub) + '</div>' : '') +
'</div>';
}).join('');
dropdown.style.display = 'block';
dropdown.querySelectorAll('.amap-dropdown-item').forEach(function (el) {
el.addEventListener('mousedown', function (ev) {
ev.preventDefault(); // 防止 input 失焦
var idx = parseInt(el.getAttribute('data-index'), 10);
selectItem(currentResults[idx]);
});
});
}
function selectItem(item) {
if (!item) return;
input.value = item.name || '';
if (addrField) {
addrField.value = item.address || item.district || '';
}
if (item.location) {
if (latField) latField.value = item.location.lat;
if (lngField) lngField.value = item.location.lng;
}
dropdown.style.display = 'none';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c];
});
}
var searchTimer = null;
input.addEventListener('input', function () {
var keyword = input.value.trim();
clearTimeout(searchTimer);
if (keyword.length < 2) {
dropdown.style.display = 'none';
return;
}
searchTimer = setTimeout(function () {
autoComplete.search(keyword, function (status, result) {
if (status === 'complete' && result && result.tips) {
renderDropdown(result.tips);
} else {
dropdown.style.display = 'none';
}
});
}, 300);
});
input.addEventListener('blur', function () {
setTimeout(function () { dropdown.style.display = 'none'; }, 200);
});
input.addEventListener('focus', function () {
if (currentResults.length) dropdown.style.display = 'block';
});
input.addEventListener('keydown', function (e) {
if (dropdown.style.display === 'none') return;
var items = dropdown.querySelectorAll('.amap-dropdown-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
currentIndex = (currentIndex + 1) % items.length;
highlight(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
currentIndex = (currentIndex - 1 + items.length) % items.length;
highlight(items);
} else if (e.key === 'Enter') {
if (currentIndex >= 0 && currentResults[currentIndex]) {
e.preventDefault();
selectItem(currentResults[currentIndex]);
}
} else if (e.key === 'Escape') {
dropdown.style.display = 'none';
}
});
function highlight(items) {
items.forEach(function (el, i) {
el.classList.toggle('active', i === currentIndex);
});
}
}
})();
</script>
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* 节点详情:基础信息 + 参与人 + 花销 + 讨论
* 一个节点一页,把挂在这个节点上的所有记录集中起来
*/
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);
$transportParts = [];
foreach (['trip_number', 'line_name', 'carrier', 'seat_info', 'gate'] as $field) {
if (!empty($node[$field])) $transportParts[] = $node[$field];
}
$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; ?>
</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; ?>
</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 require __DIR__ . '/_members.php'; ?>
<?php require __DIR__ . '/_expenses.php'; ?>
<?php
$commentTargetType = Comments::TARGET_NODE;
$commentTargetId = (int)$node['id'];
require dirname(__DIR__) . '/comments/_block.php';
?>