节点表单:参与人直接勾选 + 起点终点记录详细地址(批 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
+129 -1
View File
@@ -44,6 +44,26 @@ $eLoc = [
$startTimeVal = old_dt('start_time', $n['start_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>
<span class="muted">保存后节点详情页也会带这个链接</span>
</div>
<div class="loc-detail muted" id="start-location-detail"></div>
</div>
</fieldset>
@@ -205,6 +226,7 @@ $tripField = [
target="_blank" rel="noopener">🧭 导航到这里</a>
<span class="muted">保存后节点详情页也会带这个链接</span>
</div>
<div class="loc-detail muted" id="end-location-detail"></div>
</div>
</fieldset>
@@ -213,6 +235,30 @@ $tripField = [
<textarea name="description" rows="4" placeholder="记录一些细节..."><?= e($oldStr('description', $n['description'] ?? '')) ?></textarea>
</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
/* 照片附件:随节点表单一起提交。
新建时节点还没有 id,附件挂不上去,所以只提示「保存后一起挂上」;
@@ -512,16 +558,98 @@ $tripField = [
function selectItem(item) {
if (!item) return;
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 (latField) latField.value = item.location.lat;
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';
// 坐标刚变,导航链接要跟着变(有坐标才是真导航,没坐标只是搜索)
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;
input.addEventListener('input', function () {
var keyword = input.value.trim();