chore: sync remaining local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:13:01 +08:00
parent 0752ceccb4
commit 8b55485a82
20 changed files with 2720 additions and 121 deletions

452
public/tree.html Normal file
View File

@@ -0,0 +1,452 @@
<!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>