2026-09-22 22:03:08 +08:00
|
|
|
|
<?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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 15:18:25 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一次查完多个节点的参与人明细(头像 / 姓名 / 情绪),供活动页的节点卡片使用。
|
|
|
|
|
|
* 一条 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 22:03:08 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 新增或更新一个参与人(同一节点同一人只留一条)
|
|
|
|
|
|
* @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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 12:36:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 勾选式同步:把节点参与人「对齐」成 $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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 22:03:08 +08:00
|
|
|
|
/** 一次把一批人加进节点(用于「把活动成员一键全部加入」) */
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|