lineage_query.html 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  1. {% extends "layout.html" %}
  2. {% block title %}世系查询 - 家谱管理系统{% endblock %}
  3. {% block extra_css %}
  4. <style>
  5. .empty-state {
  6. display: flex;
  7. flex-direction: column;
  8. align-items: center;
  9. justify-content: center;
  10. height: 60vh;
  11. color: rgba(255,255,255,0.5);
  12. }
  13. /* ── 新竖列布局 ── */
  14. .lineage-view {
  15. display: flex;
  16. flex-direction: column;
  17. align-items: flex-start;
  18. min-width: fit-content;
  19. padding: 10px 30px 30px;
  20. }
  21. /* 每一行:中心列 + 右侧兄弟列 */
  22. .lin-row {
  23. display: flex;
  24. flex-direction: row;
  25. align-items: center;
  26. min-width: fit-content;
  27. }
  28. /* 中心列:固定宽度,保证所有层级垂直对齐 */
  29. .lin-center {
  30. min-width: 200px;
  31. display: flex;
  32. flex-direction: column;
  33. align-items: center;
  34. flex-shrink: 0;
  35. }
  36. /* 竖连接线 */
  37. .lin-vline {
  38. width: 3px;
  39. min-height: 36px;
  40. background: linear-gradient(to bottom, rgba(74,144,217,0.9), rgba(74,144,217,0.3));
  41. border-radius: 2px;
  42. margin: 0 auto;
  43. }
  44. /* 右侧兄弟区:横线 + 节点列表 */
  45. .lin-side {
  46. display: flex;
  47. flex-direction: row;
  48. align-items: center;
  49. flex-wrap: nowrap;
  50. margin-left: 4px;
  51. }
  52. .lin-hline {
  53. width: 28px;
  54. height: 3px;
  55. background: rgba(74,105,189,0.5);
  56. border-radius: 2px;
  57. flex-shrink: 0;
  58. }
  59. .lin-siblings {
  60. display: flex;
  61. flex-direction: row;
  62. flex-wrap: nowrap;
  63. gap: 10px;
  64. padding-left: 4px;
  65. }
  66. /* 子女排列区:第一子在左(对齐中心),其余向右 */
  67. .lin-children {
  68. display: flex;
  69. flex-direction: row;
  70. align-items: flex-start;
  71. flex-wrap: nowrap;
  72. gap: 14px;
  73. }
  74. .lin-child-col {
  75. display: flex;
  76. flex-direction: column;
  77. align-items: center;
  78. flex-shrink: 0;
  79. }
  80. .child-order-badge {
  81. background: rgba(255,215,0,0.12);
  82. border: 1px solid rgba(255,215,0,0.35);
  83. color: #ffd700;
  84. font-size: 11px;
  85. font-weight: 600;
  86. padding: 2px 10px;
  87. border-radius: 10px;
  88. margin-bottom: 5px;
  89. white-space: nowrap;
  90. }
  91. /* 分区标题 */
  92. .section-divider {
  93. display: flex;
  94. align-items: center;
  95. gap: 12px;
  96. margin: 18px 0 10px;
  97. color: rgba(255,215,0,0.6);
  98. font-size: 12px;
  99. font-weight: 500;
  100. white-space: nowrap;
  101. }
  102. .section-divider::after {
  103. content: '';
  104. flex: 1;
  105. height: 1px;
  106. background: rgba(255,215,0,0.15);
  107. min-width: 40px;
  108. }
  109. .tree-container {
  110. padding: 20px;
  111. min-height: 60vh;
  112. background: #1a1a2e;
  113. border-radius: 12px;
  114. border: 1px solid rgba(255,215,0,0.2);
  115. }
  116. /* Tree node styles */
  117. .tree-wrapper {
  118. display: flex;
  119. flex-direction: column;
  120. align-items: center;
  121. padding: 20px 0;
  122. }
  123. .tree-node {
  124. background: linear-gradient(135deg, #2d3436, #1e272e);
  125. border: 2px solid #4a69bd;
  126. border-radius: 8px;
  127. padding: 12px 24px;
  128. margin: 10px 0;
  129. text-align: center;
  130. min-width: 160px;
  131. transition: all 0.3s;
  132. cursor: pointer;
  133. box-shadow: 0 4px 15px rgba(0,0,0,0.3);
  134. }
  135. .tree-node:hover {
  136. background: linear-gradient(135deg, #4a69bd, #2d3436);
  137. transform: scale(1.05);
  138. box-shadow: 0 6px 20px rgba(74, 105, 189, 0.4);
  139. }
  140. .tree-node.center {
  141. background: linear-gradient(135deg, #ffd700, #ff8c00);
  142. border-color: #ffd700;
  143. box-shadow: 0 0 30px rgba(255,215,0,0.5);
  144. }
  145. .tree-node.center .node-name {
  146. color: #1a1a2e !important;
  147. }
  148. .tree-node.center .node-name.simplified {
  149. color: rgba(26,26,46,0.8) !important;
  150. }
  151. .tree-node.center .node-info {
  152. color: rgba(26,26,46,0.8) !important;
  153. }
  154. .tree-node.direct-ancestor {
  155. background: linear-gradient(135deg, #4a90d9, #2d5a87);
  156. border-color: #4a90d9;
  157. box-shadow: 0 0 20px rgba(74, 144, 217, 0.4);
  158. }
  159. .tree-node.direct-ancestor .node-name {
  160. color: #fff !important;
  161. }
  162. .tree-node.direct-ancestor .node-name.simplified {
  163. color: rgba(255,255,255,0.8) !important;
  164. }
  165. .tree-node.direct-ancestor .node-info {
  166. color: rgba(255,255,255,0.9) !important;
  167. }
  168. .tree-node.clickable {
  169. cursor: pointer;
  170. transition: transform 0.2s, box-shadow 0.2s;
  171. }
  172. .tree-node.clickable:hover {
  173. transform: translateY(-3px);
  174. box-shadow: 0 8px 25px rgba(255, 255, 255, 0.15);
  175. }
  176. .node-name {
  177. font-size: 18px;
  178. font-weight: 700;
  179. color: #fff;
  180. margin-bottom: 4px;
  181. }
  182. .node-name.simplified {
  183. font-size: 13px;
  184. color: rgba(255,255,255,0.7);
  185. }
  186. .node-info {
  187. font-size: 12px;
  188. color: rgba(255,255,255,0.8);
  189. font-weight: 500;
  190. }
  191. /* Connection lines */
  192. .connection-line {
  193. width: 4px;
  194. height: 40px;
  195. background: linear-gradient(to bottom, #4a90d9, #2d3436);
  196. margin: 0 auto;
  197. border-radius: 2px;
  198. }
  199. .connection-line.vertical-line {
  200. width: 4px;
  201. height: 40px;
  202. background: linear-gradient(to bottom, #4a90d9, #2d3436);
  203. margin: 0 auto;
  204. border-radius: 2px;
  205. }
  206. .connection-line.horizontal {
  207. width: 60px;
  208. height: 4px;
  209. margin: 8px auto;
  210. background: linear-gradient(to right, #4a69bd, #2d3436);
  211. }
  212. .connection-line.horizontal.main-line {
  213. width: 60px;
  214. height: 4px;
  215. margin: 8px auto;
  216. background: linear-gradient(to right, #4a90d9, #4a90d9);
  217. }
  218. /* Children container */
  219. .children-container {
  220. display: flex;
  221. flex-wrap: nowrap;
  222. justify-content: flex-start;
  223. gap: 30px;
  224. margin-top: 20px;
  225. align-items: flex-start;
  226. min-width: max-content;
  227. }
  228. .child-group {
  229. display: flex;
  230. flex-direction: column;
  231. align-items: center;
  232. flex-shrink: 0;
  233. }
  234. .child-group.direct-line {
  235. display: flex;
  236. flex-direction: column;
  237. align-items: center;
  238. flex-shrink: 0;
  239. position: relative;
  240. }
  241. /* Expand/Collapse button */
  242. .expand-btn {
  243. background: linear-gradient(135deg, #4a69bd, #2d3436);
  244. border: 2px solid #4a69bd;
  245. border-radius: 50%;
  246. width: 36px;
  247. height: 36px;
  248. color: #fff;
  249. font-size: 18px;
  250. font-weight: bold;
  251. cursor: pointer;
  252. display: flex;
  253. align-items: center;
  254. justify-content: center;
  255. margin: 15px auto;
  256. transition: all 0.3s;
  257. box-shadow: 0 2px 10px rgba(0,0,0,0.3);
  258. }
  259. .expand-btn:hover {
  260. background: linear-gradient(135deg, #ffd700, #ff8c00);
  261. border-color: #ffd700;
  262. transform: rotate(90deg);
  263. box-shadow: 0 4px 15px rgba(255,215,0,0.4);
  264. }
  265. /* Generation label */
  266. .generation-label {
  267. color: #ffd700;
  268. font-size: 16px;
  269. font-weight: 700;
  270. margin-bottom: 20px;
  271. text-align: center;
  272. padding: 10px 20px;
  273. background: rgba(255,215,0,0.1);
  274. border: 1px solid rgba(255,215,0,0.3);
  275. border-radius: 25px;
  276. display: inline-block;
  277. }
  278. /* Search box */
  279. .search-box {
  280. max-width: 350px;
  281. }
  282. /* Section styling */
  283. .ancestors-section, .children-section {
  284. margin: 30px 0;
  285. }
  286. /* Header styling */
  287. .page-header {
  288. color: #ffd700;
  289. font-size: 24px;
  290. font-weight: 700;
  291. text-shadow: 0 0 10px rgba(255,215,0,0.3);
  292. }
  293. /* Tree container with scrollable area */
  294. .tree-container {
  295. background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
  296. border-radius: 16px;
  297. padding: 30px;
  298. overflow: auto;
  299. position: relative;
  300. box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  301. width: calc(100% + 2px);
  302. margin: 0 -1px;
  303. }
  304. .tree-container::-webkit-scrollbar {
  305. width: 8px;
  306. height: 8px;
  307. }
  308. .tree-container::-webkit-scrollbar-track {
  309. background: rgba(255, 255, 255, 0.1);
  310. border-radius: 4px;
  311. }
  312. .tree-container::-webkit-scrollbar-thumb {
  313. background: rgba(255, 215, 0, 0.5);
  314. border-radius: 4px;
  315. }
  316. .tree-container::-webkit-scrollbar-thumb:hover {
  317. background: rgba(255, 215, 0, 0.7);
  318. }
  319. /* Tree wrapper for horizontal scrolling */
  320. .tree-wrapper {
  321. width: 100%;
  322. overflow-x: auto;
  323. padding-bottom: 10px;
  324. scrollbar-width: thin;
  325. scrollbar-color: rgba(255,215,0,0.5) rgba(255,255,255,0.1);
  326. }
  327. .tree-wrapper::-webkit-scrollbar {
  328. width: 8px;
  329. height: 8px;
  330. }
  331. .tree-wrapper::-webkit-scrollbar-track {
  332. background: rgba(255, 255, 255, 0.1);
  333. border-radius: 4px;
  334. }
  335. .tree-wrapper::-webkit-scrollbar-thumb {
  336. background: rgba(255, 215, 0, 0.5);
  337. border-radius: 4px;
  338. }
  339. /* Generation row wrapper */
  340. .generation-row {
  341. display: flex;
  342. justify-content: center;
  343. width: 100%;
  344. overflow-x: auto;
  345. }
  346. /* Adoption styles */
  347. .tree-node.adopted-out {
  348. border: 2px dashed #ff6b6b !important;
  349. background: rgba(255, 107, 107, 0.1) !important;
  350. }
  351. .tree-node.adopted-in {
  352. border: 2px solid #4ecdc4 !important;
  353. background: rgba(78, 205, 196, 0.1) !important;
  354. }
  355. .adoption-label {
  356. font-size: 10px;
  357. color: #ff6b6b;
  358. font-weight: bold;
  359. margin-top: 4px;
  360. text-align: center;
  361. }
  362. /* 兄弟节点:稍小,低调 */
  363. .tree-node.sibling-node {
  364. background: rgba(74,105,189,0.2);
  365. border-color: rgba(74,105,189,0.4);
  366. min-width: 110px;
  367. padding: 8px 14px;
  368. }
  369. .tree-node.sibling-node .node-name {
  370. font-size: 14px;
  371. }
  372. .tree-node.sibling-node .node-info {
  373. font-size: 11px;
  374. }
  375. </style>
  376. {% endblock %}
  377. {% block content %}
  378. <div class="container-fluid">
  379. <!-- Header -->
  380. <div class="row mb-6">
  381. <div class="col-md-12">
  382. <div class="d-flex justify-content-between align-items-center">
  383. <h2 class="page-header">世系查询</h2>
  384. <div class="search-box">
  385. <div class="input-group">
  386. <input type="text" id="searchInput" class="form-control" placeholder="搜索成员姓名(支持简繁)" />
  387. <button class="btn btn-primary" onclick="searchMember()">
  388. <i class="bi bi-search"></i>
  389. </button>
  390. </div>
  391. </div>
  392. </div>
  393. </div>
  394. </div>
  395. <!-- Main Content -->
  396. <div class="row">
  397. <div class="col-md-12">
  398. <div id="treeContent">
  399. <!-- Empty State -->
  400. <div class="empty-state" id="emptyState">
  401. <i class="bi bi-search text-6xl mb-4"></i>
  402. <p class="text-lg">请在右上角搜索框中输入成员姓名</p>
  403. <p class="text-sm mt-2">支持简体和繁体姓名搜索</p>
  404. </div>
  405. <!-- Tree View -->
  406. <div id="treeView" style="display: none;" class="tree-container">
  407. <!-- Ancestors Section -->
  408. <div class="ancestors-section" id="ancestorsTree"></div>
  409. <!-- Siblings and Center Person Section -->
  410. <div class="siblings-section" id="siblingsTree"></div>
  411. <!-- Children Section -->
  412. <div class="children-section" id="childrenTree"></div>
  413. </div>
  414. </div>
  415. </div>
  416. </div>
  417. </div>
  418. <script>
  419. // Search member
  420. async function searchMember() {
  421. const keyword = document.getElementById('searchInput').value.trim();
  422. if (!keyword) {
  423. alert('请输入搜索关键词');
  424. return;
  425. }
  426. // Show loading state
  427. const searchBtn = document.querySelector('.search-box button');
  428. const originalBtnHtml = searchBtn.innerHTML;
  429. searchBtn.disabled = true;
  430. searchBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
  431. try {
  432. const response = await fetch('/manager/api/search_member', {
  433. method: 'POST',
  434. headers: { 'Content-Type': 'application/json' },
  435. body: JSON.stringify({ keyword: keyword }),
  436. credentials: 'include'
  437. });
  438. const result = await response.json();
  439. if (result.success && result.members) {
  440. if (result.members.length === 1) {
  441. // Only one match, load directly
  442. document.getElementById('emptyState').style.display = 'none';
  443. document.getElementById('treeView').style.display = 'block';
  444. await loadLineage(result.members[0].id);
  445. } else {
  446. // Multiple matches, show selection dialog
  447. showMemberSelection(result.members);
  448. }
  449. } else {
  450. alert('未找到匹配的成员');
  451. }
  452. } finally {
  453. // Restore button
  454. searchBtn.disabled = false;
  455. searchBtn.innerHTML = originalBtnHtml;
  456. }
  457. }
  458. // Show member selection dialog
  459. function showMemberSelection(members) {
  460. // Remove existing dialog
  461. const existingDialog = document.getElementById('memberSelectionDialog');
  462. if (existingDialog) {
  463. existingDialog.remove();
  464. }
  465. // Store members in sessionStorage
  466. sessionStorage.setItem('searchMembers', JSON.stringify(members));
  467. // Create dialog
  468. const dialog = document.createElement('div');
  469. dialog.id = 'memberSelectionDialog';
  470. dialog.style.cssText = `
  471. position: fixed;
  472. top: 50%;
  473. left: 50%;
  474. transform: translate(-50%, -50%);
  475. background: #1a1a2e;
  476. border: 2px solid #4a69bd;
  477. border-radius: 12px;
  478. padding: 24px;
  479. z-index: 10000;
  480. min-width: 400px;
  481. box-shadow: 0 10px 40px rgba(0,0,0,0.5);
  482. `;
  483. // Dialog content
  484. dialog.innerHTML = `
  485. <h3 style="color: #ffd700; margin-bottom: 16px; text-align: center;">找到多个匹配成员,请输入编号选择:</h3>
  486. <div style="margin-bottom: 16px; max-height: 200px; overflow-y: auto;">
  487. ${members.map((member, index) => `
  488. <div style="padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.1);">
  489. <span style="color: #ffd700; margin-right: 10px;">${index + 1}.</span>
  490. <span style="color: #fff; font-weight: 600;">${member.name}</span>
  491. ${member.simplified_name && member.simplified_name !== member.name ?
  492. `<span style="color: rgba(255,255,255,0.7);">(${member.simplified_name})</span>` : ''}
  493. </div>
  494. `).join('')}
  495. </div>
  496. <input type="text" id="selectionInput"
  497. placeholder="请输入编号选择(1-${members.length})"
  498. style="width: 100%; padding: 10px; margin-bottom: 16px;
  499. background: #2d3436; border: 1px solid #4a69bd;
  500. border-radius: 8px; color: #fff; text-align: center;
  501. font-size: 16px;" />
  502. <div style="display: flex; justify-content: center; gap: 16px;">
  503. <button onclick="selectMember()"
  504. style="padding: 10px 30px; background: linear-gradient(135deg, #4a69bd, #2d3436);
  505. border: 2px solid #4a69bd; border-radius: 8px; color: #fff;
  506. cursor: pointer; font-weight: 600;">确定</button>
  507. <button onclick="closeSelectionDialog()"
  508. style="padding: 10px 30px; background: #2d3436;
  509. border: 2px solid #666; border-radius: 8px; color: #aaa;
  510. cursor: pointer;">取消</button>
  511. </div>
  512. `;
  513. // Add overlay
  514. const overlay = document.createElement('div');
  515. overlay.id = 'dialogOverlay';
  516. overlay.style.cssText = `
  517. position: fixed;
  518. top: 0;
  519. left: 0;
  520. width: 100%;
  521. height: 100%;
  522. background: rgba(0,0,0,0.7);
  523. z-index: 9999;
  524. `;
  525. overlay.onclick = closeSelectionDialog;
  526. document.body.appendChild(overlay);
  527. document.body.appendChild(dialog);
  528. // Focus input
  529. document.getElementById('selectionInput').focus();
  530. // Enter key
  531. document.getElementById('selectionInput').addEventListener('keypress', function(e) {
  532. if (e.key === 'Enter') {
  533. selectMember();
  534. }
  535. });
  536. }
  537. // Close selection dialog
  538. function closeSelectionDialog() {
  539. document.getElementById('memberSelectionDialog')?.remove();
  540. document.getElementById('dialogOverlay')?.remove();
  541. }
  542. // Select member
  543. function selectMember() {
  544. const input = document.getElementById('selectionInput');
  545. const index = parseInt(input.value) - 1;
  546. const membersStr = sessionStorage.getItem('searchMembers');
  547. if (!membersStr) {
  548. alert('数据错误,请重新搜索');
  549. closeSelectionDialog();
  550. return;
  551. }
  552. const members = JSON.parse(membersStr);
  553. if (index >= 0 && index < members.length) {
  554. // Load lineage for selected member
  555. document.getElementById('emptyState').style.display = 'none';
  556. document.getElementById('treeView').style.display = 'block';
  557. loadLineage(members[index].id);
  558. // Close dialog
  559. closeSelectionDialog();
  560. } else {
  561. alert(`请输入有效的编号(1-${members.length})`);
  562. }
  563. }
  564. // Load lineage data
  565. async function loadLineage(memberId) {
  566. // Show loading state - preserve container elements
  567. document.getElementById('ancestorsTree').innerHTML = `
  568. <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
  569. <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
  570. <span class="visually-hidden">Loading...</span>
  571. </div>
  572. <p class="mt-4 text-white text-lg">正在加载祖先数据...</p>
  573. </div>
  574. `;
  575. document.getElementById('siblingsTree').innerHTML = `
  576. <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
  577. <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
  578. <span class="visually-hidden">Loading...</span>
  579. </div>
  580. <p class="mt-4 text-white text-lg">正在加载同辈数据...</p>
  581. </div>
  582. `;
  583. document.getElementById('childrenTree').innerHTML = `
  584. <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px;">
  585. <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
  586. <span class="visually-hidden">Loading...</span>
  587. </div>
  588. <p class="mt-4 text-white text-lg">正在加载后代数据...</p>
  589. </div>
  590. `;
  591. try {
  592. const response = await fetch(`/manager/api/get_lineage/${memberId}`, {
  593. credentials: 'include'
  594. });
  595. const result = await response.json();
  596. if (result.success) {
  597. console.log('Lineage data received:', result.data);
  598. // Use requestAnimationFrame for smoother rendering
  599. requestAnimationFrame(() => {
  600. renderLineage(result.data);
  601. });
  602. } else {
  603. console.error('API error:', result.message);
  604. document.getElementById('ancestorsTree').innerHTML = '';
  605. document.getElementById('siblingsTree').innerHTML = `
  606. <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px;">
  607. <i class="bi bi-x-circle text-red-500 text-6xl mb-4"></i>
  608. <p class="text-white text-lg">加载失败</p>
  609. <p class="text-gray-400 text-sm">${result.message || '服务器错误,请稍后重试'}</p>
  610. </div>
  611. `;
  612. document.getElementById('childrenTree').innerHTML = '';
  613. }
  614. } catch (error) {
  615. console.error('Fetch error:', error);
  616. document.getElementById('ancestorsTree').innerHTML = '';
  617. document.getElementById('siblingsTree').innerHTML = `
  618. <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px;">
  619. <i class="bi bi-x-circle text-red-500 text-6xl mb-4"></i>
  620. <p class="text-white text-lg">加载失败</p>
  621. <p class="text-gray-400 text-sm">网络错误: ${error.message}</p>
  622. </div>
  623. `;
  624. document.getElementById('childrenTree').innerHTML = '';
  625. }
  626. }
  627. // ── 工具函数 ─────────────────────────────────────────────────────────────────
  628. // 根据 child_order 生成"长子/次子/三子..."标签
  629. function getChildOrderLabel(childOrder, fallbackIndex) {
  630. const ord = childOrder != null ? childOrder : (fallbackIndex + 1);
  631. const labels = ['长', '次', '三', '四', '五', '六', '七', '八', '九', '十'];
  632. if (ord >= 1 && ord <= 10) return labels[ord - 1] + '子';
  633. return `第${ord}子`;
  634. }
  635. // 渲染单个节点 HTML
  636. // type: 'center' | 'ancestor' | 'sibling' | 'child'
  637. function renderNode(person, type) {
  638. if (!person) return '';
  639. let cls = 'tree-node clickable';
  640. if (type === 'center') cls += ' center';
  641. else if (type === 'ancestor') cls += ' direct-ancestor';
  642. // siblings / children use default style (can be slightly smaller)
  643. if (type === 'sibling') cls += ' sibling-node';
  644. if (person.sub_relation_type === 2) cls += ' adopted-out';
  645. else if (person.sub_relation_type === 3) cls += ' adopted-in';
  646. const adoptLabel = person.sub_relation_type === 2
  647. ? `<div class="adoption-label">${person.adoptive_parent_name ? '出继给 ' + person.adoptive_parent_name : '出继'}</div>`
  648. : '';
  649. return `
  650. <div class="${cls}" data-id="${person.id}" onclick="openPersonDetail(${person.id})">
  651. <div class="node-name">${person.name}</div>
  652. ${person.simplified_name && person.simplified_name !== person.name
  653. ? `<div class="node-name simplified">(${person.simplified_name})</div>` : ''}
  654. <div class="node-info">
  655. ${person.name_word ? person.name_word + ' · ' : ''}${person.name_word_generation || ''}
  656. </div>
  657. ${adoptLabel}
  658. </div>`;
  659. }
  660. // 渲染兄弟列表(横向,带横线连接)
  661. function renderSiblingsList(siblings) {
  662. if (!siblings || siblings.length === 0) return '';
  663. const MAX_SHOW = 6;
  664. const shown = siblings.slice(0, MAX_SHOW);
  665. const more = siblings.length - MAX_SHOW;
  666. return `
  667. <div class="lin-side">
  668. <div class="lin-hline"></div>
  669. <div class="lin-siblings">
  670. ${shown.map(s => renderNode(s, 'sibling')).join('')}
  671. ${more > 0 ? `<div class="tree-node sibling-node" style="opacity:.7;">+${more} 人</div>` : ''}
  672. </div>
  673. </div>`;
  674. }
  675. // ── 主渲染函数 ────────────────────────────────────────────────────────────────
  676. function renderLineage(data) {
  677. const { center, generations, siblings, children } = data;
  678. const ancestorGens = [...generations].reverse(); // 从最远祖先 → 父亲
  679. let html = '<div class="lineage-view">';
  680. // ── 1. 祖先竖列(最远→父亲)────────────────────────────────────────────
  681. if (ancestorGens.length > 0) {
  682. html += `<div class="section-divider">祖先世系</div>`;
  683. }
  684. ancestorGens.forEach((gen, idx) => {
  685. // 竖连接线(第一个不加顶部线,后面每个加)
  686. if (idx > 0) {
  687. html += `<div class="lin-row"><div class="lin-center"><div class="lin-vline"></div></div></div>`;
  688. }
  689. html += `<div class="lin-row">`;
  690. html += ` <div class="lin-center">${renderNode(gen.ancestor, 'ancestor')}</div>`;
  691. // 该祖先的兄弟(向右展示)
  692. html += renderSiblingsList(gen.siblings || []);
  693. html += `</div>`;
  694. });
  695. // ── 2. 中心人物 ──────────────────────────────────────────────────────────
  696. // 连接线
  697. if (ancestorGens.length > 0) {
  698. html += `<div class="lin-row"><div class="lin-center"><div class="lin-vline"></div></div></div>`;
  699. }
  700. html += `<div class="section-divider">查询人物</div>`;
  701. html += `<div class="lin-row">`;
  702. html += ` <div class="lin-center">${renderNode(center, 'center')}</div>`;
  703. // 中心人物的兄弟姐妹(向右展示)
  704. html += renderSiblingsList(siblings || []);
  705. html += `</div>`;
  706. // ── 3. 子女横排(第一子对齐,其余向右)──────────────────────────────────
  707. if (children && children.length > 0) {
  708. html += `<div class="lin-row"><div class="lin-center"><div class="lin-vline"></div></div></div>`;
  709. html += `<div class="section-divider">子女</div>`;
  710. html += `<div class="lin-row">`;
  711. html += ` <div class="lin-children">`;
  712. children.forEach((child, idx) => {
  713. const badge = getChildOrderLabel(child.child_order, idx);
  714. html += `<div class="lin-child-col">`;
  715. html += ` <div class="child-order-badge">${badge}</div>`;
  716. html += renderNode(child, 'child');
  717. if (child.has_children) {
  718. html += `<button class="expand-btn" onclick="toggleChildren(this,${child.id})">+</button>`;
  719. html += `<div class="children-container" style="display:none;" data-parent-id="${child.id}"></div>`;
  720. }
  721. html += `</div>`;
  722. });
  723. html += ` </div>`;
  724. html += `</div>`;
  725. }
  726. html += '</div>'; // lineage-view
  727. // 写入容器(ancestorsTree 承载全部内容,其余清空)
  728. document.getElementById('ancestorsTree').innerHTML = html;
  729. document.getElementById('siblingsTree').innerHTML = '';
  730. document.getElementById('childrenTree').innerHTML = '';
  731. }
  732. // 展开子孙(按钮旁的懒加载容器)
  733. function renderChildrenRecursive(children) {
  734. if (!children || children.length === 0) return '';
  735. return `<div class="lin-children" style="flex-wrap:wrap;gap:12px;">
  736. ${children.map((child, idx) => {
  737. const badge = getChildOrderLabel(child.child_order, idx);
  738. return `<div class="lin-child-col">
  739. <div class="child-order-badge">${badge}</div>
  740. ${renderNode(child, 'child')}
  741. ${child.has_children
  742. ? `<button class="expand-btn" onclick="toggleChildren(this,${child.id})">+</button>
  743. <div class="children-container" style="display:none;" data-parent-id="${child.id}"></div>`
  744. : ''}
  745. </div>`;
  746. }).join('')}
  747. </div>`;
  748. }
  749. // Open person detail in new tab
  750. function openPersonDetail(personId) {
  751. window.open(`/manager/member_detail/${personId}`, '_blank');
  752. }
  753. // Toggle children visibility with lazy loading
  754. async function toggleChildren(btn, parentId) {
  755. const container = btn.nextElementSibling;
  756. const isExpanded = container.style.display !== 'none';
  757. if (isExpanded) {
  758. container.style.display = 'none';
  759. btn.innerHTML = '+';
  760. } else {
  761. // Check if children are already loaded
  762. if (container.innerHTML.trim() === '') {
  763. // Load children lazily
  764. btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
  765. // Get already displayed descendant IDs to exclude
  766. const excludedIds = getExcludedDescendantIds(btn);
  767. const excludeParam = excludedIds.length > 0 ? `?exclude=${excludedIds.join(',')}` : '';
  768. console.log(`[ToggleChildren] Parent ID: ${parentId}, Excluded IDs: ${excludedIds}, URL: /manager/api/get_descendants/${parentId}${excludeParam}`);
  769. try {
  770. const response = await fetch(`/manager/api/get_descendants/${parentId}${excludeParam}`, {
  771. credentials: 'include'
  772. });
  773. const result = await response.json();
  774. if (result.success && result.children) {
  775. // Render children
  776. container.innerHTML = renderChildrenRecursive(result.children);
  777. }
  778. } catch (error) {
  779. console.error('Failed to load children:', error);
  780. } finally {
  781. btn.innerHTML = '−';
  782. }
  783. }
  784. container.style.display = 'flex';
  785. btn.innerHTML = '−';
  786. }
  787. }
  788. // Get descendant IDs that are already displayed in the tree (to avoid duplicates)
  789. function getExcludedDescendantIds(btn) {
  790. const excluded = new Set();
  791. // Helper function to extract IDs from tree nodes
  792. const extractIdsFromNodes = (container) => {
  793. if (!container) return;
  794. const nodes = container.querySelectorAll('.tree-node');
  795. nodes.forEach(node => {
  796. const id = node.getAttribute('data-id');
  797. if (id && !isNaN(parseInt(id))) {
  798. excluded.add(parseInt(id));
  799. }
  800. });
  801. };
  802. // Get IDs from ancestors tree
  803. const ancestorsTree = document.getElementById('ancestorsTree');
  804. extractIdsFromNodes(ancestorsTree);
  805. // Get IDs from siblings tree (center person and siblings)
  806. const siblingsTree = document.getElementById('siblingsTree');
  807. extractIdsFromNodes(siblingsTree);
  808. // Get IDs from children tree
  809. const childrenTree = document.getElementById('childrenTree');
  810. extractIdsFromNodes(childrenTree);
  811. console.log('Excluded IDs:', Array.from(excluded));
  812. return Array.from(excluded);
  813. }
  814. // Enter key search
  815. document.getElementById('searchInput').addEventListener('keypress', function(e) {
  816. if (e.key === 'Enter') {
  817. searchMember();
  818. }
  819. });
  820. </script>
  821. {% endblock %}