用户十条,逐条落地:
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 与线上逐一核对一致。
206 lines
8.9 KiB
PHP
206 lines
8.9 KiB
PHP
<?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] ?? '📌';
|
||
}
|
||
} |