Files
FamilyBook/templates/kin/tree.php
T
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

346 lines
16 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
/**
* 我的家谱:以我为中心,上一代 / 这一代 / 下一代所有有关系的人。
*
* v21 —— 回退到 family-chart 库(用户 2026-10-02 明确要求)。
*
* 之前自研布局叠了多层修补后位置漂移,用户决定还是交给成熟库来做。
* 数据准备要点:
* · 用 familyMap 把「上两代 / 本代 / 下两代」所有有关系的人(含姻亲旁支)全捞出来;
* · 转成 family-chart 的 Datum 格式 {id, data, rels: {parents, spouses, children}};
* · 「我」排数组第一位(库把 data[0].id 当 main_id);
* · 子女按长幼排序后写进 rels.children,库按数组顺序摆左右。
*/
$graph = $graph ?? \App\Kinship::graph((int)\App\Auth::familyId());
$people = $people ?? [];
// ---- ① 以我为中心,检索**所有有关系的人**(不设代数上限)----
// ⚠️ 用户明确要求「把以我为中心的关联数据全部取出来」。
// familyMap 的 $up/$down 只影响**代数标号**(超出就压到边界那一档),
// 遍历本身是走遍整个连通图的 —— 所以这两个值不放宽的话,
// 曾祖辈与祖辈会被压成同一代,排序就乱了。给足余量。
// ⚠️ v24 第 12 条:中心是 $focusId(默认=我)。点姻亲入口进来时,中心换成那个人,
// 这样 TA 那一侧的姻亲(岳父母等)才画得出来。
$focusId = (int)($focusId ?? $meId);
if (!isset($people[$focusId]) || !is_array($people[$focusId])) {
$focusId = (int)$meId;
}
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), $focusId, $people, 9, 9);
$gen = $map['gen'];
$units = $map['units'];
$unitOf = $map['unit_of'];
if (!isset($people[$focusId]) || !is_array($people[$focusId])) {
$canChart = false;
} else {
$onTree = array_fill_keys(array_map('intval', array_keys($gen)), true);
// ---- ② 树根 = 「我」往上走到的最高那一代 ----
//
// family-chart 画的是「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」。
// 树根 = 我 时,伯父/堂兄/嫂子/侄子 不在「我的后代」里,也不会被画出来;
// 把树根换成我的最高祖先,整棵树就变成「这位祖先的全部后代」,
// 上一代、这一代、下一代的血亲与旁支才能全部落进来。
// 代价是「我」不再是几何中心 —— 用「回到我」按钮 + 加载后自动居中补回来。
$rootId = (int)$focusId;
for ($guard = 0; $guard < 8; $guard++) {
$up = \App\Kinship::parentIds($graph, $rootId);
$next = 0;
foreach ($up as $p) {
$p = (int)$p;
if (isset($onTree[$p]) && (int)$gen[$p] < (int)$gen[$rootId]) {
$next = $p;
break;
}
}
if ($next === 0) { break; }
$rootId = $next;
}
// ---- ③ 连边:子女一律挂在「夫妻」上(normalizeLinks 的对称补齐)----
// 这样库才会把连线从夫妻横线中点垂下来,母亲登录也能看到孩子。
$links = \App\Kinship::normalizeLinks($graph, array_keys($onTree));
// ---- ④ 转成 family-chart 的 Datum 格式 ----
$rankKey = static function (int $id) use ($people): string {
return \App\Kinship::rankValueFor($people, $id);
};
// 树根排最前(库默认以 data[0].id 为 main_id);其余按「代数 → 长幼」跟上
$order = [$rootId];
$rest = [];
foreach (array_keys($onTree) as $pid) {
$pid = (int)$pid;
if ($pid !== $rootId) { $rest[] = $pid; }
}
usort($rest, static function (int $a, int $b) use ($gen, $rankKey): int {
return ((int)$gen[$a] <=> (int)$gen[$b])
?: strcmp($rankKey($a), $rankKey($b))
?: ($a <=> $b);
});
$order = array_merge($order, $rest);
$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));
};
$datum = [];
foreach ($order as $pid) {
if (!isset($people[$pid]) || !is_array($people[$pid])) { continue; }
$p = $people[$pid];
$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);
$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),
// 「我」在图上高亮;中心那个人另有标记($focusId)
'me' => $pid === (int)$focusId,
'is_me_viewer' => $pid === (int)$meId,
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
],
'rels' => [
'parents' => $parents,
'spouses' => $spouses,
'children' => $kids,
],
];
}
// v24 第 12 条:本图上「有姻亲但没画出来」的人 —— 卡片上给个入口
$branchOuts = \App\Kinship::branchOuts($graph, $people, $onTree);
foreach ($datum as &$dn) {
$did = (int)$dn['id'];
if (isset($branchOuts[$did])) {
$dn['data']['branch'] = $branchOuts[$did]['name'];
}
}
unset($dn);
$canChart = count($datum) > 1;
}
$meName = (string)($people[$meId]['name'] ?? '自己');
$datumJson = json_encode(
$datum ?? [],
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
?>
<div class="page-head">
<h1><?= $focusId === (int)$meId ? '我的家谱' : e((string)($people[$focusId]['name'] ?? '') . ' 的家谱') ?></h1>
<span class="sub">
<?= $focusId === (int)$meId
? '以我为中心 · 夫妻并排,子女从两人之间的连线连出'
: '以 TA 为中心(这样 TA 那一侧的姻亲才露得出来)' ?>
</span>
<div class="actions">
<?php if ($focusId !== (int)$meId): ?>
<?php /* v24 第 12 条:从姻亲入口进来的,给一个返回,回到「我的家谱」 */ ?>
<a class="btn btn-ghost" href="<?= e(url('kin')) ?>">← 返回我的家谱</a>
<?php endif; ?>
</div>
</div>
<?php if (!$canChart): ?>
<div class="empty">
<b>还没连上任何亲属关系</b>
到「<?= e($meName) ?>」的资料页,把父母、配偶、子女填进去,
这张家谱就会自动长出来。兄弟姐妹不用单独填 —— 只要你们有同一对父母,就会自动认出来。
</div>
<?php else: ?>
<div class="kin-tools">
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
<?php /* 中心是别人时这颗按钮是"跳回我的家谱"(不是在本图里找我的坐标) */ ?>
<button type="button" class="btn btn-ghost btn-sm" id="kinMe"><?= $focusId === (int)$meId ? '回到我' : '回到我的家谱' ?></button>
</div>
<!-- ⚠️ `f3` 这个类名是**库的 CSS 作用域**,不是我们起的名字。
官方示例就是 `<div id="FamilyChart" class="f3">` + 引 family-chart.css;
少了它,库里 `.f3 svg.main_svg{width:100%;height:100%}` 这条撑满规则不生效,
SVG 会退化成浏览器默认的 300×150(连线全被裁掉,而 HTML 卡片层不受影响 →
表现为「卡在、线没了」)。 -->
<div class="kin-chart f3" id="kinChart" data-datum="<?= e($datumJson) ?>"
data-focus="<?= e((string)$focusId) ?>" data-me-viewer="<?= e((string)$meId) ?>"></div>
<p class="tree-legend">
每代一行;<b>一对夫妻之间有一条短横线</b>,
<b>他们的子女一律从这条横线的正中间垂下来</b>(不管是谁登录,孩子都挂在这对夫妻上)。
「待确认」= 只填了称呼、还没对应到真实的人(虚线卡片)。
可以**拖拽平移**、**滚轮缩放**;点「回到我」把镜头挪回你自己,点卡片进对应成员页。
<br><span class="muted">注:本图由 family-chart 绘制。免费版只画「同一位祖先的血脉后代 + 各人配偶」,
妻子那侧的岳父母、舅子等姻亲不在图内(在名册与成员详情页仍可看到)。</span>
</p>
<!-- 官方用法是「容器带 class="f3" + 引 family-chart.css」(见 family-chart 官方示例)。
之前没引这份 CSS,靠自己在 app.css 里手补 `.kin-chart svg.main_svg{width/height:100%}`
绕过去 —— 属于"调用方法不对"。现在按官方来。
库的深色主题(--male-color / --background-color…)在 app.css 里覆盖成家书的宣纸色。 -->
<link rel="stylesheet" href="<?= e(asset('/assets/vendor/family-chart/family-chart.css')) ?>">
<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 window.f3 === 'undefined') { return; }
var raw;
try {
raw = JSON.parse(root.getAttribute('data-datum') || '[]');
} catch (err) {
raw = [];
}
if (!raw.length) { return; }
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(190)
.setCardYSpacing(180);
chart.setCardHtml()
.setStyle('rect')
.setCardDim({ w: 148, h: 138, mini_tree: false })
.setCardInnerHtmlCreator(function (d) {
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' : '');
// v24 第 12 条:头像改**方形**(原来是圆角矩形 + cover,把脸裁掉了)。
// 卡片本身是 <a> 进成员页;有姻亲没画出来的,再叠一个独立的小按钮
// (用 <span role=button> 而不是 <a>:<a> 不能套 <a>)。
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>' : '')
+ (p.branch
? '<span class="kin-branch" role="button" tabindex="0" title="看看 '
+ esc(p.branch) + ' 那一侧的家谱" data-branch-id="' + esc(id) + '">⇄ 姻亲</span>'
: '')
+ '</a>';
});
chart.editTree().setCanAdd(false);
chart.setSingleParentEmptyCard(false);
chart.updateTree({ initial: true, tree_position: 'fit' });
window.kinChart = chart;
// family-chart 的 zoom 行为绑在 #f3Canvas 上,SVG 与 HTML 卡片层由库的 onZoom 同步。
// 必须拿到 f3Canvas 才能正确驱动镜头;直接操作 svg.__zoomObj 或只动 svg 都会让
// HTML 卡片层不同步。
var f3Canvas = root.querySelector('#f3Canvas');
function zoomObj() {
if (!f3Canvas) { f3Canvas = root.querySelector('#f3Canvas'); }
// __zoomObj 挂在 #f3Canvas 上;库的 d() helper 也会往父节点找
var el = f3Canvas && f3Canvas.__zoomObj ? f3Canvas
: (f3Canvas && f3Canvas.parentNode && f3Canvas.parentNode.__zoomObj ? f3Canvas.parentNode : null);
return el ? el.__zoomObj : null;
}
function applyTransform(t) {
var z = zoomObj(), d3 = window.d3;
if (!z || !d3 || !f3Canvas) return;
d3.select(f3Canvas).call(z.transform, t);
}
// 把镜头挪到指定人:用库算出的树坐标,固定比例 0.9,避免「看全部」时缩得太小
function centerOn(id) {
var d3 = window.d3;
if (!d3) return;
var tree = chart.store.getTree();
if (!tree || !tree.data) return;
var node = tree.data.find(function (n) { return n.data && n.data.id === id; });
if (!node) return;
var rr = root.getBoundingClientRect();
var k = 0.9;
var tx = rr.width / 2 - node.x * k;
var ty = rr.height / 2 - node.y * k;
// d3 zoom 的 {x,y} 是「缩放后再平移」的屏幕像素偏移,
// 所以先 translate(tx,ty) 再 scale(k) 才能得到 screen = node * k + offset。
applyTransform(d3.zoomIdentity.translate(tx, ty).scale(k));
}
window.kinCenterOn = centerOn;
var focusId = root.getAttribute('data-focus') || '';
var meId = root.getAttribute('data-me-viewer') || focusId;
// ⚠️ 中心是别人时,「回到我」要跳到 focus=我 的图,而不是在图里找我的坐标
var centerTarget = root.getAttribute('data-me-viewer') || focusId;
// fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心)
if (meId) {
setTimeout(function () { centerOn(focusId); }, 900);
// 万一 zoom 对象还没初始化好,再补一次
setTimeout(function () { centerOn(focusId); }, 1400);
}
var btnFit = document.getElementById('kinFit');
var btnMe = document.getElementById('kinMe');
if (btnFit) {
btnFit.addEventListener('click', function () {
chart.updateTree({ tree_position: 'fit' });
});
}
/* v24 第 12 条:卡片右下角的「⇄ 姻亲」按钮 —— 以那个人为中心重画一张家谱。
⚠️ 这个 span 在卡片的 <a> 里,必须 preventDefault + stopPropagation,
否则会先触发外面那个链接跳去成员页。 */
Array.prototype.forEach.call(root.querySelectorAll('.kin-branch'), function (btn) {
function go(e) {
e.preventDefault();
e.stopPropagation();
var bid = btn.getAttribute('data-branch-id') || '';
if (bid) { window.location.href = '/index.php?r=kin&focus=' + encodeURIComponent(bid); }
}
btn.addEventListener('click', go);
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { go(e); }
});
// 键盘/触摸都别让它冒泡到外层链接
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
});
if (btnMe && meId) {
btnMe.addEventListener('click', function () {
// 图是别人的家谱时,先跳回「我的家谱」再居中
if (focusId !== meId) {
window.location.href = '/index.php?r=kin';
return;
}
centerOn(centerTarget);
});
}
})();
</script>
<?php endif; ?>