节点表单:参与人直接勾选 + 起点终点记录详细地址(批 13)

参与人(用户反馈:不想等全部填完、保存之后才回详情页加人):
- 节点表单里新增「参与人(直接勾选)」区块:家庭成员按胶囊复选框列出,
  保存即生效;编辑时预勾选已有人。
- Members::syncForNode() 只做增删,**不动留下来的那些人的情绪 / 签到 /
  角色标签**(重新勾一遍不该把之前记的心情清掉)。
- 安全性:新增走 Members::save(),逐条校验「用户属于该活动所在家庭」,
  夹带别家 user_id 进不来;只有请求带 members_present=1(表单确实渲染了
  参与人区块)才同步 —— 否则没这个字段的请求会把已有成员清空。

起点/终点记录详细地址(用户反馈:高德联想只返回简化地名):
- 联想项选中后不再是「青岛北站」就完事:
  ① 有坐标 → 逆地理编码(AMap.Geocoder.getAddress)取该点的
     formattedAddress;
  ② 没坐标 → 按「区县 + 名称」正查,顺带把坐标补回来。
- 详细地址写进 start/end_location 的 address 字段,输入框下方实时显示
  「详细地址:…」,节点详情页本来就展示这一项。
- 路线地图补点(只有地名没坐标时)改为**优先用详细地址**查询,
  比简化地名定位准。

