补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 { '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>
|
||||
@@ -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';
|
||||
?>
|
||||
Reference in New Issue
Block a user