` 这种形态,与内置的英文 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] ?? '📌'; } }