| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223 |
- {% extends "layout.html" %}
- {% block title %}世系查询 - 家谱管理系统{% endblock %}
- {% block extra_css %}
- <style>
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: calc(100vh - 155px);
- min-height: 400px;
- color: rgba(255,255,255,0.5);
- }
- /* ── 新竖列布局 ── */
- .lineage-view {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- min-width: max-content;
- padding: 10px 60px 60px;
- }
- /* 每一行容器 */
- .lin-row {
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- min-width: fit-content;
- justify-content: flex-start;
- }
- /* 同代横排(祖先/中心 + 兄弟按 child_order 合并排序) */
- .gen-peer-row {
- display: flex;
- flex-direction: row;
- flex-wrap: nowrap;
- gap: 12px;
- align-items: flex-start;
- padding: 0 6px;
- }
- /* 每个人的列:排行徽章 + 节点卡 */
- .gen-peer-col {
- display: flex;
- flex-direction: column;
- align-items: center;
- flex-shrink: 0;
- }
- /* 直系祖先/中心所在列:加竖线指示器 */
- .gen-peer-col.direct-col {
- position: relative;
- }
- /* 竖连接线:居中于 lin-row */
- .lin-center {
- min-width: 200px;
- display: flex;
- flex-direction: column;
- align-items: center;
- flex-shrink: 0;
- }
- .lin-vline {
- width: 3px;
- min-height: 36px;
- background: linear-gradient(to bottom, rgba(74,144,217,0.9), rgba(74,144,217,0.3));
- border-radius: 2px;
- margin: 0 auto;
- }
- /* 连接线行(居中) */
- .lin-vline-row {
- display: flex;
- flex-direction: row;
- min-width: fit-content;
- padding: 0 6px;
- }
- /* 子女排列区:由 JS alignAndCenter 动态设置 margin-left 居中 */
- .lin-children {
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- flex-wrap: nowrap;
- gap: 14px;
- margin-left: 0;
- }
- .lin-child-col {
- display: flex;
- flex-direction: column;
- align-items: center;
- flex-shrink: 0;
- }
- .child-order-badge {
- background: rgba(255,215,0,0.12);
- border: 1px solid rgba(255,215,0,0.35);
- color: #ffd700;
- font-size: 11px;
- font-weight: 600;
- padding: 2px 10px;
- border-radius: 10px;
- margin-bottom: 5px;
- white-space: nowrap;
- }
- /* 分区标题 */
- .section-divider {
- display: flex;
- align-items: center;
- gap: 12px;
- margin: 18px 0 10px;
- color: rgba(255,215,0,0.6);
- font-size: 12px;
- font-weight: 500;
- white-space: nowrap;
- }
- .section-divider::after {
- content: '';
- flex: 1;
- height: 1px;
- background: rgba(255,215,0,0.15);
- min-width: 40px;
- }
- .load-more-ancestors-btn {
- display: flex;
- align-items: center;
- gap: 8px;
- background: linear-gradient(135deg, rgba(74,105,189,0.15), rgba(74,105,189,0.05));
- border: 1px dashed rgba(74,144,217,0.5);
- border-radius: 8px;
- color: rgba(74,144,217,0.9);
- font-size: 13px;
- padding: 10px 20px;
- cursor: pointer;
- margin: 8px auto 4px;
- transition: all 0.2s;
- }
- .load-more-ancestors-btn:hover {
- background: linear-gradient(135deg, rgba(74,105,189,0.3), rgba(74,105,189,0.15));
- border-color: #4a90d9;
- color: #4a90d9;
- }
- .load-more-ancestors-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
- .tree-container {
- padding: 20px;
- height: calc(100vh - 155px);
- min-height: 500px;
- background: #1a1a2e;
- border-radius: 12px;
- border: 1px solid rgba(255,215,0,0.2);
- overflow: auto;
- position: relative;
- }
-
- /* Tree node styles */
- .tree-wrapper {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 20px 0;
- }
-
- .tree-node {
- background: linear-gradient(135deg, #2d3436, #1e272e);
- border: 2px solid #4a69bd;
- border-radius: 8px;
- padding: 12px 24px;
- margin: 10px 0;
- text-align: center;
- min-width: 160px;
- transition: all 0.3s;
- cursor: pointer;
- box-shadow: 0 4px 15px rgba(0,0,0,0.3);
- }
-
- .tree-node:hover {
- background: linear-gradient(135deg, #4a69bd, #2d3436);
- transform: scale(1.05);
- box-shadow: 0 6px 20px rgba(74, 105, 189, 0.4);
- }
-
- .tree-node.center {
- background: linear-gradient(135deg, #ffd700, #ff8c00);
- border-color: #ffd700;
- box-shadow: 0 0 30px rgba(255,215,0,0.5);
- }
-
- .tree-node.center .node-name {
- color: #1a1a2e !important;
- }
-
- .tree-node.center .node-name.simplified {
- color: rgba(26,26,46,0.8) !important;
- }
-
- .tree-node.center .node-info {
- color: rgba(26,26,46,0.8) !important;
- }
-
- .tree-node.direct-ancestor {
- background: linear-gradient(135deg, #4a90d9, #2d5a87);
- border-color: #4a90d9;
- box-shadow: 0 0 20px rgba(74, 144, 217, 0.4);
- }
-
- .tree-node.direct-ancestor .node-name {
- color: #fff !important;
- }
-
- .tree-node.direct-ancestor .node-name.simplified {
- color: rgba(255,255,255,0.8) !important;
- }
-
- .tree-node.direct-ancestor .node-info {
- color: rgba(255,255,255,0.9) !important;
- }
-
- .tree-node.clickable {
- cursor: pointer;
- transition: transform 0.2s, box-shadow 0.2s;
- }
-
- .tree-node.clickable:hover {
- transform: translateY(-3px);
- box-shadow: 0 8px 25px rgba(255, 255, 255, 0.15);
- }
-
- .node-name {
- font-size: 18px;
- font-weight: 700;
- color: #fff;
- margin-bottom: 4px;
- }
-
- .node-name.simplified {
- font-size: 13px;
- color: rgba(255,255,255,0.7);
- }
-
- .node-info {
- font-size: 12px;
- color: rgba(255,255,255,0.8);
- font-weight: 500;
- }
-
- /* Connection lines */
- .connection-line {
- width: 4px;
- height: 40px;
- background: linear-gradient(to bottom, #4a90d9, #2d3436);
- margin: 0 auto;
- border-radius: 2px;
- }
-
- .connection-line.vertical-line {
- width: 4px;
- height: 40px;
- background: linear-gradient(to bottom, #4a90d9, #2d3436);
- margin: 0 auto;
- border-radius: 2px;
- }
-
- .connection-line.horizontal {
- width: 60px;
- height: 4px;
- margin: 8px auto;
- background: linear-gradient(to right, #4a69bd, #2d3436);
- }
-
- .connection-line.horizontal.main-line {
- width: 60px;
- height: 4px;
- margin: 8px auto;
- background: linear-gradient(to right, #4a90d9, #4a90d9);
- }
-
- /* Children container */
- .children-container {
- display: flex;
- flex-wrap: nowrap;
- justify-content: flex-start;
- gap: 30px;
- margin-top: 20px;
- align-items: flex-start;
- min-width: max-content;
- }
-
- .child-group {
- display: flex;
- flex-direction: column;
- align-items: center;
- flex-shrink: 0;
- }
-
- .child-group.direct-line {
- display: flex;
- flex-direction: column;
- align-items: center;
- flex-shrink: 0;
- position: relative;
- }
-
- /* Expand/Collapse button */
- .expand-btn {
- background: linear-gradient(135deg, #4a69bd, #2d3436);
- border: 2px solid #4a69bd;
- border-radius: 50%;
- width: 36px;
- height: 36px;
- color: #fff;
- font-size: 18px;
- font-weight: bold;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- margin: 15px auto;
- transition: all 0.3s;
- box-shadow: 0 2px 10px rgba(0,0,0,0.3);
- }
-
- .expand-btn:hover {
- background: linear-gradient(135deg, #ffd700, #ff8c00);
- border-color: #ffd700;
- transform: rotate(90deg);
- box-shadow: 0 4px 15px rgba(255,215,0,0.4);
- }
-
- /* Generation label */
- .generation-label {
- color: #ffd700;
- font-size: 16px;
- font-weight: 700;
- margin-bottom: 20px;
- text-align: center;
- padding: 10px 20px;
- background: rgba(255,215,0,0.1);
- border: 1px solid rgba(255,215,0,0.3);
- border-radius: 25px;
- display: inline-block;
- }
-
- /* Search box */
- .search-box {
- max-width: 350px;
- }
-
- /* Section styling */
- .ancestors-section, .children-section {
- margin: 30px 0;
- }
-
- /* Header styling */
- .page-header {
- color: #ffd700;
- font-size: 24px;
- font-weight: 700;
- text-shadow: 0 0 10px rgba(255,215,0,0.3);
- }
-
- /* Tree container with scrollable area */
- .tree-container {
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
- border-radius: 16px;
- padding: 20px;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
- }
-
- .tree-container::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
-
- .tree-container::-webkit-scrollbar-track {
- background: rgba(255, 255, 255, 0.1);
- border-radius: 4px;
- }
-
- .tree-container::-webkit-scrollbar-thumb {
- background: rgba(255, 215, 0, 0.5);
- border-radius: 4px;
- }
-
- .tree-container::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 215, 0, 0.7);
- }
-
- /* Tree wrapper for horizontal scrolling */
- .tree-wrapper {
- width: 100%;
- overflow-x: auto;
- padding-bottom: 10px;
- scrollbar-width: thin;
- scrollbar-color: rgba(255,215,0,0.5) rgba(255,255,255,0.1);
- }
-
- .tree-wrapper::-webkit-scrollbar {
- width: 8px;
- height: 8px;
- }
-
- .tree-wrapper::-webkit-scrollbar-track {
- background: rgba(255, 255, 255, 0.1);
- border-radius: 4px;
- }
-
- .tree-wrapper::-webkit-scrollbar-thumb {
- background: rgba(255, 215, 0, 0.5);
- border-radius: 4px;
- }
-
- /* Generation row wrapper */
- .generation-row {
- display: flex;
- justify-content: center;
- width: 100%;
- overflow-x: auto;
- }
-
- /* Adoption styles */
- .tree-node.adopted-out {
- border: 2px dashed #ff6b6b !important;
- background: rgba(255, 107, 107, 0.1) !important;
- }
-
- /* 入继节点:橙黄色虚线框,区别于实线 */
- .tree-node.adopted-in {
- border: 2px dashed #f59e0b !important;
- background: rgba(245,158,11,0.08) !important;
- }
-
- .adoption-label {
- font-size: 10px;
- color: #ff6b6b;
- font-weight: bold;
- margin-top: 4px;
- text-align: center;
- }
- .adoption-label.adopted-in-label {
- color: #f59e0b;
- }
- /* 兄弟节点:稍小,低调 */
- .tree-node.sibling-node {
- background: rgba(74,105,189,0.2);
- border-color: rgba(74,105,189,0.4);
- min-width: 110px;
- padding: 8px 14px;
- }
- .tree-node.sibling-node .node-name {
- font-size: 14px;
- }
- .tree-node.sibling-node .node-info {
- font-size: 11px;
- }
- /* 世系模式开关:所有样式已内联到HTML元素上 */
- </style>
- {% endblock %}
- {% block content %}
- <div class="container-fluid">
- <!-- Header -->
- <div class="row mb-6">
- <div class="col-md-12">
- <div class="d-flex justify-content-between align-items-center">
- <h2 class="page-header">世系查询</h2>
- <div class="d-flex align-items-center" style="gap:10px;">
- <!-- 世系追溯模式:左右胶囊开关 -->
- <div id="lineageModeSwitch" style="display:inline-flex;align-items:center;background:#e5e7eb;border-radius:24px;padding:3px;gap:2px;flex-shrink:0;">
- <button id="modeIncense"
- onclick="setLineageMode('incense')"
- title="入继人员以养父为上辈,沿宗族香火追溯"
- style="padding:6px 20px;border:none;border-radius:20px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;background:#f59e0b;color:#1a1a2e;box-shadow:0 2px 8px rgba(245,158,11,0.5);">香火传承</button>
- <button id="modeBlood"
- onclick="setLineageMode('blood')"
- title="出继人员以亲生父为上辈,沿血缘源流追溯"
- style="padding:6px 20px;border:none;border-radius:20px;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;background:transparent;color:#6b7280;">血缘传承</button>
- </div>
- <!-- 搜索框 -->
- <div class="search-box">
- <div class="input-group">
- <input type="text" id="searchInput" class="form-control" placeholder="搜索成员姓名(支持简繁)" />
- <button class="btn btn-primary" onclick="searchMember()">
- <i class="bi bi-search"></i>
- </button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- Main Content -->
- <div class="row">
- <div class="col-md-12">
- <div id="treeContent">
- <!-- Empty State -->
- <div class="empty-state" id="emptyState">
- <i class="bi bi-search text-6xl mb-4"></i>
- <p class="text-lg">请在右上角搜索框中输入成员姓名</p>
- <p class="text-sm mt-2">支持简体和繁体姓名搜索</p>
- </div>
-
- <!-- Tree View -->
- <div id="treeView" style="display: none;" class="tree-container">
- <!-- Ancestors Section -->
- <div class="ancestors-section" id="ancestorsTree"></div>
-
- <!-- Siblings and Center Person Section -->
- <div class="siblings-section" id="siblingsTree"></div>
-
- <!-- Children Section -->
- <div class="children-section" id="childrenTree"></div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <script>
- // Search member
- async function searchMember() {
- const keyword = document.getElementById('searchInput').value.trim();
- if (!keyword) {
- alert('请输入搜索关键词');
- return;
- }
-
- // Show loading state
- const searchBtn = document.querySelector('.search-box button');
- const originalBtnHtml = searchBtn.innerHTML;
- searchBtn.disabled = true;
- searchBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
-
- try {
- const response = await fetch('/manager/api/search_member', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ keyword: keyword }),
- credentials: 'include'
- });
-
- const result = await response.json();
- if (result.success && result.members) {
- if (result.members.length === 1) {
- // Only one match, load directly
- document.getElementById('emptyState').style.display = 'none';
- document.getElementById('treeView').style.display = 'block';
- await loadLineage(result.members[0].id);
- } else {
- // Multiple matches, show selection dialog
- showMemberSelection(result.members);
- }
- } else {
- alert('未找到匹配的成员');
- }
- } finally {
- // Restore button
- searchBtn.disabled = false;
- searchBtn.innerHTML = originalBtnHtml;
- }
- }
- // Show member selection dialog
- function showMemberSelection(members) {
- // Remove existing dialog
- const existingDialog = document.getElementById('memberSelectionDialog');
- if (existingDialog) {
- existingDialog.remove();
- }
-
- // Store members in sessionStorage
- sessionStorage.setItem('searchMembers', JSON.stringify(members));
-
- // Build member list rows
- const memberRows = members.map((member, index) => {
- const simplifiedPart = member.simplified_name && member.simplified_name !== member.name
- ? `<span style="color: rgba(255,255,255,0.6); font-size: 13px;">(${member.simplified_name})</span>` : '';
- const genPart = member.name_word_generation
- ? `<span style="background: rgba(74,105,189,0.3); color: #8ab4f8; border-radius: 4px; padding: 1px 6px; font-size: 12px; margin-left: 6px;">第${member.name_word_generation}世</span>` : '';
- const fatherPart = member.father_name
- ? `<span style="color: rgba(255,255,255,0.5); font-size: 12px; margin-left: 6px;">父: ${member.father_name}${member.father_simplified_name && member.father_simplified_name !== member.father_name ? '(' + member.father_simplified_name + ')' : ''}</span>` : '';
- const idPart = `<span style="color: rgba(255,200,100,0.6); font-size: 11px; margin-left: 6px;">ID:${member.id}</span>`;
- const adoptionPart = member.adoption_note
- ? `<div style="margin-top: 3px; margin-left: 2px;">
- <span style="display:inline-block; background: rgba(220,80,30,0.18); border: 1px solid rgba(220,100,30,0.45);
- color: #f4a460; font-size: 11px; border-radius: 4px; padding: 1px 7px; white-space: nowrap;">
- 🔄 ${member.adoption_note}
- </span>
- </div>` : '';
- // 子女信息
- let childrenPart = '';
- if (member.children && member.children.length > 0) {
- const kids = member.children;
- const MAX_SHOW = 3;
- const shown = kids.slice(0, MAX_SHOW);
- const extra = kids.length - MAX_SHOW;
- const kidLinks = shown.map(k =>
- `<a href="/manager/member_detail/${k.id}" target="_blank"
- onclick="event.stopPropagation()"
- style="color:#86efac; text-decoration:none; font-size:11px;"
- onmouseover="this.style.textDecoration='underline'"
- onmouseout="this.style.textDecoration='none'">${k.name}</a>`
- ).join('<span style="color:rgba(255,255,255,0.3);margin:0 2px;">·</span>');
- const extraBadge = extra > 0
- ? `<span style="color:rgba(255,255,255,0.35);font-size:11px;margin-left:3px;">+${extra}子</span>` : '';
- childrenPart = `<div style="margin-top:3px;margin-left:2px;">
- <span style="color:rgba(255,255,255,0.35);font-size:11px;">子:</span>${kidLinks}${extraBadge}
- </div>`;
- }
- return `
- <div class="member-select-row"
- onclick="selectMemberByIndex(${index})"
- style="padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.08);
- cursor: pointer; display: flex; justify-content: space-between;
- align-items: center; transition: background 0.15s; border-radius: 6px;"
- onmouseover="this.style.background='rgba(74,105,189,0.2)'"
- onmouseout="this.style.background=''">
- <div style="flex: 1; min-width: 0;">
- <div>
- <span style="color: #ffd700; font-size: 13px; margin-right: 8px; flex-shrink: 0;">${index + 1}.</span>
- <span style="color: #fff; font-weight: 600;">${member.name}</span>
- ${simplifiedPart}${genPart}${fatherPart}${idPart}
- </div>
- ${adoptionPart}${childrenPart}
- </div>
- <a href="/manager/member_detail/${member.id}" target="_blank"
- onclick="event.stopPropagation()"
- title="新标签页查看详情"
- style="flex-shrink: 0; margin-left: 12px; padding: 4px 10px;
- background: rgba(74,105,189,0.4); border: 1px solid #4a69bd;
- border-radius: 6px; color: #8ab4f8; font-size: 12px;
- text-decoration: none; white-space: nowrap;">
- 查看详情 ↗
- </a>
- </div>`;
- }).join('');
-
- // Create dialog
- const dialog = document.createElement('div');
- dialog.id = 'memberSelectionDialog';
- dialog.style.cssText = `
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: #1a1a2e;
- border: 2px solid #4a69bd;
- border-radius: 12px;
- padding: 24px;
- z-index: 10000;
- min-width: 480px;
- max-width: 680px;
- width: 90vw;
- box-shadow: 0 10px 40px rgba(0,0,0,0.6);
- `;
-
- dialog.innerHTML = `
- <h3 style="color: #ffd700; margin-bottom: 4px; text-align: center; font-size: 16px;">找到多个匹配成员</h3>
- <p style="color: rgba(255,255,255,0.5); text-align: center; font-size: 12px; margin-bottom: 14px;">点击条目加载世系;点击「查看详情」在新标签页中查看</p>
- <div style="margin-bottom: 16px; max-height: 320px; overflow-y: auto; padding-right: 2px;">
- ${memberRows}
- </div>
- <div style="display: flex; justify-content: center;">
- <button onclick="closeSelectionDialog()"
- style="padding: 8px 30px; background: #2d3436;
- border: 2px solid #666; border-radius: 8px; color: #aaa;
- cursor: pointer;">取消</button>
- </div>
- `;
-
- // Add overlay
- const overlay = document.createElement('div');
- overlay.id = 'dialogOverlay';
- overlay.style.cssText = `
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0,0,0,0.7);
- z-index: 9999;
- `;
- overlay.onclick = closeSelectionDialog;
-
- document.body.appendChild(overlay);
- document.body.appendChild(dialog);
- }
- // Close selection dialog
- function closeSelectionDialog() {
- document.getElementById('memberSelectionDialog')?.remove();
- document.getElementById('dialogOverlay')?.remove();
- }
- // Select member by row click
- function selectMemberByIndex(index) {
- const membersStr = sessionStorage.getItem('searchMembers');
- if (!membersStr) {
- alert('数据错误,请重新搜索');
- closeSelectionDialog();
- return;
- }
-
- const members = JSON.parse(membersStr);
-
- if (index >= 0 && index < members.length) {
- document.getElementById('emptyState').style.display = 'none';
- document.getElementById('treeView').style.display = 'block';
- loadLineage(members[index].id);
- closeSelectionDialog();
- }
- }
- // Load lineage data
- async function loadLineage(memberId) {
- _currentMemberId = memberId;
- // Show loading state - preserve container elements
- document.getElementById('ancestorsTree').innerHTML = `
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
- <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
- <span class="visually-hidden">Loading...</span>
- </div>
- <p class="mt-4 text-white text-lg">正在加载祖先数据...</p>
- </div>
- `;
- document.getElementById('siblingsTree').innerHTML = `
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
- <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
- <span class="visually-hidden">Loading...</span>
- </div>
- <p class="mt-4 text-white text-lg">正在加载同辈数据...</p>
- </div>
- `;
- document.getElementById('childrenTree').innerHTML = `
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
- <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
- <span class="visually-hidden">Loading...</span>
- </div>
- <p class="mt-4 text-white text-lg">正在加载后代数据...</p>
- </div>
- `;
-
- try {
- const response = await fetch(`/manager/api/get_lineage/${memberId}?mode=${_lineageMode}`, {
- credentials: 'include'
- });
-
- const result = await response.json();
- if (result.success) {
- console.log('Lineage data received:', result.data);
- // Use requestAnimationFrame for smoother rendering
- requestAnimationFrame(() => {
- renderLineage(result.data);
- });
- } else {
- console.error('API error:', result.message);
- document.getElementById('ancestorsTree').innerHTML = '';
- document.getElementById('siblingsTree').innerHTML = `
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px;">
- <i class="bi bi-x-circle text-red-500 text-6xl mb-4"></i>
- <p class="text-white text-lg">加载失败</p>
- <p class="text-gray-400 text-sm">${result.message || '服务器错误,请稍后重试'}</p>
- </div>
- `;
- document.getElementById('childrenTree').innerHTML = '';
- }
- } catch (error) {
- console.error('Fetch error:', error);
- document.getElementById('ancestorsTree').innerHTML = '';
- document.getElementById('siblingsTree').innerHTML = `
- <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px;">
- <i class="bi bi-x-circle text-red-500 text-6xl mb-4"></i>
- <p class="text-white text-lg">加载失败</p>
- <p class="text-gray-400 text-sm">网络错误: ${error.message}</p>
- </div>
- `;
- document.getElementById('childrenTree').innerHTML = '';
- }
- }
- // ── 工具函数 ─────────────────────────────────────────────────────────────────
- // 根据 child_order 生成"长子/次子/三子..."标签
- function getChildOrderLabel(childOrder, fallbackIndex) {
- const ord = childOrder != null ? childOrder : (fallbackIndex + 1);
- const labels = ['长', '次', '三', '四', '五', '六', '七', '八', '九', '十'];
- if (ord >= 1 && ord <= 10) return labels[ord - 1] + '子';
- return `第${ord}子`;
- }
- // 渲染单个节点 HTML
- // type: 'center' | 'ancestor' | 'sibling' | 'child'
- function renderNode(person, type) {
- if (!person) return '';
- let cls = 'tree-node clickable';
- if (type === 'center') cls += ' center';
- else if (type === 'ancestor') cls += ' direct-ancestor';
- // siblings / children use default style (can be slightly smaller)
- if (type === 'sibling') cls += ' sibling-node';
- // adoption_label:后端直接标注在当事人身上(如"从xx出继"),优先使用
- // sub_relation_type 仅用于子女/入继节点的补充显示
- if (person.adoption_label) {
- cls += ' adopted-out';
- } else if (person.sub_relation_type === 2) {
- cls += ' adopted-out';
- } else if (person.sub_relation_type === 3) {
- cls += ' adopted-in';
- }
- let adoptLabel = '';
- if (person.adoption_label) {
- // 后端已计算好的标注(用于出继/入继当事人本人卡片)
- adoptLabel = `<div class="adoption-label">${person.adoption_label}</div>`;
- } else if (person.sub_relation_type === 2) {
- adoptLabel = `<div class="adoption-label">${person.adoptive_parent_name ? '出继给 ' + person.adoptive_parent_name : '出继'}</div>`;
- } else if (person.sub_relation_type === 3) {
- // 入继(养父母侧子女记录):优先显示完整说明
- const adoptText = person.adopt_info || (person.bio_parent_name ? `入继自 ${person.bio_parent_name}` : '入继');
- adoptLabel = `<div class="adoption-label adopted-in-label">${adoptText}</div>`;
- }
- return `
- <div class="${cls}" data-id="${person.id}" onclick="openPersonDetail(${person.id})">
- <div class="node-name">${person.name}</div>
- ${person.simplified_name && person.simplified_name !== person.name
- ? `<div class="node-name simplified">(${person.simplified_name})</div>` : ''}
- <div class="node-info">
- ${person.name_word ? person.name_word + ' · ' : ''}${person.name_word_generation || ''}
- </div>
- ${adoptLabel}
- </div>`;
- }
- // 渲染同代横排:direct(祖先/中心)+ siblings 合并按 child_order 排序
- // mainType: 'ancestor' | 'center'
- function renderPeerRow(main, siblings, mainType) {
- if (!main) return '';
- const all = [
- { ...main, _isDirect: true },
- ...(siblings || [])
- ].sort((a, b) => {
- const oa = (a.child_order != null ? a.child_order : 9999);
- const ob = (b.child_order != null ? b.child_order : 9999);
- return oa !== ob ? oa - ob : (a.id - b.id);
- });
- const MAX_SHOW = 12;
- const shown = all.slice(0, MAX_SHOW);
- const more = all.length - MAX_SHOW;
- let cols = shown.map((p, i) => {
- const badge = getChildOrderLabel(p.child_order, i);
- const type = p._isDirect ? mainType : 'sibling';
- const directCls = p._isDirect ? ' direct-col' : '';
- return `<div class="gen-peer-col${directCls}">
- <div class="child-order-badge">${badge}</div>
- ${renderNode(p, type)}
- </div>`;
- }).join('');
- if (more > 0) {
- cols += `<div class="gen-peer-col">
- <div class="child-order-badge" style="visibility:hidden">-</div>
- <div class="tree-node sibling-node" style="opacity:.6;min-width:80px;font-size:13px;">+${more} 人</div>
- </div>`;
- }
- return `<div class="gen-peer-row">${cols}</div>`;
- }
- // ── 主渲染函数 ────────────────────────────────────────────────────────────────
- // 全局状态:当前展示的 generations(用于"继续向上"追加)
- let _currentGenerations = [];
- let _currentCenter = null;
- let _currentSiblings = [];
- let _currentChildren = [];
- let _currentMemberId = null; // 记录当前查询人员,切换模式时重新加载
- let _lineageMode = 'incense'; // 'incense':香火传承(养父为上辈) | 'blood':血脉追溯(亲生父为上辈)
- // 切换世系追溯模式(element.style 直接控制,最高优先级,不受CSS影响)
- function setLineageMode(mode) {
- const changed = (_lineageMode !== mode);
- _lineageMode = mode;
- const btnI = document.getElementById('modeIncense');
- const btnB = document.getElementById('modeBlood');
- // 页面 header 背景是浅色,用深色方案确保可见
- if (mode === 'incense') {
- if (btnI) {
- btnI.style.cssText = 'padding:6px 20px;border:none;border-radius:20px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;background:#f59e0b;color:#1a1a2e;box-shadow:0 2px 8px rgba(245,158,11,0.5);';
- }
- if (btnB) {
- btnB.style.cssText = 'padding:6px 20px;border:none;border-radius:20px;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;background:transparent;color:#6b7280;box-shadow:none;';
- }
- } else {
- if (btnI) {
- btnI.style.cssText = 'padding:6px 20px;border:none;border-radius:20px;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;background:transparent;color:#6b7280;box-shadow:none;';
- }
- if (btnB) {
- btnB.style.cssText = 'padding:6px 20px;border:none;border-radius:20px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;background:#ef4444;color:#ffffff;box-shadow:0 2px 8px rgba(239,68,68,0.45);';
- }
- }
- // 模式变化且已有查询人员时,重新加载世系
- if (changed && _currentMemberId) {
- loadLineage(_currentMemberId);
- }
- }
- function renderLineage(data) {
- const { center, generations, siblings, children } = data;
- _currentGenerations = [...generations];
- _currentCenter = center;
- _currentSiblings = siblings || [];
- _currentChildren = children || [];
- _renderAncestorView(center, _currentGenerations, _currentSiblings, _currentChildren,
- data.has_more_ancestors, data.topmost_ancestor_id);
- }
- function _renderAncestorView(center, generations, siblings, children, hasMore, topmostId) {
- const ancestorGens = [...generations].reverse(); // 从最远祖先 → 父亲
- let html = '<div class="lineage-view">';
- // ── 1. 祖先竖列(最远→父亲)────────────────────────────────────────────
- if (ancestorGens.length > 0) {
- // "继续向上追溯"按钮(如果还有更高祖先)
- if (hasMore && topmostId) {
- html += `<div class="lin-row"><div class="lin-center">
- <button class="load-more-ancestors-btn" id="loadMoreAncestorsBtn"
- onclick="loadMoreAncestors(${topmostId}, this)">
- <i class="bi bi-arrow-up-circle"></i> 继续向上追溯(仍有更早的祖先)
- </button>
- </div></div>`;
- } else if (ancestorGens.length > 0) {
- html += `<div class="lin-row"><div class="lin-center">
- <div style="font-size:12px;color:rgba(255,255,255,0.3);text-align:center;padding:6px 0;">
- ↑ 已到达最上辈先祖
- </div>
- </div></div>`;
- }
- html += `<div class="section-divider">祖先世系</div>`;
- }
- ancestorGens.forEach((gen, idx) => {
- // 竖连接线(第一个不加顶部线)
- if (idx > 0) {
- html += `<div class="lin-vline-row"><div class="lin-vline" style="margin-left:6px;"></div></div>`;
- }
- html += `<div class="lin-row">`;
- html += renderPeerRow(gen.ancestor, gen.siblings || [], 'ancestor');
- html += `</div>`;
- });
- // ── 2. 中心人物 ──────────────────────────────────────────────────────────
- if (ancestorGens.length > 0) {
- html += `<div class="lin-vline-row"><div class="lin-vline" style="margin-left:6px;"></div></div>`;
- }
- html += `<div class="section-divider">查询人物</div>`;
- html += `<div class="lin-row">`;
- html += renderPeerRow(center, siblings || [], 'center');
- html += `</div>`;
- // ── 3. 子女横排(按 child_order 排序)────────────────────────────────────
- if (children && children.length > 0) {
- html += `<div class="lin-vline-row"><div class="lin-vline" style="margin-left:6px;"></div></div>`;
- html += `<div class="section-divider">子女</div>`;
- html += `<div class="lin-row">`;
- html += ` <div class="lin-children">`;
- children.forEach((child, idx) => {
- const badge = getChildOrderLabel(child.child_order, idx);
- html += `<div class="lin-child-col">`;
- html += ` <div class="child-order-badge">${badge}</div>`;
- html += renderNode(child, 'child');
- if (child.has_children) {
- html += `<button class="expand-btn" onclick="toggleChildren(this,${child.id})">+</button>`;
- html += `<div class="children-container" style="display:none;" data-parent-id="${child.id}"></div>`;
- }
- html += `</div>`;
- });
- html += ` </div>`;
- html += `</div>`;
- }
- html += '</div>'; // lineage-view
- // 写入容器(ancestorsTree 承载全部内容,其余清空)
- document.getElementById('ancestorsTree').innerHTML = html;
- document.getElementById('siblingsTree').innerHTML = '';
- document.getElementById('childrenTree').innerHTML = '';
- // 渲染后对齐直系列并居中显示
- requestAnimationFrame(() => alignAndCenter());
- }
- // 对齐直系列并将中心人物滚动至视口中央
- function alignAndCenter() {
- const treeView = document.getElementById('treeView');
- const lineageView = document.querySelector('#ancestorsTree .lineage-view');
- if (!treeView || !lineageView) return;
- // ── Step 1: 让每代行的 direct-col 对齐到同一 x 坐标 ──────────────────────
- const directCols = Array.from(lineageView.querySelectorAll('.gen-peer-col.direct-col'));
- if (directCols.length > 0) {
- const lineageLeft = lineageView.getBoundingClientRect().left;
- // 记录每个 direct-col 相对于 lineageView 的左偏移
- const offsets = directCols.map(col =>
- col.getBoundingClientRect().left - lineageLeft
- );
- const maxOffset = Math.max(...offsets);
- // 给偏移不足的那一行在其 gen-peer-row 上补 padding-left
- directCols.forEach((col, i) => {
- const diff = maxOffset - offsets[i];
- if (diff > 0) {
- const row = col.closest('.gen-peer-row');
- if (row) {
- const cur = parseFloat(row.style.paddingLeft) || 6;
- row.style.paddingLeft = (cur + diff) + 'px';
- }
- }
- });
- }
- // ── Step 2: 布局刷新后,子女居中 + 滚动至中心人物居中 ───────────────────
- requestAnimationFrame(() => {
- const centerNode = lineageView.querySelector('.tree-node.center');
- if (!centerNode) return;
- const lvRect = lineageView.getBoundingClientRect();
- const cnRect = centerNode.getBoundingClientRect();
- // 中心人物的水平中心(相对于 lineageView 左边缘)
- const centerX = cnRect.left + cnRect.width / 2 - lvRect.left;
- // 将 .lin-children 居中对齐到中心人物
- const linChildren = lineageView.querySelector('.lin-children');
- if (linChildren) {
- const childrenW = linChildren.scrollWidth;
- const desiredMargin = Math.max(0, centerX - childrenW / 2);
- linChildren.style.marginLeft = desiredMargin + 'px';
- }
- // 布局再次刷新后滚动视口
- requestAnimationFrame(() => {
- const tvRect = treeView.getBoundingClientRect();
- const cnRect2 = centerNode.getBoundingClientRect();
- // 水平居中
- const hDelta = (cnRect2.left + cnRect2.width / 2) - (tvRect.left + tvRect.width / 2);
- treeView.scrollLeft += hDelta;
- // 垂直居中:让中心人物处于视口中部偏上(1/3 处),上方留给祖先
- const targetY = tvRect.top + tvRect.height * 0.4;
- const vDelta = (cnRect2.top + cnRect2.height / 2) - targetY;
- treeView.scrollTop += vDelta;
- });
- });
- }
- // 继续向上追溯:加载 ancestor_id 以上的祖先链,并前插到当前列表
- async function loadMoreAncestors(topmostAncestorId, btn) {
- if (btn) {
- btn.disabled = true;
- btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> 追溯中...';
- }
- try {
- const resp = await fetch(`/manager/api/get_ancestors_above/${topmostAncestorId}?mode=${_lineageMode}`, {
- credentials: 'include'
- });
- const result = await resp.json();
- if (!result.success) {
- alert('加载失败:' + result.message);
- if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-up-circle"></i> 继续向上追溯'; }
- return;
- }
- const newGens = result.data.generations; // 顺序:从 topmostAncestorId 的父亲 → 更高祖先
- if (!newGens || newGens.length === 0) {
- if (btn) btn.outerHTML = '<div style="font-size:12px;color:rgba(255,255,255,0.3);text-align:center;padding:6px 0;">↑ 已到达最上辈先祖</div>';
- return;
- }
- // 若 anchor 节点本身有出继标注,更新其在 _currentGenerations 中的记录
- if (result.data.anchor_adoption_label) {
- const anchorEntry = _currentGenerations.find(g => g.ancestor && g.ancestor.id === topmostAncestorId);
- if (anchorEntry) {
- anchorEntry.ancestor.adoption_label = result.data.anchor_adoption_label;
- }
- }
- // 将新祖先追加到全局 _currentGenerations 末尾(末尾 = 更远的祖先)
- _currentGenerations = _currentGenerations.concat(newGens);
- // 重新渲染整个视图(中心人物、兄弟、子女使用缓存数据)
- _renderAncestorView(
- _currentCenter,
- _currentGenerations,
- _currentSiblings,
- _currentChildren,
- result.data.has_more_ancestors,
- result.data.topmost_ancestor_id
- );
- } catch (e) {
- alert('网络错误:' + e.message);
- if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-up-circle"></i> 继续向上追溯'; }
- }
- }
- // 展开子孙(按钮旁的懒加载容器)
- function renderChildrenRecursive(children) {
- if (!children || children.length === 0) return '';
- return `<div class="lin-children" style="flex-wrap:wrap;gap:12px;">
- ${children.map((child, idx) => {
- const badge = getChildOrderLabel(child.child_order, idx);
- return `<div class="lin-child-col">
- <div class="child-order-badge">${badge}</div>
- ${renderNode(child, 'child')}
- ${child.has_children
- ? `<button class="expand-btn" onclick="toggleChildren(this,${child.id})">+</button>
- <div class="children-container" style="display:none;" data-parent-id="${child.id}"></div>`
- : ''}
- </div>`;
- }).join('')}
- </div>`;
- }
- // Open person detail in new tab
- function openPersonDetail(personId) {
- window.open(`/manager/member_detail/${personId}`, '_blank');
- }
- // Toggle children visibility with lazy loading
- async function toggleChildren(btn, parentId) {
- const container = btn.nextElementSibling;
- const isExpanded = container.style.display !== 'none';
-
- if (isExpanded) {
- container.style.display = 'none';
- btn.innerHTML = '+';
- } else {
- // Check if children are already loaded
- if (container.innerHTML.trim() === '') {
- // Load children lazily
- btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
-
- // Get already displayed descendant IDs to exclude
- const excludedIds = getExcludedDescendantIds(btn);
- const excludeParam = excludedIds.length > 0 ? `?exclude=${excludedIds.join(',')}` : '';
-
- console.log(`[ToggleChildren] Parent ID: ${parentId}, Excluded IDs: ${excludedIds}, URL: /manager/api/get_descendants/${parentId}${excludeParam}`);
-
- try {
- const response = await fetch(`/manager/api/get_descendants/${parentId}${excludeParam}`, {
- credentials: 'include'
- });
- const result = await response.json();
-
- if (result.success && result.children) {
- // Render children
- container.innerHTML = renderChildrenRecursive(result.children);
- }
- } catch (error) {
- console.error('Failed to load children:', error);
- } finally {
- btn.innerHTML = '−';
- }
- }
-
- container.style.display = 'flex';
- btn.innerHTML = '−';
- }
- }
- // Get descendant IDs that are already displayed in the tree (to avoid duplicates)
- function getExcludedDescendantIds(btn) {
- const excluded = new Set();
-
- // Helper function to extract IDs from tree nodes
- const extractIdsFromNodes = (container) => {
- if (!container) return;
- const nodes = container.querySelectorAll('.tree-node');
- nodes.forEach(node => {
- const id = node.getAttribute('data-id');
- if (id && !isNaN(parseInt(id))) {
- excluded.add(parseInt(id));
- }
- });
- };
-
- // Get IDs from ancestors tree
- const ancestorsTree = document.getElementById('ancestorsTree');
- extractIdsFromNodes(ancestorsTree);
-
- // Get IDs from siblings tree (center person and siblings)
- const siblingsTree = document.getElementById('siblingsTree');
- extractIdsFromNodes(siblingsTree);
-
- // Get IDs from children tree
- const childrenTree = document.getElementById('childrenTree');
- extractIdsFromNodes(childrenTree);
-
- console.log('Excluded IDs:', Array.from(excluded));
- return Array.from(excluded);
- }
- // Enter key search
- document.getElementById('searchInput').addEventListener('keypress', function(e) {
- if (e.key === 'Enter') {
- searchMember();
- }
- });
- </script>
- {% endblock %}
|