用户 10 条原话要点与落地:
- ①「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,有多段行程,
都有青岛作为出发地或者目的地,那么就只记一次」
→ trip_nodes 补 from_city/to_city(Schema v22),Trip::cities() 改成
「出发地 ∪ 到达地」去重、按最早时间 + 出发地优先排。
详情页信息区显示的就是这个聚合结果(第 4 条:「图片右侧显示的是聚合的城市,
而不是终点地址」)。
- ②「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」
→ NodeType::TRIPLOG_TYPES(休息/突发/购物/拜访亲友),在 all() 里读时自愈补齐。
- ③「出行方式,只有选择自驾,才让选车辆;车辆下拉框不需要出现什么财务里登记的车,
只把车辆列出来就行了」→ DRIVE_MODES 收窄成 ['drive'];
Vehicle::pickable() 只回车辆库;车辆页删掉「财务里已登记的车」那块。
- ③「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
→ seat_info / gate 从表单与列表**彻底移除**(列保留,老数据不丢;
saveNode 的 UPDATE 也不再写这两列,否则会抹掉迁来的数据)。
- ③「出发地和到达地,都用高德地图的接口,记录详细地址」+「参与人弹框选择」
+「给参与人标注角色这个功能去掉」
→ 新增 templates/trips/_loc_field.php(高德联想 + 详细地址 + 坐标 + 城市 +
导航链接,没配 key 也能手输);参与人改用全局弹框选人组件;
角色输入框删除,saveNodeMembers 改成差集增删(否则老 role_tag 被静默抹掉)。
- ④「triplog 中的行程花销数据,没有同步过来,需要迁移过来」
→ 新增 _tooling/_fb_b17_backfill.php:迁地址(20 条)、花销(4 笔 ¥8584)、
车辆(3 台)。线上已执行并核对(笔数与金额一分不差)。
- ④「行程中,时间明明具体到了时分秒,怎么不显示?」
→ Trip::nodeSpan():行程时间精确到时分(同一天只写一次日期、跨天写月日、
两头都没时分就不写 00:00)。
其他:
- App\Geo 扩展 cityFromPoint / cityFromText / navUrl / jsConfig。
中文地址提城市修掉四种错法(白名单→黑名单、取最后→取第一个、直辖市最先认、
离线表只能兜底),并加了「五大连池 ≠ 大连」的位置守卫。
- config.example.php 加 amap 段(key / security_js / nav_src)。
验证:
- e2e 920/0(+29 条本批断言)· 升级 149/0(+15 条 v22 断言)·
城市解析 32/32(新套件,样例全是 TripLog 线上真实地址)·
桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0。
- 反向验证 5/5:城市退回读 place / 只算到达地 / 时间退回只到日期 /
打车也要车 / 类型不补 —— 五种坏法全部被断言抓住。
- 部署:服务器自检 19/19;线上 schema v22;补迁后花销 4 笔 ¥8584 与源库对齐。
283 lines
13 KiB
PHP
283 lines
13 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' => ['其他', '📌'],
|
||
];
|
||
|
||
/**
|
||
* TripLog(trip.yangyq.net)实际用过的类型 —— 用户第 17 批 第 2 条要求
|
||
* 「你看看 triplog 里面都有哪些,都拿过来」。
|
||
*
|
||
* ⚠️ 这份清单是**照着线上库抄的**(7 种,见 Schema 里那句注释)。
|
||
* 与 BUILTIN 的差别就两种:
|
||
* · 这里**没有「住宿」** —— 本地有,保留(家庭出游住宿是刚需,
|
||
* TripLog 当时用「休息」兼职了这件事,不代表我们也要那样);
|
||
* · 这里**多「休息/突发/购物/拜访亲友」** —— 本地要补上。
|
||
* 图标也照 TripLog 抄(🍽️ 😴 ⚠️ 🛍️ 🏠),保持两边认得出是同一类。
|
||
*
|
||
* @var array<string,string> 名字 => 图标
|
||
*/
|
||
public const TRIPLOG_TYPES = [
|
||
'赶路' => '🚗',
|
||
'游玩' => '🎡',
|
||
'用餐' => '🍽️',
|
||
'休息' => '😴',
|
||
'突发' => '⚠️',
|
||
'购物' => '🛍️',
|
||
'拜访亲友' => '🏠',
|
||
];
|
||
|
||
/**
|
||
* 一个家的全部类型(表里没有就先把内置的预置进去)。
|
||
*
|
||
* ⚠️ 预置放在**读**里而不是只放在安装/升级里:升级步骤是容错的
|
||
* (`tryStep`),万一那一步在某个老库上失败了,这里还能自愈 ——
|
||
* 否则"类型"是新建行程的必填项,缺了它整个"添加行程"都用不了。
|
||
*/
|
||
public static function all(int $familyId): array
|
||
{
|
||
self::seedDefaults($familyId);
|
||
// ⚠️ TripLog 那几种(休息/突发/购物/拜访亲友)也要在**读时**自愈地补。
|
||
// 只放在升级步骤里不够:升级是**一次性**的(靠 Schema 指纹触发),
|
||
// 而"重装/清库后重建"不会重跑升级 —— 那时表里只有 seedDefaults 的 5 种,
|
||
// 用户第 17 批 第 2 条要的那 4 种永远进不来(e2e 实测就是这个症状:
|
||
// 库重建后类型只剩「游玩,用餐,赶路,住宿,其他,赶海」)。
|
||
// seedFromTripLog 内部是 INSERT IGNORE,重复调用零成本。
|
||
self::seedFromTripLog($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 = $n; // 上面已经查过一次了,直接用
|
||
// ⚠️ 只在**一条都没有**时才预置:用户可能特意删掉了「用餐」(他家不用这个),
|
||
// 每次读都补回去会让人永远删不掉系统内置的那几种。
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 补齐 **TripLog 实际用过的**那一批类型(v24 第 17 批 用户第 2 条)。
|
||
*
|
||
* 用户原话:「添加行程那里,类型不够丰富,你看看 triplog 里面都有哪些,都拿过来。」
|
||
*
|
||
* TripLog 线上(trip.yangyq.net)实际有 7 种,其中 5 种是它自己的内置、
|
||
* 2 种是本人后来自己加的(`is_system=0`):
|
||
* 赶路🚗 / 游玩🎡 / 用餐🍽️ / 休息😴 / 突发⚠️ / 购物🛍️ / 拜访亲友🏠
|
||
*
|
||
* 与本地原有 5 种(游玩/用餐/赶路/住宿/其他)相比:
|
||
* · 差的是 **休息、突发、购物、拜访亲友**(本地没有);
|
||
* · 「住宿」是本地有而 TripLog 没有的 —— **保留**(用户没说要删,而且
|
||
* 家庭出游住宿是刚需,TripLog 当时是用「休息」兼职了这件事)。
|
||
*
|
||
* ⚠️ 与 `seedDefaults` 的关键区别:那个只在**表全空**时预置(保护用户的删除),
|
||
* 这个要**按名字逐个补**(只补缺的那几个)—— 因为用户明确要求"都拿过来",
|
||
* 而线上库已经有 5 条了,不补的话差的 4 种永远进不来。
|
||
* ⚠️ 用 `INSERT IGNORE`(靠 uk_nt_family_name 唯一键)而不是先查后插:
|
||
* 并发/重跑都安全,少一轮 N+1 查询。
|
||
* ⚠️ 标 `is_system=1`:这几个是"系统预置"(与 TripLog 同源),不该被删掉
|
||
* —— 它们在 TripLog 那边也是内置的。
|
||
*
|
||
* @return int 补了几条
|
||
*/
|
||
public static function seedFromTripLog(int $familyId): int
|
||
{
|
||
$db = Database::get();
|
||
try {
|
||
// 表还没建(极老的库,升级跑到这一步之前)→ 静默返回,别把页面炸掉
|
||
$db->query('SELECT 1 FROM node_types LIMIT 1');
|
||
} catch (\Throwable $e) {
|
||
return 0;
|
||
}
|
||
$st = $db->prepare(
|
||
'INSERT IGNORE INTO node_types (family_id, name, icon, is_system, position)
|
||
VALUES (?,?,?,1,?)'
|
||
);
|
||
$pos = (int)($db->query('SELECT COALESCE(MAX(position),0) FROM node_types WHERE family_id = '
|
||
. (int)$familyId)->fetchColumn());
|
||
$n = 0;
|
||
foreach (self::TRIPLOG_TYPES as $name => $icon) {
|
||
$pos += 10;
|
||
$st->execute([$familyId, $name, $icon, $pos]);
|
||
$n += $st->rowCount();
|
||
}
|
||
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] ?? '📌';
|
||
}
|
||
} |