chore: sync remaining local changes to Gitea
This commit is contained in:
91
public/admin-login.html
Normal file
91
public/admin-login.html
Normal 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
357
public/admin.html
Normal 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>
|
||||
@@ -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
452
public/tree.html
Normal 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>
|
||||
Reference in New Issue
Block a user