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

91
public/admin-login.html Normal file
View File

@@ -0,0 +1,91 @@
<!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;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.card {
background: #fff;
border-radius: 16px;
padding: 40px 36px 32px;
box-shadow: 0 4px 24px rgba(120,170,100,0.13);
max-width: 360px;
width: 100%;
border-top: 4px solid #8bc34a;
animation: up .4s ease;
}
@keyframes up { from { opacity:0; transform:translateY(16px);} to { opacity:1; transform:translateY(0);} }
.logo { text-align: center; margin-bottom: 28px; }
.logo h1 { font-size: 24px; color: #558b2f; letter-spacing: 1px; margin-bottom: 4px; }
.logo p { color: #aaa; font-size: 13px; }
label { display: block; margin-bottom: 6px; color: #555; font-size: 14px; }
input[type=password] {
width: 100%; padding: 10px 13px; border: 1.5px solid #d4e8c2;
border-radius: 8px; font-size: 15px; background: #f8fdf3;
transition: border-color .2s, box-shadow .2s; margin-bottom: 18px;
}
input[type=password]:focus { outline: none; border-color: #8bc34a; box-shadow: 0 0 0 3px rgba(139,195,74,.15); }
.btn {
width: 100%; padding: 10px; border: none;
border-radius: 8px; font-size: 14px; font-weight: 600;
cursor: pointer; transition: background .2s, transform .15s; margin-bottom: 8px;
}
.btn-primary { background: #8bc34a; color: #fff; }
.btn-primary:hover { background: #7cb342; transform: translateY(-1px); }
.btn-secondary { background: #f1f8e9; color: #558b2f; border: 1.5px solid #c5e1a5; }
.btn-secondary:hover { background: #e8f5e9; }
.error { color: #e57373; font-size: 13px; margin-top: 8px; text-align: center; display: none; }
.error.show { display: block; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<h1>管理员登录</h1>
<p>唐氏族谱后台管理</p>
</div>
<form id="loginForm">
<label>管理员密码</label>
<input type="password" id="password" placeholder="请输入管理员密码" required>
<button type="submit" class="btn btn-primary">登录</button>
<button type="button" class="btn btn-secondary" onclick="location.href='/'">返回首页</button>
<div class="error" id="error">密码错误,请重试</div>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async e => {
e.preventDefault();
const err = document.getElementById('error');
err.classList.remove('show');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: document.getElementById('password').value, isAdmin: true })
});
const data = await res.json();
if (data.success && data.isAdmin) {
localStorage.setItem('token', data.token);
localStorage.setItem('isAdmin', 'true');
location.href = '/admin.html';
} else {
err.textContent = data.error || '密码错误,请重试';
err.classList.add('show');
document.getElementById('password').value = '';
}
} catch { err.textContent = '连接失败,请重试'; err.classList.add('show'); }
});
</script>
</body>
</html>

357
public/admin.html Normal file
View File

@@ -0,0 +1,357 @@
<!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 16px; display: flex; align-items: center;
justify-content: space-between; position: sticky; top: 0; z-index: 200;
}
.topbar h1 { font-size: 18px; color: #558b2f; }
.topbar-right { display: flex; gap: 6px; align-items: center; }
.tbtn {
padding: 5px 12px; border-radius: 6px; border: 1.5px solid #c5e1a5;
background: #f1f8e9; color: #558b2f; font-size: 13px; cursor: pointer; transition: background .15s;
}
.tbtn:hover { background: #dcedc8; }
.tbtn.green { background: #8bc34a; color: #fff; border-color: #8bc34a; }
.tbtn.green:hover { background: #7cb342; }
.tbtn.red { background: #ef9a9a; color: #fff; border-color: #ef9a9a; }
.tbtn.red:hover { background: #e57373; }
.tbtn.active { background: #8bc34a; color: #fff; border-color: #8bc34a; }
.tbtn.active:hover { background: #7cb342; }
/* 主体 */
.main { padding: 14px 16px; max-width: 960px; margin: 0 auto; }
/* 快速新增行 */
.quick-add {
background: #fff; border: 1.5px solid #c5e1a5; border-radius: 10px;
padding: 10px 14px; margin-bottom: 12px;
display: grid;
grid-template-columns: 1fr 80px 70px 1.4fr 1fr 1fr auto;
gap: 8px; align-items: center;
}
.quick-add input, .quick-add select {
padding: 6px 9px; border: 1.5px solid #d4e8c2; border-radius: 6px;
font-size: 13px; background: #f8fdf3; width: 100%;
}
.quick-add input:focus, .quick-add select:focus { outline: none; border-color: #8bc34a; }
.quick-add label { display: none; }
/* 成员表格 */
.table-wrap { background: #fff; border: 1.5px solid #c5e1a5; border-radius: 10px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
background: #f1f8e9; color: #558b2f; padding: 8px 10px;
text-align: left; font-weight: 600; border-bottom: 1.5px solid #c5e1a5;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid #f0f7ee; transition: background .15s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: #f9fef5; }
td { padding: 6px 10px; color: #333; vertical-align: middle; }
td.name-cell { font-weight: 600; color: #2e4a1a; }
td.gender-m { color: #1976d2; }
td.gender-f { color: #c2185b; }
/* 行内编辑 */
.row-edit input, .row-edit select {
padding: 4px 6px; border: 1.5px solid #a5d6a7; border-radius: 5px;
font-size: 13px; background: #f8fdf3; width: 100%;
}
.row-edit input:focus, .row-edit select:focus { outline: none; border-color: #66bb6a; }
.actions { display: flex; gap: 5px; white-space: nowrap; }
/* 空/加载 */
.tip { text-align: center; padding: 40px 0; color: #bbb; font-size: 14px; }
/* 提示 toast */
#toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: #558b2f; color: #fff; padding: 8px 22px;
border-radius: 20px; font-size: 13px; opacity: 0;
transition: opacity .3s; pointer-events: none; z-index: 9999;
}
#toast.show { opacity: 1; }
/* 响应式:小屏隐藏部分列 */
@media (max-width: 700px) {
.quick-add { grid-template-columns: 1fr 70px 60px auto; }
.quick-add .col-father, .quick-add .col-mother, .quick-add .col-gen { display: none; }
.hide-sm { display: none; }
}
@media (max-width: 480px) {
.quick-add { grid-template-columns: 1fr 60px auto; }
.quick-add .col-gender { display: none; }
}
</style>
</head>
<body>
<div class="topbar">
<h1>族谱管理后台</h1>
<div class="topbar-right">
<span id="countBadge" style="color:#888;font-size:12px;"></span>
<button class="tbtn" id="yearToggle" onclick="toggleYear()">显示年份</button>
<button class="tbtn" onclick="location.href='/tree.html'">查看族谱</button>
</div>
</div>
<div class="main">
<!-- 快速新增 -->
<form id="quickForm" class="quick-add" onsubmit="quickAdd(event)">
<input id="qName" placeholder="姓名 *" required>
<input id="qYear" type="number" placeholder="出生年" min="1900" max="2100" required>
<select id="qGender" class="col-gender" required>
<option value="">性别</option>
<option value="男"></option>
<option value="女"></option>
</select>
<select id="qGen" class="col-gen">
<option value="">辈分(选填)</option>
<option value="鼻祖">鼻祖</option>
<option value="远祖">远祖</option>
<option value="太祖">太祖</option>
<option value="烈祖">烈祖</option>
<option value="天祖">天祖</option>
<option value="高祖">高祖</option>
<option value="曾祖">曾祖</option>
<option value="祖父">祖父</option>
<option value="父亲">父亲</option>
<option value="自己">自己</option>
<option value="子女">子女</option>
<option value="孙辈">孙辈</option>
<option value="曾孙">曾孙</option>
<option value="玄孙">玄孙</option>
<option value="来孙">来孙</option>
<option value="晜孙">晜孙</option>
<option value="仍孙">仍孙</option>
<option value="云孙">云孙</option>
<option value="耳孙">耳孙</option>
</select>
<select id="qFather" class="col-father"><option value="">父亲(选填)</option></select>
<select id="qMother" class="col-mother"><option value="">母亲(选填)</option></select>
<button type="submit" class="tbtn green">+ 添加</button>
</form>
<!-- 成员列表 -->
<div class="table-wrap">
<table>
<thead>
<tr>
<th>姓名</th>
<th>出生年</th>
<th>性别</th>
<th class="hide-sm">辈分</th>
<th class="hide-sm">父亲</th>
<th class="hide-sm">母亲</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tbody">
<tr><td colspan="7" class="tip">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
<div id="toast"></div>
<script>
const token = localStorage.getItem('token');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
if (!token || !isAdmin) location.href = '/';
let members = [];
let editingId = null;
// ── 显示年份开关 ──────────────────────────────────────
function getShowYear() { return localStorage.getItem('showYear') !== 'false'; }
function toggleYear() {
const next = !getShowYear();
localStorage.setItem('showYear', next ? 'true' : 'false');
updateYearToggleBtn();
}
function updateYearToggleBtn() {
const btn = document.getElementById('yearToggle');
if (!btn) return;
const on = getShowYear();
btn.textContent = on ? '显示年份:开' : '显示年份:关';
btn.classList.toggle('active', on);
}
updateYearToggleBtn();
const GEN_OPTIONS = [
'鼻祖','远祖','太祖','烈祖','天祖',
'高祖','曾祖','祖父','父亲','自己',
'子女','孙辈','曾孙','玄孙','来孙',
'晜孙','仍孙','云孙','耳孙'
];
function genSelectOpts(current) {
return `<option value="">无</option>` +
GEN_OPTIONS.map(g => `<option value="${g}" ${current === g ? 'selected' : ''}>${g}</option>`).join('');
}
// ── 数据加载 ──────────────────────────────────────────
async function loadMembers() {
try {
const res = await fetch('/api/members', { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error();
members = await res.json();
members.sort((a, b) => a.birthYear - b.birthYear);
renderTable();
updateSelects();
document.getElementById('countBadge').textContent = `${members.length}`;
} catch {
document.getElementById('tbody').innerHTML = '<tr><td colspan="7" class="tip" style="color:#e57373">加载失败,请刷新</td></tr>';
}
}
// ── 渲染表格 ──────────────────────────────────────────
function renderTable() {
const map = {};
members.forEach(m => map[m.id] = m);
const tbody = document.getElementById('tbody');
if (members.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="tip">暂无成员,请在上方快速添加</td></tr>';
return;
}
tbody.innerHTML = members.map(m => {
if (editingId === m.id) return renderEditRow(m, map);
const father = m.fatherId && map[m.fatherId] ? map[m.fatherId].name : '—';
const mother = m.motherId && map[m.motherId] ? map[m.motherId].name : '—';
return `<tr id="row-${m.id}">
<td class="name-cell">${m.name}</td>
<td>${m.birthYear}</td>
<td class="${m.gender === '男' ? 'gender-m' : 'gender-f'}">${m.gender}</td>
<td class="hide-sm">${m.generation || '—'}</td>
<td class="hide-sm">${father}</td>
<td class="hide-sm">${mother}</td>
<td><div class="actions">
<button class="tbtn" onclick="startEdit('${m.id}')">编辑</button>
<button class="tbtn red" onclick="deleteMember('${m.id}','${m.name}')">删除</button>
</div></td>
</tr>`;
}).join('');
}
function renderEditRow(m, map) {
const males = members.filter(x => x.gender === '男' && x.id !== m.id);
const females = members.filter(x => x.gender === '女' && x.id !== m.id);
const fOpts = `<option value="">无</option>` + males.map(x => `<option value="${x.id}" ${m.fatherId === x.id ? 'selected' : ''}>${x.name}(${x.birthYear})</option>`).join('');
const mOpts = `<option value="">无</option>` + females.map(x => `<option value="${x.id}" ${m.motherId === x.id ? 'selected' : ''}>${x.name}(${x.birthYear})</option>`).join('');
return `<tr id="row-${m.id}" class="row-edit" style="background:#f1f8e9">
<td><input id="eName" value="${m.name}" required></td>
<td><input id="eYear" type="number" value="${m.birthYear}" min="1900" max="2100" style="width:70px"></td>
<td><select id="eGender">
<option value="男" ${m.gender==='男'?'selected':''}>男</option>
<option value="女" ${m.gender==='女'?'selected':''}>女</option>
</select></td>
<td class="hide-sm"><select id="eGen">${genSelectOpts(m.generation || '')}</select></td>
<td class="hide-sm"><select id="eFather">${fOpts}</select></td>
<td class="hide-sm"><select id="eMother">${mOpts}</select></td>
<td><div class="actions">
<button class="tbtn green" onclick="saveEdit('${m.id}')">保存</button>
<button class="tbtn" onclick="cancelEdit()">取消</button>
</div></td>
</tr>`;
}
// ── 编辑相关 ──────────────────────────────────────────
function startEdit(id) {
editingId = id;
renderTable();
const row = document.getElementById(`row-${id}`);
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function cancelEdit() {
editingId = null;
renderTable();
}
async function saveEdit(id) {
const name = document.getElementById('eName').value.trim();
const birthYear = parseInt(document.getElementById('eYear').value);
const gender = document.getElementById('eGender').value;
const generation = document.getElementById('eGen') ? document.getElementById('eGen').value || undefined : undefined;
const fatherId = document.getElementById('eFather') ? document.getElementById('eFather').value || undefined : undefined;
const motherId = document.getElementById('eMother') ? document.getElementById('eMother').value || undefined : undefined;
if (!name || !birthYear || !gender) { toast('请填写完整信息', true); return; }
await saveMember({ id, name, birthYear, gender, generation, fatherId, motherId });
editingId = null;
}
// ── 快速添加 ──────────────────────────────────────────
async function quickAdd(e) {
e.preventDefault();
const name = document.getElementById('qName').value.trim();
const birthYear = parseInt(document.getElementById('qYear').value);
const gender = document.getElementById('qGender').value;
const generation = document.getElementById('qGen').value || undefined;
const fatherId = document.getElementById('qFather').value || undefined;
const motherId = document.getElementById('qMother').value || undefined;
if (!name || !birthYear || !gender) { toast('请填写姓名、出生年、性别', true); return; }
await saveMember({ name, birthYear, gender, generation, fatherId, motherId });
document.getElementById('quickForm').reset();
}
// ── 公共保存 ──────────────────────────────────────────
async function saveMember(member) {
try {
const res = await fetch('/api/members', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(member)
});
if (!res.ok) throw new Error();
toast(member.id ? '已保存' : '已添加');
await loadMembers();
} catch { toast('保存失败,请重试', true); }
}
// ── 删除 ──────────────────────────────────────────────
async function deleteMember(id, name) {
if (!confirm(`确定删除「${name}」?`)) return;
try {
const res = await fetch('/api/members/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ id })
});
if (!res.ok) throw new Error();
toast('已删除');
await loadMembers();
} catch { toast('删除失败', true); }
}
// ── 更新父母下拉选项 ──────────────────────────────────
function updateSelects() {
const males = members.filter(m => m.gender === '男');
const females = members.filter(m => m.gender === '女');
document.getElementById('qFather').innerHTML = '<option value="">父亲(选填)</option>' +
males.map(m => `<option value="${m.id}">${m.name}(${m.birthYear})</option>`).join('');
document.getElementById('qMother').innerHTML = '<option value="">母亲(选填)</option>' +
females.map(m => `<option value="${m.id}">${m.name}(${m.birthYear})</option>`).join('');
}
// ── Toast 提示 ────────────────────────────────────────
function toast(msg, err = false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.style.background = err ? '#e57373' : '#558b2f';
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2000);
}
loadMembers();
</script>
</body>
</html>

View File

@@ -1,32 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hello, World!</title>
</head>
<body>
<h1 id="heading"></h1>
<p>This page comes from a static asset stored at `public/index.html` as configured in `wrangler.jsonc`.</p>
<button id="button" type="button">Fetch a random UUID</button>
<output id="random" for="button"></output>
<script>
fetch('/message')
.then((resp) => resp.text())
.then((text) => {
const h1 = document.getElementById('heading');
h1.textContent = text;
});
const button = document.getElementById("button");
button.addEventListener("click", () => {
fetch('/random')
.then((resp) => resp.text())
.then((text) => {
const random = document.getElementById('random');
random.textContent = text;
});
});
</script>
</body>
<!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;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.card {
background: #fff;
border-radius: 16px;
padding: 40px 36px 32px;
box-shadow: 0 4px 24px rgba(120,170,100,0.13);
max-width: 360px;
width: 100%;
border-top: 4px solid #8bc34a;
animation: up .4s ease;
}
@keyframes up { from { opacity:0; transform:translateY(16px);} to { opacity:1; transform:translateY(0);} }
.logo { text-align: center; margin-bottom: 28px; }
.logo h1 { font-size: 28px; color: #558b2f; letter-spacing: 2px; margin-bottom: 4px; }
.logo p { color: #aaa; font-size: 13px; }
label { display: block; margin-bottom: 6px; color: #555; font-size: 14px; }
input[type=password] {
width: 100%; padding: 10px 13px; border: 1.5px solid #d4e8c2;
border-radius: 8px; font-size: 15px; background: #f8fdf3;
transition: border-color .2s, box-shadow .2s; margin-bottom: 18px;
}
input[type=password]:focus { outline: none; border-color: #8bc34a; box-shadow: 0 0 0 3px rgba(139,195,74,.15); }
.btn {
width: 100%; padding: 11px; background: #8bc34a; border: none;
border-radius: 8px; color: #fff; font-size: 15px; font-weight: 600;
cursor: pointer; transition: background .2s, transform .15s;
}
.btn:hover { background: #7cb342; transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.error { color: #e57373; font-size: 13px; margin-top: 10px; text-align: center; display: none; }
.error.show { display: block; }
@media (max-width: 400px) { .card { padding: 28px 18px 24px; } }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<h1>唐氏族谱</h1>
<p>Tang Family Tree</p>
</div>
<form id="loginForm">
<label>访问密码</label>
<input type="password" id="password" placeholder="请输入密码" required>
<button type="submit" class="btn">进入族谱</button>
<div class="error" id="error">密码错误,请重试</div>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async e => {
e.preventDefault();
const err = document.getElementById('error');
err.classList.remove('show');
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: document.getElementById('password').value, isAdmin: false })
});
const data = await res.json();
if (data.success) {
localStorage.setItem('token', data.token);
localStorage.setItem('isAdmin', data.isAdmin);
location.href = '/tree.html';
} else {
err.textContent = data.error || '密码错误,请重试';
err.classList.add('show');
document.getElementById('password').value = '';
}
} catch { err.textContent = '连接失败,请重试'; err.classList.add('show'); }
});
</script>
</body>
</html>

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>