diff --git a/app/ContactImporter.php b/app/ContactImporter.php index 4bcfefe..618fc6e 100644 --- a/app/ContactImporter.php +++ b/app/ContactImporter.php @@ -28,13 +28,22 @@ final class ContactImporter /** * @return array{added:int,updated:int,skipped:int,people:array} */ - public static function run(int $familyId, int $meId, array $records, int $groupId = 0): array + public static function run(int $familyId, int $meId, array $records, int $groupId = 0, array $tags = []): array { $added = 0; $updated = 0; $skipped = 0; $log = []; + /* 🔴 v54(用户 2026-10-16 第 2 条):导入时可以**顺手打一串标签**。 + 用户原话:「现在已经没有分组的概念了,**全部都是标签的概念**。」 + ⚠️ 用 `PersonTag::addOne()`(**追加**)而不是 `setFor()`(整组替换)—— + 命中已有的人时,`setFor` 会把他身上**原有**的标签一起清掉 + ("用一次导入,别人原有的标签全没了")。语义上这里就是"多加一个"。 + ⚠️ 老参数 `$groupId` 保留:不为别的,只为**不改既有调用契约**; + 新界面不再传它(`templates/people/import.php` 已换成标签输入)。 */ + $tags = PersonTag::clean($tags); + // 现有的人一次取出来做索引(几十~几百人,比逐条查库快得多,也不会漏判) // 索引键:phone_hash(精确)+ 姓名(小写) $byPhone = []; @@ -87,6 +96,10 @@ final class ContactImporter ⚠️ `ensure` 是幂等的(已在名册里就返回 false),所以重复导入不会出问题。 */ PersonRoster::ensure($familyId, $meId, (int)$hit['id'], $groupId > 0 ? $groupId : null, 'self'); + // 🔴 v54:命中已有的人时**追加**标签(不动他原有的 —— 见方法头的说明) + foreach ($tags as $tg) { + PersonTag::addOne($familyId, $meId, (int)$hit['id'], $tg); + } if ($filled) { $updated++; $log[] = ['name' => (string)$hit['name'], 'action' => 'updated', 'added' => $filled]; @@ -159,6 +172,10 @@ final class ContactImporter // 进导入者的名册(谁导入的,就先出现在谁的名册里) PersonRoster::ensure($familyId, $meId, $pid, $groupId > 0 ? $groupId : null, 'self'); + // 🔴 v54:新建的人也打上这串标签 + foreach ($tags as $tg) { + PersonTag::addOne($familyId, $meId, $pid, $tg); + } // 建完把索引补上,避免同一个文件里出现两条同名时建出两个人 $byName[mb_strtolower($d['name'], 'UTF-8')] = ['id' => $pid, 'name' => $d['name']]; diff --git a/app/PersonTag.php b/app/PersonTag.php index 9ab9cea..9ef8619 100644 --- a/app/PersonTag.php +++ b/app/PersonTag.php @@ -95,17 +95,57 @@ final class PersonTag return $st->fetchAll(); } - /** 建议用的标签词(写标签时的联想;按人气排序) */ - public static function suggest(int $familyId, int $ownerId, int $limit = 30): array + /** + * 打标签时的**候选词**(名册卡片浮层 / 编辑页 / 新增页都用它)。 + * + * 🔴 v54(用户 2026-10-16 第 2 条)—— 这条是本次改动的核心。 + * 用户原话:「现在名册里面给人打标签的时候,**这个标签数据是从哪读出来的**呀? + * 在这个名册的设置页面,有很多分组,现在还叫分组。但是打标签的时候, + * **却不是从这些分组里读出来的**。所以现在应该在这个设置页面,不应该再叫 + * 「名册分组」了吧?应该叫「名册标签」,是不是?**应该从这里面把所有标签都读出来**吧? + * 但是现在标签里面却只有几个,也不知道是从哪儿读出来的。」 + * + * **真根因(用户观察得完全正确)**: + * · 设置页显示的词表来自 `person_groups`(**分组表**,管理员/我维护的词); + * · 打标签的候选来自 `cloud()`(**你已经打过的词**,现算的); + * ⇒ 两边**根本不是一个数据源** —— 所以"设置里列着一堆、打标签时却只有几个"。 + * + * ⇒ 合并两者,**并集**: + * · 词表(`PersonGroup::orderFor` = 公共同我的)在前 —— 这是用户说的 + * "应该从这里面把所有标签都读出来";**还没用过**的词也要能一点就打上, + * 这正是"词表"相对于"用过的词"的价值(否则新标签永远得手打)。 + * · 用过的词补在后面(词表里没收录、但历史打过的,不能凭空消失)。 + * + * ⚠️ 只返回**词**(`array_column(...,'tag')` 那种扁平字符串列表), + * 与既有 `tagChoices` 的消费方(模板 `foreach` 出 checkbox)契约一致 —— + * 不返回 cloud 那种 `[['tag'=>..,'n'=>..]]`,否则调用点全要改。 + * ⚠️ 那个 `suggest()` 已被它取代(原名容易让人以为是"按人气排序的联想词", + * 而实际需求是"设置里配的 + 用过的"并集)—— 保留一个没用处的旧口径 + * 只会让下一个人继续从 `cloud()` 读、把用户这条需求又绕过一遍。 + * + * @return array + */ + public static function choices(int $familyId, int $ownerId, int $limit = 60): array { + if ($familyId <= 0 || $ownerId <= 0) { + return []; + } $out = []; - foreach (self::cloud($familyId, $ownerId) as $r) { - $out[] = (string)$r['tag']; - if (count($out) >= $limit) { - break; + // ① 词表(设置页「名册标签」里配的那些:公共同我的,带顺序) + foreach (PersonGroup::orderFor($familyId, $ownerId) as $g) { + $nm = trim((string)($g['name'] ?? '')); + if ($nm !== '' && !in_array($nm, $out, true)) { + $out[] = $nm; } } - return $out; + // ② 我已经打过的词(词表里没收录的补上;按人气降序) + foreach (self::cloud($familyId, $ownerId) as $r) { + $t = (string)($r['tag'] ?? ''); + if ($t !== '' && !in_array($t, $out, true)) { + $out[] = $t; + } + } + return array_slice($out, 0, max(1, $limit)); } /** @@ -151,6 +191,34 @@ final class PersonTag Database::get()->prepare('DELETE FROM person_tags WHERE person_id = ?')->execute([$personId]); } + /** + * 给某人**追加**一个标签(已存在则不重复)。 + * + * 🔴 v54(用户 2026-10-16 第 2 条):挑人 / 导入联系人时要用它。 + * 为什么必须是"追加"而不是 `setFor()`(整组替换): + * `setFor` 会**先 DELETE 再 INSERT** —— 用在这里会把那些人 + * **原有**的标签一起清掉。而这两个入口的语义是"顺手给新人打一个标签", + * 不是"把他们的标签改成这一个"。这种"用整组替换去做追加"的错, + * 症状是"用一次导入,别人身上原有的标签全没了",而且很难联想到原因。 + * + * ⚠️ 靠 `uk_pt`(owner_id, person_id, tag 三列唯一)保证幂等: + * 重复打同一个词会 `duplicate`,这里用 INSERT IGNORE 吞掉,不报错。 + * @return bool 这次真的插进去了吗(已存在 = false) + */ + public static function addOne(int $familyId, int $ownerId, int $personId, string $tag): bool + { + $one = self::clean($tag); + if ($familyId <= 0 || $ownerId <= 0 || $personId <= 0 || !$one) { + return false; + } + $st = Database::get()->prepare( + 'INSERT IGNORE INTO person_tags (family_id, owner_id, person_id, tag, position) + VALUES (?,?,?,?,?)' + ); + $st->execute([$familyId, $ownerId, $personId, $one[0], 100]); + return $st->rowCount() > 0; + } + /** * 标签归一:`#小学同学` / `小学同学 ` / `小学,同学` 都要变成干净的词。 * diff --git a/app/Trip.php b/app/Trip.php index 42390a0..fe7b7b4 100644 --- a/app/Trip.php +++ b/app/Trip.php @@ -245,6 +245,115 @@ final class Trip return implode(' · ', $cities); } + /** + * 🔴 v54(用户 2026-10-16 第 3 条):给**一批**旅程补上「代表坐标」 + * —— 旅程列表的**地图视图**用它把每趟旅程钉在地图上的一个点。 + * + * 用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,可以在地图上显示旅程数据。 + * 1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」 + * + * 做法(移植 TripLog `ActivityController::index` 的思路,但适配本仓的数据): + * TripLog 是「把该活动下所有节点的经纬度取**重心**」—— + * 本仓的数据更规整(`trip_nodes` 已有 from_lng/from_lat/to_lng/to_lat 四列, + * 见 Schema 的 v17 段),所以照同一思路来: + * + * ① 优先用**真实坐标**:把该旅程所有节点的 from/to 坐标收集起来取平均 + * (= 重心)。为什么取重心而不是"第一段/最后一段": + * 几段行程串起来的一条线,重心最能代表"这趟旅程大概在哪儿"; + * 取端点会让"青岛→伊春"这种整条北上的旅程偏到某一头。 + * ② 没有坐标就**退到城市**:拿 `cities()` 里第一个城市, + * 用本仓内置的 `Geo::lookup()`(离线城市表)换中心坐标。 + * ⚠️ 这是离线表、**不发网络请求** —— 列表页要为几十趟旅程算坐标, + * 若逐条打高德逆地理,一次开页面就是几十个外部请求(配额与延迟都受不了)。 + * ③ 两者都没有 ⇒ 该旅程**不在地图上出现**(`lng/lat` 为 null), + * 由模板跳过;列表视图里它照常显示。 + * + * ⚠️ skipped(未成行)的节点**不算**:计划去但没去成的地方不该把标记拽过去 + * (与 `cities()` 同一口径)。 + * ⚠️ 一次查完(IN 批查 + 内存分组),不做 N+1 —— 与 `attachDerived` 同一理由。 + * ⚠️ 只**读**,不改 `$rows` 里既有的键之外的东西;算完挂 `trip_lng`/`trip_lat`/`trip_spot` + * (前缀 `trip_` 避免与节点的 `lng/lat` 撞名)。 + */ + public static function attachSpots(array &$rows): void + { + $ids = []; + foreach ($rows as $r) { + $id = (int)($r['id'] ?? 0); + if ($id > 0) { + $ids[] = $id; + } + } + if (!$ids) { + return; + } + $in = implode(',', array_fill(0, count($ids), '?')); + $st = Database::get()->prepare( + "SELECT event_id, from_lng, from_lat, to_lng, to_lat + FROM trip_nodes + WHERE event_id IN ($in) AND stage IN ('plan','actual')" + ); + $st->execute($ids); + $acc = []; // event_id => [sumLng, sumLat, n] + foreach ($st->fetchAll() as $n) { + $eid = (int)$n['event_id']; + foreach ([['from_lng', 'from_lat'], ['to_lng', 'to_lat']] as $pair) { + $lng = $n[$pair[0]] ?? null; + $lat = $n[$pair[1]] ?? null; + if ($lng === null || $lat === null || $lng === '' || $lat === '') { + continue; + } + $lng = (float)$lng; + $lat = (float)$lat; + // 0,0 是"没坐标"的常见脏值(高德没返回时容易被写成 0),跳过 + if ($lng === 0.0 && $lat === 0.0) { + continue; + } + if (!isset($acc[$eid])) { + $acc[$eid] = [0.0, 0.0, 0]; + } + $acc[$eid][0] += $lng; + $acc[$eid][1] += $lat; + $acc[$eid][2]++; + } + } + foreach ($rows as &$r) { + $eid = (int)($r['id'] ?? 0); + $lng = null; + $lat = null; + if (isset($acc[$eid]) && $acc[$eid][2] > 0) { + $lng = $acc[$eid][0] / $acc[$eid][2]; + $lat = $acc[$eid][1] / $acc[$eid][2]; + } else { + /* 退到城市(离线表,不发网络请求;见方法头的说明)。 + 🔴 这里取**该旅程所有能认出的城市的中心点的平均值**(重心), + 而不是"第一个城市" —— 与上面"有坐标时取重心"**同一口径**。 + 为什么不取第一个:`cities()` 的顺序是**按时间**(出发地在前), + 而出发地往往是同一个家(青岛)⇒ 所有旅程的标记会**全叠在家上**, + 地图就没有信息量了。取重心则"青岛→哈尔滨"落在两地之间, + 与"有坐标"那条路的表现一致(写成两套口径迟早对不上)。 */ + $sumLng = 0.0; + $sumLat = 0.0; + $nCity = 0; + foreach ((array)($r['cities'] ?? []) as $c) { + $hit = \App\Geo::lookup((string)$c); + if ($hit) { + $sumLng += (float)$hit['lon']; + $sumLat += (float)$hit['lat']; + $nCity++; + } + } + if ($nCity > 0) { + $lng = $sumLng / $nCity; + $lat = $sumLat / $nCity; + } + } + $r['trip_lng'] = $lng; + $r['trip_lat'] = $lat; + $r['trip_spot'] = ($lng !== null && $lat !== null); + } + unset($r); + } + /** * 给**一批**活动补上派生时间与城市(列表页用;N+1 会让几十趟活动明显变慢)。 * diff --git a/assets/css/app.css b/assets/css/app.css index 5926e46..f01babc 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -2950,7 +2950,20 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, .dash-row:first-child { border-top: 0; } .dash-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; } .dash-when { flex: 0 0 96px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } -.dash-what { flex: 1 1 auto; min-width: 0; font-size: 14px; } +/* 🔴 v54(用户 2026-10-16 第 1 条):「首页**近期任务**那个地方要注意截断: + 如果任务标题太长,会把这个任务**挤成两行**。」 + 真根因:这一条只有 `flex:1 1 auto` + `min-width:0` —— 它能**收缩**, + 但行内文字**没有禁止换行** ⇒ 收缩之后标题在框内绕成第二行,把这一行撑高、 + 把旁边的状态/看板徽标挤到下一行。缺的正是这三句。 + ⚠️ 三句缺一不可:`nowrap` 让它不绕行、`hidden` 让溢出被裁、 + `ellipsis` 给出"后面还有"的信号(不然会像被截断的错字)。 + ⚠️ 全名走 `title=` 悬停(模板里加的),点开也会弹详情浮层 —— 短的看不全不是问题。 + ⚠️ 这条**同时**管「往年的今天」那一块(同一个 class):那边的标题也可能长, + 一并对齐成"一行 + 省略号"比一处换行一处截断更整齐。 */ +.dash-what { + flex: 1 1 auto; min-width: 0; font-size: 14px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} .dash-tag { flex: 0 0 auto; font-size: 11px; color: var(--faint); } /* ---- v24 第 11 批 #4:影院下拉(带色点)+ 新建影院弹框 ---- 原生