Files
TripLog/src/Members.php
T
laoyang 142b187911 节点表单:参与人直接勾选 + 起点终点记录详细地址(批 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 渲染、内容断言全过后提交。
2026-09-24 12:36:01 +08:00

248 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App;
/**
* 节点参与人:谁参加了这个节点、当时担任什么角色、心情如何、有没有签到
* 对应 trip_nodes 的从表 node_members(trip_node_id + user_id 唯一)
*/
class Members
{
/**
* 情绪目录:emoji 只在这里出现,库里存的就是 emoji 字符本身(列宽 varchar(10) 只够 1 个字符)
* 全部选用 Emoji 11.0(2018-06)及以前的码位 —— 更新的码位在旧系统 emoji 字体里没有字形,
* 会显示成空白/方框。
*/
public const EMOTIONS = [
'😄' => '开心',
'🥰' => '温暖',
'😍' => '喜欢',
'🤩' => '兴奋',
'🤗' => '满足',
'😎' => '得意',
'🤔' => '沉思',
'😌' => '平静',
'😋' => '好吃',
'😅' => '无奈',
'😴' => '累了',
'😵' => '晕了',
'😢' => '难过',
'😭' => '大哭',
'😱' => '惊吓',
'😠' => '生气',
'🤒' => '不舒服',
];
public static function isValidEmotion(string $emoji): bool
{
return $emoji !== '' && isset(self::EMOTIONS[$emoji]);
}
/** 某个节点的参与人列表(带姓名、头像、角色标签名) */
public static function forNode(int $nodeId): array
{
$stmt = Database::get()->prepare('
SELECT nm.*, u.name, u.avatar, u.role
FROM node_members nm
LEFT JOIN users u ON u.id = nm.user_id
WHERE nm.trip_node_id = ?
ORDER BY nm.id ASC');
$stmt->execute([$nodeId]);
$rows = $stmt->fetchAll();
$tagNames = self::roleTagMap();
foreach ($rows as &$r) {
$ids = json_decode((string)($r['role_tag_ids'] ?? '[]'), true);
$ids = is_array($ids) ? array_map('intval', $ids) : [];
$r['role_tag_ids'] = $ids;
$r['role_tag_names'] = array_values(array_filter(array_map(
static fn($id) => $tagNames[$id] ?? null,
$ids
)));
}
unset($r);
return $rows;
}
/** 一次查完多个节点的参与人数与情绪,供列表/卡片使用 */
public static function summaryForNodes(array $nodeIds): array
{
if (empty($nodeIds)) return [];
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
$stmt = Database::get()->prepare("SELECT trip_node_id, COUNT(*) AS cnt,
GROUP_CONCAT(emotion ORDER BY id SEPARATOR '') AS emotions
FROM node_members WHERE trip_node_id IN ($ph) GROUP BY trip_node_id");
$stmt->execute(array_values($nodeIds));
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['trip_node_id']] = [
'count' => (int)$row['cnt'],
'emotions' => (string)($row['emotions'] ?? ''),
];
}
return $out;
}
/**
* 一次查完多个节点的参与人明细(头像 / 姓名 / 情绪),供活动页的节点卡片使用。
* 一条 IN 查询带回全部节点,避免每张卡片单独查(N+1)。
* @return array<int, array<int, array>> node_id => 参与人列表(按 node_members.id ASC)
*/
public static function forNodes(array $nodeIds): array
{
if (empty($nodeIds)) return [];
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
$stmt = Database::get()->prepare('
SELECT nm.trip_node_id, nm.user_id, nm.emotion, u.name, u.avatar
FROM node_members nm
LEFT JOIN users u ON u.id = nm.user_id
WHERE nm.trip_node_id IN (' . $ph . ')
ORDER BY nm.id ASC');
$stmt->execute(array_values($nodeIds));
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['trip_node_id']][] = $row;
}
return $out;
}
/**
* 新增或更新一个参与人(同一节点同一人只留一条)
* @return array{ok: bool, error?: string}
*/
public static function save(int $nodeId, int $userId, array $roleTagIds, string $emotion, ?string $checkIn): array
{
$db = Database::get();
if ($userId <= 0) {
return ['ok' => false, 'error' => '请选择参与人'];
}
// 只能选本家庭的成员(注意:必须同时比对 users.family_id,
// 否则「节点存在 + 用户存在」就放行,别家用户也能被塞进来)
$stmt = $db->prepare('SELECT u.id, a.family_id
FROM trip_nodes tn
JOIN activities a ON a.id = tn.activity_id
JOIN users u ON u.id = ? AND u.family_id = a.family_id
WHERE tn.id = ?');
$stmt->execute([$userId, $nodeId]);
$row = $stmt->fetch();
if (!$row) {
return ['ok' => false, 'error' => '该成员不属于这个活动所在家庭'];
}
// 角色标签也要属于同一个家庭
$validTags = array_keys(self::roleTagMap((int)$row['family_id']));
$roleTagIds = array_values(array_intersect(array_map('intval', $roleTagIds), $validTags));
if ($emotion !== '' && !self::isValidEmotion($emotion)) {
return ['ok' => false, 'error' => '情绪表情不在可选范围内'];
}
$checkIn = $checkIn !== null && trim($checkIn) !== '' ? trim($checkIn) : null;
if ($checkIn !== null) {
$ts = strtotime(str_replace('T', ' ', $checkIn));
$checkIn = $ts !== false ? date('Y-m-d H:i:s', $ts) : null;
}
$stmt = $db->prepare('INSERT INTO node_members (trip_node_id, user_id, role_tag_ids, emotion, check_in_time)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE role_tag_ids = VALUES(role_tag_ids),
emotion = VALUES(emotion), check_in_time = VALUES(check_in_time)');
$stmt->execute([
$nodeId,
$userId,
$roleTagIds ? json_encode($roleTagIds, JSON_UNESCAPED_UNICODE) : null,
$emotion !== '' ? $emotion : null,
$checkIn,
]);
return ['ok' => true];
}
public static function remove(int $nodeId, int $userId): void
{
$stmt = Database::get()->prepare('DELETE FROM node_members WHERE trip_node_id = ? AND user_id = ?');
$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
{
$n = 0;
foreach (array_unique(array_map('intval', $userIds)) as $uid) {
$r = self::save($nodeId, $uid, $roleTagIds, '', null);
if ($r['ok']) $n++;
}
return $n;
}
/** 家庭内的角色标签 [id => name] */
public static function roleTagMap(?int $familyId = null): array
{
$db = Database::get();
if ($familyId === null) {
$stmt = $db->query('SELECT id, name, family_id FROM role_tags');
} else {
$stmt = $db->prepare('SELECT id, name, family_id FROM role_tags WHERE family_id = ?');
$stmt->execute([$familyId]);
}
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['id']] = $row['name'];
}
return $out;
}
/** 家庭内可作为参与人的用户(guest 也算,记录出行时常常是"待激活"的亲戚) */
public static function familyUsers(int $familyId): array
{
$stmt = Database::get()->prepare('SELECT id, name, avatar, role FROM users
WHERE family_id = ? ORDER BY FIELD(role, "admin","user","guest"), id ASC');
$stmt->execute([$familyId]);
return $stmt->fetchAll();
}
}