453 lines
18 KiB
HTML
453 lines
18 KiB
HTML
<!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>
|