Files
FamilyBook/app/NodeType.php
T
laoyang 2e51d3f5e4 v24 第 16 批:旅程 / 行程两级(术语固定 · 题图16:9 · 五项统计 · 独立行程页 · 设置三页)
用户十条,逐条落地:

1. 「题图应该按照 16:9 显示,因为添加的时候是按这个比例裁剪的,现在的显示比例不对。」
   → 详情页题图容器从 4:3 改成 16:9,与 ImageSlot 的 cover 槽位同一个数字
     (部署断言比对这两处,避免哪天改一处漏一处)。

2. 「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、一共几段行程,
     一共花了多少钱,这些信息,都从该活动下的所有行程统计总结,简介那一段话保留。」
   → 新增 Trip::summary():参与人(各段并集)/ 城市(去重)/ 天数 / 段数 / 总花销,
     全部读时算(与"时间城市派生"同一条原则,不存冗余)。
     天数按**日历天**含首尾(8/1→8/3 = 3 天,不是 24 小时整除的 2 天)。
     城市与时间从标题旁挪进信息区,简介保留。

3. 「活动页面,不需要上传图片,图片视频,都是针对一次具体的行程的。」
   → 删掉旅程页的照片上传板块与照片墙;照片一律挂在**行程**上
     (行程表单里有上传,附件 target_type='trip_node')。

4. 「计划行程、未成行的行程,都不要显示……只要是没有就是 0 条,那个类型就不显示了,
     现在显示的是 0 条,这样没意义。」
   → 空分组直接跳过不渲染;全空时给一句引导(否则新建旅程页面一片空白)。
     有内容的分组照常显示(计划 / 实际 / 未成行三种)。

5. 「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
   → 行程的新增与编辑改成**独立页面**(trips/node_new / trips/node_edit)。
     原来内联在详情页:N 份表单互相撞元素 id、密码管理器逐个扫描变卡,
     正是"把页面撑破"的成因。

6. 「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
   → 详情页右上角两个按钮(+ 计划行程 / + 实际行程),指向独立新增页。
     顺带:标题旁边不再放时间(时间移到题图右侧信息区)。

7. 「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
   → 字段全量照搬 TripLog 的 nodes/form.php:
     类型 / 标题 / 起止时间 / 出行方式 + 车辆 / 线路名 / 承运方 / 车次航班号 /
     座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(含角色)/ 照片 / 花销。
     交通字段不做条件列(先填高铁车次、后来改成自驾,填过的东西不该丢)。

8. 「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
     注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加那里面的车辆,
     直接读取就可以,但是可以添加其他的车辆,比如租的车,借别人的车。」
   → 新增旅程设置菜单(三页):车辆管理 / 出行方式 / 节点类型。
     车辆**两个来源刻意不合并**:财务里 kind='car' 的账目(那是资产,没有车牌车型)
     由 Vehicle::fromLedger() 只读列出,选车下拉里标「来自财务」;
     自家 / 租的 / 借的车录进 vehicles 表。不复制财务的车(避免同一台车两处维护)。
     节点类型做成**可自定义**的表(TripLog 同款),内置 5 种不可删;
     行程里存类型**名字**,所以改类型名会级联更新已用它的行程。

9. 「活动都改名吧,改成旅程,新建旅程,旅程包含行程,这个名字就固定下来。」
   → 术语固定:**旅程**(容器)包含**行程**(单元)。
     顶层按钮「新建旅程」、详情页三组叫「计划的行程 / 实际行程 / 未成行」、
     列表「N 段行程」、设置菜单「旅程设置」。上一批的"活动"叫法全部换掉。

10. 「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,这些都是从行程里面
      总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
   → 旅程表单只剩:标题 → 题图 → 简介 + 公开开关。
     备注与同行的人全部移除(参与人 = 各段行程参与人的合集)。
     ⚠️ 但**老数据里活动级那份参与人名单不能被清空**(那时还没有行程级参与人),
     所以路由"没传这个键就不动",并加了断言钉住这一点。

数据结构(Schema v21,老库自动升级):
· 新表 vehicles(自家/租的/借的)、node_types(可自定义)、trip_expenses(行程级花销)
· trip_nodes 补 9 列:出行方式 / 车辆 / 线路名 / 承运方 / 车次 / 座位 / 登机口 / 起终点
· trip_node_members 补 role_tag(司机 / 摄影担当这类角色)
· 花销金额存**分**,解析走字符串拆小数点(128.55*100 在浮点下会差一分)

