fetchOwnActivity($activityId); view('nodes/form', $this->formData($db, $activity, $category, null)); } public function edit(): void { $db = Database::get(); $node = $this->fetchOwnNode((int)($_GET['id'] ?? 0)); $stmt = $db->prepare('SELECT * FROM activities WHERE id = ?'); $stmt->execute([$node['activity_id']]); $activity = $stmt->fetch(); view('nodes/form', $this->formData($db, $activity, $node['node_category'], $node)); } public function store(): void { // 表单里带了照片时,整个请求体可能超过 post_max_size —— 这时 PHP 会 // **静默丢掉 $_POST 和 $_FILES**,紧接着 csrf_check() 报「请求已过期」, // 用户看到的是「点了保存但没反应」。提前认出来,说清怎么办。 // (这一步放在 csrf_check() 之前:它只读长度、不改任何状态。) if (Uploads::requestTooLarge()) { $this->flashTooLarge(); } csrf_check(); $db = Database::get(); $activity = $this->fetchOwnActivity((int)($_POST['activity_id'] ?? 0)); $category = $_POST['node_category'] ?? 'plan'; if (!in_array($category, ['plan', 'reality'], true)) { exit('无效的节点分类'); } $input = $this->collectInput($db, $activity, $category); if ($input['error'] !== null) { // 校验失败必须把用户刚填的内容原样带回去,否则一重定向表单就是空的,等于白填 flash_old($_POST); flash('error', $input['error']); redirect('nodes/create', ['activity_id' => $activity['id'], 'category' => $category]); } $data = $input['data']; // 交通信息只留 trip_number(车次/航班号)—— 航班号本身就隐含航空公司, // 座位号/登机口对「一趟车多人同行」没有意义,所以这些字段不再收集。 // // ⚠️ 历史列(line_name / carrier / seat_info / gate)仍然**留在 SQL 里**, // 只是值固定为 null(collectInput 里不再从表单取)。原因不是「要写它们」, // 而是 $data 里带着这几个键:Database 用的是原生预处理 // (ATTR_EMULATE_PREPARES => false),SQL 里少了占位符就会抛 // SQLSTATE[HY093](参数个数对不上)→ 新建/编辑节点整条挂掉。 // 顺带的好处是:编辑一次旧节点就把它身上的历史脏数据清干净。 $sql = 'INSERT INTO trip_nodes (activity_id, node_type_id, node_category, original_plan_node_id, start_time, end_time, duration_minutes, start_location, end_location, title, description, travel_mode_id, vehicle_id, line_name, carrier, trip_number, seat_info, gate, status, cancel_reason) VALUES (:activity_id, :node_type_id, :node_category, :original_plan_node_id, :start_time, :end_time, :duration_minutes, :start_location, :end_location, :title, :description, :travel_mode_id, :vehicle_id, :line_name, :carrier, :trip_number, :seat_info, :gate, :status, :cancel_reason)'; $stmt = $db->prepare($sql); $stmt->execute($data); $nodeId = (int)$db->lastInsertId(); // 节点必须先有 id 才能挂附件,所以附件在插入之后处理 $extra = $this->handleNodeUploads($nodeId); // 表单里勾的参与人(members_present=1 才认,避免没渲染这个区块的请求误清) $extra .= $this->syncMembers($nodeId); flash('error', '节点已保存' . $extra); redirect('activities/show', ['id' => (int)$activity['id']]); } public function update(): void { if (Uploads::requestTooLarge()) { $this->flashTooLarge(); } csrf_check(); $db = Database::get(); $node = $this->fetchOwnNode((int)($_POST['id'] ?? 0)); $category = $node['node_category']; $stmt = $db->prepare('SELECT * FROM activities WHERE id = ?'); $stmt->execute([$node['activity_id']]); $activity = $stmt->fetch(); $input = $this->collectInput($db, $activity, $category, $node); if ($input['error'] !== null) { flash_old($_POST); // 同 store():失败不能让用户白填 flash('error', $input['error']); redirect('nodes/edit', ['id' => (int)$node['id']]); } $data = $input['data']; unset($data['activity_id'], $data['node_category'], $data['original_plan_node_id']); $data['id'] = (int)$node['id']; // 同样必须保留历史列的占位符 —— 见 store() 里的说明(原生预处理不接受多余键) $sql = 'UPDATE trip_nodes SET node_type_id = :node_type_id, start_time = :start_time, end_time = :end_time, duration_minutes = :duration_minutes, start_location = :start_location, end_location = :end_location, title = :title, description = :description, travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id, line_name = :line_name, carrier = :carrier, trip_number = :trip_number, seat_info = :seat_info, gate = :gate, status = :status, cancel_reason = :cancel_reason WHERE id = :id'; $stmt = $db->prepare($sql); $stmt->execute($data); // 表单里勾了「删除」的已有照片 $removed = $this->deleteCheckedAttachments((int)$node['id']); // 再处理这次新选的照片 $extra = $this->handleNodeUploads((int)$node['id']); // 参与人在表单里直接勾(编辑时重新勾选不会清掉已记的情绪/签到) $extra .= $this->syncMembers((int)$node['id']); $msg = '保存成功'; if ($removed > 0) { $msg .= ",删除了 {$removed} 个附件"; } flash('error', $msg . $extra); redirect('activities/show', ['id' => (int)$node['activity_id']]); } /** * 卡片上的状态快捷切换(计划节点:标记完成 / 撤销完成) */ public function status(): void { csrf_check(); $db = Database::get(); $node = $this->fetchOwnNode((int)($_POST['id'] ?? 0)); $status = (string)($_POST['status'] ?? ''); $allowed = $node['node_category'] === 'plan' ? self::PLAN_STATUSES : self::REALITY_STATUSES; if (!in_array($status, $allowed, true)) { flash('error', '无效的状态'); redirect('activities/show', ['id' => (int)$node['activity_id']]); } $reason = trim($_POST['cancel_reason'] ?? ''); $stmt = $db->prepare('UPDATE trip_nodes SET status = ?, cancel_reason = ? WHERE id = ?'); $stmt->execute([ $status, $status === 'cancelled' ? ($reason !== '' ? $reason : null) : null, (int)$node['id'], ]); $labels = ['planned' => '已恢复为待执行', 'completed' => '已标记为完成', 'cancelled' => '已取消', 'ongoing' => '已标记为进行中']; flash('error', $labels[$status] ?? '状态已更新'); redirect('activities/show', ['id' => (int)$node['activity_id']]); } /** * 用计划节点生成一条执行记录(复制字段,保留 original_plan_node_id 指向计划节点) */ public function fromPlan(): void { csrf_check(); $db = Database::get(); $plan = $this->fetchOwnNode((int)($_POST['plan_node_id'] ?? 0)); if ($plan['node_category'] !== 'plan') { flash('error', '只有计划节点可以生成为执行记录'); redirect('activities/show', ['id' => (int)$plan['activity_id']]); } // 已经生成过就不重复生成 $chk = $db->prepare('SELECT id FROM trip_nodes WHERE original_plan_node_id = ? LIMIT 1'); $chk->execute([(int)$plan['id']]); $exists = $chk->fetchColumn(); if ($exists) { flash('error', '该计划节点已经有执行记录了'); redirect('activities/show', ['id' => (int)$plan['activity_id']]); } $stmt = $db->prepare('INSERT INTO trip_nodes (activity_id, node_type_id, node_category, original_plan_node_id, start_time, end_time, duration_minutes, start_location, end_location, title, description, travel_mode_id, vehicle_id, trip_number, status) VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")'); $stmt->execute([ (int)$plan['activity_id'], (int)$plan['node_type_id'], (int)$plan['id'], $plan['start_time'], $plan['end_time'], $plan['duration_minutes'], $plan['start_location'], $plan['end_location'], $plan['title'], $plan['description'], $plan['travel_mode_id'], $plan['vehicle_id'], $plan['trip_number'], ]); flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况'); redirect('activities/show', ['id' => (int)$plan['activity_id']]); } public function destroy(): void { csrf_check(); $db = Database::get(); $node = $this->fetchOwnNode((int)($_POST['id'] ?? 0)); // 附件是软关联:交给 Attachments 统一清(同时会删本地文件或 OSS 对象) Attachments::purgeTarget(Attachments::TARGET_NODE, (int)$node['id']); $stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?'); $stmt->execute([(int)$node['id']]); // 若有执行记录指向本计划节点,外键是 ON DELETE SET NULL,会自动置空 $stmt = $db->prepare('DELETE FROM trip_nodes WHERE id = ?'); $stmt->execute([(int)$node['id']]); flash('error', '节点已删除'); redirect('activities/show', ['id' => (int)$node['activity_id']]); } /** * 节点详情页:把挂在这个节点上的参与人、花销、讨论集中到一页 */ public function show(): void { $db = Database::get(); $nodeId = (int)($_GET['id'] ?? 0); $node = Access::node($nodeId); // 含 family_id / activity_id / activity_title // 节点类型(本家庭) $stmt = $db->prepare('SELECT name, icon FROM node_types WHERE id = ?'); $stmt->execute([(int)$node['node_type_id']]); $type = $stmt->fetch(); if (!$type) $type = ['name' => '未分类', 'icon' => '·']; // 出行方式(连同车辆信息拼成一行展示文本) $travelModeLabel = ''; $travelModeType = ''; if (!empty($node['travel_mode_id'])) { $stmt = $db->prepare(' SELECT tm.mode_type, v.plate_number, v.model_name FROM travel_modes tm LEFT JOIN vehicles v ON v.id = tm.vehicle_id WHERE tm.id = ?'); $stmt->execute([(int)$node['travel_mode_id']]); $tm = $stmt->fetch(); if ($tm) { $label = TravelModeController::MODE_LABELS[$tm['mode_type']] ?? $tm['mode_type']; if (!empty($tm['plate_number'])) { $label .= ' · ' . $tm['plate_number']; } elseif (!empty($tm['model_name'])) { $label .= ' · ' . $tm['model_name']; } $travelModeLabel = $label; $travelModeType = (string)$tm['mode_type']; } } $nodeAttachments = Attachments::forTarget(Attachments::TARGET_NODE, $nodeId); $attachmentCount = count($nodeAttachments); $familyId = (int)$node['family_id']; view('nodes/show', [ 'node' => array_merge($node, [ 'node_type_name' => (string)$type['name'], 'node_type_icon' => (string)$type['icon'], 'travel_mode_label' => $travelModeLabel, 'travel_mode_type' => $travelModeType, ]), 'attachmentCount' => $attachmentCount, 'attachments' => $nodeAttachments, 'members' => Members::forNode($nodeId), 'familyUsers' => Members::familyUsers($familyId), 'roleTags' => Members::roleTagMap($familyId), 'emotions' => Members::EMOTIONS, 'expenses' => Expenses::forNode($nodeId), 'expenseSummary' => Expenses::summaryForNode($nodeId), 'categories' => Expenses::CATEGORIES, 'currencies' => Expenses::currencyLabels(), 'expenseSymbols' => Expenses::SYMBOLS, 'currentUserId' => (int)Auth::id(), 'commentTree' => Comments::tree(Comments::TARGET_NODE, $nodeId), 'pageTitle' => $node['title'] !== null && $node['title'] !== '' ? (string)$node['title'] : '节点详情', ]); } // ------------------------------------------------------------------ // 内部辅助 // ------------------------------------------------------------------ /** 组装 nodes/form 模板所需数据 */ private function formData(\PDO $db, array $activity, string $category, ?array $node): array { $stmt = $db->prepare('SELECT * FROM node_types WHERE family_id = ? ORDER BY is_system DESC, id ASC'); $stmt->execute([$activity['family_id']]); $nodeTypes = $stmt->fetchAll(); $stmt = $db->prepare(' SELECT tm.*, v.plate_number, v.model_name FROM travel_modes tm LEFT JOIN vehicles v ON v.id = tm.vehicle_id WHERE tm.family_id = ? ORDER BY FIELD(tm.mode_type, "drive_own","drive_rental","train","plane","bus","subway","taxi","bike","walk","ship","other"), tm.id ASC '); $stmt->execute([$activity['family_id']]); $travelModes = $stmt->fetchAll(); // 执行节点可以关联计划节点(编辑已有执行节点时不再改关联关系) $planNodes = []; if ($category === 'reality' && $node === null) { $s = $db->prepare('SELECT id, title, start_time FROM trip_nodes WHERE activity_id = ? AND node_category = "plan" ORDER BY start_time'); $s->execute([$activity['id']]); $planNodes = $s->fetchAll(); } $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 [ 'modeLabels' => TravelModeController::MODE_LABELS, 'activity' => $activity, 'category' => $category, 'nodeTypes' => $nodeTypes, 'travelModes' => $travelModes, 'planNodes' => $planNodes, 'node' => $node, 'isEdit' => $isEdit, 'familyUsers' => $familyUsers, 'nodeMemberIds' => $nodeMemberIds, // 编辑时把已有照片带进表单:能看、能勾选删除。新建时节点还没 id, // 附件挂不上去,所以只能是空数组(模板里会提示先保存)。 'attachments' => $isEdit ? Attachments::forTarget(Attachments::TARGET_NODE, (int)$node['id']) : [], 'maxPhotoMb' => (int)\App\Settings::get('photo_max_mb', '12'), 'pageTitle' => ($isEdit ? '编辑' : '新建') . ($category === 'plan' ? '计划' : '执行') . '节点', ]; } /** * 校验并整理表单输入 * @return array{error: ?string, data: array} */ private function collectInput(\PDO $db, array $activity, string $category, ?array $node = null): array { // 「日期 + 24 小时制时/分」组合控件(read_dt_input 里说明了为什么不用 datetime-local) $startTime = read_dt_input('start_time'); $endTime = read_dt_input('end_time'); if ($startTime === null || $endTime === null) { return ['error' => '开始和结束时间必填', 'data' => []]; } if (strtotime($endTime) < strtotime($startTime)) { return ['error' => '结束时间不能早于开始时间', 'data' => []]; } $nodeTypeId = (int)($_POST['node_type_id'] ?? 0); $chk = $db->prepare('SELECT id FROM node_types WHERE id = ? AND family_id = ?'); $chk->execute([$nodeTypeId, $activity['family_id']]); if (!$chk->fetch()) { return ['error' => '节点类型不存在', 'data' => []]; } $travelModeId = !empty($_POST['travel_mode_id']) ? (int)$_POST['travel_mode_id'] : null; $vehicleId = null; if ($travelModeId) { $s = $db->prepare('SELECT vehicle_id FROM travel_modes WHERE id = ? AND family_id = ?'); $s->execute([$travelModeId, $activity['family_id']]); $row = $s->fetch(); if (!$row) { return ['error' => '出行方式不存在', 'data' => []]; } $vehicleId = $row['vehicle_id'] !== null ? (int)$row['vehicle_id'] : null; } // 状态:计划节点默认待执行,执行节点默认已完成 $defaultStatus = $category === 'plan' ? 'planned' : 'completed'; $status = (string)($_POST['status'] ?? $defaultStatus); $allowed = $category === 'plan' ? self::PLAN_STATUSES : self::REALITY_STATUSES; if (!in_array($status, $allowed, true)) { $status = $node['status'] ?? $defaultStatus; } $originalPlanId = null; if ($category === 'reality' && $node === null && !empty($_POST['original_plan_node_id'])) { $originalPlanId = (int)$_POST['original_plan_node_id']; // 关联的计划节点必须属于同一个活动,否则视为未关联 $s = $db->prepare('SELECT id FROM trip_nodes WHERE id = ? AND activity_id = ? AND node_category = "plan"'); $s->execute([$originalPlanId, $activity['id']]); if (!$s->fetch()) { $originalPlanId = null; } } $data = [ 'activity_id' => (int)$activity['id'], 'node_type_id' => $nodeTypeId, 'node_category' => $category, 'original_plan_node_id' => $originalPlanId, 'start_time' => $startTime, 'end_time' => $endTime, 'duration_minutes' => $this->calcDuration($startTime, $endTime), 'start_location' => $this->buildLocation('start'), 'end_location' => $this->buildLocation('end'), 'title' => trim($_POST['title'] ?? ''), 'description' => trim($_POST['description'] ?? ''), 'travel_mode_id' => $travelModeId, 'vehicle_id' => $vehicleId, 'line_name' => null, 'carrier' => null, 'trip_number' => $this->strOrNull('trip_number'), 'seat_info' => null, 'gate' => null, 'status' => $status, 'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null, ]; return ['error' => null, 'data' => $data]; } private function fetchOwnActivity(int $id): array { if ($id <= 0) { 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()) { http_response_code(404); exit('活动不存在'); } return $activity; } private function fetchOwnNode(int $id): array { if ($id <= 0) { http_response_code(404); exit('节点不存在'); } $stmt = Database::get()->prepare(' SELECT tn.*, a.family_id FROM trip_nodes tn JOIN activities a ON a.id = tn.activity_id WHERE tn.id = ?'); $stmt->execute([$id]); $node = $stmt->fetch(); if (!$node || (int)$node['family_id'] !== Auth::familyId()) { http_response_code(404); exit('节点不存在'); } return $node; } private function calcDuration(string $start, string $end): int { $s = strtotime($start); $e = strtotime($end); return max(0, (int)round(($e - $s) / 60)); } private function buildLocation(string $which): ?string { $name = trim($_POST["{$which}_location_name"] ?? ''); $address = trim($_POST["{$which}_location_address"] ?? ''); if ($name === '' && $address === '') return null; $lat = $_POST["{$which}_location_lat"] ?? null; $lng = $_POST["{$which}_location_lng"] ?? null; return json_encode([ 'name' => $name, 'address' => $address, 'lat' => ($lat === '' || $lat === null) ? null : $lat, 'lng' => ($lng === '' || $lng === null) ? null : $lng, ], JSON_UNESCAPED_UNICODE); } private function strOrNull(string $key): ?string { $v = trim($_POST[$key] ?? ''); return $v === '' ? null : $v; } /** * 请求体超限的统一提示。 * * 这时 $_POST 已经被 PHP 丢掉了,**无从回填**(服务端拿不到任何字段), * 只能尽量退回一个合理页面;已填内容的恢复靠表单里的 sessionStorage 草稿。 */ private function flashTooLarge(): void { flash('error', '这次提交的内容太大(超过服务器上限 ' . ini_get('post_max_size') . '),节点没有保存。请把照片分成几批上传,或先保存节点、再进「📷 照片」页上传。'); $actId = (int)($_POST['activity_id'] ?? 0); if ($actId > 0) { $cat = (string)($_POST['node_category'] ?? 'plan'); redirect('nodes/create', [ 'activity_id' => $actId, 'category' => in_array($cat, ['plan', 'reality'], true) ? $cat : 'plan', ]); } redirect('activities/index'); } /** * 处理节点表单里一起提交的附件(新选的 files[] 与前端裁过的 cropped[])。 * * @return string 追加到 flash 文案后的后缀;没有附件时是空串 */ private function handleNodeUploads(int $nodeId): string { $uploaderId = (int)Auth::id(); $target = Attachments::TARGET_NODE; $files = Uploads::handleFiles($_FILES['files'] ?? [], $target, $nodeId, $uploaderId, 0); $crop = Uploads::handleCropped( (string)($_POST['cropped'] ?? ''), $target, $nodeId, $uploaderId, $files['count'] ); $uploaded = $files['uploaded'] + $crop['uploaded']; $errors = array_merge($files['errors'], $crop['errors']); if ($uploaded === 0 && $errors === []) { return ''; } $msg = $uploaded > 0 ? ",另存了 {$uploaded} 张照片/附件" : ',但照片没传上去'; if ($errors !== []) { $msg .= '(' . implode(';', array_slice($errors, 0, 3)) . ')'; } return $msg; } /** * 删除表单里勾选的已有附件。 * * ⚠️ 必须逐条校验归属:attachments 是「target_type + target_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 { $ids = $_POST['delete_attachments'] ?? []; if (!is_array($ids)) { return 0; } $removed = 0; foreach ($ids as $id) { $id = (int)$id; $att = $id > 0 ? Attachments::find($id) : null; if (!$att || $att['target_type'] !== Attachments::TARGET_NODE || (int)$att['target_id'] !== $nodeId) { continue; } Attachments::delete($id); $removed++; } return $removed; } }