From 8d79857f1a5f75cc8c536303f76b5bf4f86efd3f Mon Sep 17 00:00:00 2001 From: laoyang Date: Sat, 17 Oct 2026 00:15:06 +0800 Subject: [PATCH] =?UTF-8?q?v54=EF=BC=9A=E9=A6=96=E9=A1=B5=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=A0=87=E9=A2=98=E6=88=AA=E6=96=AD=EF=BC=88=E7=9C=9F?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0=3D=E4=B8=A4=E5=88=97=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E8=A2=AB=E6=92=91=E7=A0=B4=EF=BC=89/=20=E5=90=8D=E5=86=8C?= =?UTF-8?q?=E5=88=86=E7=BB=84=E2=86=92=E6=A0=87=E7=AD=BE=E4=B8=94=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=AF=BB=E8=AF=8D=E8=A1=A8=20/=20=E6=97=85=E7=A8=8B?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E8=A7=86=E5=9B=BE=20/=20=E6=97=85=E7=A8=8B?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=8C=89=E9=92=AE=20/=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E9=BB=98=E8=AE=A4=E5=88=97=E8=A1=A8=E6=88=96?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-10-16 傍晚五条。 ① 首页「近期任务」标题太长会挤成两行 用户原话:「首页**近期任务**那个地方要注意截断:如果任务标题太长, 会把这个任务**挤成两行**。」 🔴🔴 **真根因是两层,第一版只修了表面**: · 表面:`.dash-what` 只有 `flex:1 1 auto; min-width:0`,**缺** nowrap/hidden/ellipsis ⇒ 收缩后文字在框内换行; · 真身:`.home-duo` 是**两列 grid**,grid 子项默认 `min-width: auto` ("我最小也得装下我的内容")⇒ 一条长标题把那一列顶到 **1763px** (父 `home-duo` 才 1032px)⇒ **整个两列布局被撑破**、页面横向溢出到 **2089px** (视口 1440)。⇒ 行宽 1721px、`scrollWidth === clientWidth`, **`text-overflow: ellipsis` 看起来"没生效"** —— 其实容器自己先爆了。 修法:`.home-duo > * { min-width: 0; }`,且 **整条 flex 链**都补 (`.hd-pane` → `.hd-pane-head` → `.hd-pane-body` → `.dash-list/.tk-list` → `.tk-row`) —— 链上任何一层漏了,收缩就断在那一层、下面的 nowrap 全白写。 配套:任务标题加 `title=` 悬停看全。 ⚠️ 这是本项目**第二次**踩 flex/grid 的 `min-width:auto`(v53 的 `.mv-grid` 是同类)。 **以后"文字截断不生效"先量容器有没有被撑破**。 ② 名册「分组」→「标签」;打标签的候选要**从设置里读** 用户原话:「现在名册里面给人打标签的时候,**这个标签数据是从哪读出来的**呀? 在这个名册的设置页面,有很多分组,现在还叫分组。但是打标签的时候, **却不是从这些分组里读出来的**。……应该叫「名册标签」,是不是? **应该从这里面把所有标签都读出来**吧?……以后就没有分组的概念了, **全部都是标签的概念**。」 🔴 **用户观察得完全正确,这是两套数据源**: · 设置页显示的词表来自 `person_groups`(**分组表**); · 打标签的候选来自 `PersonTag::cloud()`(**你已经打过的词**,现算的); ⇒ 两边根本不是一个数据源 ⇒ "设置里列着一堆、打标签时却只有几个"。 修法:**`PersonTag::choices()` = 设置页词表 ∪ 我用过的词**(并集,词表在前)。 · 词表里的词**还没用过**也能一点就打上(这正是词表的意义); · 用过的词补在后面(历史不丢); · 名册列表 / 新增 / 编辑 / 挑人 / 导入 **五处同一个函数**。 界面口径全面改"标签"(**表名仍是 `person_groups`**:改名要背着 `PersonGroup` 类、 `person_roster.group_id`、以及**另一个会话正在写的 `app/ApiHandler.php`** (它引用了 `PersonGroup::orderFor`)一起动,风险远大于收益): · `_person_groups.php` 标题「名册标签」、按钮「加一个我自己的标签」; · 删除文案改成"只是从候选里去掉这个词,已打在别人身上的不受影响" (**语义真的变了**:标签是各自身上的字符串,不是引用); · `people/_settings_menu.php`、`people/settings.php`、`settings/me.php`、 `settings.php`(「公共分组」→「公共标签」)全部同步。 顺带按"全部都是标签"改另两条入口: · `people/pick.php`「加进哪一组」下拉 → **打标签**; · `people/import.php` 同上;`ContactImporter::run()` 加 `$tags` 参数。 🔴 新增 `PersonTag::addOne()`(**追加**一个标签)—— 这两处**绝不能用 `setFor()`** (它是"先 DELETE 再 INSERT"的整组替换)⇒ 会把人**原有**的标签清光 (症状是"用一次导入,别人原有的标签全没了",极难联想)。 ⚠️ `group_id` 在 pick/import 的**服务端仍收着**(老书签/脚本兼容),只是界面不再展示。 ③ 旅程模块加**地图视图**(读 TripLog 移植) 用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,**可以在地图上显示旅程数据**。 1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」 读 TripLog(`templates/activities/index.php`)得到的要点: · 用**高德 AMap**(合规白名单内,绝不是境外瓦片); · `position` 必须是 **[lng,lat] 数组**(传 `{lng,lat}` 裸对象会让 AMap 抛错、 整个绘制循环当场中断 —— 它注释里写着"批 11 的光秃秃地图就是它"); · `setFitView()` 后**上限 11 级**(只有一趟时会被放大到街道级,那是导航视角); · `.amap-marker` 整层 `pointer-events:none`(否则包装层挡住别家卡片的点击); · **容器必须有固定高度**(0 高度 → 空白地图)。 它那套"同城环形扇开"是**几百行几何**,本仓数据量没压出这个问题 ⇒ **有意不做**。 本仓数据更规整:`trip_nodes` 早有 `from_lng/from_lat/to_lng/to_lat`(v17 批) + `from_city/to_city` ⇒ 新增 `Trip::attachSpots()`: ① 有坐标取**重心**(不是端点 —— 端点会让"青岛→伊春"偏到某一头); ② 没坐标退到**城市重心**(`Geo::lookup` 离线城市表,**不发网络请求**: 列表页几十趟若逐条打高德逆地理,一次开页就是几十个外部请求); ③ 都没有就不上图(列表里照常有,模板会说清"有几趟没上图")。 🔴 城市那里**必须也取重心**:`cities()` 的顺序是**按时间**(出发地在最前), 取"第一个城市"会让**所有旅程都叠在家上**、地图毫无信息量。 布局:两个视图用**标签页**(`?view=map` 是**链接**不是纯 JS 按钮 —— 书签/前进后退/无 JS 都正确;也**有意不**把地图藏在 `display:none` 里, 高德在 0 尺寸容器里会渲染空白)。 高德 SDK 加载器重构:原来 `fbLoadAddr()` 顺带拉 `addr_suggest.js`, 而地图只用 SDK 自带的 `AMap.Map/Marker` ⇒ 抽出 `window.fbAmapSdk()`(只拉 SDK)、 新增 `fb-amap` meta(不带 plugin),`fb-addr` 那份共用同一加载器。 ④ 旅程列表页「新建旅程」**后面**加「设置」按钮 用户原话:「2. 在旅程的「新建旅程」后面也加一个「设置」按钮。」 ⇒ 补 `_settings_menu.php`(设置恒排最右),菜单**第一条**加「显示方式」。 ⑤ 显示方式:默认列表还是地图(用户自配) 用户原话:「3. 里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」 ⇒ 新增 `trips/display`(设置页 + `?set=` 快速设置)与 `trips/display_save`, 存 **`user_settings.trip_default_view`**(每人一份,**不是**全家设置)。 ⚠️ 访客也能改(只影响自己看到的默认,不是数据变更)。 验证(新增 2 套件 + 更新 e2e/deploy 断言): · _fb_v54_ui.py(**33 条**真机断言) · _b54_falsify.py(**11 注入 / 11 全抓**,基线 33/0) · e2e **1518/0**;familybook_deploy.sh 加「第54批形态」段(**24 条**) · 部署 **0 FAIL**;线上 **20 个文件 md5 逐字节一致**; 线上实测:词表 24 条、`choices()` 24 条、**`cloud` 15 条全部 ⊆ `choices`**、 4 趟旅程全部可上图(样例落在济南·青岛重心);诊断页已删复验 404 ⚠️ 本轮踩坑(已写进 pitfalls.md): · 🔴🔴 **"文字截断不生效"要先量容器有没有被撑破**(第一嫌疑是 flex/grid 子项 `min-width:auto`,不是 `text-overflow` 写错); · **反向断言("不该再出现 X")必须用 `_code_has`(剥注释)** —— 需求原话正当留在注释里,裸 `grep -F` 会命中自己的说明(本轮部署假红一次); · **冗余防线会让反向验证打不红**(两处 `min-width:0` 只去掉一条 ⇒ 探针照旧全绿, 注入必须一起去掉); · **界面多加一个字符就可能打歪既有断言**(我在词表管理页给标签名加了 `#`, 把 e2e 的 `my_group_order()` 读成 `#家人`)。 --- app/ContactImporter.php | 19 +++- app/PersonTag.php | 82 ++++++++++++++-- app/Trip.php | 109 +++++++++++++++++++++ assets/css/app.css | 145 ++++++++++++++++++++++++++-- assets/js/trip_map.js | 119 +++++++++++++++++++++++ index.php | 93 ++++++++++++++++-- templates/_person_groups.php | 74 ++++++++++---- templates/home.php | 4 + templates/layout.php | 107 +++++++++++++++----- templates/people/_settings_menu.php | 9 +- templates/people/import.php | 33 +++++-- templates/people/index.php | 20 +++- templates/people/pick.php | 36 +++++-- templates/people/settings.php | 17 ++-- templates/settings.php | 31 +++--- templates/settings/me.php | 14 +-- templates/trips/_map_body.php | 73 ++++++++++++++ templates/trips/_settings_menu.php | 16 ++- templates/trips/display.php | 72 ++++++++++++++ templates/trips/index.php | 75 +++++++++++++- 20 files changed, 1023 insertions(+), 125 deletions(-) create mode 100644 assets/js/trip_map.js create mode 100644 templates/trips/_map_body.php create mode 100644 templates/trips/display.php 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:影院下拉(带色点)+ 新建影院弹框 ---- 原生