数据库里早就建好了 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。
370 lines
15 KiB
PHP
370 lines
15 KiB
PHP
<?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>
|