tree.html 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094
  1. {% extends "layout.html" %}
  2. {% block title %}家谱世系树状图 - 家谱管理系统{% endblock %}
  3. {% block extra_css %}
  4. <style>
  5. #tree-container {
  6. width: 100%;
  7. height: calc(100vh - 160px);
  8. min-height: 500px;
  9. background: white;
  10. border: 1px solid #e9ecef;
  11. border-radius: 8px;
  12. margin-top: 10px;
  13. box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
  14. position: relative;
  15. overflow: hidden;
  16. }
  17. .zoom-controls {
  18. position: absolute;
  19. top: 10px;
  20. right: 10px;
  21. z-index: 100;
  22. background: white;
  23. border-radius: 4px;
  24. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  25. padding: 5px;
  26. }
  27. .zoom-btn {
  28. display: block;
  29. width: 30px;
  30. height: 30px;
  31. margin: 5px 0;
  32. border: 1px solid #ddd;
  33. border-radius: 4px;
  34. background: white;
  35. cursor: pointer;
  36. display: flex;
  37. align-items: center;
  38. justify-content: center;
  39. }
  40. .zoom-btn:hover {
  41. background: #f8f9fa;
  42. }
  43. #tree-container::-webkit-scrollbar {
  44. width: 8px;
  45. height: 8px;
  46. }
  47. #tree-container::-webkit-scrollbar-track {
  48. background: #f1f1f1;
  49. border-radius: 4px;
  50. }
  51. #tree-container::-webkit-scrollbar-thumb {
  52. background: #c1c1c1;
  53. border-radius: 4px;
  54. }
  55. #tree-container::-webkit-scrollbar-thumb:hover {
  56. background: #a1a1a1;
  57. }
  58. .node rect, .node circle {
  59. stroke-width: 2px;
  60. filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
  61. }
  62. .node text { font: 13px 'Microsoft YaHei', sans-serif; }
  63. .node .node-name {
  64. font-size: 13px;
  65. font-weight: 500;
  66. fill: #334155;
  67. stroke: #fff;
  68. stroke-width: 4px;
  69. paint-order: stroke;
  70. stroke-linejoin: round;
  71. }
  72. /* 样图一致:较粗的细实线、浅灰蓝色,显得专业 */
  73. .link { fill: none; stroke: #94A3B8; stroke-width: 2px; stroke-linejoin: round; }
  74. /* 关系标签文字,取消白色粗描边,因为已有胶囊形背景 */
  75. .link-label { font-size: 11px; fill: #64748B; font-weight: 500; }
  76. .link-label-sibling { fill: #64748B; }
  77. /* 男女形状与颜色区分:男性方形(蓝),女性圆形(粉红),添加轻微圆角与投影 */
  78. .node-male rect { stroke: #3B82F6; fill: #EFF6FF; rx: 8px; ry: 8px; }
  79. /* 出继节点样式:同色虚线框,仅边框样式区别,颜色与普通节点保持一致 */
  80. .node-adopted-out rect, .node-adopted-out circle {
  81. stroke-dasharray: 6, 4 !important;
  82. }
  83. .node-female circle { stroke: #EC4899; fill: #FDF2F8; }
  84. /* 未知性别默认 */
  85. .node-leaf circle, .node-internal circle { stroke: #94A3B8; fill: #F8FAFC; }
  86. .node-male circle { stroke: none; fill: none; } /* 清除可能的干扰 */
  87. /* 样图一致:全部细实线 */
  88. .link-parent-child { stroke: #333; stroke-dasharray: none; }
  89. .link-spouse { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
  90. .link-sibling { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
  91. /* 右键菜单样式 */
  92. .context-menu {
  93. position: absolute;
  94. display: none;
  95. background: white;
  96. border: 1px solid #ccc;
  97. box-shadow: 2px 2px 10px rgba(0,0,0,0.2);
  98. z-index: 1000;
  99. border-radius: 4px;
  100. padding: 5px 0;
  101. min-width: 120px;
  102. }
  103. .context-menu-item {
  104. padding: 8px 15px;
  105. cursor: pointer;
  106. font-size: 14px;
  107. color: #333;
  108. }
  109. .context-menu-item:hover {
  110. background-color: #f8f9fa;
  111. color: #0d6efd;
  112. }
  113. .context-menu-item i {
  114. margin-right: 8px;
  115. }
  116. /* 折叠/展开 +/- 小圆钮 */
  117. .collapse-toggle {
  118. cursor: pointer;
  119. }
  120. .collapse-toggle circle {
  121. transition: fill 0.2s, transform 0.2s;
  122. }
  123. .collapse-toggle:hover circle {
  124. opacity: 0.85;
  125. }
  126. .collapse-toggle text {
  127. pointer-events: none;
  128. user-select: none;
  129. }
  130. /* 节点被折叠时降低透明度以区分 */
  131. .node-collapsed > rect,
  132. .node-collapsed > circle {
  133. opacity: 0.75;
  134. stroke-dasharray: 5, 3;
  135. }
  136. </style>
  137. {% endblock %}
  138. {% block content %}
  139. <div class="d-flex justify-content-between align-items-center mb-3">
  140. <h2><i class="bi bi-diagram-3"></i> 家谱关系树状图</h2>
  141. <div class="d-flex gap-2">
  142. <div class="input-group" style="width: 300px;">
  143. <input type="text" id="memberSearch" class="form-control form-control-sm" placeholder="输入成员名字搜索">
  144. <button class="btn btn-sm btn-primary" onclick="searchMember()">
  145. <i class="bi bi-search"></i> 搜索
  146. </button>
  147. </div>
  148. <button class="btn btn-outline-secondary btn-sm" onclick="expandAll()" title="展开所有已折叠节点">
  149. <i class="bi bi-arrows-expand"></i> 全部展开
  150. </button>
  151. <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-primary btn-sm">
  152. <i class="bi bi-printer"></i> 导出传统吊线图
  153. </a>
  154. </div>
  155. </div>
  156. <div class="alert alert-light border small py-2">
  157. <i class="bi bi-info-circle me-1"></i> 提示:图中按家谱世系树状图格式展示。支持拖拽建立关系,右键点击成员可查看、编辑或新增。
  158. </div>
  159. <div id="tree-container">
  160. <!-- 缩放控制 -->
  161. <div class="zoom-controls">
  162. <button class="zoom-btn" onclick="zoomIn()"><i class="bi bi-plus"></i></button>
  163. <button class="zoom-btn" onclick="zoomOut()"><i class="bi bi-dash"></i></button>
  164. <button class="zoom-btn" onclick="zoomReset()"><i class="bi bi-arrow-counterclockwise"></i></button>
  165. </div>
  166. <!-- 右键菜单 -->
  167. <div id="contextMenu" class="context-menu">
  168. <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
  169. <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
  170. <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
  171. <div class="context-menu-item" id="collapseMenuItem" onclick="menuAction('collapse')"><i class="bi bi-arrows-collapse"></i>收起子节点</div>
  172. </div>
  173. </div>
  174. <!-- 关系选择弹窗 -->
  175. <div class="modal fade" id="relationModal" tabindex="-1">
  176. <div class="modal-dialog modal-sm">
  177. <div class="modal-content">
  178. <div class="modal-header">
  179. <h5 class="modal-title">建立关系</h5>
  180. <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
  181. </div>
  182. <div class="modal-body">
  183. <p id="relationInfo" class="small mb-3"></p>
  184. <input type="hidden" id="sourceMid">
  185. <input type="hidden" id="targetMid">
  186. <div class="mb-3">
  187. <label class="form-label small">关系类型</label>
  188. <select id="relType" class="form-select form-select-sm">
  189. <option value="1">是其 儿子/女儿</option>
  190. <option value="10">是其 妻子/丈夫</option>
  191. <option value="11">是其 兄弟</option>
  192. <option value="12">是其 姐妹</option>
  193. </select>
  194. </div>
  195. <div class="mb-3">
  196. <label class="form-label small">子类型</label>
  197. <select id="subRelType" class="form-select form-select-sm">
  198. <option value="0">亲生/正妻</option>
  199. <option value="1">养子/女</option>
  200. <option value="2">过继</option>
  201. </select>
  202. </div>
  203. </div>
  204. <div class="modal-footer">
  205. <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">取消</button>
  206. <button type="button" class="btn btn-sm btn-primary" onclick="saveRelation()">保存关系</button>
  207. </div>
  208. </div>
  209. </div>
  210. </div>
  211. {% endblock %}
  212. {% block extra_js %}
  213. <!-- 优先使用本地 D3.js -->
  214. <script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
  215. <!-- fallback to CDN if local fails -->
  216. <script>
  217. if (typeof d3 === 'undefined') {
  218. var script = document.createElement('script');
  219. script.src = 'https://cdn.jsdelivr.net/npm/d3@7.8.5/dist/d3.min.js';
  220. script.onload = function() {
  221. if (typeof d3 !== 'undefined') {
  222. loadTree();
  223. } else {
  224. var container = document.getElementById('tree-container');
  225. if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
  226. }
  227. };
  228. script.onerror = function() {
  229. var container = document.getElementById('tree-container');
  230. if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
  231. };
  232. document.head.appendChild(script);
  233. }
  234. </script>
  235. <script>
  236. (function() {
  237. if (typeof d3 === 'undefined') {
  238. // CDN will handle loading and initialization
  239. }
  240. })();
  241. let currentData = null;
  242. let dragSource = null;
  243. let dragTarget = null;
  244. let selectedMid = null; // 当前选中的成员 ID
  245. let collapsedNodes = new Set(); // 已折叠的节点 ID 集合
  246. let zoomScale = 1;
  247. let zoomTransform = d3.zoomIdentity;
  248. let zoomBehavior = d3.zoom().scaleExtent([0.1, 4]).on("zoom", zoomed);
  249. const relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
  250. const contextMenu = document.getElementById('contextMenu');
  251. // 隐藏右键菜单
  252. window.addEventListener('click', () => {
  253. contextMenu.style.display = 'none';
  254. });
  255. // 全部展开
  256. function expandAll() {
  257. collapsedNodes.clear();
  258. renderTree(currentData);
  259. }
  260. // 处理菜单点击
  261. function menuAction(type) {
  262. if (!selectedMid && type !== 'add') return;
  263. switch(type) {
  264. case 'detail':
  265. window.location.href = `/manager/member_detail/${selectedMid}`;
  266. break;
  267. case 'edit':
  268. window.location.href = `/manager/edit_member/${selectedMid}`;
  269. break;
  270. case 'add':
  271. window.location.href = '/manager/add_member';
  272. break;
  273. case 'collapse': {
  274. const savedTr = zoomTransform;
  275. if (collapsedNodes.has(selectedMid)) {
  276. collapsedNodes.delete(selectedMid);
  277. } else {
  278. collapsedNodes.add(selectedMid);
  279. }
  280. renderTree(currentData);
  281. d3.select("#tree-container svg").call(zoomBehavior.transform, savedTr);
  282. break;
  283. }
  284. }
  285. }
  286. // 获取数据并渲染
  287. function loadTree() {
  288. if (typeof d3 === 'undefined') return;
  289. fetch('/manager/api/tree_data')
  290. .then(response => response.json())
  291. .then(data => {
  292. currentData = data;
  293. renderTree(data);
  294. });
  295. }
  296. if (typeof d3 !== 'undefined') loadTree();
  297. function renderTree(data) {
  298. const container = document.getElementById('tree-container');
  299. container.innerHTML = '';
  300. container.appendChild(contextMenu);
  301. try {
  302. const { members, relations } = data;
  303. if (!members || members.length === 0) {
  304. container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted">暂无成员数据,无法生成关系图。</div>';
  305. return;
  306. }
  307. // 构建节点,保留所有关系信息
  308. const nodes = members.map(m => ({ id: m.id, name: m.name, simplified_name: m.simplified_name, sex: m.sex, adoptedOut: false, adoptedOutTarget: null }));
  309. // 获取所有父子关系(包括出继/入继)
  310. const allHierarchicalRelations = relations.filter(r => r.relation_type === 1 || r.relation_type === 2);
  311. // 对于出继的子女,只要有 sub_relation_type===2 的关系即标记;同时尝试找到入继目标名称
  312. const adoptedOutMap = new Map();
  313. allHierarchicalRelations.forEach(r => {
  314. if (r.sub_relation_type === 2) { // 出继
  315. // 即便未找到入继关系也先标记(target 置 null)
  316. if (!adoptedOutMap.has(r.child_mid)) {
  317. adoptedOutMap.set(r.child_mid, null);
  318. }
  319. // 尝试找到对应的入继关系,获取养父名
  320. const targetRelation = allHierarchicalRelations.find(r2 => r2.child_mid === r.child_mid && r2.sub_relation_type === 3);
  321. if (targetRelation) {
  322. const targetMember = members.find(m => m.id === targetRelation.parent_mid);
  323. if (targetMember) {
  324. adoptedOutMap.set(r.child_mid, targetMember.name);
  325. }
  326. }
  327. }
  328. });
  329. // 更新节点的出继标记
  330. nodes.forEach(node => {
  331. if (adoptedOutMap.has(node.id)) {
  332. node.adoptedOut = true;
  333. node.adoptedOutTarget = adoptedOutMap.get(node.id);
  334. }
  335. });
  336. // 保留所有父子关系(包括出继),用于构建树
  337. const hierarchicalLinks = allHierarchicalRelations.map(r => ({
  338. source: r.parent_mid,
  339. target: r.child_mid,
  340. sub_relation_type: r.sub_relation_type
  341. }));
  342. // ── 推断缺失的入继关系 ──────────────────────────────────────────────
  343. // 若同一亲生父的多个出继子女共享同一已知养父,则为缺失入继记录的子女补全推断链接
  344. {
  345. // 按亲生父分组:parentId → [childId, ...](仅 sub=2 的出继子女)
  346. const outByParent = new Map();
  347. allHierarchicalRelations.forEach(r => {
  348. if (r.sub_relation_type === 2) {
  349. if (!outByParent.has(r.parent_mid)) outByParent.set(r.parent_mid, []);
  350. outByParent.get(r.parent_mid).push(r.child_mid);
  351. }
  352. });
  353. outByParent.forEach((childMids, _parentId) => {
  354. // 收集已知养父 ID 集合
  355. const knownTargetIds = new Set();
  356. childMids.forEach(childId => {
  357. const link = hierarchicalLinks.find(l => l.target === childId && l.sub_relation_type === 3);
  358. if (link) knownTargetIds.add(link.source);
  359. });
  360. // 只有唯一确定的养父时才推断(避免歧义)
  361. if (knownTargetIds.size === 1) {
  362. const adoptiveParentId = [...knownTargetIds][0];
  363. childMids.forEach(childId => {
  364. const exists = hierarchicalLinks.some(l => l.source === adoptiveParentId && l.target === childId && l.sub_relation_type === 3);
  365. if (!exists) {
  366. hierarchicalLinks.push({ source: adoptiveParentId, target: childId, sub_relation_type: 3 });
  367. // 同步更新 adoptedOutTarget
  368. const targetMember = members.find(m => m.id === adoptiveParentId);
  369. if (targetMember && adoptedOutMap.has(childId) && !adoptedOutMap.get(childId)) {
  370. adoptedOutMap.set(childId, targetMember.name);
  371. }
  372. }
  373. });
  374. }
  375. });
  376. // adoptedOutTarget 同步回 nodes
  377. nodes.forEach(node => {
  378. if (adoptedOutMap.has(node.id)) {
  379. node.adoptedOut = true;
  380. node.adoptedOutTarget = adoptedOutMap.get(node.id);
  381. }
  382. });
  383. }
  384. // ────────────────────────────────────────────────────────────────────
  385. const spouseLinks = relations.filter(r => r.relation_type === 10);
  386. const otherLinks = relations.filter(r => r.relation_type >= 11);
  387. const childIds = new Set(hierarchicalLinks.map(l => l.target));
  388. const allSpouseIds = new Set(spouseLinks.map(l => l.child_mid));
  389. const roots = nodes.filter(n => !childIds.has(n.id) && !allSpouseIds.has(n.id));
  390. function buildHierarchy(nodeId, processedNodes = new Set()) {
  391. const node = nodes.find(n => n.id === nodeId);
  392. if (!node || processedNodes.has(nodeId)) return null;
  393. processedNodes.add(nodeId);
  394. const childLinks = hierarchicalLinks.filter(l => l.source === nodeId);
  395. const hasHierarchicalChildren = childLinks.length > 0;
  396. const isCollapsed = collapsedNodes.has(nodeId);
  397. // 折叠时不递归子代(但仍保留配偶以维持同层显示)
  398. const children = isCollapsed
  399. ? []
  400. : childLinks.map(l => {
  401. if (processedNodes.has(l.target)) {
  402. // 入继(sub_relation_type===3)子女已在亲生父处渲染过,
  403. // 在养父下创建浅引用节点(不再递归,避免重复/死循环)
  404. if (l.sub_relation_type === 3) {
  405. const cn = nodes.find(n => n.id === l.target);
  406. if (cn) {
  407. return {
  408. id: cn.id,
  409. name: cn.name,
  410. simplified_name: cn.simplified_name,
  411. sex: cn.sex,
  412. adoptedIn: true,
  413. hasHierarchicalChildren: false,
  414. isCollapsed: false,
  415. children: []
  416. };
  417. }
  418. }
  419. return null;
  420. }
  421. return buildHierarchy(l.target, processedNodes);
  422. }).filter(c => c !== null);
  423. const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
  424. .map(l => {
  425. const spouseId = l.child_mid;
  426. if (!childIds.has(spouseId)) {
  427. const sNode = nodes.find(n => n.id === spouseId);
  428. if (sNode && !processedNodes.has(spouseId)) {
  429. processedNodes.add(spouseId);
  430. return { id: sNode.id, name: sNode.name, simplified_name: sNode.simplified_name, sex: sNode.sex, isSpouseNode: true, children: [] };
  431. }
  432. }
  433. return null;
  434. })
  435. .filter(s => s !== null);
  436. return {
  437. id: node.id,
  438. name: node.name,
  439. simplified_name: node.simplified_name,
  440. sex: node.sex,
  441. adoptedOut: node.adoptedOut,
  442. adoptedOutTarget: node.adoptedOutTarget,
  443. hasHierarchicalChildren,
  444. isCollapsed,
  445. children: spouses.concat(children)
  446. };
  447. }
  448. let treeData;
  449. if (roots.length > 1) {
  450. treeData = { name: "家谱根源", children: roots.map(root => buildHierarchy(root.id)).filter(r => r !== null) };
  451. } else if (roots.length === 1) {
  452. treeData = buildHierarchy(roots[0].id);
  453. } else {
  454. treeData = buildHierarchy(nodes[0].id);
  455. }
  456. const margin = {top: 80, right: 60, bottom: 80, left: 60};
  457. const containerWidth = document.getElementById('tree-container').offsetWidth;
  458. let rootNode = d3.hierarchy(treeData, d => (d && d.children) || []);
  459. // 动态调整间距,保证节点绝对不重叠,长辈/同辈/配偶使用固定基础间距
  460. const nodeWidth = 120; // 增加基础宽度以避免重叠
  461. const nodeHeight = 260; // 基础高度,调大以避免上下层重叠
  462. const treemap = d3.tree().nodeSize([nodeWidth, nodeHeight]).separation((a, b) => {
  463. return a.parent === b.parent ? 2.0 : 2.0; // 增加同级节点间距
  464. });
  465. let nodesHier = treemap(rootNode);
  466. // 配偶固定在同一水平线上:与本人并列显示
  467. const spouseSpreadX = 140; // 增加间距以确保不重叠
  468. nodesHier.descendants().forEach(parent => {
  469. const spouses = (parent.children || []).filter(c => c.data && c.data.isSpouseNode);
  470. if (spouses.length === 0) return;
  471. spouses.forEach((spouse, idx) => {
  472. // 计算配偶位置,从右侧开始排列
  473. spouse.x = parent.x + (idx + 1) * spouseSpreadX;
  474. spouse.y = parent.y; // 同一水平线
  475. });
  476. });
  477. // 计算边界以动态设置 SVG 宽高,实现自动滚动不挤压
  478. let x0 = Infinity;
  479. let x1 = -Infinity;
  480. let y1 = -Infinity;
  481. nodesHier.descendants().forEach(d => {
  482. if (d.x < x0) x0 = d.x;
  483. if (d.x > x1) x1 = d.x;
  484. if (d.y > y1) y1 = d.y;
  485. });
  486. // 增加更宽的边距以确保左侧不被切断
  487. const minWidth = containerWidth;
  488. const calculatedWidth = x1 - x0 + margin.left + margin.right + 400; // 增加额外的宽度缓冲
  489. const svgWidth = Math.max(minWidth, calculatedWidth);
  490. const svgHeight = Math.max(600, y1 + margin.top + margin.bottom);
  491. // 修正偏移量计算:确保最小的 x0 节点完全在可视区域内(加上足够的左边距)
  492. // 这样即便是负的很大,也会被完整平移到正数区域
  493. const extraLeftMargin = 200; // 增加更多左侧空间
  494. let offsetX = margin.left - x0 + extraLeftMargin; // 强制将最左侧节点右移确保文字不被截断
  495. // 确保offsetX至少为margin.left,防止内容被左侧菜单遮挡
  496. offsetX = Math.max(offsetX, margin.left + 50);
  497. const svg = d3.select("#tree-container").append("svg")
  498. .attr("width", svgWidth)
  499. .attr("height", svgHeight)
  500. .call(zoomBehavior)
  501. .append("g")
  502. .attr("transform", `translate(${offsetX},${margin.top})`);
  503. // 存储SVG元素引用,用于缩放操作
  504. window.svgGroup = svg;
  505. // 节点圆圈半径(连线与节点共用),调大让图谱更清晰大气
  506. const circleR = 20;
  507. // 辅助函数:绘制精致的徽章式关系标签
  508. function addBadge(g, x, y, text) {
  509. const group = g.append("g").attr("transform", `translate(${x},${y})`);
  510. group.append("rect")
  511. .attr("x", -20).attr("y", -10)
  512. .attr("width", 40).attr("height", 20)
  513. .attr("rx", 10).attr("ry", 10) // 胶囊形状
  514. .attr("fill", "#fff")
  515. .attr("stroke", "#CBD5E1").attr("stroke-width", 1.2);
  516. group.append("text")
  517. .attr("class", "link-label")
  518. .attr("x", 0).attr("y", 0).attr("dy", "0.32em")
  519. .attr("text-anchor", "middle")
  520. .text(text);
  521. }
  522. // 连线:第二层连线设计(U型配偶线 + 亲子水平线)
  523. nodesHier.descendants().forEach(node => {
  524. if (!node.children || node.children.length === 0) return;
  525. const realChildren = (node.children || []).filter(c => c.data && !c.data.isSpouseNode);
  526. const spouses = (node.children || []).filter(c => c.data && c.data.isSpouseNode);
  527. // 配偶连线拐点:位于本人与配偶节点之间,匹配“下层半级”显示
  528. // const hY = node.y + Math.round(spouseHalfLevelOffsetY * 0.55);
  529. const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
  530. // 夫妻关系:水平连线(主节点右侧连接到配偶左侧)
  531. if (spouses.length > 0) {
  532. const g = svg.append("g").attr("class", "link-group");
  533. spouses.forEach((spouse, idx) => {
  534. // 水平连线,稍微向下偏移一点以避开节点
  535. const linkY = node.y + 10;
  536. const pathD = `M${num(node.x + circleR)},${num(linkY)}
  537. L${num(spouse.x - circleR)},${num(linkY)}`;
  538. g.append("path").attr("class", "link link-spouse").attr("d", pathD);
  539. if (idx === 0) addBadge(g, (node.x + spouse.x) / 2, linkY, "配偶");
  540. });
  541. }
  542. if (realChildren.length === 0) return;
  543. const childrenY = realChildren[0].y;
  544. const minChildX = d3.min(realChildren, c => c.x);
  545. const maxChildX = d3.max(realChildren, c => c.x);
  546. // 如果有配偶,子代主线从本人和最右侧配偶的中间引出,否则从自己直接引出
  547. const startX = spouses.length > 0 ? (node.x + spouses[spouses.length - 1].x) / 2 : node.x;
  548. // 若节点有折叠按钮(底部),连线从按钮下方起始
  549. const startY = node.y + circleR + (node.data.hasHierarchicalChildren ? 28 : 0);
  550. // 将子女横线也相应地下移一些,避免和配偶长名字重叠
  551. const sibsY = childrenY - 60;
  552. const g = svg.append("g").attr("class", "link-group");
  553. const hLineLeft = Math.min(minChildX, startX);
  554. const hLineRight = Math.max(maxChildX, startX);
  555. // 主线:从配偶U型线中点连到子女水平线
  556. let pathD = `M${num(startX)},${num(startY)} L${num(startX)},${num(sibsY)} M${num(hLineLeft)},${num(sibsY)} L${num(hLineRight)},${num(sibsY)}`;
  557. // 短竖线:连到每个子女顶部
  558. realChildren.forEach(child => {
  559. pathD += ` M${num(child.x)},${num(sibsY)} L${num(child.x)},${num(child.y) - circleR}`;
  560. });
  561. g.append("path").attr("class", "link link-parent-child").attr("d", pathD);
  562. // 去除家谱根源到第一层的关系展示;下层亲子关系标记使用徽章式标签放在短竖线上,一目了然
  563. const isRootToFirst = !node.parent || !(node.data && node.data.id);
  564. if (!isRootToFirst && node.data) {
  565. realChildren.forEach(child => {
  566. const pSex = node.data.sex;
  567. const cSex = child.data && child.data.sex;
  568. let label = "亲子";
  569. if (child.data && child.data.adoptedIn) {
  570. label = cSex === 1 ? "养子" : "养女";
  571. } else if (pSex === 1) {
  572. label = cSex === 1 ? "父子" : "父女";
  573. } else if (pSex === 2) {
  574. label = cSex === 1 ? "母子" : "母女";
  575. }
  576. const childLinkMidY = (sibsY + child.y - circleR) / 2;
  577. addBadge(g, child.x, childLinkMidY, label);
  578. });
  579. }
  580. });
  581. // 兄弟/姐妹:上方 U 型连线避免穿透节点
  582. const idToPos = {};
  583. nodesHier.descendants().forEach(d => { if (d.data.id) idToPos[d.data.id] = { x: d.x, y: d.y }; });
  584. otherLinks.forEach(rel => {
  585. const s = idToPos[rel.parent_mid], t = idToPos[rel.child_mid];
  586. if (s && t) {
  587. const x1 = Math.min(s.x, t.x), x2 = Math.max(s.x, t.x);
  588. const y = s.y - circleR - 25; // 兄弟线在节点上方
  589. const g = svg.append("g");
  590. const pathD = `M${x1},${s.y - circleR} L${x1},${y} L${x2},${y} L${x2},${t.y - circleR}`;
  591. g.append("path").attr("class", "link link-sibling").attr("d", pathD);
  592. addBadge(g, (x1 + x2) / 2, y, rel.relation_type === 11 ? "兄弟" : "姐妹");
  593. }
  594. });
  595. const node = svg.selectAll(".node")
  596. .data(nodesHier.descendants())
  597. .enter().append("g")
  598. .attr("class", d => {
  599. let cls = "node" + (d.children ? " node--internal" : " node--leaf");
  600. if (d.data.sex === 1) cls += " node-male";
  601. else if (d.data.sex === 2) cls += " node-female";
  602. if (d.data.adoptedOut) cls += " node-adopted-out";
  603. return cls;
  604. })
  605. .attr("transform", d => `translate(${d.x},${d.y})`)
  606. .on("contextmenu", function(event, d) {
  607. if (!d.data.id) return;
  608. event.preventDefault();
  609. selectedMid = d.data.id;
  610. // 动态更新折叠菜单项文字与可见性
  611. const collapseItem = document.getElementById('collapseMenuItem');
  612. if (d.data.hasHierarchicalChildren) {
  613. collapseItem.style.display = '';
  614. const icon = collapseItem.querySelector('i');
  615. if (collapsedNodes.has(d.data.id)) {
  616. icon.className = 'bi bi-arrows-expand';
  617. collapseItem.childNodes[1].textContent = '展开子节点';
  618. } else {
  619. icon.className = 'bi bi-arrows-collapse';
  620. collapseItem.childNodes[1].textContent = '收起子节点';
  621. }
  622. } else {
  623. collapseItem.style.display = 'none';
  624. }
  625. const containerRect = document.getElementById('tree-container').getBoundingClientRect();
  626. contextMenu.style.display = 'block';
  627. contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
  628. contextMenu.style.top = (event.clientY - containerRect.top) + 'px';
  629. })
  630. .call(d3.drag()
  631. .on("start", dragstarted)
  632. .on("drag", dragged)
  633. .on("end", dragended));
  634. // 图形:男性方形,女性圆形,更符合生物遗传图谱
  635. node.each(function(d) {
  636. const el = d3.select(this);
  637. // 出继节点 hover 提示
  638. if (d.data.adoptedOut) {
  639. const tip = d.data.adoptedOutTarget ? `出继给 ${d.data.adoptedOutTarget}` : '出继';
  640. el.append('title').text(tip);
  641. }
  642. if (d.data.sex === 1) {
  643. el.append("rect")
  644. .attr("x", -circleR).attr("y", -circleR)
  645. .attr("width", circleR * 2).attr("height", circleR * 2)
  646. .style("cursor", "grab");
  647. } else {
  648. el.append("circle")
  649. .attr("r", circleR)
  650. .style("cursor", "grab");
  651. }
  652. });
  653. // 人名:往下移动更多,避免和长方形/圆形图形或者连线重叠
  654. // 有折叠按钮的节点多留出按钮空间(按钮中心在 y=circleR,高度约 20px,再留 8px 间距)
  655. // circleR(20) + 按钮中心(12) + 按钮半径(11) + 间距(6) = 49
  656. const nameOffsetY = circleR + 49;
  657. const maxNameLen = 12; // 允许稍微长一点的文字
  658. function fullName(d) {
  659. if (!d.data) return '';
  660. return d.data.simplified_name ? `${d.data.name || ''} (${d.data.simplified_name})` : (d.data.name || '');
  661. }
  662. const nameGroup = node.append("g").attr("class", "node-name-wrap").attr("transform", "translate(0, " + nameOffsetY + ")");
  663. nameGroup.each(function(d) {
  664. const g = d3.select(this);
  665. const full = fullName(d);
  666. const disp = full.length <= maxNameLen ? full : full.slice(0, maxNameLen) + '…';
  667. // 姓名可能较长,我们这里做个简单的多行拆分显示或者让它有一个白色背景遮挡线
  668. // 这里为了简单不破坏原有结构,仅使用 text,但可以给一个白色的 stroke 做底或者调整 y 坐标
  669. const textNode = g.append("text")
  670. .attr("class", "node-name")
  671. .attr("x", 0).attr("y", 0)
  672. .attr("dy", "0.8em") // 使得文字基线往下靠,进一步远离图形
  673. .attr("text-anchor", "middle")
  674. .style("pointer-events", "all")
  675. .style("cursor", "default")
  676. .text(disp);
  677. if (full.length > maxNameLen) {
  678. textNode.append("title").text(full);
  679. }
  680. });
  681. // 标记折叠节点样式
  682. node.each(function(d) {
  683. if (d.data.isCollapsed) d3.select(this).classed("node-collapsed", true);
  684. });
  685. // ── 折叠/展开按钮 ──────────────────────────────────────────────────
  686. // 直接追加到 SVG 坐标层(非节点 g 内部),彻底脱离 drag 事件树,确保可见可点
  687. nodesHier.descendants().forEach(function(nd) {
  688. if (!nd.data.id || !nd.data.hasHierarchicalChildren) return;
  689. const collapsed = nd.data.isCollapsed;
  690. const bx = nd.x;
  691. const by = nd.y + circleR + 14; // 形状底边再往下 14px
  692. const tg = svg.append("g")
  693. .attr("class", "collapse-toggle")
  694. .attr("transform", `translate(${bx},${by})`)
  695. .style("cursor", "pointer");
  696. tg.on("mousedown", function(e) { e.stopPropagation(); });
  697. tg.on("click", function(e) {
  698. e.stopPropagation();
  699. const savedTransform = zoomTransform; // 保存当前视图位置
  700. if (collapsedNodes.has(nd.data.id)) {
  701. collapsedNodes.delete(nd.data.id);
  702. } else {
  703. collapsedNodes.add(nd.data.id);
  704. }
  705. renderTree(currentData);
  706. // 重建后立即还原 zoom 位置,不触发动画
  707. d3.select("#tree-container svg").call(zoomBehavior.transform, savedTransform);
  708. });
  709. tg.append("title").text(collapsed ? "展开子节点" : "收起子节点");
  710. // 白色底圆(防止线条透底)
  711. tg.append("circle").attr("r", 11).attr("fill", "white");
  712. // 彩色实心圆:展开=蓝,折叠=绿
  713. tg.append("circle")
  714. .attr("r", 10)
  715. .attr("fill", collapsed ? "#10B981" : "#3B82F6")
  716. .attr("stroke", "white").attr("stroke-width", 1.5);
  717. // 符号文字
  718. tg.append("text")
  719. .attr("text-anchor", "middle").attr("dy", "0.38em")
  720. .attr("fill", "white").attr("font-size", "16px").attr("font-weight", "bold")
  721. .attr("pointer-events", "none")
  722. .text(collapsed ? "+" : "−");
  723. });
  724. function dragstarted(event, d) {
  725. if (!d.data.id) return;
  726. d3.select(this).raise().classed("active", true);
  727. d._currentX = d.x; d._currentY = d.y;
  728. dragSource = d.data;
  729. }
  730. function dragged(event, d) {
  731. d._currentX += event.dx; d._currentY += event.dy;
  732. d3.select(this).attr("transform", `translate(${d._currentX},${d._currentY})`);
  733. }
  734. function dragended(event, d) {
  735. d3.select(this).classed("active", false);
  736. const mouseX = event.sourceEvent.clientX, mouseY = event.sourceEvent.clientY;
  737. let foundNode = null;
  738. svg.selectAll(".node").each(function(nodeData) {
  739. if (!nodeData.data || nodeData.data.id === d.data.id || !nodeData.data.id) return;
  740. const rect = this.getBoundingClientRect();
  741. if (mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom) foundNode = nodeData.data;
  742. });
  743. if (foundNode && foundNode.id) {
  744. dragTarget = foundNode;
  745. document.getElementById('sourceMid').value = dragSource.id;
  746. document.getElementById('targetMid').value = dragTarget.id;
  747. document.getElementById('relationInfo').innerHTML = `确认将 <strong>${dragSource.name}</strong> 设定为 <strong>${dragTarget.name}</strong> 的关系人?`;
  748. relationModal.show();
  749. }
  750. setTimeout(() => { renderTree(currentData); }, 100);
  751. }
  752. } catch (err) {
  753. console.error('renderTree error:', err);
  754. container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">关系图渲染出错,请刷新重试。<br>' + (err.message || '') + '</div>';
  755. }
  756. }
  757. function saveRelation() {
  758. const payload = {
  759. source_mid: document.getElementById('sourceMid').value,
  760. target_mid: document.getElementById('targetMid').value,
  761. relation_type: document.getElementById('relType').value,
  762. sub_relation_type: document.getElementById('subRelType').value
  763. };
  764. fetch('/manager/api/save_relation', {
  765. method: 'POST',
  766. headers: { 'Content-Type': 'application/json' },
  767. body: JSON.stringify(payload)
  768. }).then(res => res.json()).then(data => {
  769. if (data.success) { relationModal.hide(); loadTree(); }
  770. else { alert('保存失败: ' + data.message); }
  771. });
  772. }
  773. // 缩放函数
  774. function zoomed(event) {
  775. zoomTransform = event.transform;
  776. zoomScale = event.transform.k;
  777. if (window.svgGroup) {
  778. window.svgGroup.attr("transform", event.transform);
  779. }
  780. }
  781. function zoomIn() {
  782. if (zoomScale < 4) {
  783. const newScale = zoomScale * 1.2;
  784. const container = document.getElementById('tree-container');
  785. const centerX = container.clientWidth / 2;
  786. const centerY = container.clientHeight / 2;
  787. const svg = d3.select("#tree-container svg");
  788. svg.transition().duration(300).call(
  789. zoomBehavior.transform,
  790. d3.zoomIdentity.translate(centerX, centerY).scale(newScale).translate(-centerX, -centerY)
  791. );
  792. }
  793. }
  794. function zoomOut() {
  795. if (zoomScale > 0.1) {
  796. const newScale = zoomScale / 1.2;
  797. const container = document.getElementById('tree-container');
  798. const centerX = container.clientWidth / 2;
  799. const centerY = container.clientHeight / 2;
  800. const svg = d3.select("#tree-container svg");
  801. svg.transition().duration(300).call(
  802. zoomBehavior.transform,
  803. d3.zoomIdentity.translate(centerX, centerY).scale(newScale).translate(-centerX, -centerY)
  804. );
  805. }
  806. }
  807. function zoomReset() {
  808. const svg = d3.select("#tree-container svg");
  809. svg.transition().duration(300).call(
  810. zoomBehavior.transform,
  811. d3.zoomIdentity
  812. );
  813. zoomScale = 1;
  814. }
  815. // 搜索成员并定位
  816. function searchMember() {
  817. const searchTerm = document.getElementById('memberSearch').value.trim();
  818. if (!searchTerm) {
  819. alert('请输入成员名字');
  820. return;
  821. }
  822. if (!currentData || !currentData.members) {
  823. alert('数据未加载完成,请稍后再试');
  824. return;
  825. }
  826. // 搜索匹配的成员
  827. const matchedMembers = currentData.members.filter(member => {
  828. if (!member) return false;
  829. const name = (member.name || '').toLowerCase();
  830. const simplifiedName = (member.simplified_name || '').toLowerCase();
  831. const searchLower = searchTerm.toLowerCase();
  832. return name.includes(searchLower) || simplifiedName.includes(searchLower);
  833. });
  834. if (matchedMembers.length === 0) {
  835. alert('未找到匹配的成员');
  836. return;
  837. }
  838. // 如果找到多个匹配项,让用户选择
  839. if (matchedMembers.length === 1) {
  840. locateMember(matchedMembers[0].id);
  841. } else {
  842. showTreeMemberSelectionDialog(matchedMembers);
  843. }
  844. }
  845. // 显示多成员选择弹窗(树图搜索)
  846. function showTreeMemberSelectionDialog(members) {
  847. // Remove existing dialog
  848. document.getElementById('treeMemberSelectionDialog')?.remove();
  849. document.getElementById('treeDialogOverlay')?.remove();
  850. // Build member index for quick name lookup
  851. const memberMap = {};
  852. if (currentData && currentData.members) {
  853. currentData.members.forEach(m => { memberMap[m.id] = m; });
  854. }
  855. // Build rows
  856. const rows = members.map((member, index) => {
  857. const simplifiedPart = member.simplified_name && member.simplified_name !== member.name
  858. ? `<span style="color:rgba(255,255,255,0.6);font-size:13px;">(${member.simplified_name})</span>` : '';
  859. const genPart = member.name_word_generation
  860. ? `<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>` : '';
  861. // Look up father via relations
  862. let fatherName = '';
  863. if (currentData && currentData.relations) {
  864. const fatherRel = currentData.relations.find(r => r.child_mid === member.id && r.relation_type === 1);
  865. if (fatherRel && memberMap[fatherRel.parent_mid]) {
  866. const fm = memberMap[fatherRel.parent_mid];
  867. fatherName = fm.name;
  868. if (fm.simplified_name && fm.simplified_name !== fm.name) fatherName += `(${fm.simplified_name})`;
  869. }
  870. }
  871. const fatherPart = fatherName
  872. ? `<span style="color:rgba(255,255,255,0.5);font-size:12px;margin-left:6px;">父: ${fatherName}</span>` : '';
  873. const idPart = `<span style="color:rgba(255,200,100,0.6);font-size:11px;margin-left:6px;">ID:${member.id}</span>`;
  874. return `
  875. <div onclick="selectTreeMember(${index})"
  876. style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.08);
  877. cursor:pointer;display:flex;justify-content:space-between;
  878. align-items:center;border-radius:6px;transition:background 0.15s;"
  879. onmouseover="this.style.background='rgba(74,105,189,0.2)'"
  880. onmouseout="this.style.background=''">
  881. <div style="flex:1;min-width:0;">
  882. <span style="color:#ffd700;font-size:13px;margin-right:8px;">${index + 1}.</span>
  883. <span style="color:#fff;font-weight:600;">${member.name}</span>
  884. ${simplifiedPart}${genPart}${fatherPart}${idPart}
  885. </div>
  886. <a href="/manager/member_detail/${member.id}" target="_blank"
  887. onclick="event.stopPropagation()"
  888. style="flex-shrink:0;margin-left:12px;padding:4px 10px;
  889. background:rgba(74,105,189,0.4);border:1px solid #4a69bd;
  890. border-radius:6px;color:#8ab4f8;font-size:12px;
  891. text-decoration:none;white-space:nowrap;">
  892. 查看详情 ↗
  893. </a>
  894. </div>`;
  895. }).join('');
  896. // Overlay
  897. const overlay = document.createElement('div');
  898. overlay.id = 'treeDialogOverlay';
  899. overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;';
  900. overlay.onclick = () => {
  901. document.getElementById('treeMemberSelectionDialog')?.remove();
  902. document.getElementById('treeDialogOverlay')?.remove();
  903. };
  904. // Dialog
  905. const dialog = document.createElement('div');
  906. dialog.id = 'treeMemberSelectionDialog';
  907. dialog.style.cssText = `
  908. position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
  909. background:#1a1a2e;border:2px solid #4a69bd;border-radius:12px;
  910. padding:24px;z-index:10000;min-width:480px;max-width:680px;
  911. width:90vw;box-shadow:0 10px 40px rgba(0,0,0,0.6);
  912. `;
  913. dialog.innerHTML = `
  914. <h3 style="color:#ffd700;margin-bottom:4px;text-align:center;font-size:16px;">找到多个匹配成员</h3>
  915. <p style="color:rgba(255,255,255,0.5);text-align:center;font-size:12px;margin-bottom:14px;">点击条目在树图中定位;点击「查看详情」在新标签页中查看</p>
  916. <div id="treeSelectionList" style="margin-bottom:16px;max-height:320px;overflow-y:auto;">${rows}</div>
  917. <div style="display:flex;justify-content:center;">
  918. <button onclick="document.getElementById('treeMemberSelectionDialog').remove();document.getElementById('treeDialogOverlay').remove();"
  919. style="padding:8px 30px;background:#2d3436;border:2px solid #666;border-radius:8px;color:#aaa;cursor:pointer;">取消</button>
  920. </div>
  921. `;
  922. // Store matched members for click handler
  923. window._treeDialogMembers = members;
  924. document.body.appendChild(overlay);
  925. document.body.appendChild(dialog);
  926. }
  927. function selectTreeMember(index) {
  928. const members = window._treeDialogMembers;
  929. if (members && members[index]) {
  930. locateMember(members[index].id);
  931. }
  932. document.getElementById('treeMemberSelectionDialog')?.remove();
  933. document.getElementById('treeDialogOverlay')?.remove();
  934. }
  935. // 定位到指定成员
  936. function locateMember(memberId) {
  937. const svg = d3.select("#tree-container svg");
  938. if (!svg.empty()) {
  939. // 查找对应的节点
  940. const node = d3.selectAll(".node").filter(function(d) {
  941. return d.data && d.data.id === memberId;
  942. });
  943. if (node.size() > 0) {
  944. // 获取节点位置
  945. const nodeData = node.datum();
  946. const container = document.getElementById('tree-container');
  947. const containerWidth = container.clientWidth;
  948. const containerHeight = container.clientHeight;
  949. // 计算缩放和平移,使节点位于中心
  950. const scale = 1.5; // 放大一点以突出显示
  951. const translateX = containerWidth / 2 - nodeData.x * scale;
  952. const translateY = containerHeight / 2 - nodeData.y * scale;
  953. // 应用变换
  954. svg.transition().duration(1000).call(
  955. zoomBehavior.transform,
  956. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  957. );
  958. // 高亮显示节点
  959. node.select("rect, circle").transition().duration(500)
  960. .attr("stroke", "#ff0000")
  961. .attr("stroke-width", 3)
  962. .transition().duration(1000).attr("stroke", function(d) {
  963. return d.data.sex === 1 ? "#3B82F6" : "#EC4899";
  964. }).attr("stroke-width", 2);
  965. } else {
  966. alert('未在树中找到该成员');
  967. }
  968. } else {
  969. alert('树图未加载完成,请稍后再试');
  970. }
  971. }
  972. // 支持回车键搜索
  973. document.getElementById('memberSearch').addEventListener('keypress', function(e) {
  974. if (e.key === 'Enter') {
  975. searchMember();
  976. }
  977. });
  978. </script>
  979. {% endblock %}