参与人(用户反馈:不想等全部填完、保存之后才回详情页加人):
- 节点表单里新增「参与人(直接勾选)」区块:家庭成员按胶囊复选框列出,
保存即生效;编辑时预勾选已有人。
- 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 渲染、内容断言全过后提交。
660 lines
27 KiB
PHP
660 lines
27 KiB
PHP
<?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_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;
|
||
}
|
||
}
|