行程:#47~#51 打包 —— 双轨状态、动态类型、动作按钮、去提示 (#51)
#47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊 #48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间 #50 类型点击无反应(@Builder 值传参不建立依赖) #51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调 API 新增:GET /trips/<id>/node-types、PATCH /trips/<id>/nodes/action DB 新增:trip_nodes.actual_started_at / actual_ended_at
This commit is contained in:
+215
-10
@@ -904,9 +904,152 @@ final class Trip
|
||||
* 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 +
|
||||
* 该出行方式相关的),这样换个方式再换回来,填过的东西还在。
|
||||
*/
|
||||
/**
|
||||
* 行程动作常量(#48)。
|
||||
*
|
||||
* ⚠️ 只做三个动作,**不含"删除"**:删除走既有软删(`deleted_at`),
|
||||
* 与"取消"是两件完全不同的事 —— 用户明确要求区分:
|
||||
* · 取消 = 不去了,数据保留,列在「未成行」组
|
||||
* · 删除 = 彻底不要了,不再显示
|
||||
*/
|
||||
public const NODE_ACTIONS = ['start', 'done', 'cancel'];
|
||||
|
||||
/**
|
||||
* 执行一个行程动作(#48 的核心逻辑)。
|
||||
*
|
||||
* 用户原话(2026-11-05):
|
||||
* 「添加计划行程时,刚开始添加的时候,它的状态就是"计划中"。
|
||||
* 当我开始执行这个计划时…这时候就要记录住 8:30 这个时间,因为 8:30 会
|
||||
* 成为实际行程的开始时间。也就是说,原来计划的时间和现在真正开始的时间都要记录。
|
||||
* 一旦开始,这个行程的状态就变成"进行中"。等到走完了…我点"完成"。
|
||||
* 这时候这个内容或者计划行程就变成一个实际行程。
|
||||
* **一个实际行程没有再没有状态了,因为实际已经发生完了。**
|
||||
* 但如果这个计划我没有去做…点击一个按钮,然后它就变成了"取消"或者"删除"…
|
||||
* **取消和删除是不一样的**。」
|
||||
*
|
||||
* ## 三个动作
|
||||
*
|
||||
* | action | stage | status | 真实时间 |
|
||||
* |---|---|---|---|
|
||||
* | `start` | 不变(plan) | `ongoing` | 写 `actual_started_at` = 此刻 |
|
||||
* | `done` | **plan → actual** | `done` | 写 `actual_ended_at` = 此刻 |
|
||||
* | `cancel` | **plan → skipped** | `cancelled` | 不写 |
|
||||
*
|
||||
* ⚠️ `done` 会把 stage 改成 `actual` —— 这是"完成即转实际"的落点。
|
||||
* 转成 actual 之后,界面**不再显示任何状态**(用户:实际行程没状态)。
|
||||
* ⚠️ `cancel` 改成 `skipped` 而不是软删:数据要保留,
|
||||
* 最后旅程结束时要能看到"哪些计划没做"。
|
||||
*
|
||||
* @param string $action start / done / cancel
|
||||
* @return array{ok:bool,stage?:string,status?:string,error?:string}
|
||||
*/
|
||||
public static function nodeAction(int $familyId, int $eventId, int $nodeId, string $action): array
|
||||
{
|
||||
if (!in_array($action, self::NODE_ACTIONS, true)) {
|
||||
return ['ok' => false, 'error' => '不认识的动作。'];
|
||||
}
|
||||
$db = Database::get();
|
||||
$st = $db->prepare(
|
||||
'SELECT * FROM trip_nodes WHERE id = ? AND event_id = ? AND deleted_at IS NULL'
|
||||
);
|
||||
$st->execute([$nodeId, $eventId]);
|
||||
$cur = $st->fetch();
|
||||
if (!$cur) {
|
||||
return ['ok' => false, 'error' => '找不到这段行程。'];
|
||||
}
|
||||
/* ⚠️ 已经转成实际行程的,不再接受状态类动作。
|
||||
用户:「如果这个行程已经转变成实际行程了…那这个行程就不再有其他状态了。」
|
||||
⇒ `done` 之后点 `start` / `cancel` 都无意义,直接拒绝。 */
|
||||
$curStage = (string)($cur['stage'] ?? 'plan');
|
||||
if ($curStage === 'actual' && $action !== 'done') {
|
||||
return ['ok' => false, 'error' => '这段已经是实际行程了,不再有状态。'];
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$stage = $curStage;
|
||||
$status = (string)($cur['status'] ?? 'planned');
|
||||
|
||||
if ($action === 'start') {
|
||||
$status = 'ongoing';
|
||||
/* 只在**第一次**开始时记录:重复点不该刷新真实开始时间 */
|
||||
if ((string)($cur['actual_started_at'] ?? '') === '') {
|
||||
$up = $db->prepare('UPDATE trip_nodes SET actual_started_at = ? WHERE id = ?');
|
||||
$up->execute([$now, $nodeId]);
|
||||
}
|
||||
} elseif ($action === 'done') {
|
||||
$stage = 'actual';
|
||||
$status = 'done';
|
||||
/* 若用户没点过「开始」直接点「完成」,用计划开始时间兜底记实际开始
|
||||
(否则实际行程缺开始时间,展示上少一半信息) */
|
||||
if ((string)($cur['actual_started_at'] ?? '') === '') {
|
||||
$up0 = $db->prepare(
|
||||
'UPDATE trip_nodes SET actual_started_at = COALESCE(starts_at, ?) WHERE id = ?'
|
||||
);
|
||||
$up0->execute([$now, $nodeId]);
|
||||
}
|
||||
$up2 = $db->prepare('UPDATE trip_nodes SET actual_ended_at = ? WHERE id = ?');
|
||||
$up2->execute([$now, $nodeId]);
|
||||
} else { // cancel
|
||||
$stage = 'skipped';
|
||||
$status = 'cancelled';
|
||||
}
|
||||
|
||||
/* 同步 category(老的双轨字段,由 stage 派生,两边不能打架) */
|
||||
$category = $stage === 'actual' ? 'reality' : 'plan';
|
||||
|
||||
$up3 = $db->prepare(
|
||||
'UPDATE trip_nodes SET stage = ?, status = ?, category = ?, updated_at = NOW()
|
||||
WHERE id = ?'
|
||||
);
|
||||
$up3->execute([$stage, $status, $category, $nodeId]);
|
||||
|
||||
return ['ok' => true, 'stage' => $stage, 'status' => $status];
|
||||
}
|
||||
|
||||
/**
|
||||
* 这段行程该显示哪些动作(#48)。
|
||||
*
|
||||
* ⚠️ 前后端**共用同一份规则**,避免"网页端能点、端上不能点"这种不一致。
|
||||
*
|
||||
* 规则:
|
||||
* · `actual` → 空数组(实际行程没有状态类动作,只有编辑/删除)
|
||||
* · `skipped` → 空数组(已经取消,不再转)
|
||||
* · `plan` + planned → 开始
|
||||
* · `plan` + ongoing → 完成
|
||||
* · `plan` + 其他 → 开始
|
||||
*
|
||||
* @return string[] 动作 key 列表
|
||||
*/
|
||||
public static function nodeAvailableActions(string $stage, string $status): array
|
||||
{
|
||||
if ($stage === 'actual' || $stage === 'skipped') {
|
||||
/* ⚠️ 实际行程 / 未成行**没有状态动作** ——
|
||||
用户:「实际行程是没有状态的…实际已经发生完了,它再有状态就没有意义了。」 */
|
||||
return [];
|
||||
}
|
||||
if ($status === 'ongoing') {
|
||||
return ['done'];
|
||||
}
|
||||
return ['start'];
|
||||
}
|
||||
|
||||
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
|
||||
{
|
||||
$db = Database::get();
|
||||
/* 🔴 2026-11-05(#27 事故):**先取出当前行**,供下面"没传就沿用原值"用。
|
||||
⚠️ 不要用 `$cur ?? []` —— `saveNode()` 这层根本没有 `$cur`
|
||||
(那是 `ApiHandler::trips()` 的局部变量),
|
||||
写成 `?? []` 会永远拿到空数组,"保留原值"形同虚设。
|
||||
CUR_ROW_LOADED */
|
||||
$curRow = [];
|
||||
if ($id !== null && $id > 0) {
|
||||
$sc = $db->prepare('SELECT * FROM trip_nodes WHERE id = ?');
|
||||
$sc->execute([$id]);
|
||||
$got = $sc->fetch();
|
||||
if (is_array($got)) {
|
||||
$curRow = $got;
|
||||
}
|
||||
}
|
||||
// v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。
|
||||
// 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。
|
||||
$stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true)
|
||||
@@ -923,18 +1066,54 @@ final class Trip
|
||||
// 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。
|
||||
// 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
|
||||
// → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。
|
||||
$fromPlace = (string)($d['from_place'] ?? '');
|
||||
$toPlace = (string)($d['to_place'] ?? '');
|
||||
$fromAddr = trim((string)($d['from_addr'] ?? ''));
|
||||
$toAddr = trim((string)($d['to_addr'] ?? ''));
|
||||
$fromLng = self::coordOrNull($d['from_lng'] ?? null);
|
||||
$fromLat = self::coordOrNull($d['from_lat'] ?? null);
|
||||
$toLng = self::coordOrNull($d['to_lng'] ?? null);
|
||||
$toLat = self::coordOrNull($d['to_lat'] ?? null);
|
||||
/* 🔴🔴 2026-11-05 事故修复(#27「北京返回青岛」的出发地/到达地被清空)。
|
||||
|
||||
原来这 8 行是 `(string)($d['xxx'] ?? '')` —— **无条件取**:
|
||||
调用方没传 `from_place` 就得到空串,接着 `textOrNull2('')` 写成 NULL,
|
||||
**库里原有的地点被静默抹掉**。
|
||||
|
||||
踩到的调用方:
|
||||
· 端上编辑浮层(旧版只传 `place`,不传 from_place/to_place)
|
||||
· 任何脚本/第三方调用
|
||||
· 我的验证脚本(就是这次事故的直接原因)
|
||||
|
||||
⇒ 改成 PATCH 语义:**没传就沿用原值,传了空串才是明确清空**。
|
||||
这样"用户主动删掉地点"仍然生效(他传空串),
|
||||
而"调用方不关心这个字段"不会造成破坏。
|
||||
|
||||
⚠️ `$cur` 是本方法开头查出来的当前行(编辑时有值,新建时为 null)。
|
||||
取不到时退回空串 —— 新建本来就该是空。 */
|
||||
$hasF = static fn(string $k): bool => array_key_exists($k, $d);
|
||||
$fromPlace = $hasF('from_place')
|
||||
? (string)$d['from_place'] : (string)($curRow['from_place'] ?? '');
|
||||
$toPlace = $hasF('to_place')
|
||||
? (string)$d['to_place'] : (string)($curRow['to_place'] ?? '');
|
||||
$fromAddr = $hasF('from_addr')
|
||||
? trim((string)$d['from_addr']) : (string)($curRow['from_addr'] ?? '');
|
||||
$toAddr = $hasF('to_addr')
|
||||
? trim((string)$d['to_addr']) : (string)($curRow['to_addr'] ?? '');
|
||||
$fromLng = $hasF('from_lng')
|
||||
? self::coordOrNull($d['from_lng']) : self::coordOrNull($curRow['from_lng'] ?? null);
|
||||
$fromLat = $hasF('from_lat')
|
||||
? self::coordOrNull($d['from_lat']) : self::coordOrNull($curRow['from_lat'] ?? null);
|
||||
$toLng = $hasF('to_lng')
|
||||
? self::coordOrNull($d['to_lng']) : self::coordOrNull($curRow['to_lng'] ?? null);
|
||||
$toLat = $hasF('to_lat')
|
||||
? self::coordOrNull($d['to_lat']) : self::coordOrNull($curRow['to_lat'] ?? null);
|
||||
// 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本),
|
||||
// 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。
|
||||
$fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8');
|
||||
$toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8');
|
||||
/* 🔴 2026-11-05 修(用户:「城市…省也带上了,市也带上了,区甚至都带上了,
|
||||
这没必要,直接城市就可以」)。
|
||||
|
||||
前端传来的可能是「山东省青岛市市南区」(高德的 district 整串),
|
||||
直接存会让旅程的城市聚合变成一长串行政区划。
|
||||
⇒ 过一道 `Geo::cityOnly()` 剥成纯市名「青岛市」。
|
||||
|
||||
⚠️ 不能指望前端改好就完事:老版本 App 还在用、
|
||||
第三方调用不经过我们的前端、老数据已经脏了。
|
||||
服务端归一化是**唯一的真防线**。 */
|
||||
$fromCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8'));
|
||||
$toCity = \App\Geo::cityOnly(mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8'));
|
||||
if ($fromCity === '') {
|
||||
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
|
||||
}
|
||||
@@ -1464,6 +1643,32 @@ final class Trip
|
||||
return self::NODE_TYPES[$key][1] ?? '📌';
|
||||
}
|
||||
|
||||
/**
|
||||
* 行程卡左侧的图标:**出行方式优先,节点类型兜底**。
|
||||
*
|
||||
* 🔴 2026-11-05(用户:「明明选的是坐火车,但网页版的图标显示的还是一辆汽车」)。
|
||||
*
|
||||
* 原来左侧只按 `node_type` 取 ⇒「赶路」永远显示 🚗,
|
||||
* 哪怕这段实际是坐火车(`travel_mode = 'train'` 应该是 🚄)。
|
||||
*
|
||||
* 现在:
|
||||
* ① 有出行方式 → 用它(火车 🚄 / 飞机 ✈️ / 轮船 🚢 / 自驾 🚗…)
|
||||
* ② 没有 → 退回节点类型(游玩 🎡 / 用餐 🍜 / 住宿 🏨)
|
||||
*
|
||||
* ⚠️ 端上 `fbNodeLeadIcon()` 必须是**同一套优先级**,否则两边图标不一致。
|
||||
*/
|
||||
public static function leadIcon(string $nodeType, string $mode, int $familyId = 0): string
|
||||
{
|
||||
if ($mode !== '') {
|
||||
$mi = self::modeIcon($mode);
|
||||
/* modeIcon 对未知 key 回 '🧭'(不是空串),所以这里判空保底 */
|
||||
if ($mi !== '') {
|
||||
return $mi;
|
||||
}
|
||||
}
|
||||
return self::typeIcon($nodeType, $familyId);
|
||||
}
|
||||
|
||||
/** 出行方式的名字 / 图标 */
|
||||
public static function modeWord(string $key): string
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user