` 这种形态,与内置的英文 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 名字 => 图标 */ 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] ?? '📌'; } }