Files
FamilyBook/app/NodeType.php
T

206 lines
8.9 KiB
PHP
Raw Normal View History

<?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] ?? '📌';
}
}