验证:e2e 891/0(+46 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 交互 92/0 ·
窄屏溢出 30/0(扩到行程表单 + 设置三页 + 详情页)· 题图点击 4/0 · 静态体检全绿。
**反向验证 6/6**:题图退回 4:3、标题旁加回时间、空分组照旧渲染、编辑退回非独立页、
车辆不读财务、总花销不汇总 —— 六种坏法全部被断言抓住。
部署静态断言新增第 16 批一组(含"空分组不许渲染""行程页不许有照片上传"
"旅程表单不许有备注与同行的人""题图必须 16:9"等反退化防线)。
部署后服务器自检 19/19,六个关键文件 md5 与线上逐一核对一致。
2026-10-05 13:26:49 +08:00

206 lines
8.9 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
/**
* 行程节点类型(v24 第 16 批 用户第 8 条)。
*
* 用户原话:「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。」
*
* 为什么从"写死的常量"改成"可自定义的表":
* 用户第 7 条要求照 TripLog 的添加节点功能做,TripLog 的节点类型本来就是
* 一张用户可增删的表(系统内置几种 + 自己加)。家庭出游的场景差异很大:
* 有人要「赶海」有人要「探亲」,写死的五种永远差一点。
*
* ⚠️ 与 `Trip::NODE_TYPES` 常量的关系:
* 常量是**初始值与兜底**(表还没建、或某个 type 值已从表里删掉但老数据还在用它),
* 表是"用户改过的真相"。读取一律走 `all()`,它保证表里至少有内置那几种。
* 老数据里的 `trip_nodes.node_type` 存的是 **key**('play'/'meal'…),
* 用户自定义的类型 key 就用 `c<id>` 这种形态,与内置的英文 key 不会撞。
*/
declare(strict_types=1);
namespace App;
final class NodeType
{
/** 内置类型:key => [名字, 图标](与 Trip::NODE_TYPES 保持一致,是初始值) */
public const BUILTIN = [
'play' => ['游玩', '🎡'],
'meal' => ['用餐', '🍜'],
'move' => ['赶路', '🚗'],
'stay' => ['住宿', '🏨'],
'other' => ['其他', '📌'],
];
/**
* 一个家的全部类型(表里没有就先把内置的预置进去)。
*
* ⚠️ 预置放在**读**里而不是只放在安装/升级里:升级步骤是容错的
* (`tryStep`),万一那一步在某个老库上失败了,这里还能自愈 ——
* 否则"类型"是新建行程的必填项,缺了它整个"添加行程"都用不了。
*/
public static function all(int $familyId): array
{
self::seedDefaults($familyId);
$st = Database::get()->prepare(
'SELECT id, name, icon, is_system, position FROM node_types
WHERE family_id = ? ORDER BY position, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 把内置类型补进表里(幂等:按 (family_id, name) 唯一键查过再插)。
*
* @return int 补了几条
*/
public static function seedDefaults(int $familyId): int
{
$db = Database::get();
// 表可能还不存在(老库升级跑到这一步之前 / 极少数失败情形)→ 直接返回,
// 别让"读列表"这个动作本身抛异常把页面炸掉。
try {
$n = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn();
$has = $n;
} catch (\Throwable $e) {
return 0;
}
if ($has >= count(self::BUILTIN)) {
return 0; // 已经有内置那几条了,不必逐个查(热点路径)
}
$hasAny = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn();
// ⚠️ 只在**一条都没有**时才预置:用户可能特意删掉了「用餐」(他家不用这个),
// 每次读都补回去会让人永远删不掉系统内置的那几种。
if ($hasAny > 0) {
return 0;
}
$st = $db->prepare(
'INSERT INTO node_types (family_id, name, icon, is_system, position) VALUES (?,?,?,1,?)'
);
$pos = 10;
$n = 0;
foreach (self::BUILTIN as $k => $v) {
$st->execute([$familyId, $v[0], $v[1], $pos]);
$pos += 10;
$n++;
}
return $n;
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/** 同名判重(返回已存在的 id,没有则 0) */
public static function findByName(int $familyId, string $name): int
{
$st = Database::get()->prepare(
'SELECT id FROM node_types WHERE family_id = ? AND name = ? LIMIT 1'
);
$st->execute([$familyId, trim($name)]);
return (int)($st->fetchColumn() ?: 0);
}
/** @return array{ok:bool,id?:int,error?:string} */
public static function save(int $familyId, array $d, ?int $id = null): array
{
$name = mb_substr(trim((string)($d['name'] ?? '')), 0, 30, 'UTF-8');
if ($name === '') {
return ['ok' => false, 'error' => '类型名字不能为空'];
}
$icon = mb_substr(trim((string)($d['icon'] ?? '')), 0, 20, 'UTF-8');
$db = Database::get();
$dup = self::findByName($familyId, $name);
if ($dup > 0 && $dup !== $id) {
return ['ok' => false, 'error' => '已经有一个叫「' . $name . '」的类型了'];
}
if ($id !== null && $id > 0) {
$old = self::find($familyId, $id);
if (!$old) {
return ['ok' => false, 'error' => '找不到这个类型'];
}
$oldName = (string)$old['name'];
$db->prepare('UPDATE node_types SET name=?, icon=? WHERE id=? AND family_id=?')
->execute([$name, $icon !== '' ? $icon : null, $id, $familyId]);
// ⚠️ 改名必须**级联**到已经在用它的行程:`trip_nodes.node_type` 存的是
// **名字**(不是 id)。只改类型表的话,所有老行程都会变成
// "一个类型表里不存在的值" → 图标与名称显示双双落回"其他",
// 用户看到的是"我一改名,历史行程的类型全没了"。
if ($oldName !== $name) {
$db->prepare('UPDATE trip_nodes SET node_type = ? WHERE family_id = ? AND node_type = ?')
->execute([$name, $familyId, $oldName]);
}
return ['ok' => true, 'id' => $id];
}
$pos = (int)($db->query('SELECT COALESCE(MAX(position),0)+10 FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn());
$db->prepare('INSERT INTO node_types (family_id, name, icon, is_system, position) VALUES (?,?,?,0,?)')
->execute([$familyId, $name, $icon !== '' ? $icon : null, $pos]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/**
* 删一个类型。
*
* ⚠️ 内置的**不许删**:它们被 `node_type` 的兜底逻辑引用(表空时会重新预置),
* 删了会立刻又冒出来,看起来像"删除没生效"。要停用请改名或忽略。
* ⚠️ 还在被行程使用的也不许删:行程里存的是**名字**(见 usedCount),
* 删了类型定义,那些行程的显示就会落回"其他"—— 数据没丢,但用户会以为丢了。
* 所以这里给出"还有 N 段行程在用"的明确理由,而不是静默失败。
*/
public static function destroy(int $familyId, int $id): array
{
$t = self::find($familyId, $id);
if (!$t) {
return ['ok' => false, 'error' => '找不到这个类型'];
}
if ((int)$t['is_system'] === 1) {
return ['ok' => false, 'error' => '内置类型不能删除(可以改名)'];
}
$n = self::usedCount($familyId, (string)$t['name']);
if ($n > 0) {
return ['ok' => false, 'error' => '还有 ' . $n . ' 段行程在用这个类型,先改掉它们再删'];
}
Database::get()->prepare('DELETE FROM node_types WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
return ['ok' => true];
}
/** 有多少段行程在用这个类型名 */
public static function usedCount(int $familyId, string $name): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM trip_nodes WHERE family_id = ? AND node_type = ?'
);
$st->execute([$familyId, $name]);
return (int)$st->fetchColumn();
}
/**
* 类型名 → 图标。行程里存的是名字,图标随类型定义走。
* 认不出(类型被删/老数据是英文 key)时返回内置兜底。
*/
public static function iconOf(int $familyId, string $name): string
{
static $cache = [];
if (!isset($cache[$familyId])) {
$map = [];
foreach (self::all($familyId) as $t) {
$map[(string)$t['name']] = (string)($t['icon'] ?? '');
}
$cache[$familyId] = $map;
}
$icon = $cache[$familyId][$name] ?? '';
if ($icon !== '') {
return $icon;
}
// 老数据用的是英文 key(play/move/…)→ 回落到内置那张表
return self::BUILTIN[$name][1] ?? '📌';
}
}