Files
TripLog/src/Controllers/NodeController.php
T

614 lines
25 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Attachments;
use App\Auth;
use App\Comments;
use App\Database;
use App\Expenses;
use App\Members;
use App\Uploads;
class NodeController
{
/** 计划节点只可能有 planned / completed / cancelled */
private const PLAN_STATUSES = ['planned', 'completed', 'cancelled'];
/** 执行节点本身就是"已完成",额外允许 cancelled */
private const REALITY_STATUSES = ['completed', 'cancelled', 'ongoing', 'planned'];
public function create(): void
{
$db = Database::get();
$activityId = (int)($_GET['activity_id'] ?? 0);
$category = $_GET['category'] ?? 'plan';
$category = in_array($category, ['plan', 'reality'], true) ? $category : 'plan';
$activity = $this->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('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);
// 节点必须先有 id 才能挂附件,所以附件在插入之后处理
$extra = $this->handleNodeUploads((int)$db->lastInsertId());
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('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']);
$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;
return [
'modeLabels' => TravelModeController::MODE_LABELS,
'activity' => $activity,
'category' => $category,
'nodeTypes' => $nodeTypes,
'travelModes' => $travelModes,
'planNodes' => $planNodes,
'node' => $node,
'isEdit' => $isEdit,
// 编辑时把已有照片带进表单:能看、能勾选删除。新建时节点还没 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
{
$startTime = $this->normalizeDateTime((string)($_POST['start_time'] ?? ''));
$endTime = $this->normalizeDateTime((string)($_POST['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];
}
/** datetime-local 传上来是 2026-09-12T10:30,统一成 MySQL 认的 "Y-m-d H:i:s" */
private function normalizeDateTime(string $value): ?string
{
$value = trim($value);
if ($value === '') return null;
$value = str_replace('T', ' ', $value);
$ts = strtotime($value);
if ($ts === false) return null;
return date('Y-m-d H:i:s', $ts);
}
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 丢掉了,拿不到 activity_id,只能尽量退回一个合理页面。
*/
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 是用户可以随便改的,不校验就等于给了跨家庭删文件的入口。
*/
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;
}
}