Files
FamilyBook/templates/kin/tree.php
T
laoyang 71a7ec35a0 v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
2026-10-02 09:28:07 +08:00

328 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 我的家谱:以我为中心,向上向下各三代。
*
* **v13 —— 改用成熟的第三方库 family-chart(D3 生态,MIT)来画。**
*
* 为什么放弃自研(v12 的「单一网格」也救不回来):
* v11「每代各一个网格」的列宽由内容决定,各代对不齐;v12 收成「整棵树一个 grid」,
* 几何上确实对齐了,但仍有一个**结构性死结**:连线的横杆是**网格项**,只能横跨
* 「从父母那一列到子女那一列」的整段列区间。于是只要中间夹着**跟这条血缘无关**的人
* (最典型的是配偶带进来的岳父母),横杆就会**从他们卡片中间穿过去**。
* 这不是调样式能修的 —— 横杆的跨度由列位决定,而列位是为「同代并排」服务的,
* 两套诉求在同一张网格里没法同时满足。
*
* 换成库之后:
* · 布局(谁放哪、怎么折线、怎么避让)全部交给库的树算法,不再由我们用网格硬凑;
* · 库只画**本家血脉 + 配偶**,不画配偶的父母 —— 这正是「横杆穿卡片」的根源被消掉的原因,
* 语义上也算合理(姻亲本来就不是本家的血亲,v11 起就单独标着「姻亲」两个字)。
* · 自带拖拽平移 / 滚轮缩放,家里人多了不用我们做横向滚动。
*
* ⚠️ 接入时踩过的三个坑(都写进注释,别再踩):
* ① 库把**数据数组里的第一个人**当默认"主人物"(createChart 里 `main_id = data[0].id`),
* 所以「我」必须排在数组最前面,否则整棵树会以别人为中心。
* ② 卡片回调拿到的是 `TreeDatum`,它 `.data` 是**整个 Datum**({id,data,rels}),
* 我们自己塞的字段在 **`d.data.data.xxx`**(双层)。写一层就得到空白卡片。
* ③ 库的 family-chart.css **不必引入**:实测库里那些规则**全部挂在 `.f3` 下**,
* 而运行时并不会生成任何 `.f3` 元素(卡片的定位全靠 inline style:
* `.card_cont{position:absolute;transform:translate(...)}`、
* `.cards_view{transform:scale(...)}`)—— 引进来也是死文件,反而多了 16KB。
* 真正需要的三条(svg 撑满、连线颜色、卡片观感)我们自己写,见 app.css 的 `.kin-chart`。
*
* 称呼、头像、「本人」高亮、待确认这些语义都由 App\Kinship::label() / person 记录决定,一字未改。
*/
$nodes = $tree['nodes'];
$me = $nodes[$meId] ?? null;
// ---- v14:先圈定「上 / 本 / 下三代里所有有关系的人」----
//
// ⚠️ 这是本轮重写的核心(用户第 5 条:「显示不齐全…以本人为中心去搜索,只要有关系的
// 都检索出来进行展示」)。旧写法把**全名册**塞给库,看着多,其实库只画
// 「从 main_id 出发的层次结构」,横向的堂表亲、配偶那边的旁支照样进不来,
// 而树上又挂了一堆根本没关系的人(看着像"家谱里混进了外人")。
// 现在先自己按**代数**筛选(focusMap),再交给库 —— 库拿到的人就是该出现的人。
$focusGen = \App\Kinship::focusMap($graph, (int)$meId, $people, 2, 2);
if (!isset($focusGen[(int)$meId])) {
$focusGen[(int)$meId] = 0;
}
$focusIds = array_map('intval', array_keys($focusGen));
// ---- 树根 = 「我」往上走到的最高那一代 ----
//
// ⚠️⚠️ 这是 v14 最关键的一处实测结论(_fb_kin_probe.py / probe3 跑出来的):
// family-chart 画的是「**树根**的祖先链 + 后代 + 树根的兄弟姐妹 + 各人的配偶」。
// 树根 = 我 时,**伯父 / 堂兄 / 嫂子 / 侄子 一个都不画**(实测 12 人只画出 8 人)——
// 因为堂兄是"我伯父的孩子",不在"我的后代"里,也不是"我的兄弟姐妹"。
// 把树根换成**我的最高祖先**,整棵树就变成"这位祖先的全部后代",
// 于是上一代(伯叔姑舅姨)、这一代(堂表亲)、下一代(侄甥)**全部**落进树里
// (实测 12 人全画出,且按辈分正确分层)。
// 这正是用户说的「显示不齐全…以本人为中心去搜索,只要有关系的都检索出来」。
//
// ⚠️ 代价是"我"不再是几何中心 —— 用下面的「定位到我」按钮 + 加载后自动居中补回来。
$rootId = (int)$meId;
for ($guard = 0; $guard < 8; $guard++) {
$up = \App\Kinship::parentIds($graph, $rootId);
$next = 0;
foreach ($up as $p) {
$p = (int)$p;
if (isset($focusGen[$p]) && (int)$focusGen[$p] < (int)$focusGen[$rootId]) {
$next = $p;
break;
}
}
if ($next === 0) {
break;
}
$rootId = $next;
}
// ---- 连边:子女一律挂在「夫妻」上(normalizeLinks 的对称补齐)----
// 见 App\Kinship::normalizeLinks 的注释:孩子同时挂到父母双方、双方也都有这个孩子,
// 库才会把连线从**夫妻之间那条横线**的中点拉下来 —— 否则就是"从父亲一个人身上
// 垂下来一根线",母亲那条线永远连不到孩子(用户点名的重大 bug)。
$links = \App\Kinship::normalizeLinks($graph, $focusIds);
// 同一代里按长幼排(长子左、次子右);长幼键取不到时退到 id,保证顺序稳定
$rankKey = static function (int $id) use ($people): string {
return \App\Kinship::rankValueFor($people, $id);
};
// ---- 转成 family-chart 需要的 Datum 格式 ----
// ⚠️ **树根**排最前(见上面坑①:`main_id = data[0].id`)。其余按「代数 → 长幼」跟上。
$order = [$rootId];
$rest = [];
foreach ($focusIds as $pid) {
if ((int)$pid !== $rootId) {
$rest[] = (int)$pid;
}
}
usort($rest, static function (int $a, int $b) use ($focusGen, $rankKey): int {
return ((int)$focusGen[$a] <=> (int)$focusGen[$b])
?: strcmp($rankKey($a), $rankKey($b))
?: ($a <=> $b);
});
$order = array_merge($order, $rest);
$onTree = array_fill_keys($focusIds, true);
$datum = [];
foreach ($order as $pid) {
if (!isset($people[$pid]) || !is_array($people[$pid])) {
continue;
}
$p = $people[$pid];
$mapIds = static function (array $ids) use ($onTree): array {
$out = [];
foreach ($ids as $x) {
$x = (int)$x;
if (isset($onTree[$x])) {
$out[] = (string)$x;
}
}
return array_values(array_unique($out));
};
$parents = $mapIds($links['parents'][$pid] ?? []);
$spouses = $mapIds($links['spouses'][$pid] ?? []);
$kids = $mapIds($links['children'][$pid] ?? []);
// 子女按长幼排:库按 rels.children 的**数组顺序**摆放(它不是自己排序的),
// 顺序错了就是"弟弟排在哥哥左边"。
$kidsInt = array_map('intval', $kids);
usort($kidsInt, static fn(int $a, int $b): int => strcmp($rankKey($a), $rankKey($b)) ?: ($a <=> $b));
$kids = array_map('strval', $kidsInt);
// 库认 M / F / U 三种(决定卡片底色)
$g = strtolower((string)($p['gender'] ?? ''));
$gender = $g === 'm' ? 'M' : ($g === 'f' ? 'F' : 'U');
$datum[] = [
'id' => (string)$pid,
'data' => [
'gender' => $gender,
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people),
'avatar' => avatar_url($p['avatar'] ?? null),
'me' => $pid === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
],
'rels' => [
'parents' => $parents,
'spouses' => $spouses,
'children' => $kids,
],
];
}
// ⚠️ 名字是用户自己输入的:拼 HTML 前必须转义,否则有人把名字填成一段「能提前闭合
// 脚本块」的标签,就能从脚本里逃出去、把整页结构打断。
// ⚠️ 这里**刻意不写出那个闭合标签的字面量** —— 静态检查(check_inline_js.py)
// 是按原文找脚本块的,注释里写出来会被它当成真的脚本段去解析,报假错。
$datumJson = json_encode(
$datum,
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
$canChart = count($datum) > 1;
?>
<div class="page-head">
<h1>我的家谱</h1>
<span class="sub">以我为中心 · 上一代 / 这一代 / 下一代 · 夫妻并排,子女从两人之间的横线连出</span>
</div>
<?php if (!$canChart): ?>
<div class="empty">
<b>还没连上任何亲属关系</b>
到「<?= e($me['name'] ?? '自己') ?>」的资料页,把父母、配偶、子女填进去,
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
</div>
<?php else: ?>
<?php
/* 家谱画布。
高度用 clamp 给:太矮时三代人挤成一团,太高又白占屏。
⚠️ 库初始化那一刻**容器必须已经有真实尺寸**:它按容器宽高算 fit 缩放,
还在 0×0 时初始化会一路缩到 0.4 倍,卡片小到看不清(实测踩过)。 */
?>
<div class="kin-tools">
<?php /* 树根是「最高祖先」之后,整棵树可能很大 —— 这两个按钮把「看全部」与
「回到我」分开,不然每次进来都要手动拖半天才能找到自己。 */ ?>
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button>
</div>
<div class="kin-chart" id="kinChart" data-datum="<?= e($datumJson) ?>"
data-me="<?= e((string)$meId) ?>"></div>
<p class="tree-legend">
画的是**以我的最高祖先为根、本家血脉的整棵后代树**:父母与伯叔姑舅姨(上一代)、
我与兄弟姐妹和堂表亲及各自的配偶(这一代)、他们的孩子(下一代)都在上面。
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
夫妻并排,**子女一律从两人之间的横线连出** —— 不管是谁登录,孩子都挂在这对夫妻上。
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
</p>
<script src="<?= e(asset('/assets/vendor/family-chart/d3.min.js')) ?>"></script>
<script src="<?= e(asset('/assets/vendor/family-chart/family-chart.min.js')) ?>"></script>
<script>
(function () {
var root = document.getElementById('kinChart');
if (!root || typeof f3 === 'undefined') { return; }
var raw;
try {
raw = JSON.parse(root.getAttribute('data-datum') || '[]');
} catch (err) {
raw = [];
}
if (!raw.length) { return; }
// 名字是用户输入 —— 插进 innerHTML 前必须转义(库只负责摆放,不管转义)
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
var chart = f3.createChart('#kinChart', raw)
.setTransitionTime(600)
.setOrientationVertical()
.setShowSiblingsOfMain(true)
.setCardXSpacing(140)
.setCardYSpacing(140);
chart.setCardHtml()
.setStyle('rect')
.setCardDim({ w: 116, h: 104, mini_tree: false })
.setCardInnerHtmlCreator(function (d) {
// ⚠️ 双层:d.data 是整个 Datum,自定义字段在 d.data.data(见文件头坑②)
var p = (d.data && d.data.data) || {};
var id = (d.data && d.data.id) || '';
var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : '');
// ⚠️ 卡片本身就是个 <a>,直接跳成员页 —— **不要**去找库的点击回调。
// 踩过:写了 chart.setOnCardClick(...),而 chart 上根本没有这个方法
// (抛 TypeError),异常把后面的 updateTree() 一起带走了 →
// 整张家谱**一张卡都不画**(页面 200、容器有尺寸、看着"正常")。
// 用 <a> 件不依赖任何库 API,也不怕库版本变了改签名。
return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id='
+ encodeURIComponent(id) + '">'
+ '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
+ '<span class="kin-nm">' + esc(p.name) + '</span>'
+ '<span class="kin-lb">' + esc(p.label) + '</span>'
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '')
+ '</a>';
});
// ⚠️⚠️ 关掉库自带的「+ 添加亲属」空卡。
// **setCanAdd 不在 chart 上,在 chart.editTree() 返回的对象上**(实测:
// chart 的原型上是 calculateKinships / updateTree / setCardHtml ...,
// 没有 setCanAdd;editTree() 返回的编辑树对象上才有)。
// 写成 chart.setCanAdd(false) 会抛 TypeError,异常把后面的 updateTree() 一起
// 带走 → 整张图**一张卡都不画**(页面照样 200、容器照样有尺寸,光看页面像"正常")。
// 这个坑在本文件里连着犯了三次(setOnCardClick / setCanAdd 链式调用 / 这一处),
// 所以:**新增库调用前先把方法名在浏览器里打印出来确认,别照文档或习惯猜。**
// 为什么要关:家书不从家谱图上改关系(都在各人资料页里改,那里有权限与校验)。
// 不关的话树上会多一张没有名字的空卡(data-id 是库现生成的 uuid、
// 内容为空、点了跳空页),还会因为排在已婚的「我」旁边而像"多出一个配偶"。
chart.editTree().setCanAdd(false);
// ⚠️ 关掉「单亲补空卡」。库默认(single_parent_empty_card: true)会在
// 「某个孩子只登记了一位父母」时,**自动给那位父母补一个配偶占位卡**,
// 好让孩子在图上"看着有两位父母"。实测:测试夹具里有个孩子**只登记了父亲**
// (没登记母亲),库里就凭空造了一张**没有名字、没有称谓、点了跳空页**的卡
// 挂在父亲旁边 —— 可在用户眼里这就是"我凭空多出一个配偶",比"缺一位家长"糟得多。
// 家书本来就有自己的占位语义(真实库记录 + 「待确认」角标),不需要库再补。
// ⚠️ 这个方法名在 chart 原型上(实测确认过),与 setCanAdd 不在同一处。
chart.setSingleParentEmptyCard(false);
// initial:true 才会画出第一版;fit 让整棵树自适应容器大小
chart.updateTree({ initial: true, tree_position: 'fit' });
window.kinChart = chart;
// ---- 平移 / 居中 ----
// 库自带拖拽与滚轮缩放,但**没有**"定位到某个人"的 API(method 清单里没有)。
// 好在缩放是标准 d3.zoom,它把对象挂在 svg 元素上(__zoomObj / __zoom),
// 直接拿它改 transform 就行 —— 不用碰库的内部状态。
// ⚠️ transform 的平移量是**屏幕像素**:想把某张卡挪到容器中心,
// 就是把它当前的屏幕中心与容器中心之差加到 t.x / t.y 上,k 不动。
var svg = root.querySelector('svg.main_svg');
function currentTransform() {
if (!svg) return null;
return svg.__zoom || null;
}
function applyTransform(t) {
if (!svg || !svg.__zoomObj || !window.d3) return;
window.d3.select(svg).call(svg.__zoomObj.transform, t);
}
function centerOn(id) {
var el = root.querySelector('.kin-card[data-kin-id="' + id + '"]');
if (!el || !window.d3) return;
var t = currentTransform();
if (!t) return;
var rr = root.getBoundingClientRect();
var er = el.getBoundingClientRect();
var dx = (rr.left + rr.width / 2) - (er.left + er.width / 2);
var dy = (rr.top + rr.height / 2) - (er.top + er.height / 2);
applyTransform(window.d3.zoomIdentity.translate(t.x + dx, t.y + dy).scale(t.k));
}
window.kinCenterOn = centerOn;
var meId = root.getAttribute('data-me') || '';
// 库是「过渡」出来的:卡片位置要等动画停了才准。等一小会儿再居中。
if (meId) { setTimeout(function () { centerOn(meId); }, 700); }
var btnFit = document.getElementById('kinFit');
var btnMe = document.getElementById('kinMe');
if (btnFit) {
btnFit.addEventListener('click', function () {
chart.updateTree({ tree_position: 'fit' });
});
}
if (btnMe && meId) {
btnMe.addEventListener('click', function () { centerOn(meId); });
}
})();
</script>
<?php endif; ?>