已验证:本地全链 12 步全绿(渲染检查 350 项、真 AMap 浏览器验证 11 项、
表单交互 75 项);远端 e2e 350 项 0 失败(新增 10 项:勾选落库 / 跨家庭
拒绝 / 取消勾选移出 / 情绪不被清 / 无标记不动参与人);部署静态断言、
双 PHP 渲染、内容断言全过后提交。
This commit is contained in:
2026-09-24 12:36:01 +08:00
parent c4c3b17cf7
commit 142b187911
5 changed files with 254 additions and 4 deletions
+19
View File
@@ -1303,3 +1303,22 @@ video.video-thumb.video-thumb-mini {
.route-map { height: 62vh; } .route-map { height: 62vh; }
.route-tab { padding: 7px 14px; font-size: 13px; } .route-tab { padding: 7px 14px; font-size: 13px; }
} }
/* ── 21. 节点表单:参与人勾选 + 位置详细地址(批 13)───────────── */
.member-pick { display: flex; flex-wrap: wrap; gap: 8px; }
.member-pick-item {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; border: 1px solid var(--border);
border-radius: 999px; background: var(--surface); cursor: pointer;
font-size: 14px; transition: background .15s, border-color .15s;
}
.member-pick-item:hover { border-color: var(--border-strong); }
.member-pick-item input[type="checkbox"] { margin: 0; accent-color: var(--accent); }
.member-pick-item:has(input:checked) {
border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, #fff);
}
.member-pick-avatar {
width: 22px; height: 22px; border-radius: 50%; object-fit: cover; display: block;
}
/* 联想选中后回填的详细地址(让人看见记下来的是什么) */
.loc-detail { margin-top: 6px; font-size: 12.5px; word-break: break-all; }
+53 -1
View File
@@ -95,8 +95,13 @@ class NodeController
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute($data); $stmt->execute($data);
$nodeId = (int)$db->lastInsertId();
// 节点必须先有 id 才能挂附件,所以附件在插入之后处理 // 节点必须先有 id 才能挂附件,所以附件在插入之后处理
$extra = $this->handleNodeUploads((int)$db->lastInsertId()); $extra = $this->handleNodeUploads($nodeId);
// 表单里勾的参与人(members_present=1 才认,避免没渲染这个区块的请求误清)
$extra .= $this->syncMembers($nodeId);
flash('error', '节点已保存' . $extra); flash('error', '节点已保存' . $extra);
redirect('activities/show', ['id' => (int)$activity['id']]); redirect('activities/show', ['id' => (int)$activity['id']]);
@@ -148,6 +153,9 @@ class NodeController
// 再处理这次新选的照片 // 再处理这次新选的照片
$extra = $this->handleNodeUploads((int)$node['id']); $extra = $this->handleNodeUploads((int)$node['id']);
// 参与人在表单里直接勾(编辑时重新勾选不会清掉已记的情绪/签到)
$extra .= $this->syncMembers((int)$node['id']);
$msg = '保存成功'; $msg = '保存成功';
if ($removed > 0) { if ($removed > 0) {
$msg .= ",删除了 {$removed} 个附件"; $msg .= ",删除了 {$removed} 个附件";
@@ -358,6 +366,18 @@ class NodeController
$isEdit = $node !== null; $isEdit = $node !== null;
/* 参与人在表单里直接勾(不必先保存再回详情页加人)。
编辑时把已有人带过去预勾选;新建时空着。 */
$familyUsers = Members::familyUsers((int)$activity['family_id']);
$nodeMemberIds = [];
if ($isEdit) {
$s = $db->prepare('SELECT user_id FROM node_members WHERE trip_node_id = ?');
$s->execute([(int)$node['id']]);
foreach ($s->fetchAll() as $row) {
$nodeMemberIds[] = (int)$row['user_id'];
}
}
return [ return [
'modeLabels' => TravelModeController::MODE_LABELS, 'modeLabels' => TravelModeController::MODE_LABELS,
'activity' => $activity, 'activity' => $activity,
@@ -367,6 +387,8 @@ class NodeController
'planNodes' => $planNodes, 'planNodes' => $planNodes,
'node' => $node, 'node' => $node,
'isEdit' => $isEdit, 'isEdit' => $isEdit,
'familyUsers' => $familyUsers,
'nodeMemberIds' => $nodeMemberIds,
// 编辑时把已有照片带进表单:能看、能勾选删除。新建时节点还没 id, // 编辑时把已有照片带进表单:能看、能勾选删除。新建时节点还没 id,
// 附件挂不上去,所以只能是空数组(模板里会提示先保存)。 // 附件挂不上去,所以只能是空数组(模板里会提示先保存)。
'attachments' => $isEdit ? Attachments::forTarget(Attachments::TARGET_NODE, (int)$node['id']) : [], 'attachments' => $isEdit ? Attachments::forTarget(Attachments::TARGET_NODE, (int)$node['id']) : [],
@@ -583,6 +605,36 @@ class NodeController
* ⚠️ 必须逐条校验归属:attachments 是「target_type + target_id」的软关联, * ⚠️ 必须逐条校验归属:attachments 是「target_type + target_id」的软关联,
* 表单里的 id 是用户可以随便改的,不校验就等于给了跨家庭删文件的入口。 * 表单里的 id 是用户可以随便改的,不校验就等于给了跨家庭删文件的入口。
*/ */
/**
* 表单里勾的参与人 → 同步成节点成员(只增删,不动已记的情绪/签到)。
*
* 只在表单确实渲染了参与人区块时(members_present=1)才同步:
* 没有渲染却收到空数组的话,会把节点已有成员全删掉。
* 跨家庭的 user_id 由 Members::save() 逐条拦掉。
*/
private function syncMembers(int $nodeId): string
{
if (($_POST['members_present'] ?? '') !== '1') {
return '';
}
$picked = $_POST['members'] ?? [];
if (!is_array($picked)) {
return '';
}
$res = Members::syncForNode($nodeId, $picked);
if ($res['added'] === 0 && $res['removed'] === 0) {
return '';
}
$parts = [];
if ($res['added'] > 0) {
$parts[] = "加入 {$res['added']} 位参与人";
}
if ($res['removed'] > 0) {
$parts[] = "移出 {$res['removed']} 位参与人";
}
return ',' . implode(',', $parts);
}
private function deleteCheckedAttachments(int $nodeId): int private function deleteCheckedAttachments(int $nodeId): int
{ {
$ids = $_POST['delete_attachments'] ?? []; $ids = $_POST['delete_attachments'] ?? [];
+42
View File
@@ -166,6 +166,48 @@ class Members
$stmt->execute([$nodeId, $userId]); $stmt->execute([$nodeId, $userId]);
} }
/**
* 勾选式同步:把节点参与人「对齐」成 $userIds(节点表单里直接勾选用)。
*
* 只做增删 —— 留下来的人的情绪 / 签到 / 角色标签**一律不动**(编辑节点时
* 重新勾一遍参与人不该把之前记的心情清掉)。新增走 save(),
* 家庭归属校验与「一键加入」那条链路完全一致。
*
* @return array{added:int, removed:int}
*/
public static function syncForNode(int $nodeId, array $userIds): array
{
$db = Database::get();
$want = [];
foreach ($userIds as $uid) {
$uid = (int)$uid;
if ($uid > 0 && !in_array($uid, $want, true)) {
$want[] = $uid;
}
}
$stmt = $db->prepare('SELECT user_id FROM node_members WHERE trip_node_id = ?');
$stmt->execute([$nodeId]);
$have = [];
foreach ($stmt->fetchAll(\PDO::FETCH_COLUMN) as $uid) {
$have[] = (int)$uid;
}
$added = 0;
$removed = 0;
foreach (array_diff($have, $want) as $uid) {
self::remove($nodeId, (int)$uid);
$removed++;
}
foreach (array_diff($want, $have) as $uid) {
if (self::save($nodeId, (int)$uid, [], '', null)['ok']) {
$added++;
}
}
return ['added' => $added, 'removed' => $removed];
}
/** 一次把一批人加进节点(用于「把活动成员一键全部加入」) */ /** 一次把一批人加进节点(用于「把活动成员一键全部加入」) */
public static function addMany(int $nodeId, array $userIds, array $roleTagIds = []): int public static function addMany(int $nodeId, array $userIds, array $roleTagIds = []): int
{ {
+11 -2
View File
@@ -61,6 +61,8 @@ foreach (($realityNodes ?? []) as $rn) {
'url' => url('nodes/show', ['id' => (int)$rn['id']]), 'url' => url('nodes/show', ['id' => (int)$rn['id']]),
'sname' => $sname, // 出发地名:没有坐标时交给高德地理编码兜底 'sname' => $sname, // 出发地名:没有坐标时交给高德地理编码兜底
'ename' => $ename, // 到达地名:同上 'ename' => $ename, // 到达地名:同上
'saddr' => trim((string)($s['address'] ?? '')), // 详细地址:地理编码优先用它
'eaddr' => trim((string)($e['address'] ?? '')),
'start' => $hasS ? ['lng' => (float)$s['lng'], 'lat' => (float)$s['lat']] : null, 'start' => $hasS ? ['lng' => (float)$s['lng'], 'lat' => (float)$s['lat']] : null,
'end' => $hasE ? ['lng' => (float)$e['lng'], 'lat' => (float)$e['lat']] : null, 'end' => $hasE ? ['lng' => (float)$e['lng'], 'lat' => (float)$e['lat']] : null,
]; ];
@@ -253,9 +255,16 @@ require dirname(__DIR__) . '/comments/_block.php';
} catch (e) { /* 地理编码服务不可用时保持原状 */ } } catch (e) { /* 地理编码服务不可用时保持原状 */ }
}); });
} }
/* 没有坐标的点:优先拿**详细地址**去查(比简化地名准得多),没记地址才用地名 */
route.forEach(function (n) { route.forEach(function (n) {
if (!n.start && n.sname) geocode(n.sname, function (p) { n.start = p; drawRoute(); }); if (!n.start) {
if (!n.end && n.ename) geocode(n.ename, function (p) { n.end = p; drawRoute(); }); 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> </script>
+129 -1
View File
@@ -44,6 +44,26 @@ $eLoc = [
$startTimeVal = old_dt('start_time', $n['start_time'] ?? null); $startTimeVal = old_dt('start_time', $n['start_time'] ?? null);
$endTimeVal = old_dt('end_time', $n['end_time'] ?? null); $endTimeVal = old_dt('end_time', $n['end_time'] ?? null);
/* 参与人在表单里直接勾(不必先保存再回详情页加人)。
回填优先:上次提交失败时,用刚勾的而不是库里的。 */
$familyUsers = $familyUsers ?? [];
$nodeMemberIds = $nodeMemberIds ?? [];
$oldMembers = isset($_SESSION['_old']) && is_array($_SESSION['_old'])
? (array)($_SESSION['_old']['members'] ?? [])
: null;
$pickedMembers = static function () use ($oldMembers, $nodeMemberIds): array {
$src = $oldMembers ?? $nodeMemberIds;
$out = [];
foreach ((array)$src as $v) {
$v = (int)$v;
if ($v > 0) {
$out[] = $v;
}
}
return $out;
};
$picked = $pickedMembers();
/** /**
* 交通信息只留一个输入框:飞机只要航班号(航空公司是隐含的),火车只要车次。 * 交通信息只留一个输入框:飞机只要航班号(航空公司是隐含的),火车只要车次。
* 座位号/登机口这类「一趟车多人同行」时根本没法记,所以不再收集。 * 座位号/登机口这类「一趟车多人同行」时根本没法记,所以不再收集。
@@ -185,6 +205,7 @@ $tripField = [
target="_blank" rel="noopener">🧭 导航到这里</a> target="_blank" rel="noopener">🧭 导航到这里</a>
<span class="muted">保存后节点详情页也会带这个链接</span> <span class="muted">保存后节点详情页也会带这个链接</span>
</div> </div>
<div class="loc-detail muted" id="start-location-detail"></div>
</div> </div>
</fieldset> </fieldset>
@@ -205,6 +226,7 @@ $tripField = [
target="_blank" rel="noopener">🧭 导航到这里</a> target="_blank" rel="noopener">🧭 导航到这里</a>
<span class="muted">保存后节点详情页也会带这个链接</span> <span class="muted">保存后节点详情页也会带这个链接</span>
</div> </div>
<div class="loc-detail muted" id="end-location-detail"></div>
</div> </div>
</fieldset> </fieldset>
@@ -213,6 +235,30 @@ $tripField = [
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($oldStr('description', $n['description'] ?? '')) ?></textarea> <textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($oldStr('description', $n['description'] ?? '')) ?></textarea>
</div> </div>
<?php /* 参与人:直接在表单里勾,不必先保存再回详情页加(批 13) */ ?>
<fieldset class="fieldset" id="member-field">
<legend>参与人(直接勾选)</legend>
<?php if ($familyUsers === []): ?>
<p class="muted">这个家庭还没有成员。</p>
<?php else: ?>
<input type="hidden" name="members_present" value="1">
<div class="member-pick">
<?php foreach ($familyUsers as $u): ?>
<?php $uid = (int)$u['id']; ?>
<label class="member-pick-item">
<input type="checkbox" name="members[]" value="<?= $uid ?>"
<?= in_array($uid, $picked, true) ? 'checked' : '' ?>>
<?php if (!empty($u['avatar'])): ?>
<img class="member-pick-avatar" src="<?= e((string)$u['avatar']) ?>" alt="">
<?php endif; ?>
<span class="member-pick-name"><?= e((string)$u['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<p class="form-hint">勾选后保存即生效;已选的人重新勾选不会清掉之前记的签到 / 心情。</p>
<?php endif; ?>
</fieldset>
<?php <?php
/* 照片附件:随节点表单一起提交。 /* 照片附件:随节点表单一起提交。
新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」; 新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」;
@@ -512,16 +558,98 @@ $tripField = [
function selectItem(item) { function selectItem(item) {
if (!item) return; if (!item) return;
input.value = item.name || ''; input.value = item.name || '';
if (addrField) addrField.value = item.address || item.district || '';
/* 高德联想返回的是「简化地名」(如「青岛北站」),详细地址要另外查:
① 有坐标 → 逆地理编码拿这个点的 formattedAddress(最贴合选中的位置);
② 没坐标 → 按「区县 + 名称」正查,顺带把坐标也补回来。
查不到就退回联想项自带的 district/address,总之不把它留空。 */
var fallback = item.address || item.district || '';
if (addrField) addrField.value = fallback;
if (item.location) { if (item.location) {
if (latField) latField.value = item.location.lat; if (latField) latField.value = item.location.lat;
if (lngField) lngField.value = item.location.lng; if (lngField) lngField.value = item.location.lng;
reverseDetail(item.location, fallback);
} else {
var query = [item.district || '', item.name || ''].filter(Boolean).join(' ');
if (query) {
forwardDetail(query, fallback);
} else {
showDetail(fallback);
} }
}
dropdown.style.display = 'none'; dropdown.style.display = 'none';
// 坐标刚变,导航链接要跟着变(有坐标才是真导航,没坐标只是搜索) // 坐标刚变,导航链接要跟着变(有坐标才是真导航,没坐标只是搜索)
refreshLocNav(prefix); refreshLocNav(prefix);
} }
/* 地理编码插件按需加载(layout.php 只预装了 AutoComplete) */
function withGeocoder(cb) {
try {
if (typeof AMap.Geocoder !== 'undefined') {
cb(new AMap.Geocoder({}));
return;
}
AMap.plugin('AMap.Geocoder', function () {
cb(typeof AMap.Geocoder !== 'undefined' ? new AMap.Geocoder({}) : null);
});
} catch (e) {
cb(null);
}
}
function showDetail(addr) {
var box = document.getElementById(prefix + '-location-detail');
if (!box) return;
box.textContent = addr ? '详细地址:' + addr : '';
}
/** 有坐标:反查这个点的完整地址 */
function reverseDetail(loc, fallback) {
withGeocoder(function (g) {
if (!g) { showDetail(fallback); return; }
try {
g.getAddress([Number(loc.lng), Number(loc.lat)], function (status, result) {
var addr = (status === 'complete' && result && result.regeocode)
? (result.regeocode.formattedAddress || '') : '';
if (addr && addrField) addrField.value = addr;
showDetail(addr || fallback);
});
} catch (e) {
showDetail(fallback);
}
});
}
/** 没坐标:按「区县 + 名称」正查,拿到详细地址与坐标 */
function forwardDetail(query, fallback) {
withGeocoder(function (g) {
if (!g) { showDetail(fallback); return; }
try {
g.getLocation(query, function (status, result) {
var addr = '';
if (status === 'complete' && result && result.geocodes && result.geocodes.length) {
var gc = result.geocodes[0];
addr = gc.formattedAddress || '';
if (gc.location) {
if (latField) latField.value = Number(gc.location.getLat());
if (lngField) lngField.value = Number(gc.location.getLng());
refreshLocNav(prefix);
}
}
if (addr && addrField) addrField.value = addr;
showDetail(addr || fallback);
});
} catch (e) {
showDetail(fallback);
}
});
}
// 编辑已有节点:把库里存下来的详细地址显示出来,让人知道记的是什么
showDetail(addrField ? addrField.value : '');
var searchTimer = null; var searchTimer = null;
input.addEventListener('input', function () { input.addEventListener('input', function () {
var keyword = input.value.trim(); var keyword = input.value.trim();