v54:首页任务标题截断(真根因=两列网格被撑破)/ 名册分组→标签且候选读词表 / 旅程地图视图 / 旅程设置按钮 / 显示方式默认列表或地图

用户 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()` 读成 `#家人`)。
This commit is contained in:
2026-10-17 00:15:06 +08:00
parent 26026c7250
commit 8d79857f1a
20 changed files with 1023 additions and 125 deletions
+18 -1
View File
@@ -28,13 +28,22 @@ final class ContactImporter
/** /**
* @return array{added:int,updated:int,skipped:int,people:array<int,array{name:string,action:string,added:array}>} * @return array{added:int,updated:int,skipped:int,people:array<int,array{name:string,action:string,added: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; $added = 0;
$updated = 0; $updated = 0;
$skipped = 0; $skipped = 0;
$log = []; $log = [];
/* 🔴 v54(用户 2026-10-16 第 2 条):导入时可以**顺手打一串标签**。
用户原话:「现在已经没有分组的概念了,**全部都是标签的概念**。」
⚠️ 用 `PersonTag::addOne()`(**追加**)而不是 `setFor()`(整组替换)——
命中已有的人时,`setFor` 会把他身上**原有**的标签一起清掉
("用一次导入,别人原有的标签全没了")。语义上这里就是"多加一个"。
⚠️ 老参数 `$groupId` 保留:不为别的,只为**不改既有调用契约**;
新界面不再传它(`templates/people/import.php` 已换成标签输入)。 */
$tags = PersonTag::clean($tags);
// 现有的人一次取出来做索引(几十~几百人,比逐条查库快得多,也不会漏判) // 现有的人一次取出来做索引(几十~几百人,比逐条查库快得多,也不会漏判)
// 索引键:phone_hash(精确)+ 姓名(小写) // 索引键:phone_hash(精确)+ 姓名(小写)
$byPhone = []; $byPhone = [];
@@ -87,6 +96,10 @@ final class ContactImporter
⚠️ `ensure` 是幂等的(已在名册里就返回 false),所以重复导入不会出问题。 */ ⚠️ `ensure` 是幂等的(已在名册里就返回 false),所以重复导入不会出问题。 */
PersonRoster::ensure($familyId, $meId, (int)$hit['id'], PersonRoster::ensure($familyId, $meId, (int)$hit['id'],
$groupId > 0 ? $groupId : null, 'self'); $groupId > 0 ? $groupId : null, 'self');
// 🔴 v54:命中已有的人时**追加**标签(不动他原有的 —— 见方法头的说明)
foreach ($tags as $tg) {
PersonTag::addOne($familyId, $meId, (int)$hit['id'], $tg);
}
if ($filled) { if ($filled) {
$updated++; $updated++;
$log[] = ['name' => (string)$hit['name'], 'action' => 'updated', 'added' => $filled]; $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'); 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']]; $byName[mb_strtolower($d['name'], 'UTF-8')] = ['id' => $pid, 'name' => $d['name']];
+75 -7
View File
@@ -95,17 +95,57 @@ final class PersonTag
return $st->fetchAll(); 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<int,string>
*/
public static function choices(int $familyId, int $ownerId, int $limit = 60): array
{ {
if ($familyId <= 0 || $ownerId <= 0) {
return [];
}
$out = []; $out = [];
foreach (self::cloud($familyId, $ownerId) as $r) { // ① 词表(设置页「名册标签」里配的那些:公共同我的,带顺序)
$out[] = (string)$r['tag']; foreach (PersonGroup::orderFor($familyId, $ownerId) as $g) {
if (count($out) >= $limit) { $nm = trim((string)($g['name'] ?? ''));
break; 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]); 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;
}
/** /**
* 标签归一:`#小学同学` / `小学同学 ` / `小学,同学` 都要变成干净的词。 * 标签归一:`#小学同学` / `小学同学 ` / `小学,同学` 都要变成干净的词。
* *
+109
View File
@@ -245,6 +245,115 @@ final class Trip
return implode(' · ', $cities); 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 会让几十趟活动明显变慢)。 * 给**一批**活动补上派生时间与城市(列表页用;N+1 会让几十趟活动明显变慢)。
* *
+139 -6
View File
@@ -2950,7 +2950,20 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.dash-row:first-child { border-top: 0; } .dash-row:first-child { border-top: 0; }
.dash-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; } .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-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); } .dash-tag { flex: 0 0 auto; font-size: 11px; color: var(--faint); }
/* ---- v24 第 11 批 #4:影院下拉(带色点)+ 新建影院弹框 ---- /* ---- v24 第 11 批 #4:影院下拉(带色点)+ 新建影院弹框 ----
原生 <option> 画不了色块,所以自己做一个"按钮 + 弹出列表": 原生 <option> 画不了色块,所以自己做一个"按钮 + 弹出列表":
@@ -4417,11 +4430,30 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
对齐又失效(这是"固定高度 + 内部滚动"这套写法的经典坑)。 对齐又失效(这是"固定高度 + 内部滚动"这套写法的经典坑)。
这样两块的**外框高度恒等**,与各自内容多少无关 —— 对齐不再依赖数据。 */ 这样两块的**外框高度恒等**,与各自内容多少无关 —— 对齐不再依赖数据。 */
.home-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: stretch; } .home-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: stretch; }
.hd-pane { display: flex; flex-direction: column; height: 440px; min-height: 0; } /* 🔴🔴 v54(用户 2026-10-16 第 1 条)—— **这条才是"标题太长挤成两行"的真根因**。
.hd-pane-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex: 0 0 auto; } 症状两层,第一层在这里:
.hd-pane-head .cinema-h2 { margin: 0; flex: 1 1 auto; } `.home-duo` 是**两列网格**(`1fr 1fr`),而 grid/flex 子项的默认
`min-width: auto` 意思是"我最小也得装下我的内容" ⇒ 只要右栏有一条**长标题**,
那一列就被顶到 1700+px(实测:`home-duo` 1032px,里面 `hd-pane` 1763px),
整个两列布局被撑破、横向溢出到 2089px。
⇒ 于是"截断"根本没机会发生:**容器自己先被撑爆了**,行宽 1721px,
`scrollWidth === clientWidth`,`text-overflow: ellipsis` 看起来"没生效"。
⚠️ `min-width: 0` 是让 grid/flex 子项**允许收缩到内容以下**的唯一开关;
缺它时,在子项里怎么写 nowrap/ellipsis 都没用(本项目第二次踩这个:
v53 的 `.mv-grid` 是同类,那次是 `grid-column`,这次是 `min-width`)。
⚠️ 加在**两个**网格项上(两个 pane 都要能收缩,不只是有任务的那个)。 */
.home-duo > * { min-width: 0; }
.hd-pane { display: flex; flex-direction: column; height: 440px; min-height: 0; min-width: 0; }
.hd-pane-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex: 0 0 auto; min-width: 0; }
.hd-pane-head .cinema-h2 { margin: 0; flex: 1 1 auto; min-width: 0; }
/* ⚠️ v54:列表链上每一层都要 `min-width:0`(中间任何一层没给,
收缩就断在那一层、下面的 nowrap 又白写)—— `.dash-list`/`.tk-list` 是 ul,
`.tk-row` 是 li,都是 flex 项。 */
.dash-list { min-width: 0; }
.tk-list { min-width: 0; }
.tk-row { min-width: 0; }
.hd-pane-body { .hd-pane-body {
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; flex: 1 1 auto; min-height: 0; min-width: 0; overflow-y: auto; overscroll-behavior: contain;
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* 旧 Edge / IE */ -ms-overflow-style: none; /* 旧 Edge / IE */
} }
@@ -4485,7 +4517,12 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
/* ---- 任务行:标题 + 状态 + **看板名** + 截止 ---- */ /* ---- 任务行:标题 + 状态 + **看板名** + 截止 ---- */
.tk-list { display: flex; flex-direction: column; } .tk-list { display: flex; flex-direction: column; }
.tk-row { align-items: center; } /* 🔴 v54(用户 2026-10-16 第 1 条):标题太长会把这行**挤成两行**。
与 `.dash-what` 那三句(nowrap/hidden/ellipsis)配套:行本身**不许换行**,
于是标题只能去收缩(它有 min-width:0,能一直缩到 0),徽标保持原样。
⚠️ 不复用 `.dash-row` 的 `flex-wrap:wrap` —— 那是"往年的今天"那一块要的
(窄屏时日期徽标该绕到下一行);任务行收紧成 nowrap 才有截断可言。 */
.tk-row { align-items: center; flex-wrap: nowrap; }
.tk-board { .tk-board {
flex: 0 0 auto; font-size: 11.5px; color: var(--muted); flex: 0 0 auto; font-size: 11.5px; color: var(--muted);
background: var(--line-2); border-radius: 999px; padding: 1px 8px; background: var(--line-2); border-radius: 999px; padding: 1px 8px;
@@ -5954,3 +5991,99 @@ h5.md-h, h6.md-h { font-size: 14px; }
.mod-table .mod-row.is-dragged { opacity: .5; } .mod-table .mod-row.is-dragged { opacity: .5; }
.mod-table .mod-row.is-drop-before td { border-top: 2px solid var(--brand-2); } .mod-table .mod-row.is-drop-before td { border-top: 2px solid var(--brand-2); }
.mod-table .color-picker { width: 34px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 5px; background: #fff; cursor: pointer; } .mod-table .color-picker { width: 34px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 5px; background: #fff; cursor: pointer; }
/* ==========================================================================
🔴 v54(用户 2026-10-16 第 3/4/5 条):旅程 —— **列表 / 地图**两个视图
==========================================================================
用户原话:「在旅程这个模块,**可以在地图上显示旅程数据**」(移植 TripLog);
「在旅程的「新建旅程」后面也加一个「设置」按钮」;
「里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」
两个视图用**标签页**切换(不是两页)—— 因为"看列表"与"看地图"是同一份数据的
两种看法,来回切不该刷新整页;也让"默认显示哪个"这个设置有意义
(设置决定**进来时**先显示哪一个)。
⚠️ 标签页样式沿用本仓既有的"胶囊按钮 + on 态"口径(与日历视图切换一致)。 */
/* ---- 视图切换条 ---- */
.trip-views {
display: flex; align-items: center; gap: 8px;
margin: 0 0 14px;
}
.trip-views .tv-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 14px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface);
color: var(--muted); font-size: 13.5px; cursor: pointer;
text-decoration: none; line-height: 1.5;
}
.trip-views .tv-btn:hover { color: var(--ink); border-color: var(--line-3, var(--line)); }
.trip-views .tv-btn.is-on {
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
}
.trip-views .tv-btn svg { display: block; }
/* 右上角那颗「设为默认」小链接:与标签页同一行、靠右 */
.trip-views .tv-default {
margin-left: auto; font-size: 12.5px; color: var(--faint);
text-decoration: none; white-space: nowrap;
}
.trip-views .tv-default:hover { color: var(--brand); text-decoration: none; }
/* 「地图(0)」里那个小计数:还没位置时提示一下,别让人点进去看空地图 */
.trip-views .tv-dim { font-size: 11.5px; opacity: .8; margin-left: 2px; }
/* ---- 地图容器 ----
⚠️ **必须有固定高度**:高德地图在"高度为 0 的容器"里渲染出来是空白
(这是高德最常见的坑,`.trip-map` 的 height 就是为它写的)。
⚠️ 高度跟视口走(屏幕越大看得越远),但给下限免得笔记本上太扁。 */
.trip-map-wrap { margin-top: 2px; }
.trip-map {
width: 100%; height: clamp(380px, 62vh, 720px);
border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; background: #EEF1F4; /* 瓦片到位前不闪白 */
}
.trip-map-fallback {
margin: 0; height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 13.5px; padding: 0 24px; text-align: center;
}
.trip-map-hint { margin: 8px 0 0; }
/* ---- 地图上的标记:小卡片(左文右无图,比 TripLog 那版更轻)----
⚠️ 卡片是**绝对定位在画布中心**(JS 里 left/top = C+dx / C+dy):
画布固定 260×260,卡片偏移不影响标记锚点(AMap 按画布中心对齐坐标)。 */
.trip-pin-stage { position: relative; pointer-events: none; }
.trip-pin {
position: absolute; transform: translate(-50%, -50%);
pointer-events: auto; cursor: pointer;
}
.trip-pin-card {
background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--brand);
border-radius: 8px; padding: 6px 10px;
box-shadow: 0 2px 8px rgba(0,0,0,.10);
max-width: 190px; white-space: nowrap;
}
.trip-pin-title {
font-size: 13px; font-weight: 600; color: var(--ink);
overflow: hidden; text-overflow: ellipsis;
}
.trip-pin-sub { font-size: 11.5px; color: var(--muted); margin-top: 1px; }
.trip-pin:hover .trip-pin-card { border-color: var(--brand); box-shadow: 0 4px 14px rgba(0,0,0,.16); }
/* ⚠️ AMap 的标记包装层尺寸跟着 content(260×260)走、默认可命中,
会把旁边卡片的点击整层挡住。整层禁掉命中、只留卡片本身可点
(TripLog 也踩过这个,见其 `.amap-marker` 那条注释)。 */
#tripMapCanvas .amap-marker { pointer-events: none !important; }
#tripMapCanvas .amap-marker .trip-pin { pointer-events: auto !important; }
/* ---- 显示方式设置页的单选卡片(v54 第 5 条)----
原生 radio + label 包一层卡片外观;`.is-on` 由服务端渲染时给
—— 不依赖 JS 同步选中态(无 JS 时至少"当前项"是对的)。 */
.view-choice { display: flex; flex-direction: column; gap: 10px; max-width: 480px; }
.view-choice-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius-s);
cursor: pointer; background: var(--surface);
}
.view-choice-item:hover { border-color: var(--brand-2); }
.view-choice-item.is-on { border-color: var(--brand); background: var(--brand-soft, #f2f7f3); }
.view-choice-item input { margin-top: 3px; flex: 0 0 auto; }
.view-choice-item .vci-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.view-choice-item .vci-body b { font-size: 14px; }
+119
View File
@@ -0,0 +1,119 @@
/**
* 旅程地图(v54 用户 2026-10-16 第 3 条)。
*
* 用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,可以在地图上显示旅程数据。
* 1. 先读一下它之前那个代码是怎么实现的,在我们的旅程模块也实现一下。」
*
* ── 移植自 TripLog `templates/activities/index.php` 的那套(要点)──
* · 高德 JS API 2.0;`AMap.Map` 建图 + `AMap.Marker` 打点;
* · 每枚标记的 `position` 必须是 **[lng, lat] 数组**(传 `{lng,lat}` 裸对象
* 会让 AMap 抛错、整个绘制循环当场中断 —— TripLog 踩过这个,注释里写着
* "批 11 的光秃秃地图就是它");
* · `setFitView()` 自动框住所有点,但**上限 11 级**:只有一趟旅程时
* setFitView 会一路放大到街道级,那是导航视角、不是"足迹"视角;
* · 卡片(自绘 content)用**方形画布 + 居中定位**,这样卡片可以偏离真实坐标
* 而不影响标记锚点(TripLog 用它做"同城扇开")。
*
* ── 本版**不**做"同城扇开"(有意取舍)──
* TripLog 那个扇开是为了"同一座城市的多次旅行挤在一起"。
* 本仓的旅程列表通常没有那么多趟、且每枚卡片更窄(只有标题+时间),
* 先做"同一城市多点重合就按到达顺序错开一点"这种**轻量去重叠**即可 ——
* 完整扇开(环形均匀分布 + 引线 + 与容器边界求解)是几百行几何代码,
* 在没有真实数据压出这个问题之前不值得(TripLog 也是撞到了才写的)。
*
* ⚠️ 合规:地图是高德(白名单内),SDK 由 layout.php 的 `fb-amap` 按需提供,
* 本文件**不自己拼 SDK 地址、不碰 key**。
* ⚠️ SDK 未就位(没配 key / 加载失败)时降级成一句说明,不留白框。
*/
(function () {
'use strict';
var el = document.getElementById('tripMapCanvas');
if (!el) { return; }
var points;
try {
points = JSON.parse(el.getAttribute('data-points') || '[]');
} catch (e) {
points = [];
}
if (!points || !points.length) { return; }
function fallback(msg) {
el.innerHTML = '<p class="trip-map-fallback">' + msg + '</p>';
}
function esc(s) {
return String(s === null || s === undefined ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* 视野上限:别放到街道级(见文件头的说明) */
var MAX_ZOOM = 11;
/* 卡片画布:内容绝对居中,卡片可以在这块画布里自由偏移(本版只用于微错开) */
var CANVAS = 260;
var C = CANVAS / 2;
function boot() {
var map = new AMap.Map(el, { viewMode: '2D', zoom: 5, resizeEnable: true });
/* 轻量去重叠:同一位置的多个点(坐标差 < 极小阈值)沿一个方向的螺旋微移,
免得完全重合、只能点到一个。位移**只改卡片绘制位置**,
若做不明显,至少保证每枚都能被点到(content 是各自的 DOM)。 */
var seen = {};
var markers = [];
points.forEach(function (p, i) {
var key = p.lng.toFixed(3) + ',' + p.lat.toFixed(3);
var n = seen[key] = (seen[key] || 0);
seen[key]++;
var dx = 0, dy = 0;
if (n > 0) {
// 螺旋微移:每多重一个,就往外挪一点(角度按 n 递增)
var ang = n * (Math.PI / 3);
var rad = 26 + n * 6;
dx = Math.cos(ang) * rad;
dy = Math.sin(ang) * rad;
}
var html = '<div class="trip-pin" style="left:' + (C + dx) + 'px;top:' + (C + dy) + 'px;">'
+ '<div class="trip-pin-card">'
+ '<div class="trip-pin-title">' + esc(p.title) + '</div>'
+ (p.when ? '<div class="trip-pin-sub">' + esc(p.when) + '</div>' : '')
+ (p.city ? '<div class="trip-pin-sub">📍 ' + esc(p.city) + '</div>' : '')
+ '</div></div>';
var mk = new AMap.Marker({
// ⚠️ 必须是 [lng, lat] 数组(见文件头的说明)
position: [p.lng, p.lat],
content: '<div class="trip-pin-stage" style="width:' + CANVAS + 'px;height:' + CANVAS + 'px;">'
+ html + '</div>',
anchor: 'center',
map: map,
});
mk.on('click', function () { window.location.href = p.url; });
markers.push(mk);
});
map.setFitView(null, false, [40, 40, 40, 40]);
if (map.getZoom() > MAX_ZOOM) { map.setZoom(MAX_ZOOM); }
el.setAttribute('data-map-ready', '1');
}
/* SDK 由 layout 的按需加载器提供(`window.fbAmapSdk()`)。
⚠️ 没配高德 key 时那个函数不存在 —— 直接降级。 */
if (typeof window.fbAmapSdk !== 'function') {
fallback('地图没启用(还没在配置里填高德 key);用左侧的「列表」看旅程。');
return;
}
window.fbAmapSdk().then(function (ok) {
if (!ok || typeof window.AMap === 'undefined') {
fallback('地图脚本没加载成功,暂时看不了地图;用左侧的「列表」看旅程。');
return;
}
try {
boot();
} catch (e) {
fallback('地图初始化失败:' + (e && e.message ? e.message : '未知错误'));
}
});
})();
+83 -10
View File
@@ -1070,7 +1070,12 @@ switch ($r) {
// 不再传它,模板也不再读(多传一个变量迟早有人用它,把分栏又带回来)。 // 不再传它,模板也不再读(多传一个变量迟早有人用它,把分栏又带回来)。
'groups' => PersonGroup::orderFor($familyId, $me), 'groups' => PersonGroup::orderFor($familyId, $me),
// v28 用户第 5 条:标签(筛选条 + 卡片上的标签 + 编辑标签时的候选) // v28 用户第 5 条:标签(筛选条 + 卡片上的标签 + 编辑标签时的候选)
// 🔴 v54(第 2 条):筛选条与"打标签候选"是**两件事**,别混:
// · 筛选条只该列**真有人的**标签(cloud:JOIN 了名册,点进去不会空)——
// 把没人的词也列上去,会出现"写着 5 个、点进去 0 个";
// · 打标签的候选要**词表 ∪ 用过的词**(模板里的弹层用 tagChoices)。
'tagCloud' => PersonTag::cloud($familyId, $me), 'tagCloud' => PersonTag::cloud($familyId, $me),
'tagChoices' => PersonTag::choices($familyId, $me),
'tagFilter' => $tagFilter, 'tagFilter' => $tagFilter,
'q' => $q, 'q' => $q,
'meId' => $me, 'meId' => $me,
@@ -1299,7 +1304,9 @@ switch ($r) {
if ($method !== 'POST') { if ($method !== 'POST') {
View::render('people/import', [ View::render('people/import', [
'title' => '导入联系人', 'title' => '导入联系人',
// ⚠️ v54:`groups` 仍传(兼容),新界面用 `tagChoices`(打标签候选)
'groups' => PersonGroup::orderFor($familyId, $me), 'groups' => PersonGroup::orderFor($familyId, $me),
'tagChoices' => PersonTag::choices($familyId, $me),
'report' => null, 'report' => null,
], 'people'); ], 'people');
break; break;
@@ -1325,7 +1332,9 @@ switch ($r) {
redirect('people/import'); redirect('people/import');
} }
$groupId = (int)($_POST['group_id'] ?? 0); $groupId = (int)($_POST['group_id'] ?? 0);
$res = ContactImporter::run($familyId, $me, $records, $groupId); // 🔴 v54(第 2 条):导入时可以顺手打一串标签(原来只给一个分组)
$impTags = PersonTag::clean((string)($_POST['tags'] ?? ''));
$res = ContactImporter::run($familyId, $me, $records, $groupId, $impTags);
$msg = '导入完成:新增 ' . $res['added'] . ' 人,补充信息 ' . $res['updated'] . ' 人'; $msg = '导入完成:新增 ' . $res['added'] . ' 人,补充信息 ' . $res['updated'] . ' 人';
if ($res['skipped'] > 0) { if ($res['skipped'] > 0) {
@@ -1335,6 +1344,7 @@ switch ($r) {
View::render('people/import', [ View::render('people/import', [
'title' => '导入联系人', 'title' => '导入联系人',
'groups' => PersonGroup::orderFor($familyId, $me), 'groups' => PersonGroup::orderFor($familyId, $me),
'tagChoices' => PersonTag::choices($familyId, $me),
'report' => $res, 'report' => $res,
], 'people'); ], 'people');
break; break;
@@ -1358,6 +1368,11 @@ switch ($r) {
csrf_check(); csrf_check();
$ids = array_map('intval', (array)($_POST['ids'] ?? [])); $ids = array_map('intval', (array)($_POST['ids'] ?? []));
$gid = (int)($_POST['group_id'] ?? 0); $gid = (int)($_POST['group_id'] ?? 0);
/* 🔴 v54(用户 2026-10-16 第 2 条):挑人时改成**打标签**。
⚠️ `group_id` 仍然收着(老书签/脚本、以及别的调用方可能还在传),
只是新界面**不再展示**下拉 —— 有值就照旧写进 roster.group_id,
没值(新界面的常态)就 NULL,与"未分类"同义。 */
$pickTags = PersonTag::clean((string)($_POST['tags'] ?? ''));
// 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来 // 只认「确实可挑」的那些 id —— 表单可以改,不能凭它把别人家的拉进来
$ok = array_column($familyPick, 'id'); $ok = array_column($familyPick, 'id');
$ok = array_map('intval', $ok); $ok = array_map('intval', $ok);
@@ -1368,6 +1383,10 @@ switch ($r) {
} }
if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) { if (PersonRoster::ensure($familyId, $me, $pid, $gid > 0 ? $gid : null, 'self')) {
$n++; $n++;
// 这串标签给**每个**新进名册的人各打一遍(见模板里的说明)
foreach ($pickTags as $tg) {
PersonTag::addOne($familyId, $me, $pid, $tg);
}
} }
} }
flash($n > 0 ? 'ok' : 'err', $n > 0 flash($n > 0 ? 'ok' : 'err', $n > 0
@@ -1378,7 +1397,9 @@ switch ($r) {
View::render('people/pick', [ View::render('people/pick', [
'title' => '从家里已有的人里挑', 'title' => '从家里已有的人里挑',
'people' => $familyPick, 'people' => $familyPick,
// ⚠️ v54:`groups` 仍传(老模板/兼容),但新界面用 `tagChoices` 打标签
'groups' => PersonGroup::orderFor($familyId, $me), 'groups' => PersonGroup::orderFor($familyId, $me),
'tagChoices' => PersonTag::choices($familyId, $me),
'meId' => $me, 'meId' => $me,
], 'people'); ], 'people');
break; break;
@@ -1942,7 +1963,7 @@ switch ($r) {
// 🔴 v53(第 4/5 条):新增页也要能打标签 + 填「我对他的称呼 / 备注」。 // 🔴 v53(第 4/5 条):新增页也要能打标签 + 填「我对他的称呼 / 备注」。
// 标签候选与编辑页同一个来源(我名下用过的全部标签)。 // 标签候选与编辑页同一个来源(我名下用过的全部标签)。
'myTags' => [], 'myTags' => [],
'tagChoices' => array_column(PersonTag::cloud((int)Auth::familyId(), (int)Auth::personId()), 'tag'), 'tagChoices' => PersonTag::choices((int)Auth::familyId(), (int)Auth::personId()),
'myNick' => '', 'myNick' => '',
], 'people'); ], 'people');
break; break;
@@ -2020,7 +2041,7 @@ switch ($r) {
// 🔴 v53:新增页新加的这三样也要**原样回显**,否则一撞名/一报错 // 🔴 v53:新增页新加的这三样也要**原样回显**,否则一撞名/一报错
// 用户刚打的标签、刚写的称呼与备注就全没了(重填一遍)。 // 用户刚打的标签、刚写的称呼与备注就全没了(重填一遍)。
'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')), 'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')),
'tagChoices' => array_column(PersonTag::cloud((int)Auth::familyId(), (int)Auth::personId()), 'tag'), 'tagChoices' => PersonTag::choices((int)Auth::familyId(), (int)Auth::personId()),
'myNick' => trim((string)($_POST['my_nick'] ?? '')), 'myNick' => trim((string)($_POST['my_nick'] ?? '')),
'confirmHit' => $hit, 'confirmHit' => $hit,
'confirmWhy' => (string)($hit['_match'] ?? 'name'), 'confirmWhy' => (string)($hit['_match'] ?? 'name'),
@@ -2074,7 +2095,7 @@ switch ($r) {
'jobRows' => (array)($d['jobs'] ?? []), 'jobRows' => (array)($d['jobs'] ?? []),
'myNote' => trim((string)($_POST['my_note'] ?? '')), 'myNote' => trim((string)($_POST['my_note'] ?? '')),
'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')), 'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')),
'tagChoices' => array_column(PersonTag::cloud($familyId, (int)Auth::personId()), 'tag'), 'tagChoices' => PersonTag::choices($familyId, (int)Auth::personId()),
'myNick' => trim((string)($_POST['my_nick'] ?? '')), 'myNick' => trim((string)($_POST['my_nick'] ?? '')),
], 'people'); ], 'people');
exit; exit;
@@ -2104,7 +2125,7 @@ switch ($r) {
'jobRows' => (array)($d['jobs'] ?? []), 'jobRows' => (array)($d['jobs'] ?? []),
'myNote' => trim((string)($_POST['my_note'] ?? '')), 'myNote' => trim((string)($_POST['my_note'] ?? '')),
'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')), 'myTags' => PersonTag::clean((string)($_POST['tags'] ?? '')),
'tagChoices' => array_column(PersonTag::cloud($familyId, (int)Auth::personId()), 'tag'), 'tagChoices' => PersonTag::choices($familyId, (int)Auth::personId()),
'myNick' => trim((string)($_POST['my_nick'] ?? '')), 'myNick' => trim((string)($_POST['my_nick'] ?? '')),
], 'people'); ], 'people');
exit; exit;
@@ -2191,12 +2212,14 @@ switch ($r) {
⇒ 这一页补上「标签」编辑(与名册列表上那颗标签按钮同一份数据源)。 ⇒ 这一页补上「标签」编辑(与名册列表上那颗标签按钮同一份数据源)。
⚠️ 标签是**每人一份**的(owner = 我),所以只有"我"名册里的标签有意义: ⚠️ 标签是**每人一份**的(owner = 我),所以只有"我"名册里的标签有意义:
· `myTags` 这个人当前在我名下的标签 → 回填输入框; · `myTags` 这个人当前在我名下的标签 → 回填输入框;
· `tagChoices` 我名下**用过**的全部标签 → 给可点选的候选(与列表页同一个 🔴 v54(用户 2026-10-16 第 2 条):候选改成 `PersonTag::choices()` ——
`PersonTag::cloud()`,两处口径一致)。 **设置页配的词表 ∪ 我用过的词**(原来只读 `cloud()` = 用过的词,
于是"设置里列着一堆、打标签时却只有几个")。
列表页、新增页、编辑页**三处同一个函数**,口径必须一致。
⚠️ 改自己($isSelf)不给标签:标签是"我给别人打的",给自己打没有意义 ⚠️ 改自己($isSelf)不给标签:标签是"我给别人打的",给自己打没有意义
(名册页那颗标签按钮也只在 `$canMove` 时出现)。 */ (名册页那颗标签按钮也只在 `$canMove` 时出现)。 */
'myTags' => $isSelf ? [] : PersonTag::of((int)Auth::personId(), $id), 'myTags' => $isSelf ? [] : PersonTag::of((int)Auth::personId(), $id),
'tagChoices' => $isSelf ? [] : array_column(PersonTag::cloud((int)Auth::familyId(), (int)Auth::personId()), 'tag'), 'tagChoices' => $isSelf ? [] : PersonTag::choices((int)Auth::familyId(), (int)Auth::personId()),
// 🔴 v53(第 5 条):编辑页也要回显「我对他的称呼」(与新增页同一个字段名), // 🔴 v53(第 5 条):编辑页也要回显「我对他的称呼」(与新增页同一个字段名),
// 否则在新增页填了称呼、到编辑页却看不见(会以为没存上)。 // 否则在新增页填了称呼、到编辑页却看不见(会以为没存上)。
'myNick' => $isSelf ? '' : Nickname::get((int)Auth::personId(), $id), 'myNick' => $isSelf ? '' : Nickname::get((int)Auth::personId(), $id),
@@ -2276,7 +2299,7 @@ switch ($r) {
⚠️ 取自 `$_POST['tags']`(用户刚提交的那份),**不是**库里的旧值 —— ⚠️ 取自 `$_POST['tags']`(用户刚提交的那份),**不是**库里的旧值 ——
这才叫"保留用户填的东西",`clear_old`/`old()` 那一套同理。 */ 这才叫"保留用户填的东西",`clear_old`/`old()` 那一套同理。 */
'myTags' => $isSelf ? [] : PersonTag::clean((string)($_POST['tags'] ?? '')), 'myTags' => $isSelf ? [] : PersonTag::clean((string)($_POST['tags'] ?? '')),
'tagChoices' => $isSelf ? [] : array_column(PersonTag::cloud($familyId, (int)Auth::personId()), 'tag'), 'tagChoices' => $isSelf ? [] : PersonTag::choices($familyId, (int)Auth::personId()),
], 'people'); ], 'people');
exit; exit;
} }
@@ -2308,7 +2331,7 @@ switch ($r) {
'contacts' => $contacts, 'contacts' => $contacts,
// v51:同上面那条 —— 重画时保留用户刚提交的标签 // v51:同上面那条 —— 重画时保留用户刚提交的标签
'myTags' => $isSelf ? [] : PersonTag::clean((string)($_POST['tags'] ?? '')), 'myTags' => $isSelf ? [] : PersonTag::clean((string)($_POST['tags'] ?? '')),
'tagChoices' => $isSelf ? [] : array_column(PersonTag::cloud($familyId, (int)Auth::personId()), 'tag'), 'tagChoices' => $isSelf ? [] : PersonTag::choices($familyId, (int)Auth::personId()),
], 'people'); ], 'people');
exit; exit;
} }
@@ -3672,11 +3695,20 @@ switch ($r) {
$keyword = trim((string)($_GET['keyword'] ?? '')); $keyword = trim((string)($_GET['keyword'] ?? ''));
$rows = Trip::all($familyId, $keyword); $rows = Trip::all($familyId, $keyword);
Trip::attachAttendees($rows, (int)Auth::personId()); Trip::attachAttendees($rows, (int)Auth::personId());
/* 🔴 v54(用户 2026-10-16 第 3 条):「在我们的旅程模块也实现一下」地图显示。
`attachSpots` 给每趟旅程算一个**代表坐标**(各段行程 from/to 的重心;
没有坐标就退到城市中心,离线表、不发网络请求)—— 地图视图用它打点。
⚠️ `Trip::all()` 内部**已经**调过 `attachDerived()`(挂 `cities`),
所以这里**不能再调一次** —— 重复跑一遍那几条聚合查询是白费。
⚠️ 它对**列表视图**也无害(只是多挂几个键,模板不读就不显示)。 */
Trip::attachSpots($rows);
View::render('trips/index', [ View::render('trips/index', [
'title' => '旅程', 'title' => '旅程',
'rows' => $rows, 'rows' => $rows,
'total' => count($rows), 'total' => count($rows),
'keyword' => $keyword, 'keyword' => $keyword,
/* 🔴 v54(第 5 条):默认看列表还是地图(每人一份,见 trips/display)。 */
'tripDefaultView' => (string)UserSetting::get((int)Auth::personId(), 'trip_default_view', 'list'),
], 'trips'); ], 'trips');
break; break;
@@ -3980,6 +4012,47 @@ switch ($r) {
// ---------------- 旅程设置:车辆 / 出行方式 / 节点类型(用户第 8 条)---------------- // ---------------- 旅程设置:车辆 / 出行方式 / 节点类型(用户第 8 条)----------------
/**
* 🔴 v54(用户 2026-10-16 第 5 条):「里面的设置让用户自己配置:
* **到底默认显示旅程列表,还是用地图显示**。」
* ⇒ 旅程「显示方式」设置页。
*
* 存在 `user_settings.trip_default_view`(**每人一份**,与看板色/天气地点同一层)
* —— 这是"我进来先看哪个",不是全家的偏好。
*
* `?set=list|map` 是**列表页上那颗"设为默认"小链接**用的 —— 它直接改设置再回列表,
* 省去"点进设置页 → 选 → 保存 → 再回来"四步(那正是用户抱怨过的那种绕)。
* ⚠️ 用 GET 传值只到"点一下就设默认"这种**幂等的个人偏好**为止:
* 没有副作用、不影响别人;真正的表单(页面里那两个 radio)仍走 POST + csrf。
* ⚠️ 访客(canWrite=false)也能改 —— 它只影响自己看到的默认,不是数据变更。
*/
case 'trips/display':
$me = (int)Auth::personId();
$set = (string)($_GET['set'] ?? '');
if (in_array($set, ['list', 'map'], true)) {
UserSetting::set($me, 'trip_default_view', $set);
flash('ok', '已把「' . ($set === 'map' ? '地图' : '列表') . '」设为打开旅程时的默认视图。');
redirect('trips');
}
View::render('trips/display', [
'title' => '显示方式',
'current' => (string)UserSetting::get($me, 'trip_default_view', 'list'),
], 'trips');
break;
case 'trips/display_save':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$val = (string)($_POST['view'] ?? '');
if (!in_array($val, ['list', 'map'], true)) {
flash('err', '没看懂要设成哪种显示方式。');
redirect('trips/display');
}
UserSetting::set($me, 'trip_default_view', $val);
flash('ok', '已保存:以后打开旅程默认看「' . ($val === 'map' ? '地图' : '列表') . '」。');
redirect('trips/display');
case 'trips/vehicles': case 'trips/vehicles':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
View::render('trips/vehicles', [ View::render('trips/vehicles', [
+53 -21
View File
@@ -1,18 +1,35 @@
<?php <?php
/** /**
* 「名册分组」管理卡片 —— 从个人设置**搬出来**的(v52 用户 2026-10-15 第 6 条)。 * 「名册标签」词表管理卡片 —— 从个人设置**搬出来**的(v52 用户 2026-10-15 第 6 条),
* v54(用户 2026-10-16 第 2 条)**从"分组"改口径为"标签词表"**。
* *
* 用户原话:「名册右侧,只保留一个添加成员按钮,右侧再加一个设置按钮, * 用户原话(v52):「名册右侧,只保留一个添加成员按钮,右侧再加一个设置按钮,
* 点击后下拉设置,第一个就是导入联系人,第二个就是原来个人设置那里的 * 点击后下拉设置,第一个就是导入联系人,第二个就是原来个人设置那里的
* **名册分组**,这样**个人设置中就不显示名册分组的内容**了。」 * **名册分组**,这样**个人设置中就不显示名册分组的内容**了。」
* *
* ⇒ 同一个卡片现在挂在「名册 → 设置 → 名册分组」这一页上(`templates/people/settings.php`)。 * 用户原话(v54,本次):「现在名册里面给人打标签的时候,这个标签数据是从哪读出来的呀?
* ⚠️ 表单 action 仍指向 `settings/groups/*`(**一个字段都没改**)—— * 在这个名册的设置页面,有很多分组,现在还叫分组。但是打标签的时候,
* 分组管理是"我自己的分组",与它挂在哪一页无关; * 却不是从这些分组里读出来的。所以现在应该在这个设置页面,不应该再叫
* 换路由只会白白多一处要维护的东西(而且老书签/脚本会失效)。 * 「名册分组」了吧?应该叫「名册标签」,是不是?**应该从这里面把所有标签都读出来**吧?」
*
* 🔴 澄清一个容易混的点(也是用户困惑的根源):
* 这张卡背后的表仍然是 `person_groups`(**表名没改**,因为改名要背着
* `PersonGroup` 类、`person_roster.group_id`、其它会话正在写的 App-API 一起动,
* 风险远大于收益)—— 但它的**语义**从 v28 起就已经是"**标签词表**":
* 这里配的每个词,现在会出现在名册里打标签时的**候选列表**里
* (见 `App\PersonTag::choices()`:**这张表 ∪ 我用过的词**)。
*
* 所以:
* · 界面上**一律叫"标签"**,不再出现"分组"二字;
* · 这里加的词 = "预定义标签",还没用过也能一点就打上;
* · 某个人身上真有哪几个标签,仍然在名册卡片上那颗标签按钮里改。
*
* ⚠️ 表单 action 仍指向 `settings/groups/*`(**一个字段都没改**)——
* 它是"我自己的词表",与它挂在哪一页、叫什么名字无关;
* 换路由只会白白多一处要维护的东西(而且老书签/脚本会失效)。
* *
* 变量(require 前设好;**入参仍用宿主那边的名字**,片段内部一律 pg* 前缀,见下): * 变量(require 前设好;**入参仍用宿主那边的名字**,片段内部一律 pg* 前缀,见下):
* $groups PersonGroup::orderFor(...) 的结果(公共 + 我的) * $groups PersonGroup::orderFor(...) 的结果(公共同我的)
* $groupCounts gid => 人数 * $groupCounts gid => 人数
* $rosterCount 我的名册人数 * $rosterCount 我的名册人数
*/ */
@@ -25,28 +42,38 @@ $pgTotal = count($pgGroups);
?> ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="section-title"> <h2 class="section-title">
名册分组 名册标签
<span class="hint">公共的 + 我自己加的</span> <span class="hint">公共的 + 我自己加的</span>
</h2> </h2>
<p class="small muted" style="margin:0 0 16px"> <p class="small muted" style="margin:0 0 16px">
名册是按「关系分组」分栏显示的。这里有两层: 这里配的是**给名册成员打标签时可以一点就选的词**。
<b>公共分组</b>由管理员在「系统设置」里维护(全家都能用); <b>公共标签</b>由管理员在「系统设置」里维护(全家都能用);
你要是觉得不够,就在下面<b>加只给自己看的分组</b>,别人看不到、也不受影响。 你要是觉得不够,就在下面<b>加只给自己看的标签</b>,别人看不到、也不受影响。
<br>
一个人可以有多个标签(他既是「亲戚」又是「球友」),所以标签不分组、不分栏。
谁身上真有哪个标签,在名册里点他那张卡片上的标签按钮就能改。
<br> <br>
列表里 <b>公共</b> 标的是大家共用的那几个。 列表里 <b>公共</b> 标的是大家共用的那几个。
哪一组排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。 哪个排前面也是你自己的习惯,用 ▲ ▼ 调 —— 别人看到的顺序不变。
<br> <br>
你的名册现在有 <b><?= $pgRosterN ?></b> 人。 你的名册现在有 <b><?= $pgRosterN ?></b> 人。
</p> </p>
<?php if (!$pgGroups): ?> <?php if (!$pgGroups): ?>
<p class="small muted" style="margin:0 0 14px">还没有分组。加一个吧。</p> <p class="small muted" style="margin:0 0 14px">还没有标签。加一个吧。</p>
<?php else: ?> <?php else: ?>
<ol class="order-list"> <ol class="order-list">
<?php foreach ($pgGroups as $i => $g): $pgGid = (int)$g['id']; $pgOwn = (int)$g['owner_id']; ?> <?php foreach ($pgGroups as $i => $g): $pgGid = (int)$g['id']; $pgOwn = (int)$g['owner_id']; ?>
<li class="order-row"> <li class="order-row">
<span class="order-no"><?= $i + 1 ?></span> <span class="order-no"><?= $i + 1 ?></span>
<span class="order-name"> <span class="order-name">
<?php /* ⚠️ 这里**不加 `#`** —— 词表里存的是**词本身**(`家人`),
`#` 只是名册里打标签时的显示习惯(`#家人`)。
在词表管理页加上 `#`,会让人以为"输入时要带 #",
而实际 `PersonTag::clean()` 会把 `#` 剥掉 —— 两处显示不一致。
🔴 v54:e2e 的顺序断言 `my_group_order()` 抓的是 `<ol class="order-list">`
里 `order-name` 的**第一个子串**,多出来的 `#` 会让它读成 `#家人`
⇒ 断言红(这也是"界面多加一个字符就可能打歪既有断言"的又一例)。 */ ?>
<?= e((string)$g['name']) ?> <?= e((string)$g['name']) ?>
<?php if ($pgOwn === 0): ?><span class="badge">公共</span><?php else: ?><span class="badge badge-open">我的</span><?php endif; ?> <?php if ($pgOwn === 0): ?><span class="badge">公共</span><?php else: ?><span class="badge badge-open">我的</span><?php endif; ?>
</span> </span>
@@ -56,9 +83,9 @@ $pgTotal = count($pgGroups);
<input type="hidden" name="id" value="<?= $pgGid ?>"> <input type="hidden" name="id" value="<?= $pgGid ?>">
<input type="hidden" name="scope" value="me"> <input type="hidden" name="scope" value="me">
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up" <button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button> title="上移一个" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down" <button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
title="下移一组" <?= $i === $pgTotal - 1 ? 'disabled' : '' ?>>▼</button> title="下移一个" <?= $i === $pgTotal - 1 ? 'disabled' : '' ?>>▼</button>
</form> </form>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
@@ -68,13 +95,13 @@ $pgTotal = count($pgGroups);
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px"> <form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="scope" value="me"> <input type="hidden" name="scope" value="me">
<label class="sr-only" for="newMyGroup">新分组名</label> <label class="sr-only" for="newMyGroup">新标签名</label>
<input class="input" id="newMyGroup" name="name" maxlength="20" placeholder="例如:球友、老同学"> <input class="input" id="newMyGroup" name="name" maxlength="20" placeholder="例如:球友、老同学">
<button class="btn btn-sm" type="submit">加一个我自己的分组</button> <button class="btn btn-sm" type="submit">加一个我自己的标签</button>
</form> </form>
<?php <?php
// 只有我自己建的分组才给改名 / 删除 —— 公共分组要在系统设置里改(那是管理员的事), // 只有我自己建的标签才给改名 / 删除 —— 公共标签要在系统设置里改(那是管理员的事),
// 免得有人在自己设置里把"家人"改成别的、影响全家。 // 免得有人在自己设置里把"家人"改成别的、影响全家。
$pgMine = array_values(array_filter($pgGroups, fn($g) => (int)$g['owner_id'] > 0)); $pgMine = array_values(array_filter($pgGroups, fn($g) => (int)$g['owner_id'] > 0));
?> ?>
@@ -86,7 +113,7 @@ $pgTotal = count($pgGroups);
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $pgGid ?>"> <input type="hidden" name="id" value="<?= $pgGid ?>">
<input type="hidden" name="scope" value="me"> <input type="hidden" name="scope" value="me">
<label class="sr-only" for="gname<?= $pgGid ?>">分组名</label> <label class="sr-only" for="gname<?= $pgGid ?>">标签名</label>
<input class="input" id="gname<?= $pgGid ?>" name="name" maxlength="20" <input class="input" id="gname<?= $pgGid ?>" name="name" maxlength="20"
value="<?= e((string)$g['name']) ?>"> value="<?= e((string)$g['name']) ?>">
<button class="btn btn-ghost btn-sm" type="submit">改名</button> <button class="btn btn-ghost btn-sm" type="submit">改名</button>
@@ -99,7 +126,12 @@ $pgTotal = count($pgGroups);
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $pgGid ?>"> <input type="hidden" name="id" value="<?= $pgGid ?>">
<input type="hidden" name="scope" value="me"> <input type="hidden" name="scope" value="me">
<span class="small muted">这组的人会变成「未分类」,确定?</span> <?php /* 🔴 v54:删词表的**成员语义变了** ——
原来它删的是"分组",挂在这一组的人会变成「未分类」;
现在它只是**从可选词里去掉这个词**,已经打在别人身上的标签**不受影响**
(标签是各自身上的字符串,不是引用)。文案必须说清,
否则用户以为"删了=把人身上的标签也摘了"。 */ ?>
<span class="small muted">只是从候选里去掉这个词,已经打在别人身上的不受影响,确定?</span>
<button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button> <button class="btn btn-ghost btn-sm danger" type="submit">确认删除</button>
</form> </form>
</details> </details>
@@ -110,7 +142,7 @@ $pgTotal = count($pgGroups);
<?php if (\App\Auth::isAdmin()): ?> <?php if (\App\Auth::isAdmin()): ?>
<p class="small muted" style="margin:16px 0 0"> <p class="small muted" style="margin:16px 0 0">
公共分组在「<a href="<?= e(url('settings')) ?>#public-groups">系统设置</a>」里维护。 公共标签在「<a href="<?= e(url('settings')) ?>#public-groups">系统设置</a>」里维护。
</p> </p>
<?php endif; ?> <?php endif; ?>
</div> </div>
+4
View File
@@ -473,6 +473,10 @@ unset($evRef);
行里还有复选框(勾一下完成)与其它链接, 行里还有复选框(勾一下完成)与其它链接,
整行可点会让"勾选"和"打开"互相抢。 */ ?> 整行可点会让"勾选"和"打开"互相抢。 */ ?>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>" <a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>"
<?php /* 🔴 v54(用户 2026-10-16 第 1 条):标题被 CSS 截成一行 + 省略号,
所以这里给 `title` —— 鼠标停上去能看全名。
(点开还有详情浮层,但"只看一眼全名"不该逼人点进去。) */ ?>
title="<?= e((string)$t['title']) ?>"
data-fb-entry='<?= fb_entry_json([ data-fb-entry='<?= fb_entry_json([
'title' => (string)$t['title'], 'title' => (string)$t['title'],
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')), 'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
+82 -25
View File
@@ -77,7 +77,19 @@ $isLoggedIn = \App\Auth::check();
$_amapJs = \App\Geo::jsConfig(); $_amapJs = \App\Geo::jsConfig();
$_needAddr = ($_amapJs['key'] !== ''); $_needAddr = ($_amapJs['key'] !== '');
?> ?>
<?php if ($_needAddr): ?> <?php /* 🔴 v54(用户 2026-10-16 第 3 条):**高德地图 SDK** 的独立登记项。
旅程模块的"地图视图"要用 `AMap.Map` / `AMap.Marker`(不需要地址联想),
所以 SDK 的加载器必须能从**不依赖 `[data-addr]`** 的入口触发。
做法:把 SDK 地址与密钥单独挂一个 `fb-amap` meta,`fb-addr` 那份**共用**
同一个加载器(见下面脚本里 `window.fbAmapSdk()`)。
⚠️ 仍然只在**配了 key** 时输出(凭据不该出现在用不到高德的页面)。
⚠️ 插件按需:地图本身**不需要** AutoComplete 插件,只有地址联想要 ——
所以两份 meta 各带自己的 `plugin`(`fb-amap` 不带插件)。 */
if ($_needAddr): ?>
<meta name="fb-amap" content="<?= e(json_encode([
'sdk' => 'https://webapi.amap.com/maps?v=2.0&key=' . rawurlencode($_amapJs['key']),
'security' => $_amapJs['security'],
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<meta name="fb-addr" content="<?= e(json_encode([ <meta name="fb-addr" content="<?= e(json_encode([
'js' => asset('/assets/js/addr_suggest.js'), 'js' => asset('/assets/js/addr_suggest.js'),
'sdk' => 'https://webapi.amap.com/maps?v=2.0&key=' . rawurlencode($_amapJs['key']) 'sdk' => 'https://webapi.amap.com/maps?v=2.0&key=' . rawurlencode($_amapJs['key'])
@@ -304,33 +316,32 @@ window.FB_NOTIFY = {
与 person_picker 的 `fbLoadPicker()` 完全同一套路。 */ ?> 与 person_picker 的 `fbLoadPicker()` 完全同一套路。 */ ?>
<script> <script>
(function () { (function () {
var meta = document.querySelector('meta[name="fb-addr"]'); /* 🔴 v54:配置源改成 `fb-amap`(**只要有高德 key 就输出**,与本页是否需要联想无关)——
if (!meta) { return; } // 没配高德 key(或本页不需要)→ 什么都不做 因为"地图 SDK"与"地址联想脚本"现在是两件独立的事(见 meta 那段的说明)。
⚠️ 兼容旧路径:万一哪天有页面只带了 `fb-addr`(老缓存 HTML),这里也能兜住。 */
var metaAmap = document.querySelector('meta[name="fb-amap"]');
var metaAddr = document.querySelector('meta[name="fb-addr"]');
var meta = metaAmap || metaAddr;
if (!meta) { return; } // 没配高德 key → 什么都不做
var conf; var conf;
try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; } try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; }
if (!conf || !conf.js || !conf.sdk) { return; } if (!conf || !conf.sdk) { return; }
var sdkReady = null; var sdkReady = null;
function loadSdk() {
if (window.__AMAP_LOADED) { return Promise.resolve(true); }
if (sdkReady) { return sdkReady; }
sdkReady = new Promise(function (res) {
var s = document.createElement('script');
s.src = conf.sdk;
s.setAttribute('data-fb-amap', '1');
s.onload = function () { window.__AMAP_LOADED = true; res(true); };
s.onerror = function () { sdkReady = null; res(false); }; // 失败不锁死,下次可重试
document.head.appendChild(s);
});
return sdkReady;
}
function loadOurs() { function loadOurs() {
if (window.fbAddr) { return Promise.resolve(true); } if (window.fbAddr) { return Promise.resolve(true); }
// 联想脚本只在 `fb-addr` 那份配置里(地图页不需要它)
var jsUrl = '';
if (metaAddr) {
try {
jsUrl = (JSON.parse(metaAddr.getAttribute('content') || '{}') || {}).js || '';
} catch (e) { jsUrl = ''; }
}
if (!jsUrl) { return Promise.resolve(false); }
return new Promise(function (res) { return new Promise(function (res) {
var s = document.createElement('script'); var s = document.createElement('script');
s.src = conf.js; s.src = jsUrl;
s.defer = true; s.defer = true;
s.onload = function () { res(true); }; s.onload = function () { res(true); };
s.onerror = function () { res(false); }; s.onerror = function () { res(false); };
@@ -338,15 +349,47 @@ window.FB_NOTIFY = {
}); });
} }
/* 安全密钥必须在 SDK **之前**设好(高德要求),否则 AutoComplete 会被拒。 */ /**
function boot() { * 🔴 v54(用户 2026-10-16 第 3 条):把"**只拉高德地图 SDK**"这一步单独暴露出来。
/* ⚠️ 安全密钥必须在 SDK **之前**设好(高德硬要求),否则 AutoComplete 会被拒。 *
放在这里而不是页面顶部:没用到地址联想的页面不该出现这两个凭据。 */ * 为什么不能只留 `fbLoadAddr()`:那个函数**顺带**会去拉 `addr_suggest.js`
* (地址联想那一整块),而旅程地图只用 SDK 自带的 `AMap.Map` / `AMap.Marker`,
* 根本不需要联想脚本 —— 让地图页白拉一个用不上的脚本是浪费。
*
* ⚠️ 安全密钥 `_AMapSecurityConfig` 必须在 SDK **之前**设好(高德硬要求,
* 否则 SDK 内的服务调用会被拒)—— 所以密钥设置放在这里、由两个入口共用。
* ⚠️ 失败**不锁死** `sdkReady`:下次调用可以重试(网络抖动不该让地图永久白屏)。
* @return Promise<boolean> SDK 是否可用(AMap 全局对象就位)
*/
window.fbAmapSdk = function () {
if (window.__AMAP_LOADED && typeof window.AMap !== 'undefined') {
return Promise.resolve(true);
}
if (sdkReady) { return sdkReady; }
/* 密钥必须在此刻(SDK 之前)设好,见上面的说明 */
if (!window._AMapSecurityConfig) { if (!window._AMapSecurityConfig) {
window._AMapSecurityConfig = { securityJsCode: conf.security || '' }; window._AMapSecurityConfig = { securityJsCode: conf.security || '' };
} }
return loadSdk().then(function () { sdkReady = new Promise(function (res) {
if (!window.__AMAP_LOADED || typeof window.AMap === 'undefined') { return false; } var s = document.createElement('script');
s.src = conf.sdk;
s.setAttribute('data-fb-amap', '1');
s.onload = function () {
window.__AMAP_LOADED = true;
res(typeof window.AMap !== 'undefined');
};
s.onerror = function () { sdkReady = null; res(false); }; // 失败不锁死,下次可重试
document.head.appendChild(s);
});
return sdkReady;
};
/* 安全密钥必须在 SDK **之前**设好(高德要求),否则 AutoComplete 会被拒。 */
function boot() {
/* ⚠️ 密钥的设置已经收进 `fbAmapSdk()`(地图与联想两个入口共用一处),
这里只需要"先 SDK、再我们的脚本"这个顺序。 */
return window.fbAmapSdk().then(function (ok) {
if (!ok) { return false; }
return loadOurs(); return loadOurs();
}); });
} }
@@ -378,6 +421,20 @@ window.FB_NOTIFY = {
</script> </script>
<script src="<?= e(asset('/assets/js/filter.js')) ?>"></script> <script src="<?= e(asset('/assets/js/filter.js')) ?>"></script>
<script src="<?= e(asset('/assets/js/preview.js')) ?>"></script> <script src="<?= e(asset('/assets/js/preview.js')) ?>"></script>
<?php /* 🔴 v54(用户 2026-10-16 第 3 条):旅程**地图**脚本,按需加载。
判据用 `#tripMapCanvas`(地图容器真实 id)—— 只有旅程列表的"地图视图"会输出它,
别的页面不该白拉这一个脚本(与 note_editor / addr_suggest 同一套规矩)。
⚠️ 它内部再按需去要**高德 SDK**(`window.fbAmapSdk()`,见上面 fb-amap 那段)——
所以这一条只管"要不要引我们自己的脚本",SDK 由脚本自己拉。 */ ?>
<script>
(function () {
if (!document.getElementById('tripMapCanvas')) { return; }
var s = document.createElement('script');
s.src = <?= json_encode(asset('/assets/js/trip_map.js')) ?>;
s.defer = true;
document.head.appendChild(s);
})();
</script>
<?php /* 🔴 v52(用户第 4 条):笔记**详情页**的分段编辑(就地改一段、不跳整页编辑器)。 <?php /* 🔴 v52(用户第 4 条):笔记**详情页**的分段编辑(就地改一段、不跳整页编辑器)。
⚠️ 只在阅读态需要它 —— 判据用 `.note-read-body[data-note-id]`(模板在阅读态才输出), ⚠️ 只在阅读态需要它 —— 判据用 `.note-read-body[data-note-id]`(模板在阅读态才输出),
编辑页没有这个元素,因此不会白拉一份脚本。 */ ?> 编辑页没有这个元素,因此不会白拉一份脚本。 */ ?>
+7 -2
View File
@@ -9,9 +9,14 @@
* ⇒ 名册页右上角从此只有两颗:**添加成员** + **☰ 设置**(设置恒在最右,全站统一)。 * ⇒ 名册页右上角从此只有两颗:**添加成员** + **☰ 设置**(设置恒在最右,全站统一)。
* 原来散着的那几颗(从已有家里人里挑 / 导入联系人 / …)全部收进这个下拉。 * 原来散着的那几颗(从已有家里人里挑 / 导入联系人 / …)全部收进这个下拉。
* *
* 🔴 v54(用户 2026-10-16 第 2 条):第二行**从「名册分组」改叫「名册标签」**。
* 用户原话:「现在应该在这个设置页面,不应该再叫「名册分组」了吧?
* 应该叫「名册标签」,是不是?应该从这里面把所有标签都读出来吧?」
* ⇒ 文案改掉;指向的仍是同一个页面(`people/settings`)与同一个词表。
*
* 两行(顺序是用户点名的): * 两行(顺序是用户点名的):
* ① 导入联系人 → `people/import` * ① 导入联系人 → `people/import`
* ② 名册分组 → `people/settings`(从个人设置搬过来的那一块) * ② 名册标签 → `people/settings`(从个人设置搬过来的那一块)
* *
* 用法(require 前 set 好): * 用法(require 前 set 好):
* $plSetCurrent 当前页 key('import'|'groups')用来高亮;'' 也行 * $plSetCurrent 当前页 key('import'|'groups')用来高亮;'' 也行
@@ -39,7 +44,7 @@ $plSetIcon = static function (string $name): string {
<summary class="btn btn-icon" title="名册设置" aria-label="名册设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary> <summary class="btn btn-icon" title="名册设置" aria-label="名册设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
<div class="menu-list"> <div class="menu-list">
<a href="<?= e(url('people/import')) ?>"<?= $plSetOn('import') ?>><?= $plSetIcon('import') ?><span>导入联系人</span></a> <a href="<?= e(url('people/import')) ?>"<?= $plSetOn('import') ?>><?= $plSetIcon('import') ?><span>导入联系人</span></a>
<a href="<?= e(url('people/settings')) ?>"<?= $plSetOn('groups') ?>><?= $plSetIcon('groups') ?><span>名册分组</span></a> <a href="<?= e(url('people/settings')) ?>"<?= $plSetOn('groups') ?>><?= $plSetIcon('groups') ?><span>名册标签</span></a>
</div> </div>
</details> </details>
<?php unset($plSetCurrent, $plSetOn, $plSetIcon); ?> <?php unset($plSetCurrent, $plSetOn, $plSetIcon); ?>
+25 -8
View File
@@ -10,6 +10,10 @@
*/ */
$report = $report ?? null; $report = $report ?? null;
$canMove = \App\Auth::canWrite(); $canMove = \App\Auth::canWrite();
/* 🔴 v54(用户 2026-10-16 第 2 条):打标签的候选 —— 设置页配的词表 ∪ 我用过的词。
⚠️ 显式给默认值(模板可能被别处 require、未必总带上这个键)。 */
$tagChoices = $tagChoices ?? [];
$groups = $groups ?? [];
?> ?>
<div class="page-head"> <div class="page-head">
<h1>导入联系人</h1> <h1>导入联系人</h1>
@@ -43,15 +47,28 @@ $canMove = \App\Auth::canWrite();
$sdMultiple = false; $sdRequired = true; $sdStage = false; $sdMultiple = false; $sdRequired = true; $sdStage = false;
require __DIR__ . '/../_stage_drop.php'; ?> require __DIR__ . '/../_stage_drop.php'; ?>
</div> </div>
<?php /* 🔴 v54(用户 2026-10-16 第 2 条):原来这里是「加进哪一组」的下拉。
用户原话:「现在已经没有分组的概念了,**全部都是标签的概念**。」
⇒ 换成**打标签**(与「添加成员」页同一个契约:`name=tags`)。
导入进来的新人会自动带上这串标签(owner=我)。 */ ?>
<div class="field"> <div class="field">
<label for="imp-group">加进哪一组</label> <label for="imp-tags">给导入的人打标签(可留空)</label>
<select class="select" id="imp-group" name="group_id"> <?php if (!empty($tagChoices)): ?>
<option value="0">先不分类</option> <div class="pt-picks pf-picks" style="margin-bottom:8px">
<?php foreach ($groups as $g): ?> <?php foreach ($tagChoices as $tg): ?>
<option value="<?= (int)$g['id'] ?>"><?= e((string)$g['name']) ?></option> <label class="pt-pick">
<?php endforeach; ?> <input type="checkbox" value="<?= e((string)$tg) ?>">
</select> <span>#<?= e((string)$tg) ?></span>
<span class="tip">只影响<b>新建</b>的那些人;已有的人保持他原来的分组。</span> </label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php /* 回显刚填的标签(导入失败重渲时不该让人重打一遍)——
`clean` 归一后再拼,输入什么分隔符都能正确回显。 */ ?>
<input class="input" id="imp-tags" name="tags" maxlength="300"
value="<?= e(implode(' ', \App\PersonTag::clean((string)($_POST['tags'] ?? '')))) ?>"
placeholder="如:同事 老同学(用空格分开)" autocomplete="off">
<span class="tip">只影响<b>新建</b>的那些人;已有的人身上原来的标签不动。</span>
</div> </div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
+15 -5
View File
@@ -24,7 +24,11 @@
*/ */
$canMove = \App\Auth::canWrite(); $canMove = \App\Auth::canWrite();
$pickable = $pickable ?? 0; $pickable = $pickable ?? 0;
// ⚠️ v54(用户 2026-10-16 第 2 条):两个变量**不是一个东西**,别合并:
// · $tagCloud —— **真有人的**标签(筛选条用;人的数为 0 的词不上筛选条)
// · $tagChoices —— **打标签的候选**(词表 ∪ 用过的词;浮层里的 checkbox 用它)
$tagCloud = $tagCloud ?? []; $tagCloud = $tagCloud ?? [];
$tagChoices = $tagChoices ?? [];
$tagFilter = $tagFilter ?? ''; $tagFilter = $tagFilter ?? '';
// v28:平铺列表,人数直接数行数(不再按分组累加) // v28:平铺列表,人数直接数行数(不再按分组累加)
$total = count($people ?? []); $total = count($people ?? []);
@@ -417,13 +421,19 @@ $renderGrid = function (array $list) use ($meId, $canMove): void {
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="person_id" value=""> <input type="hidden" name="person_id" value="">
<div class="rm-body"> <div class="rm-body">
<?php /* 已有的标签(我名下全部用过的):点一下勾选/取消 */ ?> <?php /* 🔴 v54(用户 2026-10-16 第 2 条):候选**不再是"我用过的词"**,
<?php if ($tagCloud): ?> 而是 `$tagChoices` = **设置页配的词表 ∪ 我用过的词**。
用户原话:「打标签的时候,**却不是从这些分组里读出来的**……
应该从这里面把所有标签都读出来吧?但是现在标签里面却只有几个,
也不知道是从哪儿读出来的。」
⇒ 词表里的词**还没用过**也能一点就打上 —— 这正是词表的意义
(否则每个新标签都得手打一遍,词表就等于没有)。 */ ?>
<?php if ($tagChoices): ?>
<div class="pt-picks" id="ptPicks"> <div class="pt-picks" id="ptPicks">
<?php foreach ($tagCloud as $t): ?> <?php foreach ($tagChoices as $tg): ?>
<label class="pt-pick"> <label class="pt-pick">
<input type="checkbox" value="<?= e((string)$t['tag']) ?>"> <input type="checkbox" value="<?= e((string)$tg) ?>">
<span>#<?= e((string)$t['tag']) ?></span> <span>#<?= e((string)$tg) ?></span>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
+28 -8
View File
@@ -12,6 +12,12 @@
* 那么他可以直接复用这个人的信息,就不需要再重新录入。」 * 那么他可以直接复用这个人的信息,就不需要再重新录入。」
*/ */
?> ?>
<?php
/* 🔴 v54(用户 2026-10-16 第 2 条):打标签的候选 —— 设置页配的词表 ∪ 我用过的词。
⚠️ 显式给默认值(模板可能被别处 require、未必总带上这个键)。 */
$tagChoices = $tagChoices ?? [];
$groups = $groups ?? [];
?>
<div class="page-head"> <div class="page-head">
<h1>从家里已有的人里挑</h1> <h1>从家里已有的人里挑</h1>
<span class="sub">选中的会进你的名册,资料共用一份,不会重复录入</span> <span class="sub">选中的会进你的名册,资料共用一份,不会重复录入</span>
@@ -34,14 +40,28 @@
<b>资料是共用的那一份</b>,你以后改的也是同一份。 <b>资料是共用的那一份</b>,你以后改的也是同一份。
</p> </p>
<div class="field" style="max-width:280px;margin-bottom:16px"> <?php /* 🔴 v54(用户 2026-10-16 第 2 条):原来这里是「加进哪一组」的下拉(group_id)。
<label for="pickGroup">加进哪一组</label> 用户原话:「现在已经没有分组的概念了,**全部都是标签的概念**。」
<select class="select" id="pickGroup" name="group_id"> ⇒ 换成**打标签**(与「添加成员」页同一个契约:`name=tags` + `#ptBlock` 那套)。
<option value="">先不分组(之后拖到那一栏也行)</option> 候选来自 `PersonTag::choices()`(设置页配的词表 ∪ 我用过的词),
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?> 服务端 people/pick 会把这一串标签逐个落进 person_tags(owner=我)。
<option value="<?= $gid ?>"><?= e((string)$g['name']) ?></option> ⚠️ 勾了多个人时,这串标签会**给每个人各打一遍** —— 一次挑进来的人
<?php endforeach; ?> 往往是同一类("小学同学"),所以这正是想要的默认。 */ ?>
</select> <div class="field" style="margin-bottom:16px">
<label for="pickTags">给他们打标签(可留空,之后在卡片上也能改)</label>
<?php if (!empty($tagChoices)): ?>
<div class="pt-picks pf-picks" style="margin-bottom:8px">
<?php foreach ($tagChoices as $tg): ?>
<label class="pt-pick">
<input type="checkbox" value="<?= e((string)$tg) ?>">
<span>#<?= e((string)$tg) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
<input class="input" id="pickTags" name="tags" maxlength="300"
placeholder="如:小学同学 球友(用空格分开)" autocomplete="off">
<span class="tip">一个人可以有多个标签;打上的标签只在你自己的名册里可见。</span>
</div> </div>
<?php /* 用户 2026-10-03 第 2 条:所有"找个人"的地方都要**输入即筛选**。 <?php /* 用户 2026-10-03 第 2 条:所有"找个人"的地方都要**输入即筛选**。
+11 -6
View File
@@ -1,15 +1,20 @@
<?php <?php
/** /**
* 名册设置(v52 用户 2026-10-15 第 6 条)。 * 名册设置(v52 用户 2026-10-15 第 6 条;v54 用户 2026-10-16 第 2 条改口径)。
* *
* 用户原话:「名册右侧,只保留一个添加成员按钮,右侧再加一个设置按钮, * 用户原话(v52):「名册右侧,只保留一个添加成员按钮,右侧再加一个设置按钮,
* 点击后下拉设置,第一个就是导入联系人,第二个就是**原来个人设置那里的名册分组**, * 点击后下拉设置,第一个就是导入联系人,第二个就是**原来个人设置那里的名册分组**,
* 这样**个人设置中就不显示名册分组的内容**了。」 * 这样**个人设置中就不显示名册分组的内容**了。」
* *
* ⇒ 这一页 = 「设置 → 名册分组」进去的那一页,内容就是从个人设置**整块搬**过来的 * 用户原话(v54):「现在应该在这个设置页面,**不应该再叫「名册分组」了吧?
* `_person_groups.php`(表单 action 一个字段没改,仍指向 settings/groups/*)。 * 应该叫「名册标签」**,是不是?应该从这里面把所有标签都读出来吧?」
* *
* ⚠️ 为什么不做成"个人设置那一块的副本":两份一模一样的分组管理界面, * ⇒ 这一页 = 「设置 → 名册标签」进去的那一页,内容就是从个人设置**整块搬**过来的
* `_person_groups.php`(表单 action 一个字段没改,仍指向 settings/groups/*)。
* v54 起界面文案**全面改成"标签"**:这是"预定义标签词表",
* 名册里打标签时的候选就从它读(见 `App\PersonTag::choices()`)。
*
* ⚠️ 为什么不做成"个人设置那一块的副本":两份一模一样的标签管理界面,
* 改一处忘一处是必然的(本项目在"设置页"上已经踩过这个)。 * 改一处忘一处是必然的(本项目在"设置页"上已经踩过这个)。
* 所以是**真的搬走** —— 个人设置里那一块删掉了,这里 require 同一个片段。 * 所以是**真的搬走** —— 个人设置里那一块删掉了,这里 require 同一个片段。
* *
@@ -19,7 +24,7 @@
?> ?>
<div class="page-head"> <div class="page-head">
<h1>名册设置</h1> <h1>名册设置</h1>
<span class="sub">分组</span> <span class="sub">标签</span>
<div class="actions"> <div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">返回名册</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('people')) ?>">返回名册</a>
<?php $plSetCurrent = 'groups'; require __DIR__ . '/_settings_menu.php'; ?> <?php $plSetCurrent = 'groups'; require __DIR__ . '/_settings_menu.php'; ?>
+18 -13
View File
@@ -45,27 +45,32 @@
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- ==================== 公共分组 ==================== --> <!-- ==================== 公共标签 ==================== -->
<?php <?php
/* 🔴 v54(用户 2026-10-16 第 2 条):这块从「公共分组」改口径为「公共标签」。
用户原话:「现在已经没有分组的概念了,**全部都是标签的概念**。」
⇒ 这里维护的是**公共标签词表**:名册里给成员打标签时,这些词会出现在候选里
(见 `App\PersonTag::choices()`)。表名仍叫 person_groups(见 _person_groups.php
的说明:改名要背着 PersonGroup 类与其它会话正在改的 App-API 一起动,不划算),
但界面上一律说"标签"。 */
$pubGroups = $pubGroups ?? []; $pubGroups = $pubGroups ?? [];
$pubCounts = $pubCounts ?? []; $pubCounts = $pubCounts ?? [];
$pubTotal = count($pubGroups); $pubTotal = count($pubGroups);
?> ?>
<div class="card card-pad" style="margin-top:20px" id="public-groups"> <div class="card card-pad" style="margin-top:20px" id="public-groups">
<h2 class="section-title"> <h2 class="section-title">
公共分组 公共标签
<span class="hint">全家人共用的一套</span> <span class="hint">全家人共用的一套</span>
</h2> </h2>
<p class="small muted" style="margin:0 0 16px"> <p class="small muted" style="margin:0 0 16px">
名册按「关系分组」分栏显示。这里维护的是<b>公共的那一套</b> —— 这里维护的是<b>公共标签</b> —— 建一次,全家每个人给名册成员打标签时都能拿来用。
建一次,全家每个人都能拿来用。
<br> <br>
每个人还可以在「个人设置 → 我的分组」里<b>再加只给自己看的分组</b>, 每个人还可以在「名册 → 设置 → 名册标签」里<b>再加只给自己看的标签</b>,
那部分不在这儿、也不会影响别人。挪顺序用 ▲ ▼(▲▼ 调的是"全家的默认顺序", 那部分不在这儿、也不会影响别人。挪顺序用 ▲ ▼(▲▼ 调的是"全家的默认顺序",
各人仍可在个人设置里改成自己习惯的顺序)。 各人仍可在名册设置里改成自己习惯的顺序)。
<br> <br>
删掉一个分组<b>不会删掉人</b>:名册里归到那一组的人会变成「未分类」, 删掉一个标签<b>只是从候选里去掉这个词</b>:已经打在别人身上的标签不受影响
各自重新归类即可。 (标签是各人身上记着的词,不是引用)。
</p> </p>
<?php if ($pubGroups): ?> <?php if ($pubGroups): ?>
@@ -80,9 +85,9 @@ $pubTotal = count($pubGroups);
<input type="hidden" name="id" value="<?= $gid ?>"> <input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="public"> <input type="hidden" name="scope" value="public">
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up" <button class="btn btn-ghost btn-sm" type="submit" name="dir" value="up"
title="上移一组" <?= $i === 0 ? 'disabled' : '' ?>>▲</button> title="上移一个" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down" <button class="btn btn-ghost btn-sm" type="submit" name="dir" value="down"
title="下移一组" <?= $i === $pubTotal - 1 ? 'disabled' : '' ?>>▼</button> title="下移一个" <?= $i === $pubTotal - 1 ? 'disabled' : '' ?>>▼</button>
</form> </form>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
@@ -92,9 +97,9 @@ $pubTotal = count($pubGroups);
<form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px"> <form method="post" action="<?= e(url('settings/groups/add')) ?>" class="inline-form" style="margin-top:16px">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="scope" value="public"> <input type="hidden" name="scope" value="public">
<label class="sr-only" for="newPubGroup">新分组名</label> <label class="sr-only" for="newPubGroup">新标签名</label>
<input class="input" id="newPubGroup" name="name" maxlength="20" placeholder="例如:邻居、亲家、老同事"> <input class="input" id="newPubGroup" name="name" maxlength="20" placeholder="例如:邻居、亲家、老同事">
<button class="btn btn-sm" type="submit">加一个公共分组</button> <button class="btn btn-sm" type="submit">加一个公共标签</button>
</form> </form>
<?php if ($pubGroups): ?> <?php if ($pubGroups): ?>
@@ -105,7 +110,7 @@ $pubTotal = count($pubGroups);
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $gid ?>"> <input type="hidden" name="id" value="<?= $gid ?>">
<input type="hidden" name="scope" value="public"> <input type="hidden" name="scope" value="public">
<label class="sr-only" for="pgname<?= $gid ?>">分组名</label> <label class="sr-only" for="pgname<?= $gid ?>">标签名</label>
<input class="input" id="pgname<?= $gid ?>" name="name" maxlength="20" <input class="input" id="pgname<?= $gid ?>" name="name" maxlength="20"
value="<?= e((string)$g['name']) ?>"> value="<?= e((string)$g['name']) ?>">
<button class="btn btn-ghost btn-sm" type="submit">改名</button> <button class="btn btn-ghost btn-sm" type="submit">改名</button>
+8 -6
View File
@@ -315,17 +315,19 @@ $fbCityN = count(\App\Geo::CITIES);
<?php /* 🔴 v52(用户 2026-10-15 第 6 条):「原来个人设置那里的名册分组…… <?php /* 🔴 v52(用户 2026-10-15 第 6 条):「原来个人设置那里的名册分组……
这样**个人设置中就不显示名册分组的内容**了。」 这样**个人设置中就不显示名册分组的内容**了。」
⇒ 整块「我的分组」卡片**搬到了**「名册 → 设置 → 名册分组」 ⇒ 整块卡片**搬到了**「名册 → 设置 → 名册标签」
(`templates/people/settings.php` + `templates/_person_groups.php`)。 (`templates/people/settings.php` + `templates/_person_groups.php`)。
⚠️ 是**真的搬走**,不是复制一份:两份一模一样的分组管理界面, ⚠️ 是**真的搬走**,不是复制一份:两份一模一样的管理界面,
改一处忘一处是必然的。这里只留一句指路,免得老用户找不到。 */ ?> 改一处忘一处是必然的。这里只留一句指路,免得老用户找不到。
🔴 v54(用户 2026-10-16 第 2 条):口径**从"分组"改成"标签"** ——
用户原话:「应该叫「名册标签」,是不是?」⇒ 这块指路文案也跟着改。 */ ?>
<div class="card card-pad" style="margin-top:20px"> <div class="card card-pad" style="margin-top:20px">
<h2 class="section-title">名册分组</h2> <h2 class="section-title">名册标签</h2>
<p class="small muted" style="margin:0 0 12px"> <p class="small muted" style="margin:0 0 12px">
分组改到**名册页**那边去了 —— 它本来就是"名册怎么分组"的事,与个人设置无关。 标签改到**名册页**那边去了 —— 它本来就是"给名册里的人打什么标签"的事,与个人设置无关。
</p> </p>
<div class="btn-row"> <div class="btn-row">
<a class="btn btn-ghost btn-sm" href="<?= e(url('people/settings')) ?>">去名册设置里改分组</a> <a class="btn btn-ghost btn-sm" href="<?= e(url('people/settings')) ?>">去名册设置里改标签</a>
</div> </div>
</div> </div>
+73
View File
@@ -0,0 +1,73 @@
<?php
/**
* 旅程**地图视图**的主体(v54 用户 2026-10-16 第 3 条)。
*
* 用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,**可以在地图上显示旅程数据**。
* 1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」
*
* ── TripLog 那套是怎么做的(读了它的 `templates/activities/index.php`)──
* · 用**高德 JS API**(`AMap.Map` + `AMap.Marker`);
* · 每个活动一枚标记;标记的 `content` 是自绘的"小卡片"(封面图 + 标题 + 地点),
* 同城/邻近的卡片会**环形扇开**+画引线,避免叠在一起;
* · `map.setFitView()` 自动框住所有点,但**上限 11 级**(不然只有一趟时会被
* 一路放大到街道级,那是导航视角不是"足迹"视角)。
*
* ── 本仓怎么落地(同一套思路,但数据源更规整)──
* · 坐标来源:`Trip::attachSpots()` —— 旅程下所有节点的 from/to 坐标**取重心**;
* 没有坐标就退到城市(内置离线城市表,**不发网络请求**,见那里的说明)。
* · 每趟旅程一枚标记,点它进 `trips/show`。
* · 卡片只放"标题 + 时间 + 城市",不放封面图 —— 本仓列表已经是图片卡片,
* 地图上再来一层图片会喧宾夺主(地图的作用是"看空间分布")。
* · 视野上限同样 11 级、贴合全部标记。
*
* ⚠️ **合规**:地图用高德(腾讯/高德/百度/天地图是合规白名单内),
* 绝不用 Google/OSM 等境外瓦片。key 走本仓既有配置(`App\Geo::jsConfig()`),
* 由 `layout.php` 的 `fb-amap` 按需加载 —— 本页不自己拼 SDK 地址。
* ⚠️ 容器必须有**固定高度**(这里是 `.trip-map` 的 CSS 高度):
* 高德地图在"高度为 0 的容器"里渲染出来是空白,这是最常见的坑。
* ⚠️ 没有坐标的旅程**不画**(数据里说了 `trip_spot=false`);
* 若**一个点都没有**,不渲染地图容器,直接给一句说明(免得用户对着白框发呆)。
*
* 变量:$rows(Trip::all + attachDerived + attachSpots 之后)、$total
*/
$mapPoints = [];
foreach ((array)($rows ?? []) as $row) {
if (empty($row['trip_spot'])) {
continue;
}
$mapPoints[] = [
'id' => (int)$row['id'],
'title' => (string)$row['title'],
'lng' => (float)$row['trip_lng'],
'lat' => (float)$row['trip_lat'],
'when' => \App\Trip::activitySpan($row['starts_at'] ?? null, $row['ends_at'] ?? null),
'city' => (string)($row['city'] ?? ''),
'url' => url('trips/show', ['id' => (int)$row['id']]),
];
}
?>
<?php if (!$mapPoints): ?>
<?php /* 一个坐标都没有 —— 如实说清楚"为什么没有",而不是给个空地图。
常见原因:行程里没填出发/到达地,或填了但(当时没配高德)没解析出坐标。 */ ?>
<div class="card card-pad empty" id="tripMapEmpty">
<b>还没有能在地图上显示的旅程</b>
地图要靠行程里的**出发地 / 到达地**(有坐标或能认出城市)。
去某趟旅程里加一段行程、把出发到达填上,它就会出现在这里。
</div>
<?php else: ?>
<?php /* 🔴 高度写在内联 style 上不够稳(有的主题会覆盖),
所以主要靠 `.trip-map` 的 CSS;这里只在**无 JS** 时兜一个提示。 */ ?>
<div class="trip-map-wrap">
<div class="trip-map" id="tripMapCanvas"
data-points="<?= e(json_encode($mapPoints, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<noscript><p class="trip-map-fallback">地图需要 JavaScript;用左侧的「列表」看旅程。</p></noscript>
</div>
<?php if (count($mapPoints) < (int)($total ?? 0)): ?>
<?php /* 有旅程不在图上 —— 说清楚,免得用户以为"少了几趟"。 */ ?>
<p class="small muted trip-map-hint">
有 <?= (int)($total ?? 0) - count($mapPoints) ?> 趟旅程没有可用位置(行程里没填出发/到达,或认不出城市),
没有画在地图上 —— 它们在「列表」里照常显示。
</p>
<?php endif; ?>
</div>
<?php endif; ?>
+13 -3
View File
@@ -1,8 +1,11 @@
<?php <?php
/** /**
* 旅程模块的「设置」下拉(v24 第 16 批 用户第 8 条)。 * 旅程模块的「设置」下拉(v24 第 16 批 用户第 8 条;v54 用户第 4 条加一项)。
* *
* 用户原话:「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。」 * 用户原话(v24):「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。」
*
* 用户原话(v54):「2. 在旅程的「新建旅程」**后面也加一个「设置」按钮**。
* 3. 里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」
* *
* 与其它模块同一套规矩(用户 2026-10-01 定的四条): * 与其它模块同一套规矩(用户 2026-10-01 定的四条):
* ① 按钮**没有文字**、只有图标,图标是**三条横线**; * ① 按钮**没有文字**、只有图标,图标是**三条横线**;
@@ -11,7 +14,7 @@
* ④ 除设置外其它按钮都有颜色。 * ④ 除设置外其它按钮都有颜色。
* *
* 用法(require 前 set 好): * 用法(require 前 set 好):
* $trSetCurrent 当前页 key('vehicles'|'modes'|'types'|'') * $trSetCurrent 当前页 key('vehicles'|'modes'|'types'|'display'|'')
*/ */
$trSetCurrent = (string)($trSetCurrent ?? ''); $trSetCurrent = (string)($trSetCurrent ?? '');
$trSetOn = static function (string $key) use ($trSetCurrent): string { $trSetOn = static function (string $key) use ($trSetCurrent): string {
@@ -30,6 +33,9 @@ $trSetIcon = static function (string $name): string {
. '<rect x="9" y="2" width="5" height="5" rx="1"/>' . '<rect x="9" y="2" width="5" height="5" rx="1"/>'
. '<rect x="2" y="9" width="5" height="5" rx="1"/>' . '<rect x="2" y="9" width="5" height="5" rx="1"/>'
. '<rect x="9" y="9" width="5" height="5" rx="1"/>', . '<rect x="9" y="9" width="5" height="5" rx="1"/>',
// 🔴 v54:显示方式 —— 列表 + 地图图钉两个小图形
'display' => '<path d="M2.5 4h11M2.5 8h11M2.5 12h6"/>'
. '<path d="M12.6 10.2a2.1 2.1 0 1 0-4.2 0c0 1.5 2.1 3.4 2.1 3.4s2.1-1.9 2.1-3.4z"/>',
]; ];
$d = $paths[$name] ?? ''; $d = $paths[$name] ?? '';
if ($d === '') { if ($d === '') {
@@ -43,6 +49,10 @@ $trSetIcon = static function (string $name): string {
<details class="movie-set menu"> <details class="movie-set menu">
<summary class="btn btn-icon" title="旅程设置" aria-label="旅程设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary> <summary class="btn btn-icon" title="旅程设置" aria-label="旅程设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
<div class="menu-list"> <div class="menu-list">
<?php /* 🔴 v54(第 4/5 条):显示方式**排在第一条** ——
它是这一页自己最常用的设置("我进来先看列表还是先看地图"),
下面三条(车/方式/类型)是"配一次就不动"的。 */ ?>
<a href="<?= e(url('trips/display')) ?>"<?= $trSetOn('display') ?>><?= $trSetIcon('display') ?><span>显示方式</span></a>
<a href="<?= e(url('trips/vehicles')) ?>"<?= $trSetOn('vehicles') ?>><?= $trSetIcon('car') ?><span>车辆管理</span></a> <a href="<?= e(url('trips/vehicles')) ?>"<?= $trSetOn('vehicles') ?>><?= $trSetIcon('car') ?><span>车辆管理</span></a>
<a href="<?= e(url('trips/modes')) ?>"<?= $trSetOn('modes') ?>><?= $trSetIcon('modes') ?><span>出行方式</span></a> <a href="<?= e(url('trips/modes')) ?>"<?= $trSetOn('modes') ?>><?= $trSetIcon('modes') ?><span>出行方式</span></a>
<a href="<?= e(url('trips/types')) ?>"<?= $trSetOn('types') ?>><?= $trSetIcon('types') ?><span>节点类型</span></a> <a href="<?= e(url('trips/types')) ?>"<?= $trSetOn('types') ?>><?= $trSetIcon('types') ?><span>节点类型</span></a>
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* 旅程「显示方式」设置(v54 用户 2026-10-16 第 5 条)。
*
* 用户原话:「3. 里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」
*
* ⇒ 就两项:**列表** / **地图**。选哪个,以后进「旅程」模块就默认先显示哪个。
* ⚠️ 这是**我自己的**偏好,存在 `user_settings.trip_default_view`(每人一份)——
* 与"看板颜色""天气地点"同一层,改它绝不影响家人。
* ⚠️ 名单页上的「列表 / 地图」标签页**任何时候都能手动切换**,
* 这个设置只决定"**刚进来时**看哪个"(用户说的"默认"就是这个意思)。
*
* 变量:$current('list'|'map')
*/
$current = (string)($current ?? 'list');
if (!in_array($current, ['list', 'map'], true)) {
$current = 'list';
}
?>
<div class="page-head">
<h1>显示方式</h1>
<span class="sub">打开「旅程」时默认先看哪个</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('trips')) ?>">返回旅程</a>
<?php $trSetCurrent = 'display'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">
默认视图
<span class="hint">只影响你自己</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
进「旅程」时先显示<b>列表</b>还是<b>地图</b>。这只是"默认看哪个"——
进去之后随时能用页面上的「列表 / 地图」切换,不受这里限制。
</p>
<form method="post" action="<?= e(url('trips/display_save')) ?>">
<?= csrf_field() ?>
<div class="view-choice">
<?php
/* 两个单选项做成"卡片样":图标 + 名称 + 一句说明。
⚠️ 用原生 radio(不是 JS 假按钮)—— 无 JS 也能提交,与全站口径一致。 */
$opts = [
'list' => ['列表', '一行一趟旅程,带封面与参与人。找某一趟、看详情走这里。'],
'map' => ['地图', '把去过的旅程钉在地图上看空间分布。看"都去过哪儿"走这里。'],
];
foreach ($opts as $k => $o): ?>
<label class="view-choice-item<?= $current === $k ? ' is-on' : '' ?>">
<input type="radio" name="view" value="<?= e($k) ?>" <?= $current === $k ? 'checked' : '' ?>>
<span class="vci-body">
<b><?= e($o[0]) ?></b>
<span class="small muted"><?= e($o[1]) ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存</button>
</div>
</form>
</div>
<div class="card card-pad" style="margin-top:16px">
<h2 class="cinema-h2">地图上的点是哪来的</h2>
<p class="small muted" style="margin-top:0">
地图上每趟旅程一枚标记,位置按该旅程**各段行程的出发/到达地点**取一个代表点;
没有坐标时退到**城市中心**(用内置的城市表,不联网)。
所以想让某趟旅程出现在地图上,得在它里面把行程的<b>出发地 / 到达地</b>填上。
</p>
</div>
+71 -4
View File
@@ -1,6 +1,7 @@
<?php <?php
/** /**
* 旅程列表(v12 建立;v24 第 16 批 第 9 条把术语**固定下来**)。 * 旅程列表(v12 建立;v24 第 16 批 第 9 条把术语**固定下来**;
* v54 用户 2026-10-16 第 3/4/5 条加了**地图视图**与显示方式设置)。
* *
* ⚠️ 术语(用户第 9 条原话):「活动都改名吧,改成旅程,新建旅程,旅程包含行程, * ⚠️ 术语(用户第 9 条原话):「活动都改名吧,改成旅程,新建旅程,旅程包含行程,
* 这个名字就固定下来。」 * 这个名字就固定下来。」
@@ -9,8 +10,36 @@
* *
* 时间与城市都是从各段**行程**派生出来的(第 15 批第 2 条)—— * 时间与城市都是从各段**行程**派生出来的(第 15 批第 2 条)——
* 列表里读的是 Trip::attachDerived() 挂上来的值,不是旅程自己的字段。 * 列表里读的是 Trip::attachDerived() 挂上来的值,不是旅程自己的字段。
*
* 🔴 v54(用户 2026-10-16 第 3 条):「你去读一下 TripLog 的代码,在旅程这个模块,
* 可以在地图上显示旅程数据。1. 先读一下它之前那个代码是怎么实现的,
* 在我们的旅程模块也实现一下。」
* ⇒ 这一页现在有**两个视图**:「列表」(原有)与「地图」(新,见 _map_body.php)。
* 两者是**同一份 $rows** 的两种看法,用标签页切换、不刷新整页。
* ⚠️ 地图坐标由 `Trip::attachSpots()` 挂在 $rows 上(见路由)。
* 🔴 v54(第 4/5 条):「2. 在旅程的「新建旅程」后面也加一个「设置」按钮。
* 3. 里面的设置让用户自己配置:到底**默认显示旅程列表,还是用地图显示**。」
* ⇒ 右上角加了 `_settings_menu.php`(含新的「显示方式」设置页);
* 默认看哪个由 `$tripDefaultView`(我的 user_settings)决定。
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
/* 默认视图:'list' | 'map'(来自 user_settings.trip_default_view,见路由)。
⚠️ 给个兜底常量而不是空串 —— 万一调用方没传,页面也要能正常显示列表。 */
$tripDefaultView = (string)($tripDefaultView ?? 'list');
if (!in_array($tripDefaultView, ['list', 'map'], true)) {
$tripDefaultView = 'list';
}
/* URL 上的 ?view=map 优先于个人默认("我就想现在看一眼地图"不该被设置卡住)。
⚠️ 值不合法时落回默认,避免 ?view=xxx 让页面空白。 */
$tripView = (string)($_GET['view'] ?? '');
if (!in_array($tripView, ['list', 'map'], true)) {
$tripView = $tripDefaultView;
}
$tripHasSpot = false;
foreach ((array)($rows ?? []) as $__r) {
if (!empty($__r['trip_spot'])) { $tripHasSpot = true; break; }
}
unset($__r);
?> ?>
<div class="page-head"> <div class="page-head">
<h1>旅程</h1> <h1>旅程</h1>
@@ -29,14 +58,52 @@ $canWrite = \App\Auth::canWrite();
<?php else: ?> <?php else: ?>
<span class="link-quiet">只读账号</span> <span class="link-quiet">只读账号</span>
<?php endif; ?> <?php endif; ?>
<?php /* 🔴 v54(第 4 条):「在旅程的「新建旅程」**后面**也加一个「设置」按钮。」
与其它旅程页同一份菜单(`_settings_menu.php`),设置恒排最右。 */ ?>
<?php $trSetCurrent = ''; require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
<?php /* ---- 列表主体:包一层 id,供"新建成功后**动态刷新**"换掉(用户第 2 条)---- */ ?> <?php /* ---- 视图切换(列表 / 地图)----
<div id="tripListHost"> ⚠️ 用**链接**而不是纯 JS 按钮:`?view=map` 能被书签、前进后退、无 JS 环境正确处理;
<?php $tripListBody = true; require __DIR__ . '/_list_body.php'; ?> 点链接会整页重载一次(比"两个 DOM 都渲染、JS 藏一个"更省内存,
地图那种案例尤其不该把它藏在 display:none 里 —— 高德在 0 尺寸容器里会渲染空白)。
⚠️ 只在**真有可显示的点**时才给「地图」这颗;否则点进去是一片空地图,
不如老实告诉用户"还没有能上图的位置"(他自己去填出发到达)。 */ ?>
<div class="trip-views" role="tablist">
<a class="tv-btn<?= $tripView === 'list' ? ' is-on' : '' ?>" role="tab"
aria-selected="<?= $tripView === 'list' ? 'true' : 'false' ?>"
href="<?= e(url('trips', ['view' => 'list'] + ($keyword !== '' ? ['keyword' => $keyword] : []))) ?>">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></svg>
列表
</a>
<a class="tv-btn<?= $tripView === 'map' ? ' is-on' : '' ?>" role="tab"
aria-selected="<?= $tripView === 'map' ? 'true' : 'false' ?>"
href="<?= e(url('trips', ['view' => 'map'] + ($keyword !== '' ? ['keyword' => $keyword] : []))) ?>">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.5l3.5-1.2 4 1.4 3.5-1.2v10.5l-3.5 1.2-4-1.4-3.5 1.2z"/><path d="M6 2.3v10.4M10 3.7v10.4"/></svg>
地图
<?php if (!$tripHasSpot): ?><span class="tv-dim" title="还没有能上图的位置">(0)</span><?php endif; ?>
</a>
<?php /* "把当前这个设成默认" —— 放在标签页同一行右侧。
⚠️ 只在**当前视图不是默认**时才给,否则会出现两颗功能重复的入口。 */ ?>
<?php if ($tripView !== $tripDefaultView): ?>
<a class="tv-default" href="<?= e(url('trips/display', ['set' => $tripView])) ?>"
title="以后打开旅程模块默认看这个">把「<?= $tripView === 'map' ? '地图' : '列表' ?>」设为默认</a>
<?php else: ?>
<span class="tv-default">默认看:<?= $tripView === 'map' ? '地图' : '列表' ?></span>
<?php endif; ?>
</div> </div>
<?php if ($tripView === 'map'): ?>
<?php /* ---- 地图视图 ---- */ ?>
<?php require __DIR__ . '/_map_body.php'; ?>
<?php else: ?>
<?php /* ---- 列表视图:包一层 id,供"新建成功后**动态刷新**"换掉(用户第 2 条)---- */ ?>
<div id="tripListHost">
<?php $tripListBody = true; require __DIR__ . '/_list_body.php'; ?>
</div>
<?php endif; ?>
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
<?php /* ---- 新建旅程弹框(内嵌同一份 form.php;初始关闭)---- */ ?> <?php /* ---- 新建旅程弹框(内嵌同一份 form.php;初始关闭)---- */ ?>
<?php <?php