fetchOwnActivity($id); if (!$activity) { http_response_code(404); exit('活动不存在'); } $stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, tm.mode_type AS travel_mode_type FROM trip_nodes tn LEFT JOIN node_types nt ON nt.id = tn.node_type_id LEFT JOIN travel_modes tm ON tm.id = tn.travel_mode_id WHERE tn.activity_id = ? ORDER BY tn.start_time ASC, tn.id ASC'); $stmt->execute([$id]); $nodes = $stmt->fetchAll(); // 已生成执行记录的计划节点 id 集合(这些计划版跳过,导执行版) $executed = []; foreach ($nodes as $n) { if (!empty($n['original_plan_node_id'])) { $executed[(int)$n['original_plan_node_id']] = true; } } $blocks = []; foreach ($nodes as $n) { if (($n['status'] ?? '') === 'cancelled') continue; if (isset($executed[(int)$n['id']])) continue; $n['activity_title'] = (string)$activity['title']; $blocks[] = Ics::event(NodeController::buildEventFields($n)); } Ics::emit('TripLog-' . (string)$activity['title'], Ics::calendar($blocks, (string)$activity['title'])); } public function index(): void { $db = Database::get(); $familyId = Auth::familyId(); $stmt = $db->prepare('SELECT * FROM activities WHERE family_id = ? ORDER BY created_at DESC'); $stmt->execute([$familyId]); $activities = $stmt->fetchAll(); $stats = []; if (!empty($activities)) { $stats = $this->collectStats(array_map('intval', array_column($activities, 'id'))); } view('activities/index', [ 'activities' => $activities, 'stats' => $stats, 'pageTitle' => '活动列表', ]); } /** * 首页要的聚合数据,**全部一次查完**(几条 IN 查询,不做逐活动的 N+1): * - 主要位置(地图图标落哪儿):所有坐标点的重心,执行节点优先、计划节点兜底 * - 去过的几个地方:起终点里的地名去重计数 * - 时间跨度:最早开始 ~ 最晚结束,跨了几个自然日 * - 主要参与人:按参与节点数降序(参与得越多越靠前) * * @param array $ids * @return array */ private function collectStats(array $ids): array { if (empty($ids)) return []; $db = Database::get(); $ph = implode(',', array_fill(0, count($ids), '?')); $out = []; foreach ($ids as $id) { $out[$id] = [ 'node_count' => 0, 'place_count' => 0, 'place_main' => '', 'lng' => null, 'lat' => null, 'start' => null, 'end' => null, 'days' => 0, 'span_text' => '', 'members' => [], 'member_count' => 0, ]; } /* ① 节点:位置 / 时间 / 地点名。取消的节点不计入(它没真的发生)。 */ $s = $db->prepare("SELECT activity_id, node_category, status, start_time, end_time, start_location, end_location FROM trip_nodes WHERE activity_id IN ($ph) ORDER BY start_time"); $s->execute($ids); $coords = []; // activity => ['reality' => [[lng,lat,name],…], 'plan' => …] $names = []; // activity => [地名 => 1] foreach ($s->fetchAll() as $n) { $aid = (int)$n['activity_id']; if (!isset($out[$aid])) continue; $st = &$out[$aid]; $st['node_count']++; if ($n['status'] === 'cancelled') continue; foreach (['start_location', 'end_location'] as $key) { $loc = json_decode((string)$n[$key], true); if (!is_array($loc)) continue; $name = trim((string)($loc['name'] ?? '')); if ($name !== '') $names[$aid][$name] = 1; $lng = isset($loc['lng']) ? (float)$loc['lng'] : 0.0; $lat = isset($loc['lat']) ? (float)$loc['lat'] : 0.0; if ($lng == 0.0 && $lat == 0.0) continue; // 只有地名、没坐标 $bucket = ($n['node_category'] === 'reality') ? 'reality' : 'plan'; $coords[$aid][$bucket][] = [$lng, $lat, $name]; } if (!empty($n['start_time']) && ($st['start'] === null || $n['start_time'] < $st['start'])) { $st['start'] = $n['start_time']; } if (!empty($n['end_time']) && ($st['end'] === null || $n['end_time'] > $st['end'])) { $st['end'] = $n['end_time']; } } unset($st); /* ② 重心 = 主要位置;离重心最近那个点的地名当它的名字。 执行节点优先:计划改了、实际没去的地方不该把图标拽过去。 */ foreach ($coords as $aid => $set) { $pts = !empty($set['reality']) ? $set['reality'] : ($set['plan'] ?? []); if (!$pts) continue; $sl = 0.0; $sb = 0.0; foreach ($pts as $p) { $sl += $p[0]; $sb += $p[1]; } $cl = $sl / count($pts); $cb = $sb / count($pts); $out[$aid]['lng'] = $cl; $out[$aid]['lat'] = $cb; $best = ''; $bestD = null; foreach ($pts as $p) { if ($p[2] === '') continue; $d = ($p[0] - $cl) * ($p[0] - $cl) + ($p[1] - $cb) * ($p[1] - $cb); if ($bestD === null || $d < $bestD) { $bestD = $d; $best = $p[2]; } } $out[$aid]['place_main'] = $best; } /* ③ 去过的地方数:地名去重(同一个城市写十次也只算一个) */ foreach ($names as $aid => $set) { $out[$aid]['place_count'] = count($set); } /* ④ 时间跨度:按自然日算(同一天出发并返回算 1 天) */ foreach ($out as $aid => $st) { if ($st['start'] === null) continue; $from = strtotime($st['start']); $to = $st['end'] !== null ? strtotime($st['end']) : $from; if (!$from) continue; $fromD = strtotime(date('Y-m-d', $from)); $toD = strtotime(date('Y-m-d', $to)); $days = (int)floor(($toD - $fromD) / 86400) + 1; $out[$aid]['days'] = max(1, $days); $out[$aid]['span_text'] = ($toD === $fromD) ? date('Y-m-d', $fromD) : date('Y-m-d', $fromD) . ' ~ ' . date('m-d', $toD); } /* ⑤ 参与人:参与节点越多越靠前 —— 「主要参与人」就是排在前面的那几位 */ $s = $db->prepare("SELECT tn.activity_id, nm.user_id, u.name, u.avatar, COUNT(*) AS hits FROM node_members nm JOIN trip_nodes tn ON tn.id = nm.trip_node_id LEFT JOIN users u ON u.id = nm.user_id WHERE tn.activity_id IN ($ph) GROUP BY tn.activity_id, nm.user_id, u.name, u.avatar ORDER BY tn.activity_id, hits DESC, nm.user_id ASC"); $s->execute($ids); foreach ($s->fetchAll() as $m) { $aid = (int)$m['activity_id']; if (!isset($out[$aid])) continue; $out[$aid]['members'][] = [ 'id' => (int)$m['user_id'], 'name' => (string)($m['name'] ?? ''), 'avatar' => media_avatar($m['avatar'] ?? null), ]; } foreach ($out as $aid => $st) { $out[$aid]['member_count'] = count($st['members']); } return $out; } public function create(): void { view('activities/form', [ 'activity' => null, 'pageTitle' => '新建活动', ]); } public function store(): void { csrf_check(); $db = Database::get(); $familyId = Auth::familyId(); $userId = Auth::id(); $title = trim($_POST['title'] ?? ''); if ($title === '') { flash('error', '标题不能为空'); redirect('activities/create'); } if (mb_strlen($title) > 200) { flash('error', '标题不能超过 200 个字符'); redirect('activities/create'); } $cover = $this->normalizeCoverUrl($_POST['cover_image'] ?? ''); $stmt = $db->prepare('INSERT INTO activities (family_id, title, description, cover_image, created_by) VALUES (?, ?, ?, ?, ?)'); $stmt->execute([ $familyId, $title, trim($_POST['description'] ?? ''), $cover, $userId, ]); $id = (int)$db->lastInsertId(); // 创建者自动成为 owner $stmt = $db->prepare('INSERT INTO activity_members (activity_id, user_id, permission) VALUES (?, ?, "owner")'); $stmt->execute([$id, $userId]); flash('error', '活动创建成功'); redirect('activities/show', ['id' => $id]); } public function edit(): void { $activity = $this->fetchOwnActivity((int)($_GET['id'] ?? 0)); view('activities/form', [ 'activity' => $activity, 'pageTitle' => '编辑活动', ]); } public function update(): void { csrf_check(); $db = Database::get(); $activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0)); $id = (int)$activity['id']; $title = trim($_POST['title'] ?? ''); if ($title === '') { flash('error', '标题不能为空'); redirect('activities/edit', ['id' => $id]); } if (mb_strlen($title) > 200) { flash('error', '标题不能超过 200 个字符'); redirect('activities/edit', ['id' => $id]); } $cover = $this->normalizeCoverUrl($_POST['cover_image'] ?? ''); $oldCover = (string)($activity['cover_image'] ?? ''); $stmt = $db->prepare('UPDATE activities SET title = ?, description = ?, cover_image = ? WHERE id = ?'); $stmt->execute([ $title, trim($_POST['description'] ?? ''), $cover, $id, ]); // 封面换过或移除后,删掉不再引用的旧文件 if ($oldCover !== '' && $oldCover !== $cover) { $this->deleteCoverFile($oldCover); } flash('error', '保存成功'); redirect('activities/show', ['id' => $id]); } public function destroy(): void { csrf_check(); $db = Database::get(); $activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0)); $id = (int)$activity['id']; // 附件表是软关联(target_type + target_id),没有外键级联,必须手工清。 // 走到 Attachments::purgeTarget 里统一处理,它同时会清掉本地文件或 OSS 对象 —— // 以前这里按 file_url 前缀判断,OSS 存储的附件根本删不掉。 $stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?'); $stmt->execute([$id]); $nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN)); Attachments::purgeTarget(Attachments::TARGET_ACTIVITY, $id); foreach ($nodeIds as $nodeId) { Attachments::purgeTarget(Attachments::TARGET_NODE, $nodeId); } // 评论同样是软关联 if (!empty($nodeIds)) { $ph = implode(',', array_fill(0, count($nodeIds), '?')); $stmt = $db->prepare("DELETE FROM comments WHERE target_type = \"trip_node\" AND target_id IN ($ph)"); $stmt->execute($nodeIds); } $stmt = $db->prepare('DELETE FROM comments WHERE target_type = "activity" AND target_id = ?'); $stmt->execute([$id]); // 删活动:外键会把 trip_nodes / activity_members / node_members / expenses 一起带走 $stmt = $db->prepare('DELETE FROM activities WHERE id = ? AND family_id = ?'); $stmt->execute([$id, Auth::familyId()]); // 数据库删干净了再删封面文件;附件对象已经在 purgeTarget 里删过了 if (!empty($activity['cover_image'])) { $this->deleteCoverFile((string)$activity['cover_image']); } flash('error', '活动已删除'); redirect('activities/index'); } public function show(): void { $db = Database::get(); $id = (int)($_GET['id'] ?? 0); $activity = $this->fetchOwnActivity($id, false); if (!$activity) { http_response_code(404); exit('活动不存在'); } $stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, nt.icon AS node_type_icon, tm.mode_type AS travel_mode_type FROM trip_nodes tn LEFT JOIN node_types nt ON nt.id = tn.node_type_id LEFT JOIN travel_modes tm ON tm.id = tn.travel_mode_id WHERE tn.activity_id = ? AND tn.node_category = "plan" ORDER BY tn.start_time ASC'); $stmt->execute([$id]); $planNodes = $stmt->fetchAll(); $this->attachCounts($db, $planNodes); $stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, nt.icon AS node_type_icon, tm.mode_type AS travel_mode_type FROM trip_nodes tn LEFT JOIN node_types nt ON nt.id = tn.node_type_id LEFT JOIN travel_modes tm ON tm.id = tn.travel_mode_id WHERE tn.activity_id = ? AND tn.node_category = "reality" ORDER BY tn.start_time ASC'); $stmt->execute([$id]); $realityNodes = $stmt->fetchAll(); $this->attachCounts($db, $realityNodes); // 计划节点 → 已生成执行记录的计划节点 id(用于在计划卡片上标记「已成行」) $stmt = $db->prepare('SELECT original_plan_node_id FROM trip_nodes WHERE activity_id = ? AND original_plan_node_id IS NOT NULL'); $stmt->execute([$id]); $executedPlanIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN)); // 每个节点的参与人(头像 / 姓名),渲染在节点卡片底部 —— 一条 IN 查询,不 N+1 $nodeMembers = Members::forNodes(array_merge( array_column($planNodes, 'id'), array_column($realityNodes, 'id') )); // 批 21:详情页顶部改用列表同款「封面行」(小封面 + 参与人/统计), // 复用与列表同一份聚合口径(collectStats),不另写一套查询 $actStats = $this->collectStats([$id]); view('activities/show', [ 'activity' => $activity, 'planNodes' => $planNodes, 'realityNodes' => $realityNodes, 'executedPlanIds' => $executedPlanIds, 'nodeMembers' => $nodeMembers, 'actStats' => $actStats, // 路线地图:每个执行节点第一张图片附件 id(圆形标记用),一条 GROUP BY 防止 N+1 'nodeFirstImage' => Attachments::firstImageIdsForNodes(array_column($realityNodes, 'id')), // 批 19:路线地图标记图标按出行方式映射(回退节点类型图标) 'modeIcons' => TravelModeController::MODE_ICONS, 'expenseSummary' => Expenses::summaryForActivity($id), 'commentTree' => Comments::tree(Comments::TARGET_ACTIVITY, $id), 'pageTitle' => $activity['title'], ]); } /** * 上传活动封面(接收前端裁剪后的 base64 图片) */ public function uploadCover(): void { header('Content-Type: application/json; charset=utf-8'); if (!Auth::check()) { http_response_code(401); echo json_encode(['error' => '未登录'], JSON_UNESCAPED_UNICODE); exit; } $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) { http_response_code(419); echo json_encode(['error' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE); exit; } $body = file_get_contents('php://input'); $data = json_decode($body, true); $base64 = $data['image'] ?? ''; if (!preg_match('/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/i', $base64, $m)) { http_response_code(400); echo json_encode(['error' => '无效的图片格式'], JSON_UNESCAPED_UNICODE); exit; } $ext = strtolower($m[1]); if ($ext === 'jpeg') $ext = 'jpg'; $raw = base64_decode($m[2], true); if ($raw === false) { http_response_code(400); echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE); exit; } // 封面体积上限 8MB(比头像宽松) if (strlen($raw) > 8 * 1024 * 1024) { http_response_code(413); echo json_encode(['error' => '图片不能超过 8MB'], JSON_UNESCAPED_UNICODE); exit; } // 不用 finfo:线上 PHP 7.4 没有 fileinfo 扩展(改用 Mime 的多层兜底) $info = Mime::detectBuffer($raw); if ($info['mime'] === null || !in_array($info['mime'], ['image/jpeg', 'image/png', 'image/webp'], true)) { http_response_code(400); echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE); exit; } $ext = Mime::extFor($info['mime']) ?? $ext; $dir = dirname(__DIR__, 2) . '/uploads/covers'; if (!is_dir($dir)) mkdir($dir, 0755, true); $filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext; $savePath = $dir . '/' . $filename; if (file_put_contents($savePath, $raw) === false) { http_response_code(500); echo json_encode(['error' => '保存文件失败'], JSON_UNESCAPED_UNICODE); exit; } // 删除被替换掉的旧封面 $this->deleteCoverFile((string)($_POST['old'] ?? '')); echo json_encode([ 'url' => '/uploads/covers/' . $filename, 'size' => strlen($raw), ], JSON_UNESCAPED_UNICODE); } /** * 取当前家庭下的活动;$allowMissing=true 时不存在返回 null 而不中断 */ private function fetchOwnActivity(int $id, bool $allowMissing = false): ?array { if ($id <= 0) { if ($allowMissing) return null; http_response_code(404); exit('活动不存在'); } $stmt = Database::get()->prepare('SELECT * FROM activities WHERE id = ?'); $stmt->execute([$id]); $activity = $stmt->fetch(); if (!$activity || (int)$activity['family_id'] !== Auth::familyId()) { if ($allowMissing) return null; http_response_code(404); exit('活动不存在'); } return $activity; } /** 只接受本站 covers 目录下的相对地址,其余一律视为未设置 */ private function normalizeCoverUrl(string $url): ?string { $url = trim($url); if ($url === '') return null; if (strpos($url, '/uploads/covers/') !== 0) return null; if (strpos($url, '..') !== false) return null; return $url; } /** 只删除本站 uploads/covers/ 下的封面文件 */ private function deleteCoverFile(string $url): void { $url = trim($url); if ($url === '' || strpos($url, '/uploads/covers/') !== 0 || strpos($url, '..') !== false) { return; } $file = dirname(__DIR__, 2) . $url; if (is_file($file)) @unlink($file); } /** * 给一批节点补上「附件数 / 参与人数 / 评论数 / 花销笔数」 * 每个指标各一条 GROUP BY 查询,避免每张卡片单独查(N+1) */ private function attachCounts(\PDO $db, array &$nodes): void { if (empty($nodes)) return; $ids = array_column($nodes, 'id'); $placeholders = implode(',', array_fill(0, count($ids), '?')); $counts = [ 'attachment_count' => $this->groupCount($db, "SELECT target_id, COUNT(*) AS cnt FROM attachments WHERE target_type = 'trip_node' AND target_id IN ($placeholders) GROUP BY target_id", $ids), 'comment_count' => $this->groupCount($db, "SELECT target_id, COUNT(*) AS cnt FROM comments WHERE target_type = 'trip_node' AND target_id IN ($placeholders) GROUP BY target_id", $ids), 'member_count' => $this->groupCount($db, "SELECT trip_node_id AS target_id, COUNT(*) AS cnt FROM node_members WHERE trip_node_id IN ($placeholders) GROUP BY trip_node_id", $ids), 'expense_count' => $this->groupCount($db, "SELECT trip_node_id AS target_id, COUNT(*) AS cnt FROM expenses WHERE trip_node_id IN ($placeholders) GROUP BY trip_node_id", $ids), ]; foreach ($nodes as &$n) { foreach ($counts as $key => $map) { $n[$key] = $map[(int)$n['id']] ?? 0; } } unset($n); } /** @return array id => 计数 */ private function groupCount(\PDO $db, string $sql, array $params): array { $stmt = $db->prepare($sql); $stmt->execute(array_values($params)); $out = []; foreach ($stmt->fetchAll() as $row) { $out[(int)$row['target_id']] = (int)$row['cnt']; } return $out; } }