Files
tangfamily/public/tree.html
2026-06-24 22:13:01 +08:00

453 lines
18 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>唐氏族谱</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background: #f0f7ee;
min-height: 100vh;
}
.topbar {
background: #fff;
border-bottom: 2px solid #c5e1a5;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.topbar h1 { font-size: 20px; color: #558b2f; letter-spacing: 2px; }
.topbar-right { display: flex; gap: 8px; }
.tbtn {
padding: 6px 14px; border-radius: 6px; border: 1.5px solid #c5e1a5;
background: #f1f8e9; color: #558b2f; font-size: 13px; cursor: pointer;
transition: background .2s;
}
.tbtn:hover { background: #dcedc8; }
.tbtn.primary { background: #8bc34a; color: #fff; border-color: #8bc34a; }
.tbtn.primary:hover { background: #7cb342; }
/* 树图区域 */
#treeWrap {
padding: 30px 20px 60px;
overflow-x: auto;
min-height: calc(100vh - 52px);
}
#treeCanvas {
position: relative;
margin: 0 auto;
}
svg.lines {
position: absolute;
top: 0; left: 0;
pointer-events: none;
overflow: visible;
}
/* 代际行 */
.gen-row {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: nowrap;
margin-bottom: 0;
}
/* 成员卡片 */
.node {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
}
.card-box {
background: #fff;
border: 2px solid #c5e1a5;
border-radius: 10px;
padding: 7px 14px;
text-align: center;
cursor: default;
transition: box-shadow .2s, border-color .2s;
min-width: 72px;
white-space: nowrap;
}
.card-box:hover { box-shadow: 0 4px 16px rgba(139,195,74,.25); border-color: #8bc34a; }
.card-box.male { border-color: #90caf9; background: #f5fbff; }
.card-box.female { border-color: #f48fb1; background: #fff5f8; }
.card-name { font-size: 15px; font-weight: 700; color: #2e4a1a; }
.card-year { font-size: 12px; color: #888; margin-top: 1px; }
.loading { text-align: center; padding: 80px 0; color: #8bc34a; font-size: 18px; }
.empty { text-align: center; padding: 80px 0; color: #bbb; font-size: 16px; }
@media (max-width: 600px) {
.topbar h1 { font-size: 16px; }
.tbtn { padding: 5px 10px; font-size: 12px; }
#treeWrap { padding: 16px 6px 40px; }
.card-name { font-size: 13px; }
.card-year { font-size: 11px; }
.card-box { padding: 5px 10px; min-width: 58px; }
}
</style>
</head>
<body>
<div class="topbar">
<h1>唐氏族谱</h1>
<div class="topbar-right">
<button class="tbtn" id="adminBtn" style="display:none" onclick="location.href='/admin.html'">后台管理</button>
<button class="tbtn" onclick="location.reload()">刷新</button>
</div>
</div>
<div id="treeWrap">
<div id="treeCanvas"><div class="loading">加载中...</div></div>
</div>
<script>
const token = localStorage.getItem('token');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
if (!token) location.href = '/';
if (isAdmin) document.getElementById('adminBtn').style.display = 'inline-block';
// ── 辈分顺序表index = 行顺序,越小越靠上)─────────────────
const GEN_ORDER = [
'鼻祖','远祖','太祖','烈祖','天祖',
'高祖','曾祖','祖父','父亲','自己',
'子女','孙辈','曾孙','玄孙','来孙',
'晜孙','仍孙','云孙','耳孙'
];
// ── 布局常量 ──────────────────────────────────────────
const NODE_W = 88; // 卡片宽
const NODE_H = 48; // 卡片高
const H_GAP = 28; // 水平间距
const V_GAP = 70; // 代际间距
async function load() {
try {
const res = await fetch('/api/members', { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 401) { localStorage.clear(); location.href = '/'; return; }
const members = await res.json();
const showYear = localStorage.getItem('showYear') !== 'false';
render(members, showYear);
} catch { document.getElementById('treeCanvas').innerHTML = '<div class="empty">加载失败,请刷新重试</div>'; }
}
function render(members, showYear) {
const canvas = document.getElementById('treeCanvas');
if (!members || members.length === 0) {
canvas.innerHTML = '<div class="empty">暂无族谱数据,请前往后台添加成员</div>';
return;
}
// id → member 映射
const map = {};
members.forEach(m => map[m.id] = m);
// ── 谁有孩子 ────────────────────────────────────────
const hasChild = new Set();
members.forEach(m => {
if (m.fatherId && map[m.fatherId]) hasChild.add(m.fatherId);
if (m.motherId && map[m.motherId]) hasChild.add(m.motherId);
});
// ── 子女列表(去重)────────────────────────────────
const childrenOf = {};
members.forEach(m => {
if (m.fatherId && map[m.fatherId]) (childrenOf[m.fatherId] = childrenOf[m.fatherId] || []).push(m.id);
if (m.motherId && map[m.motherId]) (childrenOf[m.motherId] = childrenOf[m.motherId] || []).push(m.id);
});
Object.keys(childrenOf).forEach(pid => { childrenOf[pid] = [...new Set(childrenOf[pid])]; });
// ── 确定每个成员的行 rank ────────────────────────────
// 优先 generation 字段;否则 BFS 从有 generation 的成员向下推算;
// 最终孤立成员默认放 "自己"rank=9000
const rankOf = {};
members.forEach(m => {
if (m.generation) {
const idx = GEN_ORDER.indexOf(m.generation);
rankOf[m.id] = (idx >= 0 ? idx : 9) * 1000;
}
});
const bfsQueue = Object.keys(rankOf);
const visitedBFS = new Set(bfsQueue);
let qi = 0;
while (qi < bfsQueue.length) {
const pid = bfsQueue[qi++];
(childrenOf[pid] || []).forEach(cid => {
const needed = Math.max(
map[cid].fatherId && rankOf[map[cid].fatherId] !== undefined ? rankOf[map[cid].fatherId] + 1000 : 0,
map[cid].motherId && rankOf[map[cid].motherId] !== undefined ? rankOf[map[cid].motherId] + 1000 : 0
);
if (rankOf[cid] === undefined || needed > rankOf[cid]) rankOf[cid] = needed;
if (!visitedBFS.has(cid)) { visitedBFS.add(cid); bfsQueue.push(cid); }
});
}
members.forEach(m => { if (rankOf[m.id] === undefined) rankOf[m.id] = 9 * 1000; });
// 压缩 rank → 连续行号
const uniqueRanks = [...new Set(Object.values(rankOf))].sort((a, b) => a - b);
const rankToRow = {};
uniqueRanks.forEach((r, i) => { rankToRow[r] = i; });
const rowOf = {};
members.forEach(m => { rowOf[m.id] = rankToRow[rankOf[m.id]]; });
// ── 家族单元与生物学排序 ─────────────────────────────
const families = {};
const familyKeyOfChild = {};
members.forEach(child => {
const fatherId = child.fatherId && map[child.fatherId] ? child.fatherId : '';
const motherId = child.motherId && map[child.motherId] ? child.motherId : '';
if (!fatherId && !motherId) return;
const key = `${fatherId}|${motherId}`;
familyKeyOfChild[child.id] = key;
if (!families[key]) families[key] = { fatherId: fatherId || null, motherId: motherId || null, children: [] };
families[key].children.push(child.id);
});
const familyOrderList = Object.keys(families)
.map(key => {
const fam = families[key];
const fatherYear = fam.fatherId ? map[fam.fatherId].birthYear : -Infinity;
const motherYear = fam.motherId ? map[fam.motherId].birthYear : -Infinity;
return { key, orderYear: Math.max(fatherYear, motherYear) };
})
.sort((a, b) => b.orderYear - a.orderYear || a.key.localeCompare(b.key));
const familyOrderIndex = {};
familyOrderList.forEach((item, idx) => { familyOrderIndex[item.key] = idx; });
const parentFamilyIndices = {};
Object.keys(families).forEach(key => {
const fam = families[key];
const idx = familyOrderIndex[key];
if (fam.fatherId) (parentFamilyIndices[fam.fatherId] = parentFamilyIndices[fam.fatherId] || []).push(idx);
if (fam.motherId) (parentFamilyIndices[fam.motherId] = parentFamilyIndices[fam.motherId] || []).push(idx);
});
const parentPrimaryFamilyKey = {};
Object.keys(parentFamilyIndices).forEach(pid => {
const indices = parentFamilyIndices[pid];
const minIdx = Math.min(...indices);
parentPrimaryFamilyKey[pid] = familyOrderList[minIdx] ? familyOrderList[minIdx].key : null;
});
function familyIndexOfMember(m) {
if (parentPrimaryFamilyKey[m.id]) return familyOrderIndex[parentPrimaryFamilyKey[m.id]];
if (familyKeyOfChild[m.id]) return familyOrderIndex[familyKeyOfChild[m.id]];
return null;
}
function primaryFamilyKeyOfMember(m) {
if (parentPrimaryFamilyKey[m.id]) return parentPrimaryFamilyKey[m.id];
if (familyKeyOfChild[m.id]) return familyKeyOfChild[m.id];
return null;
}
function compareByBirthName(a, b) {
if (a.birthYear !== b.birthYear) return b.birthYear - a.birthYear;
return a.name.localeCompare(b.name, 'zh-Hans-CN');
}
function sortRowWithParentOrder(row, prevOrderIndex) {
const memberSet = new Set(row.map(m => m.id));
// 找出当前行中的配偶关系(双方都在当前行)
const spouseOf = {};
Object.keys(families).forEach(key => {
const fam = families[key];
if (!fam || !fam.fatherId || !fam.motherId) return;
if (memberSet.has(fam.fatherId) && memberSet.has(fam.motherId)) {
spouseOf[fam.fatherId] = fam.motherId;
spouseOf[fam.motherId] = fam.fatherId;
}
});
// 按父母分组
const parentGroups = {};
row.forEach(m => {
const key = familyKeyOfChild[m.id] || `solo:${m.id}`;
if (!parentGroups[key]) parentGroups[key] = [];
parentGroups[key].push(m);
});
// 计算每个父母组的排序索引
function getParentGroupIndex(key) {
if (key.startsWith('solo:')) return null;
const parts = key.split('|');
const fId = parts[0] || null;
const mId = parts[1] || null;
const indices = [];
if (fId && prevOrderIndex[fId] !== undefined) indices.push(prevOrderIndex[fId]);
if (mId && prevOrderIndex[mId] !== undefined) indices.push(prevOrderIndex[mId]);
return indices.length > 0 ? Math.min(...indices) : null;
}
// 对父母组排序
const sortedGroupKeys = Object.keys(parentGroups).sort((a, b) => {
const aIdx = getParentGroupIndex(a);
const bIdx = getParentGroupIndex(b);
if (aIdx !== null && bIdx !== null && aIdx !== bIdx) return aIdx - bIdx;
if (aIdx !== null && bIdx === null) return -1;
if (aIdx === null && bIdx !== null) return 1;
return a.localeCompare(b);
});
// 展开每个父母组,先放兄弟姐妹,再放他们的配偶
const result = [];
const processed = new Set();
sortedGroupKeys.forEach(groupKey => {
const groupMembers = parentGroups[groupKey].slice().sort(compareByBirthName);
const spousesToAdd = [];
// 先添加所有兄弟姐妹
groupMembers.forEach(m => {
if (processed.has(m.id)) return;
result.push(m);
processed.add(m.id);
// 记录需要添加的配偶(如果配偶不在当前组)
const spouseId = spouseOf[m.id];
if (spouseId && memberSet.has(spouseId) && !processed.has(spouseId)) {
const spouseKey = familyKeyOfChild[spouseId] || `solo:${spouseId}`;
if (spouseKey !== groupKey) {
spousesToAdd.push(map[spouseId]);
}
}
});
// 然后添加配偶
spousesToAdd.forEach(spouse => {
if (!processed.has(spouse.id)) {
result.push(spouse);
processed.add(spouse.id);
}
});
});
return result;
}
function sortTopRow(row) {
const sorted = row.slice().sort((a, b) => {
const aKey = primaryFamilyKeyOfMember(a);
const bKey = primaryFamilyKeyOfMember(b);
if (aKey && bKey && aKey === bKey) {
const fam = families[aKey];
if (fam && fam.fatherId === a.id && fam.motherId === b.id) return -1;
if (fam && fam.motherId === a.id && fam.fatherId === b.id) return 1;
}
return compareByBirthName(a, b);
});
return sorted;
}
const maxRow = Math.max(...Object.values(rowOf));
const gens = [];
let prevOrderIndex = {};
for (let g = 0; g <= maxRow; g++) {
const rowMembers = members.filter(m => rowOf[m.id] === g);
const ordered = g === 0 ? sortTopRow(rowMembers) : sortRowWithParentOrder(rowMembers, prevOrderIndex);
gens.push(ordered);
prevOrderIndex = {};
ordered.forEach((m, idx) => { prevOrderIndex[m.id] = idx; });
}
// ── 计算坐标 ──────────────────────────────────────────
const posX = {};
const posY = {};
const rowWidth = gens.map(row => row.length * NODE_W + Math.max(0, row.length - 1) * H_GAP);
const totalWidth = Math.max(...rowWidth, 200);
gens.forEach((row, gi) => {
const rw = row.length * NODE_W + Math.max(0, row.length - 1) * H_GAP;
const startX = (totalWidth - rw) / 2;
row.forEach((m, i) => {
posX[m.id] = startX + i * (NODE_W + H_GAP);
posY[m.id] = gi * (NODE_H + V_GAP);
});
});
const totalHeight = (maxRow + 1) * (NODE_H + V_GAP) - V_GAP + 10;
// ── 渲染 HTML ─────────────────────────────────────────
canvas.innerHTML = '';
canvas.style.width = totalWidth + 'px';
canvas.style.height = totalHeight + 'px';
canvas.style.position = 'relative';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'lines');
svg.setAttribute('width', totalWidth);
svg.setAttribute('height', totalHeight);
canvas.appendChild(svg);
members.forEach(m => {
const node = document.createElement('div');
node.className = 'node';
node.style.left = posX[m.id] + 'px';
node.style.top = posY[m.id] + 'px';
node.style.width = NODE_W + 'px';
const yearHtml = showYear ? `<div class="card-year">${m.birthYear}</div>` : '';
node.innerHTML = `
<div class="card-box ${m.gender === '男' ? 'male' : 'female'}">
<div class="card-name">${m.name}</div>
${yearHtml}
</div>`;
canvas.appendChild(node);
});
// ── 绘制连线 ────────────────────────────────────────
members.forEach(child => {
const parents = [];
if (child.fatherId && map[child.fatherId] && posX[child.fatherId] !== undefined) parents.push(map[child.fatherId]);
if (child.motherId && map[child.motherId] && posX[child.motherId] !== undefined) parents.push(map[child.motherId]);
if (parents.length === 0) return;
const cx = posX[child.id] + NODE_W / 2;
const cy = posY[child.id];
if (parents.length === 1) {
const p = parents[0];
const px = posX[p.id] + NODE_W / 2;
const py = posY[p.id] + NODE_H;
const midY = py + (cy - py) / 2;
drawPath(svg, `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`);
} else {
const p0x = posX[parents[0].id] + NODE_W / 2;
const p1x = posX[parents[1].id] + NODE_W / 2;
const p0y = posY[parents[0].id] + NODE_H;
const p1y = posY[parents[1].id] + NODE_H;
const midY = Math.max(p0y, p1y) + (cy - Math.max(p0y, p1y)) * 0.4;
const midX = (p0x + p1x) / 2;
drawPath(svg, `M ${p0x} ${p0y} L ${p0x} ${midY} L ${midX} ${midY}`);
drawPath(svg, `M ${p1x} ${p1y} L ${p1x} ${midY} L ${midX} ${midY}`);
drawPath(svg, `M ${midX} ${midY} L ${midX} ${cy - 2} L ${cx} ${cy - 2} L ${cx} ${cy}`);
}
});
document.getElementById('treeWrap').style.minWidth = (totalWidth + 40) + 'px';
}
function drawPath(svg, d) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
path.setAttribute('stroke', '#a5d6a7');
path.setAttribute('stroke-width', '2');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.appendChild(path);
}
load();
</script>
</body>
</html>