Files
TripLog/src/Controllers/NodeController.php
T
laoyang 142b187911 节点表单:参与人直接勾选 + 起点终点记录详细地址(批 13)
参与人(用户反馈:不想等全部填完、保存之后才回详情页加人):
- 节点表单里新增「参与人(直接勾选)」区块:家庭成员按胶囊复选框列出,
  保存即生效;编辑时预勾选已有人。
- 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 渲染、内容断言全过后提交。
2026-09-24 12:36:01 +08:00

660 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}