add_member.html 136 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791
  1. {% extends "layout.html" %}
  2. {% block title %}{{ '编辑' if member else '录入' }}成员 - 家谱管理系统{% endblock %}
  3. {% block extra_css %}
  4. <style>
  5. .split-container { display: flex; height: calc(100vh - 100px); overflow: hidden; }
  6. .form-panel { flex: 1.2; padding: 20px; overflow-y: auto; border-right: 1px solid #dee2e6; }
  7. .image-panel { flex: 0.8; padding: 20px; background: #f8f9fa; display: flex; flex-direction: column; }
  8. .image-viewer { flex: 1; border: 1px solid #ccc; background: white; overflow: hidden; text-align: center; position: relative; }
  9. .image-viewer img { max-width: 100%; height: auto; transition: transform 0.2s, filter 0.2s; transform-origin: top left; cursor: grab; }
  10. .image-viewer img.dragging { cursor: grabbing; }
  11. /* 放大镜样式 */
  12. .magnifier-glass {
  13. position: absolute;
  14. border: 3px solid #000;
  15. border-radius: 50%;
  16. cursor: none;
  17. width: 150px;
  18. height: 150px;
  19. box-shadow: 0 0 10px rgba(0,0,0,0.5);
  20. display: none;
  21. z-index: 1000;
  22. background-repeat: no-repeat;
  23. background-color: white;
  24. pointer-events: none;
  25. }
  26. /* Image Viewer & Dragging */
  27. .image-viewer {
  28. flex: 1;
  29. border: 1px solid #ccc;
  30. background: #f0f0f0;
  31. overflow: hidden;
  32. text-align: center;
  33. position: relative;
  34. cursor: grab;
  35. user-select: none;
  36. }
  37. .image-viewer:active {
  38. cursor: grabbing;
  39. }
  40. .image-wrapper {
  41. display: inline-block;
  42. transition: transform 0.2s ease-out;
  43. transform-origin: center center;
  44. position: absolute;
  45. /* Initial centering will be handled by JS or CSS translate */
  46. top: 50%;
  47. left: 50%;
  48. transform: translate(-50%, -50%);
  49. }
  50. .image-wrapper img {
  51. max-width: 100%;
  52. max-height: 100vh;
  53. display: block;
  54. pointer-events: none;
  55. user-select: none;
  56. transition: filter 0.2s;
  57. }
  58. .image-toolbar {
  59. background: #e9ecef;
  60. padding: 5px 10px;
  61. border-bottom: 1px solid #dee2e6;
  62. display: flex;
  63. gap: 10px;
  64. align-items: center;
  65. flex-wrap: wrap;
  66. }
  67. .image-toolbar .btn-group-xs > .btn, .image-toolbar .btn-sm {
  68. padding: 0.25rem 0.5rem;
  69. font-size: 0.875rem;
  70. }
  71. .filter-controls { display: flex; align-items: center; gap: 5px; font-size: 0.8rem; }
  72. .filter-controls input[type=range] { width: 80px; }
  73. .page-nav { margin-bottom: 10px; display: flex; gap: 10px; align-items: center; }
  74. .section-title { border-left: 4px solid #0d6efd; padding-left: 10px; margin: 25px 0 15px; font-weight: bold; color: #333; }
  75. .father-lineage-hint {
  76. background-color: #f8f9fa;
  77. border-left: 4px solid #17a2b8;
  78. padding: 8px 12px;
  79. border-radius: 4px;
  80. font-size: 14px;
  81. margin-top: 8px;
  82. margin-bottom: 12px;
  83. }
  84. .father-lineage-hint .text-info {
  85. color: #17a2b8;
  86. }
  87. </style>
  88. {% endblock %}
  89. {% block content %}
  90. <div class="split-container">
  91. <!-- 左侧:录入/编辑表单 -->
  92. <div class="form-panel">
  93. <div class="card shadow-sm mb-4">
  94. <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
  95. <h5 class="mb-0">{{ '编辑成员信息' if member else '录入新成员' }}</h5>
  96. <a href="{{ url_for('members') }}" class="btn btn-sm btn-light">返回列表</a>
  97. </div>
  98. <div class="card-body">
  99. <form method="POST">
  100. <input type="hidden" name="source_record_id" value="{{ source_record_id if source_record_id else (member.source_record_id if member and member.source_record_id else '') }}">
  101. <input type="hidden" name="source_index" value="">
  102. <div class="section-title">核心信息 (必填)</div>
  103. <div class="row g-3 mb-4">
  104. <div class="col-md-6">
  105. <label class="form-label">姓名(繁体) <span class="text-danger">*</span></label>
  106. <input type="text" name="name" id="nameInput" class="form-control" required value="{{ member.name if member else '' }}">
  107. <div id="nameCheckResult" class="mt-2"></div>
  108. </div>
  109. <div class="col-md-6">
  110. <label class="form-label">姓名(简体)</label>
  111. <input type="text" name="simplified_name" class="form-control" value="{{ member.simplified_name if member else '' }}">
  112. </div>
  113. <div class="col-md-12">
  114. <label class="form-label">族谱原文(繁体)</label>
  115. <input type="text" name="genealogy_original_traditional" class="form-control" value="{{ member.genealogy_original_traditional if member and member.genealogy_original_traditional and member.genealogy_original_traditional != 'None' else '' }}" placeholder="录入族谱原文中的繁体姓名及相关信息">
  116. </div>
  117. <div class="col-md-12">
  118. <label class="form-label">族谱原文(简体)</label>
  119. <input type="text" name="genealogy_original_simplified" class="form-control" value="{{ member.genealogy_original_simplified if member and member.genealogy_original_simplified and member.genealogy_original_simplified != 'None' else '' }}" placeholder="录入族谱原文中的简体姓名及相关信息">
  120. </div>
  121. <div class="col-md-6">
  122. <label class="form-label">性别 <span class="text-danger">*</span></label>
  123. <select name="sex" class="form-select" required>
  124. <option value="1" {{ 'selected' if member and member.sex == 1 else '' }}>男</option>
  125. <option value="2" {{ 'selected' if member and member.sex == 2 else '' }}>女</option>
  126. </select>
  127. </div>
  128. <div class="col-md-6">
  129. <label class="form-label">出生日期 <span class="text-danger">*</span></label>
  130. <div class="input-group has-validation">
  131. {% set birthday_val = member.birthday_date if member and member.birthday_date != '未知' else '' %}
  132. <input type="date" name="birthday" class="form-control" required value="{{ birthday_val }}" onchange="validateAge()">
  133. <div class="input-group-text bg-white">
  134. <input class="form-check-input mt-0" type="checkbox" id="birthdayUnknown" onchange="toggleBirthdayUnknown()" {{ 'checked' if member and member.birthday_date == '未知' else '' }}>
  135. <label class="form-check-label ms-1 small user-select-none" for="birthdayUnknown">不详</label>
  136. </div>
  137. <div class="invalid-feedback" id="ageFeedback"></div>
  138. </div>
  139. </div>
  140. <div class="col-md-8">
  141. <label class="form-label">世系世代</label>
  142. <div id="lineage-generations-container" class="d-flex flex-wrap align-items-center gap-2">
  143. {% if member and member.name_word_generation %}
  144. {% set generations = member.name_word_generation.split(';') %}
  145. {% for gen in generations %}
  146. {% if gen.strip() %}
  147. <span class="lineage-tag badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 px-3 py-2 rounded-pill d-inline-flex align-items-center" style="font-size: 0.85rem;">
  148. {{ gen.strip() }}
  149. <button type="button" class="btn-close ms-2 remove-lineage" style="font-size: 0.55rem; filter: none; opacity: 0.6;" aria-label="删除"></button>
  150. <input type="hidden" name="lineage_generations[]" value="{{ gen.strip() }}">
  151. </span>
  152. {% endif %}
  153. {% endfor %}
  154. {% endif %}
  155. <div id="lineage-input-form" class="d-none">
  156. <div class="input-group input-group-sm" style="width: 220px;">
  157. <input type="text" id="lineage-input" class="form-control" placeholder="如:衢州第二十九代" maxlength="20">
  158. <button type="button" id="confirm-lineage" class="btn btn-success"><i class="bi bi-check-lg"></i></button>
  159. <button type="button" id="cancel-lineage" class="btn btn-outline-secondary"><i class="bi bi-x-lg"></i></button>
  160. </div>
  161. </div>
  162. <button type="button" id="add-lineage" class="btn btn-outline-primary btn-sm rounded-pill px-3 py-1">
  163. <i class="bi bi-plus-lg me-1"></i>添加
  164. </button>
  165. </div>
  166. </div>
  167. <div class="col-md-4">
  168. <label class="form-label">堂内排行</label>
  169. <input type="text" name="family_rank" class="form-control" value="{{ member.family_rank if member else '' }}">
  170. </div>
  171. </div>
  172. <div class="section-title">状态信息</div>
  173. <div class="row g-3 mb-4">
  174. <div class="col-md-6">
  175. <label class="form-label">是否过世</label>
  176. <select name="is_pass_away" class="form-select">
  177. <option value="0" {{ 'selected' if member and member.is_pass_away == 0 else '' }}>健在</option>
  178. <option value="1" {{ 'selected' if member and member.is_pass_away == 1 else '' }}>已故</option>
  179. <option value="2" {{ 'selected' if member and member.is_pass_away == 2 else '' }}>未知</option>
  180. </select>
  181. </div>
  182. <div class="col-md-6">
  183. <label class="form-label">婚姻状况</label>
  184. <select name="marital_status" class="form-select">
  185. <option value="0" {{ 'selected' if member and member.marital_status == 0 else '' }}>未知</option>
  186. <option value="1" {{ 'selected' if member and member.marital_status == 1 else '' }}>未婚</option>
  187. <option value="2" {{ 'selected' if member and member.marital_status == 2 else '' }}>已婚</option>
  188. <option value="3" {{ 'selected' if member and member.marital_status == 3 else '' }}>离异/丧偶</option>
  189. </select>
  190. </div>
  191. </div>
  192. <div class="section-title">关系录入 (选择关联成员及关系)</div>
  193. <div id="relations-container">
  194. <!-- Existing relations will be added here dynamically -->
  195. {% if current_relation %}
  196. <div class="row g-3 mb-3 relation-row" data-index="0">
  197. <div class="col-md-4">
  198. <label class="form-label">关联成员</label>
  199. <div class="input-group">
  200. <input type="text" class="form-control related-member-display" placeholder="点击选择关联成员" readonly value="{{ selected_member_name }}">
  201. <input type="hidden" name="relations[0][parent_mid]" class="related_mid" value="{{ current_relation.parent_mid }}">
  202. <button type="button" class="btn btn-outline-primary select-member-btn" data-index="0" data-bs-toggle="modal" data-bs-target="#memberSelectModal">
  203. <i class="bi bi-search"></i>
  204. </button>
  205. </div>
  206. </div>
  207. <div class="col-md-3">
  208. <label class="form-label">关系类型</label>
  209. <select name="relations[0][relation_type]" class="form-select relation-type">
  210. <option value="">-- 请选择 --</option>
  211. <option value="1" {{ 'selected' if current_relation.relation_type == 1 else '' }}>父子 (关联人为父)</option>
  212. <option value="2" {{ 'selected' if current_relation.relation_type == 2 else '' }}>母子 (关联人为母)</option>
  213. <option value="10" {{ 'selected' if current_relation.relation_type == 10 else '' }}>夫妻</option>
  214. <option value="11" {{ 'selected' if current_relation.relation_type == 11 else '' }}>兄弟</option>
  215. <option value="12" {{ 'selected' if current_relation.relation_type == 12 else '' }}>姐妹</option>
  216. </select>
  217. </div>
  218. <div class="col-md-3">
  219. <label class="form-label">子类型</label>
  220. <select name="relations[0][sub_relation_type]" class="form-select sub-relation-type">
  221. <option value="0" {{ 'selected' if current_relation.sub_relation_type == 0 else '' }}>亲生/正妻</option>
  222. <option value="1" {{ 'selected' if current_relation.sub_relation_type == 1 else '' }}>养父</option>
  223. <option value="2" {{ 'selected' if current_relation.sub_relation_type == 2 else '' }}>出继(亲生父母)</option>
  224. <option value="3" {{ 'selected' if current_relation.sub_relation_type == 3 else '' }}>入继(养父母)</option>
  225. <option value="10" {{ 'selected' if current_relation.sub_relation_type == 10 else '' }}>妾</option>
  226. <option value="11" {{ 'selected' if current_relation.sub_relation_type == 11 else '' }}>外室</option>
  227. </select>
  228. </div>
  229. <div class="col-md-2 d-flex align-items-end">
  230. <button type="button" class="btn btn-danger w-100 remove-relation-btn" style="display: none;">
  231. <i class="bi bi-trash"></i>
  232. </button>
  233. </div>
  234. </div>
  235. {% else %}
  236. <div class="row g-3 mb-3 relation-row" data-index="0">
  237. <div class="col-md-4">
  238. <label class="form-label">关联成员</label>
  239. <div class="input-group">
  240. <input type="text" class="form-control related-member-display" placeholder="点击选择关联成员" readonly>
  241. <input type="hidden" name="relations[0][parent_mid]" class="related_mid">
  242. <button type="button" class="btn btn-outline-primary select-member-btn" data-index="0" data-bs-toggle="modal" data-bs-target="#memberSelectModal">
  243. <i class="bi bi-search"></i>
  244. </button>
  245. </div>
  246. </div>
  247. <div class="col-md-3">
  248. <label class="form-label">关系类型</label>
  249. <select name="relations[0][relation_type]" class="form-select relation-type">
  250. <option value="">-- 请选择 --</option>
  251. <option value="1">父子 (关联人为父)</option>
  252. <option value="2">母子 (关联人为母)</option>
  253. <option value="10">夫妻</option>
  254. <option value="11">兄弟</option>
  255. <option value="12">姐妹</option>
  256. </select>
  257. </div>
  258. <div class="col-md-3">
  259. <label class="form-label">子类型</label>
  260. <select name="relations[0][sub_relation_type]" class="form-select sub-relation-type">
  261. <option value="0">亲生/正妻</option>
  262. <option value="1">养父</option>
  263. <option value="2">出继(亲生父母)</option>
  264. <option value="3">入继(养父母)</option>
  265. <option value="10">妾</option>
  266. <option value="11">外室</option>
  267. </select>
  268. </div>
  269. <div class="col-md-2 d-flex align-items-end">
  270. <button type="button" class="btn btn-danger w-100 remove-relation-btn" style="display: none;">
  271. <i class="bi bi-trash"></i>
  272. </button>
  273. </div>
  274. </div>
  275. {% endif %}
  276. </div>
  277. <div class="row mb-4">
  278. <div class="col-md-12">
  279. <button type="button" class="btn btn-outline-success" id="add-relation-btn">
  280. <i class="bi bi-plus-circle"></i> 添加关系
  281. </button>
  282. </div>
  283. </div>
  284. <div class="section-title">人员备注</div>
  285. <div class="row g-3 mb-4">
  286. <div class="col-md-12">
  287. <textarea name="notes" class="form-control" rows="2">{{ member.notes if member else '' }}</textarea>
  288. </div>
  289. </div>
  290. <div class="section-title">疑似错误标注</div>
  291. <div class="row g-3 mb-4">
  292. <div class="col-md-12">
  293. <textarea name="suspected_error" class="form-control" rows="2" onblur="this.value = this.value.trim()">
  294. {{ '' if not member or not member.suspected_error or member.suspected_error in ['None', 'none', 'NULL', 'null'] else member.suspected_error.strip() }}
  295. </textarea>
  296. <div class="form-text mt-1">请在此标注该成员信息中可能存在的错误,例如出生日期矛盾、关系冲突等</div>
  297. </div>
  298. </div>
  299. <!-- 悬浮的保存按钮,始终保持在一屏内或跟随页面底部 -->
  300. <div class="d-grid gap-2 mb-4 sticky-bottom bg-white py-2 border-top" style="z-index: 1020;">
  301. <button type="submit" class="btn btn-success btn-lg">
  302. <i class="bi bi-check-circle me-1"></i> {{ '保存修改' if member else '确认录入' }}
  303. </button>
  304. </div>
  305. <!-- 折叠的其他信息区 -->
  306. <div class="accordion" id="accordionExtraInfo">
  307. <div class="accordion-item border-0">
  308. <h2 class="accordion-header" id="headingExtra">
  309. <button class="accordion-button collapsed bg-light text-secondary rounded shadow-sm border" type="button" data-bs-toggle="collapse" data-bs-target="#collapseExtra" aria-expanded="false" aria-controls="collapseExtra">
  310. <i class="bi bi-three-dots me-2"></i> 展开更多其他信息(谱系详情、联络、履历等)
  311. </button>
  312. </h2>
  313. <div id="collapseExtra" class="accordion-collapse collapse" aria-labelledby="headingExtra" data-bs-parent="#accordionExtraInfo">
  314. <div class="accordion-body px-0 pt-3 pb-0">
  315. <div class="section-title mt-0">谱系详情</div>
  316. <div class="row g-3 mb-4">
  317. <div class="col-md-4">
  318. <label class="form-label">曾用名</label>
  319. <input type="text" name="former_name" class="form-control" value="{{ member.former_name if member else '' }}">
  320. </div>
  321. <div class="col-md-4">
  322. <label class="form-label">幼名/乳名</label>
  323. <input type="text" name="childhood_name" class="form-control" value="{{ member.childhood_name if member else '' }}">
  324. </div>
  325. <div class="col-md-4">
  326. <label class="form-label">字辈</label>
  327. <input type="text" name="name_word" class="form-control" value="{{ member.name_word if member else '' }}">
  328. </div>
  329. <div class="col-md-6">
  330. <label class="form-label">名号/封号</label>
  331. <input type="text" name="name_title" class="form-control" value="{{ member.name_title if member else '' }}">
  332. </div>
  333. <div class="col-md-6">
  334. <label class="form-label">分房/堂号</label>
  335. <input type="text" name="branch_family_hall" class="form-control" value="{{ member.branch_family_hall if member else '' }}">
  336. </div>
  337. <div class="col-md-6">
  338. <label class="form-label">聚居地</label>
  339. <input type="text" name="cluster_place" class="form-control" value="{{ member.cluster_place if member else '' }}">
  340. </div>
  341. </div>
  342. <div class="section-title">联络信息</div>
  343. <div class="row g-3 mb-4">
  344. <div class="col-md-4">
  345. <label class="form-label">民族</label>
  346. <input type="text" name="nation" class="form-control" value="{{ member.nation if member else '' }}">
  347. </div>
  348. <div class="col-md-4">
  349. <label class="form-label">手机号</label>
  350. <input type="text" name="phone" class="form-control" value="{{ member.phone if member else '' }}">
  351. </div>
  352. <div class="col-md-4">
  353. <label class="form-label">微信号</label>
  354. <input type="text" name="wechat_account" class="form-control" value="{{ member.wechat_account if member else '' }}">
  355. </div>
  356. <div class="col-md-12">
  357. <label class="form-label">现居住址</label>
  358. <input type="text" name="residential_address" class="form-control" value="{{ member.residential_address if member else '' }}">
  359. </div>
  360. </div>
  361. <div class="section-title">个人履历</div>
  362. <div class="row g-3 mb-2">
  363. <div class="col-md-6">
  364. <label class="form-label">职业</label>
  365. <textarea name="occupation" class="form-control" rows="2">{{ member.occupation if member else '' }}</textarea>
  366. </div>
  367. <div class="col-md-6">
  368. <label class="form-label">教育背景</label>
  369. <textarea name="educational" class="form-control" rows="2">{{ member.educational if member else '' }}</textarea>
  370. </div>
  371. <div class="col-md-12">
  372. <label class="form-label">标签</label>
  373. <input type="text" name="tags" class="form-control" placeholder="例如:抗战老兵, 教师 (用逗号分隔)" value="{{ member.tags if member else '' }}">
  374. </div>
  375. <div class="col-md-12">
  376. <label class="form-label">个人成就</label>
  377. <textarea name="personal_achievements" class="form-control" rows="3">{{ member.personal_achievements if member else '' }}</textarea>
  378. </div>
  379. </div>
  380. </div>
  381. </div>
  382. </div>
  383. </div>
  384. </form>
  385. </div>
  386. </div>
  387. </div>
  388. <!-- AI 推理日志及结果面板 -->
  389. <div id="aiLogPanel" class="position-fixed bottom-0 end-0 p-3 bg-dark text-white shadow"
  390. style="display: none; width: 450px; max-height: 85vh; border-radius: 8px 0 0 0; z-index: 1050; opacity: 0.95; overflow-y: auto;">
  391. <!-- 吸顶头部与当前选中详情 -->
  392. <div class="sticky-top bg-dark pb-2" style="z-index: 1060; margin-top: -1rem; padding-top: 1rem; border-bottom: 1px solid #444;">
  393. <div class="d-flex justify-content-between align-items-center mb-2 pb-1">
  394. <span class="fw-bold"><i class="bi bi-robot"></i> AI 识别助手</span>
  395. <button class="btn btn-sm btn-outline-light py-0" onclick="closeAiLog()">×</button>
  396. </div>
  397. <!-- 当前选中详情 -->
  398. <div id="aiCurrentDetail" class="mt-2 p-2 bg-secondary bg-opacity-25 rounded border border-info shadow-sm" style="display:none; max-height: 250px; overflow-y: auto;">
  399. <div class="d-flex justify-content-between align-items-center mb-2 border-bottom border-secondary pb-1">
  400. <strong class="text-info"><i class="bi bi-info-circle"></i> 当前填充详情</strong>
  401. <button class="btn btn-sm btn-link text-muted py-0 text-decoration-none" onclick="document.getElementById('aiCurrentDetail').style.display='none'">×</button>
  402. </div>
  403. <div id="aiDetailContent" class="small text-light" style="word-break: break-all;"></div>
  404. </div>
  405. </div>
  406. <!-- 推理过程 -->
  407. <div class="mb-3 mt-3">
  408. <button class="btn btn-sm btn-link text-decoration-none text-light p-0 mb-1 d-flex align-items-center" type="button" data-bs-toggle="collapse" data-bs-target="#collapseReasoning">
  409. <i class="bi bi-cpu me-1"></i> 推理过程 <span class="badge bg-secondary ms-2" id="reasoningStatus">进行中...</span>
  410. </button>
  411. <div class="collapse show" id="collapseReasoning">
  412. <pre id="aiLogContent" class="text-success small mb-0 p-2 bg-black rounded border border-secondary" style="max-height: 200px; overflow-y: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.8rem;"></pre>
  413. </div>
  414. </div>
  415. <!-- 识别结果列表 -->
  416. <div id="aiResultSection" style="display: none;">
  417. <div class="d-flex justify-content-between align-items-center mb-2">
  418. <h6 class="mb-0 text-info"><i class="bi bi-check-circle"></i> 识别结果 (<span id="resultCount">0</span>)</h6>
  419. <span class="small text-muted">点击下方条目填充</span>
  420. </div>
  421. <div id="aiResultList" class="d-flex flex-column gap-2">
  422. <!-- 结果项将动态插入 -->
  423. </div>
  424. </div>
  425. </div>
  426. <!-- 右侧:图片参考 -->
  427. <div class="image-panel">
  428. <div class="page-nav">
  429. <label class="fw-bold">扫描件参考:</label>
  430. <button id="aiBtn" onclick="recognizeImage()" class="btn btn-sm btn-info text-white ms-2 me-2">
  431. <i class="bi bi-magic"></i> AI 识别
  432. </button>
  433. <input type="number" id="pageInput" class="form-control form-control-sm" style="width: 70px;" placeholder="页码">
  434. <button onclick="gotoPage()" class="btn btn-sm btn-primary">跳转</button>
  435. <div class="ms-auto small text-muted">
  436. 当前: <span id="currentPage">1</span> / <span id="totalPages">{{ images|length }}</span>
  437. </div>
  438. </div>
  439. <div class="mb-2 small text-muted" id="imageMetadata" style="display: none;">
  440. <span class="me-2"><i class="bi bi-journal-text"></i> 版本名称: <span id="metaVersion">-</span></span>
  441. <span class="me-2"><i class="bi bi-archive"></i> 版本来源: <span id="metaSource">-</span></span>
  442. <span><i class="bi bi-person"></i> 提供人: <span id="metaPerson">-</span></span>
  443. </div>
  444. <div class="image-toolbar rounded-top">
  445. <div class="btn-group btn-group-sm">
  446. <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(-90)" title="左旋90°"><i class="bi bi-arrow-counterclockwise"></i></button>
  447. <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(90)" title="右旋90°"><i class="bi bi-arrow-clockwise"></i></button>
  448. </div>
  449. <div class="filter-controls border-start border-end px-2 mx-1">
  450. <i class="bi bi-brightness-high" title="亮度"></i>
  451. <input type="range" min="50" max="150" value="100" oninput="updateImageFilter()" id="brightnessRange">
  452. <i class="bi bi-circle-half ms-2" title="对比度"></i>
  453. <input type="range" min="50" max="200" value="100" oninput="updateImageFilter()" id="contrastRange">
  454. <button class="btn btn-link btn-sm text-decoration-none py-0" onclick="resetFilters()">重置</button>
  455. </div>
  456. <div class="form-check form-switch ms-auto mb-0" title="开启后鼠标悬停图片可局部放大">
  457. <input class="form-check-input" type="checkbox" id="magnifierSwitch">
  458. <label class="form-check-label small" for="magnifierSwitch">🔍 放大镜</label>
  459. </div>
  460. </div>
  461. <div class="image-viewer shadow-inner" id="viewer">
  462. <div id="magnifier" class="magnifier-glass"></div>
  463. <div id="imageWrapper" class="image-wrapper">
  464. {% if images %}
  465. <img id="refImage" src="{{ images[0].oss_url }}" alt="家谱图片" draggable="false">
  466. {% else %}
  467. <div class="mt-5 text-muted">
  468. <i class="bi bi-image fs-1 d-block mb-2"></i>
  469. 暂无上传的家谱图片
  470. </div>
  471. {% endif %}
  472. </div>
  473. </div>
  474. <div class="mt-2 d-flex justify-content-between">
  475. <button onclick="prevImage()" class="btn btn-sm btn-outline-secondary">上一张</button>
  476. <button onclick="nextImage()" class="btn btn-sm btn-outline-secondary">下一张</button>
  477. </div>
  478. </div>
  479. <!-- 成员选择弹窗 -->
  480. <div class="modal fade" id="memberSelectModal" tabindex="-1" aria-hidden="true">
  481. <div class="modal-dialog modal-lg">
  482. <div class="modal-content border-0 shadow-lg">
  483. <div class="modal-header bg-primary text-white">
  484. <h5 class="modal-title"><i class="bi bi-people me-2"></i>选择关联成员</h5>
  485. <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
  486. </div>
  487. <div class="modal-body">
  488. <div class="mb-4">
  489. <div class="input-group">
  490. <input type="text" id="member-search" class="form-control" placeholder="搜索成员姓名(支持繁体和简体)">
  491. <button type="button" class="btn btn-primary" onclick="searchMembers()">
  492. <i class="bi bi-search"></i> 搜索
  493. </button>
  494. </div>
  495. </div>
  496. <div id="member-list" class="list-group mb-4 max-h-96 overflow-y-auto">
  497. <!-- 成员列表将通过JavaScript动态生成 -->
  498. </div>
  499. <div class="d-flex justify-content-between align-items-center">
  500. <div class="text-muted small">共 <span id="total-members">0</span> 个成员</div>
  501. <nav>
  502. <ul class="pagination pagination-sm" style="max-width: 300px; overflow-x: auto; padding: 5px;">
  503. <li class="page-item disabled">
  504. <a class="page-link" href="#" onclick="changePage(0)">&laquo;</a>
  505. </li>
  506. <li class="page-item active">
  507. <a class="page-link" href="#" onclick="changePage(1)">1</a>
  508. </li>
  509. <li class="page-item">
  510. <a class="page-link" href="#" onclick="changePage(2)">&raquo;</a>
  511. </li>
  512. </ul>
  513. </nav>
  514. </div>
  515. </div>
  516. <div class="modal-footer">
  517. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
  518. </div>
  519. </div>
  520. </div>
  521. </div>
  522. </div>
  523. {% endblock %}
  524. {% block extra_js %}
  525. <script>
  526. let tomSelectInstance = null;
  527. let currentPage = 1;
  528. let totalPages = 1;
  529. let totalMembers = 0;
  530. let membersData = [];
  531. function toggleBirthdayUnknown() {
  532. const cb = document.getElementById('birthdayUnknown');
  533. const input = document.querySelector('input[name="birthday"]');
  534. if (!cb || !input) return;
  535. if (cb.checked) {
  536. input.value = '';
  537. input.disabled = true;
  538. input.required = false;
  539. input.classList.remove('is-invalid');
  540. const fb = document.getElementById('ageFeedback');
  541. if(fb) fb.textContent = '';
  542. } else {
  543. input.disabled = false;
  544. input.required = true;
  545. }
  546. }
  547. function validateAge() {
  548. const cb = document.getElementById('birthdayUnknown');
  549. if (cb && cb.checked) return;
  550. const birthdayInput = document.querySelector('input[name="birthday"]');
  551. const relatedSelect = document.querySelector('select[name="related_mid"]');
  552. const relationType = document.querySelector('select[name="relation_type"]');
  553. const feedback = document.getElementById('ageFeedback');
  554. if (!birthdayInput.value || !relatedSelect.value) {
  555. birthdayInput.classList.remove('is-invalid');
  556. return;
  557. }
  558. // Only check for Parent-Child relations (1: Father, 2: Mother)
  559. if (relationType.value !== '1' && relationType.value !== '2') return;
  560. // We need the parent's birthday. This is tricky as we only have the ID.
  561. // Option 1: Store parent birthdays in the select option dataset (easiest)
  562. // Option 2: Async fetch.
  563. const selectedOption = relatedSelect.options[relatedSelect.selectedIndex];
  564. const parentBirthdayTs = parseInt(selectedOption.dataset.birthday || '0');
  565. if (parentBirthdayTs > 0) {
  566. const childBirthday = new Date(birthdayInput.value).getTime() / 1000;
  567. if (childBirthday < parentBirthdayTs) {
  568. birthdayInput.classList.add('is-invalid');
  569. feedback.textContent = '警告:子女出生日期早于父母,请核对!';
  570. } else if (childBirthday - parentBirthdayTs < 12 * 365 * 24 * 3600) {
  571. // Warning if age gap < 12 years
  572. birthdayInput.classList.add('is-invalid');
  573. feedback.textContent = '警告:父母与子女年龄差小于12岁,请核对!';
  574. } else {
  575. birthdayInput.classList.remove('is-invalid');
  576. }
  577. }
  578. }
  579. // Call validation when relation changes too
  580. document.addEventListener('DOMContentLoaded', () => {
  581. const relatedSelect = document.querySelector('select[name="related_mid"]');
  582. if (relatedSelect) {
  583. relatedSelect.addEventListener('change', validateAge);
  584. if (typeof TomSelect !== 'undefined') {
  585. tomSelectInstance = new TomSelect(relatedSelect, {
  586. create: false,
  587. sortField: null,
  588. searchField: ['text'],
  589. render: {
  590. no_results: function(data, escape) {
  591. return '<div class="no-results">未找到匹配项</div>';
  592. }
  593. }
  594. });
  595. }
  596. }
  597. // Initialize birthday unknown state
  598. toggleBirthdayUnknown();
  599. });
  600. const images = [
  601. {% for img in images %}
  602. {
  603. id: {{ img.id }},
  604. url: "{{ img.oss_url }}",
  605. page: {{ img.page_number or 0 }},
  606. ai_status: {{ img.ai_status or 0 }},
  607. ai_content: {{ img.ai_content | tojson | safe if img.ai_content else 'null' }},
  608. genealogy_version: "{{ img.genealogy_version or '' }}",
  609. genealogy_source: "{{ img.genealogy_source or '' }}",
  610. upload_person: "{{ img.upload_person or '' }}"
  611. },
  612. {% endfor %}
  613. ];
  614. let currentIndex = 0;
  615. // 初始化时根据source_record_id设置正确的扫描件
  616. {% if source_record_id %}
  617. // 在模板渲染时直接设置currentIndex
  618. currentIndex = images.findIndex(img => img.id == {{ source_record_id }});
  619. // 如果找不到,保持为0
  620. if (currentIndex === -1) {
  621. currentIndex = 0;
  622. }
  623. {% endif %}
  624. let currentParsedPeople = [];
  625. // Image State
  626. let imgRotation = 0;
  627. let imgBrightness = 100;
  628. let imgContrast = 100;
  629. // Dragging State
  630. let isDragging = false;
  631. let hasDragged = false;
  632. let startX = 0, startY = 0;
  633. let currentX = 0, currentY = 0; // Relative to center (offsets)
  634. // Zoom State
  635. let isZoomedIn = false;
  636. const ZOOM_LEVEL = 2.0;
  637. // Magnifier Logic
  638. const viewer = document.getElementById('viewer');
  639. const magnifier = document.getElementById('magnifier');
  640. const magnifierSwitch = document.getElementById('magnifierSwitch');
  641. const imageWrapper = document.getElementById('imageWrapper');
  642. // 页面加载完成后更新显示
  643. document.addEventListener('DOMContentLoaded', function() {
  644. // 调用updateDisplay函数显示正确的扫描件
  645. updateDisplay();
  646. });
  647. // Initialize Dragging and Zooming
  648. if (imageWrapper) {
  649. // Center initial position
  650. imageWrapper.style.left = '50%';
  651. imageWrapper.style.top = '50%';
  652. viewer.style.cursor = 'zoom-in';
  653. viewer.addEventListener('mousedown', (e) => {
  654. if (e.target.closest('.image-toolbar') || e.target.closest('.magnifier-glass')) return;
  655. isDragging = true;
  656. hasDragged = false;
  657. startX = e.clientX;
  658. startY = e.clientY;
  659. viewer.style.cursor = 'grabbing';
  660. e.preventDefault(); // Prevent text selection
  661. });
  662. window.addEventListener('mousemove', (e) => {
  663. if (!isDragging) return;
  664. const dx = e.clientX - startX;
  665. const dy = e.clientY - startY;
  666. // Threshold to consider it a drag
  667. if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
  668. hasDragged = true;
  669. }
  670. currentX += dx;
  671. currentY += dy;
  672. startX = e.clientX;
  673. startY = e.clientY;
  674. updateImageTransform();
  675. });
  676. window.addEventListener('mouseup', (e) => {
  677. if (isDragging) {
  678. isDragging = false;
  679. viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
  680. // If it was a click (not a drag) and clicked inside the viewer
  681. if (!hasDragged && viewer.contains(e.target)) {
  682. toggleZoom();
  683. }
  684. }
  685. });
  686. }
  687. function toggleZoom() {
  688. isZoomedIn = !isZoomedIn;
  689. if (!isZoomedIn) {
  690. // Reset position when zooming out to center
  691. currentX = 0;
  692. currentY = 0;
  693. }
  694. updateImageTransform();
  695. // Update cursor immediately
  696. viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
  697. }
  698. viewer.addEventListener('mousemove', function(e) {
  699. if (!magnifierSwitch.checked) {
  700. magnifier.style.display = 'none';
  701. return;
  702. }
  703. if (isDragging) {
  704. magnifier.style.display = 'none';
  705. return;
  706. }
  707. const img = document.getElementById('refImage');
  708. if (!img) return;
  709. // Calculate position relative to the image
  710. const rect = img.getBoundingClientRect();
  711. const x = e.clientX - rect.left;
  712. const y = e.clientY - rect.top;
  713. // Only show if inside image rect (approximate for rotated)
  714. if (x < 0 || x > rect.width || y < 0 || y > rect.height) {
  715. magnifier.style.display = 'none';
  716. return;
  717. }
  718. magnifier.style.display = 'block';
  719. // Position the glass near mouse
  720. const glassOffset = 20;
  721. const viewerRect = viewer.getBoundingClientRect();
  722. magnifier.style.left = (e.clientX - viewerRect.left + glassOffset) + 'px';
  723. magnifier.style.top = (e.clientY - viewerRect.top + glassOffset) + 'px';
  724. // Background logic (Zoom 2x)
  725. const zoom = 2.5;
  726. magnifier.style.backgroundImage = `url('${img.src}')`;
  727. magnifier.style.backgroundSize = `${rect.width * zoom}px ${rect.height * zoom}px`;
  728. // Simple version (imperfect for rotation)
  729. magnifier.style.backgroundPosition = `-${x * zoom - 75}px -${y * zoom - 75}px`;
  730. });
  731. // Image Manipulation
  732. function rotateImage(deg) {
  733. imgRotation = (imgRotation + deg) % 360;
  734. updateImageTransform();
  735. }
  736. function updateImageFilter() {
  737. imgBrightness = document.getElementById('brightnessRange').value;
  738. imgContrast = document.getElementById('contrastRange').value;
  739. applyImageFilters();
  740. }
  741. function resetFilters() {
  742. imgRotation = 0;
  743. imgBrightness = 100;
  744. imgContrast = 100;
  745. currentX = 0;
  746. currentY = 0;
  747. isZoomedIn = false;
  748. document.getElementById('brightnessRange').value = 100;
  749. document.getElementById('contrastRange').value = 100;
  750. updateImageTransform();
  751. applyImageFilters();
  752. }
  753. function updateImageTransform() {
  754. const wrapper = document.getElementById('imageWrapper');
  755. if (wrapper) {
  756. const scale = isZoomedIn ? ZOOM_LEVEL : 1;
  757. wrapper.style.transform = `translate(calc(-50% + ${currentX}px), calc(-50% + ${currentY}px)) rotate(${imgRotation}deg) scale(${scale})`;
  758. // Adjust cursor based on state
  759. if (!isDragging) {
  760. viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
  761. }
  762. }
  763. }
  764. function applyImageFilters() {
  765. const img = document.getElementById('refImage');
  766. if (img) {
  767. img.style.filter = `brightness(${imgBrightness}%) contrast(${imgContrast}%)`;
  768. }
  769. }
  770. // Reuse applyImageStyles as alias for compatibility if called elsewhere
  771. function applyImageStyles() {
  772. updateImageTransform();
  773. applyImageFilters();
  774. }
  775. const fieldMapping = {
  776. name: '姓名(繁体)',
  777. simplified_name: '姓名(简体)',
  778. sex: '性别',
  779. birthday: '出生日期',
  780. father_name: '父亲姓名',
  781. spouse_name: '配偶姓名',
  782. generation: '堂内排行(代数)',
  783. name_word: '字辈',
  784. education: '学历/功名',
  785. title: '官职/称号',
  786. death_date: '逝世日期',
  787. note: '备注'
  788. };
  789. // --- Keyboard Shortcuts ---
  790. document.addEventListener('keydown', (e) => {
  791. // Ctrl/Cmd + Enter: Save
  792. if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
  793. e.preventDefault();
  794. const submitBtn = document.querySelector('form button[type="submit"]');
  795. if (submitBtn && !submitBtn.disabled) {
  796. submitBtn.click(); // Trigger form submit listener
  797. }
  798. }
  799. // Ctrl/Cmd + Right Arrow: Next Image
  800. if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowRight') {
  801. e.preventDefault();
  802. nextImage();
  803. }
  804. // Ctrl/Cmd + Left Arrow: Prev Image
  805. if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowLeft') {
  806. e.preventDefault();
  807. prevImage();
  808. }
  809. // Alt + 1: Auto Fill First person in list
  810. if (e.altKey && e.key === '1') {
  811. e.preventDefault();
  812. // Try to find the first "fill" button that is not disabled/success
  813. const firstBtn = document.querySelector('button[id^="btn-fill-"]:not(.btn-success)');
  814. if (firstBtn) firstBtn.click();
  815. }
  816. });
  817. // --- Multi-relation handling ---
  818. let currentRelationIndex = 0;
  819. document.addEventListener('DOMContentLoaded', function() {
  820. // Set initial index based on existing rows
  821. const relationRows = document.querySelectorAll('.relation-row');
  822. currentRelationIndex = relationRows.length;
  823. // Show remove button if there are multiple rows
  824. updateRemoveButtons();
  825. // Add click handler for add relation button
  826. const addBtn = document.getElementById('add-relation-btn');
  827. if (addBtn) {
  828. addBtn.addEventListener('click', addRelationRow);
  829. }
  830. // Add click handlers for select member buttons
  831. document.querySelectorAll('.select-member-btn').forEach(btn => {
  832. btn.addEventListener('click', function() {
  833. const index = parseInt(this.dataset.index);
  834. window.currentRelationIndex = index;
  835. });
  836. });
  837. // Add click handlers for remove buttons
  838. document.querySelectorAll('.remove-relation-btn').forEach(btn => {
  839. btn.addEventListener('click', removeRelationRow);
  840. });
  841. });
  842. function addRelationRow() {
  843. const container = document.getElementById('relations-container');
  844. const newIndex = currentRelationIndex++;
  845. const newRow = document.createElement('div');
  846. newRow.className = 'row g-3 mb-3 relation-row';
  847. newRow.dataset.index = newIndex;
  848. newRow.innerHTML = `
  849. <div class="col-md-4">
  850. <label class="form-label">关联成员</label>
  851. <div class="input-group">
  852. <input type="text" class="form-control related-member-display" placeholder="点击选择关联成员" readonly>
  853. <input type="hidden" name="relations[${newIndex}][parent_mid]" class="related_mid">
  854. <button type="button" class="btn btn-outline-primary select-member-btn" data-index="${newIndex}" data-bs-toggle="modal" data-bs-target="#memberSelectModal">
  855. <i class="bi bi-search"></i>
  856. </button>
  857. </div>
  858. </div>
  859. <div class="col-md-3">
  860. <label class="form-label">关系类型</label>
  861. <select name="relations[${newIndex}][relation_type]" class="form-select relation-type">
  862. <option value="">-- 请选择 --</option>
  863. <option value="1">父子 (关联人为父)</option>
  864. <option value="2">母子 (关联人为母)</option>
  865. <option value="10">夫妻</option>
  866. <option value="11">兄弟</option>
  867. <option value="12">姐妹</option>
  868. </select>
  869. </div>
  870. <div class="col-md-3">
  871. <label class="form-label">子类型</label>
  872. <select name="relations[${newIndex}][sub_relation_type]" class="form-select sub-relation-type">
  873. <option value="0">亲生/正妻</option>
  874. <option value="1">养父</option>
  875. <option value="2">出继(亲生父母)</option>
  876. <option value="3">入继(养父母)</option>
  877. <option value="10">妾</option>
  878. <option value="11">外室</option>
  879. </select>
  880. </div>
  881. <div class="col-md-2 d-flex align-items-end">
  882. <button type="button" class="btn btn-danger w-100 remove-relation-btn">
  883. <i class="bi bi-trash"></i>
  884. </button>
  885. </div>
  886. `;
  887. container.appendChild(newRow);
  888. // Add click handlers for new buttons
  889. const selectBtn = newRow.querySelector('.select-member-btn');
  890. selectBtn.addEventListener('click', function() {
  891. const index = parseInt(this.dataset.index);
  892. window.currentRelationIndex = index;
  893. });
  894. const removeBtn = newRow.querySelector('.remove-relation-btn');
  895. removeBtn.addEventListener('click', removeRelationRow);
  896. updateRemoveButtons();
  897. }
  898. function removeRelationRow() {
  899. const row = this.closest('.relation-row');
  900. if (row) {
  901. row.remove();
  902. updateRemoveButtons();
  903. }
  904. }
  905. function updateRemoveButtons() {
  906. const rows = document.querySelectorAll('.relation-row');
  907. rows.forEach((row, index) => {
  908. const removeBtn = row.querySelector('.remove-relation-btn');
  909. if (removeBtn) {
  910. // Show remove button only if there's more than one row
  911. removeBtn.style.display = rows.length > 1 ? 'block' : 'none';
  912. }
  913. // Update index attribute
  914. row.dataset.index = index;
  915. });
  916. }
  917. // Override selectMember to support multiple relations
  918. window.selectMember = function(member) {
  919. const index = window.currentRelationIndex || 0;
  920. const rows = document.querySelectorAll('.relation-row');
  921. if (rows[index]) {
  922. const displayInput = rows[index].querySelector('.related-member-display');
  923. const hiddenInput = rows[index].querySelector('.related_mid');
  924. if (displayInput) {
  925. let displayText = member.name;
  926. if (member.simplified_name && member.simplified_name !== member.name) {
  927. displayText += ` (${member.simplified_name})`;
  928. }
  929. if (member.name_word) {
  930. displayText += ` · ${member.name_word}`;
  931. }
  932. displayInput.value = displayText;
  933. }
  934. if (hiddenInput) {
  935. hiddenInput.value = member.id;
  936. }
  937. }
  938. // Close modal
  939. const modal = bootstrap.Modal.getInstance(document.getElementById('memberSelectModal'));
  940. if (modal) {
  941. modal.hide();
  942. }
  943. };
  944. // --- AJAX Form Submission ---
  945. document.addEventListener('DOMContentLoaded', () => {
  946. const form = document.querySelector('form');
  947. form.addEventListener('submit', async (e) => {
  948. e.preventDefault();
  949. // Collect form data
  950. const formData = new FormData(form);
  951. // Visual feedback on button
  952. const submitBtn = form.querySelector('button[type="submit"]');
  953. const originalBtnHtml = submitBtn.innerHTML;
  954. submitBtn.disabled = true;
  955. submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 保存中...';
  956. // Create loading overlay
  957. const loadingOverlay = document.createElement('div');
  958. loadingOverlay.className = 'position-fixed inset-0 bg-black/50 flex items-center justify-center z-50';
  959. loadingOverlay.innerHTML = `
  960. <div class="text-center text-white">
  961. <div class="spinner-border text-yellow-400" style="width: 3rem; height: 3rem;" role="status">
  962. <span class="visually-hidden">Loading...</span>
  963. </div>
  964. <p class="mt-3 text-lg">正在保存,请稍候...</p>
  965. </div>
  966. `;
  967. document.body.appendChild(loadingOverlay);
  968. try {
  969. // Determine target URL based on mode
  970. const isEditMode = {{ 'true' if member else 'false' }};
  971. const targetUrl = isEditMode ? window.location.href : '/manager/add_member';
  972. console.log('[Add Member] Submitting to:', targetUrl);
  973. const response = await fetch(targetUrl, {
  974. method: 'POST',
  975. body: formData,
  976. headers: {
  977. 'X-Requested-With': 'XMLHttpRequest'
  978. },
  979. credentials: 'include',
  980. timeout: 60000 // 60秒超时
  981. });
  982. console.log('[Add Member] Response status:', response.status);
  983. if (!response.ok) {
  984. throw new Error(`HTTP error! status: ${response.status}`);
  985. }
  986. const result = await response.json();
  987. console.log('[Add Member] Response data:', result);
  988. if (result.success) {
  989. // Remove loading overlay
  990. loadingOverlay.remove();
  991. // Success!
  992. // 1. Show a toast or small alert
  993. const toast = document.createElement('div');
  994. toast.className = 'position-fixed bottom-0 start-50 translate-middle-x mb-4 p-4 bg-success text-white rounded-lg shadow-lg';
  995. toast.style.zIndex = '2000';
  996. toast.innerHTML = `<i class="bi bi-check-circle me-2"></i> ${result.message || '保存成功!'}`;
  997. document.body.appendChild(toast);
  998. setTimeout(() => toast.remove(), 3000);
  999. // 2. Mark the AI list item as "Saved" if applicable
  1000. if (window.lastFilledIndex !== undefined) {
  1001. const btn = document.getElementById(`btn-fill-${window.lastFilledIndex}`);
  1002. if (btn) {
  1003. btn.className = 'btn btn-sm btn-success text-white ms-2 disabled';
  1004. btn.innerHTML = '<i class="bi bi-check-lg"></i> 已录入';
  1005. btn.onclick = null;
  1006. }
  1007. // Update local data state so it persists if we switch images/filters
  1008. if (window.lastFilledIndex !== undefined && currentParsedPeople[window.lastFilledIndex]) {
  1009. currentParsedPeople[window.lastFilledIndex].is_imported = true;
  1010. currentParsedPeople[window.lastFilledIndex].imported_member_id = result.member_id;
  1011. // Sync back to images array to persist across image switching
  1012. if (images[currentIndex]) {
  1013. images[currentIndex].ai_content = currentParsedPeople;
  1014. console.log('Updated images array:', images[currentIndex]);
  1015. }
  1016. }
  1017. }
  1018. // 3. Clear form (reset to defaults) or keep some fields?
  1019. // Usually for genealogy, Surname/Generation might be same, but let's clear for safety
  1020. // Resetting form but keeping "related_mid" might be useful for siblings?
  1021. // For now, simple reset.
  1022. // --- Update Local Matches before resetting form ---
  1023. // If we just saved a person, check if this person is the father/spouse of anyone else in the list
  1024. // and update their matches so 'fillForm' will work for them.
  1025. const savedName = formData.get('name'); // Traditional (Raw)
  1026. const savedSimplifiedName = formData.get('simplified_name'); // Simplified (Cleaned)
  1027. const savedId = result.member_id;
  1028. const savedSex = formData.get('sex'); // 1: Male, 2: Female
  1029. if (savedId) {
  1030. currentParsedPeople.forEach(p => {
  1031. if (!p.matches) p.matches = {};
  1032. // Check Father Match
  1033. // Try matching against Simplified Name (p.father_name is Simplified Cleaned)
  1034. // Or fallback to savedName if p.father_name happened to be Traditional (rare but possible)
  1035. if (p.father_name && (p.father_name === savedSimplifiedName || p.father_name === savedName)) {
  1036. // Assume simple match logic here (usually father is male)
  1037. if (savedSex === '1') {
  1038. if (!p.matches.father) p.matches.father = [];
  1039. // Add to matches if not exists
  1040. if (!p.matches.father.find(m => m.id === savedId)) {
  1041. p.matches.father.push({ id: savedId, name: savedName, sex: 1 }); // Mock DB object
  1042. }
  1043. }
  1044. }
  1045. // Check Spouse Match
  1046. if (p.spouse_name && (p.spouse_name === savedSimplifiedName || p.spouse_name === savedName)) {
  1047. // Spouse logic...
  1048. if (!p.matches.spouse) p.matches.spouse = [];
  1049. if (!p.matches.spouse.find(m => m.id === savedId)) {
  1050. p.matches.spouse.push({ id: savedId, name: savedName, sex: parseInt(savedSex) });
  1051. }
  1052. }
  1053. });
  1054. // Also, we need to add this new member to the <select> options for future manual selection!
  1055. // This is tricky because the select is rendered by Jinja2.
  1056. // We can append an option via JS.
  1057. const relatedSelect = document.querySelector('select[name="related_mid"]');
  1058. if (relatedSelect) {
  1059. const newOption = document.createElement('option');
  1060. newOption.value = savedId;
  1061. newOption.textContent = `${savedName} (ID: ${savedId})`;
  1062. // Add birthday data if available for validation
  1063. newOption.dataset.birthday = new Date(formData.get('birthday')).getTime() / 1000;
  1064. relatedSelect.add(newOption); // Add to end
  1065. }
  1066. }
  1067. // --- End Local Match Update ---
  1068. form.reset();
  1069. // Clear lineage generation tags
  1070. document.querySelectorAll('#lineage-generations-container .lineage-tag').forEach(t => t.remove());
  1071. const lgInput = document.getElementById('lineage-input-form');
  1072. if (lgInput) lgInput.classList.add('d-none');
  1073. const lgAdd = document.getElementById('add-lineage');
  1074. if (lgAdd) lgAdd.classList.remove('d-none');
  1075. form.querySelector('[name="personal_achievements"]').value = '';
  1076. form.querySelector('[name="notes"]').value = '';
  1077. form.querySelector('[name="tags"]').value = '';
  1078. form.querySelector('[name="family_rank"]').value = '';
  1079. // Update the source_record_id field to the current image's ID after reset
  1080. const sourceRecordIdField = document.querySelector('input[name="source_record_id"]');
  1081. if (sourceRecordIdField && images.length > 0) {
  1082. // If we're editing a member, keep the existing source_record_id
  1083. // Otherwise, use the current image's ID
  1084. const isEditMode = {{ 'true' if member else 'false' }};
  1085. if (!isEditMode) {
  1086. sourceRecordIdField.value = images[currentIndex].id;
  1087. }
  1088. }
  1089. // Close detail panel
  1090. document.getElementById('aiCurrentDetail').style.display = 'none';
  1091. // 4. Auto-Next Logic
  1092. // Find the next available person in the list to fill
  1093. if (window.lastFilledIndex !== undefined) {
  1094. const nextIndex = window.lastFilledIndex + 1;
  1095. if (currentParsedPeople[nextIndex]) {
  1096. // Automatically fill the next one!
  1097. fillForm(nextIndex);
  1098. // Scroll list to show the new active item if needed
  1099. const btn = document.getElementById(`btn-fill-${nextIndex}`);
  1100. if(btn) btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1101. }
  1102. }
  1103. } else {
  1104. // Remove loading overlay
  1105. loadingOverlay.remove();
  1106. alert('保存失败: ' + result.message);
  1107. }
  1108. } catch (error) {
  1109. // Remove loading overlay
  1110. loadingOverlay.remove();
  1111. console.error('[Add Member] Error submitting form:', error);
  1112. alert('网络或服务器错误,请稍后重试: ' + error.message);
  1113. } finally {
  1114. submitBtn.disabled = false;
  1115. submitBtn.innerHTML = originalBtnHtml;
  1116. }
  1117. });
  1118. });
  1119. // --- End AJAX Form Submission ---
  1120. function updateDisplay() {
  1121. if (images.length > 0) {
  1122. const img = images[currentIndex];
  1123. document.getElementById('refImage').src = img.url;
  1124. document.getElementById('currentPage').innerText = currentIndex + 1;
  1125. // Update metadata display
  1126. const metaContainer = document.getElementById('imageMetadata');
  1127. if (img.genealogy_version || img.genealogy_source || img.upload_person) {
  1128. metaContainer.style.display = 'block';
  1129. document.getElementById('metaVersion').innerText = img.genealogy_version || '未提供';
  1130. document.getElementById('metaSource').innerText = img.genealogy_source || '未提供';
  1131. document.getElementById('metaPerson').innerText = img.upload_person || '未提供';
  1132. } else {
  1133. metaContainer.style.display = 'none';
  1134. }
  1135. // Update the source_record_id hidden field in the form
  1136. const sourceRecordIdField = document.querySelector('input[name="source_record_id"]');
  1137. if (sourceRecordIdField) {
  1138. // If we're editing a member, keep the existing source_record_id
  1139. // Otherwise, use the current image's ID
  1140. const isEditMode = {{ 'true' if member else 'false' }};
  1141. if (!isEditMode) {
  1142. sourceRecordIdField.value = img.id;
  1143. }
  1144. }
  1145. // Reset image state on switch
  1146. resetFilters();
  1147. // AI Button Logic
  1148. const aiBtn = document.getElementById('aiBtn');
  1149. const aiPanel = document.getElementById('aiLogPanel');
  1150. const resultList = document.getElementById('aiResultList');
  1151. const resultCount = document.getElementById('resultCount');
  1152. // Hide panel when switching images to avoid confusion
  1153. if (aiPanel) aiPanel.style.display = 'none';
  1154. // Clear current data
  1155. currentParsedPeople = [];
  1156. if (resultCount) resultCount.innerText = '0';
  1157. if (resultList) resultList.innerHTML = '';
  1158. if (img.ai_status === 2 && img.ai_content) {
  1159. // Determine content
  1160. let content = img.ai_content;
  1161. // Parse if string (it might be a string if double encoded or stored as JSON string in DB)
  1162. if (typeof content === 'string') {
  1163. try { content = JSON.parse(content); } catch(e) { content = []; }
  1164. }
  1165. if (!Array.isArray(content) && content) content = [content];
  1166. if (content && content.length > 0) {
  1167. // Update Button to "View Results"
  1168. aiBtn.innerHTML = '<i class="bi bi-list-check"></i> 查看解析结果';
  1169. aiBtn.className = 'btn btn-sm btn-success text-white ms-2 me-2';
  1170. aiBtn.onclick = function() {
  1171. // Show panel with loading
  1172. if (aiPanel) aiPanel.style.display = 'block';
  1173. if (resultList) resultList.innerHTML = '<div class="text-center p-3"><div class="spinner-border text-primary" role="status"></div></div>';
  1174. // Process (small delay to allow UI update)
  1175. setTimeout(() => processAiData(content), 10);
  1176. };
  1177. return; // Done
  1178. }
  1179. }
  1180. // Default: Reset to "AI Recognition"
  1181. aiBtn.innerHTML = '<i class="bi bi-magic"></i> AI 识别';
  1182. aiBtn.className = 'btn btn-sm btn-info text-white ms-2 me-2';
  1183. aiBtn.onclick = recognizeImage;
  1184. }
  1185. }
  1186. function nextImage() {
  1187. if (images.length === 0) return;
  1188. const currentVersion = images[currentIndex].genealogy_version;
  1189. // 从当前索引开始,寻找下一个相同版本的图片
  1190. for (let i = currentIndex + 1; i < images.length; i++) {
  1191. if (images[i].genealogy_version === currentVersion) {
  1192. currentIndex = i;
  1193. updateDisplay();
  1194. return;
  1195. }
  1196. }
  1197. }
  1198. function prevImage() {
  1199. if (images.length === 0) return;
  1200. const currentVersion = images[currentIndex].genealogy_version;
  1201. // 从当前索引开始,寻找上一个相同版本的图片
  1202. for (let i = currentIndex - 1; i >= 0; i--) {
  1203. if (images[i].genealogy_version === currentVersion) {
  1204. currentIndex = i;
  1205. updateDisplay();
  1206. return;
  1207. }
  1208. }
  1209. }
  1210. function gotoPage() {
  1211. const val = document.getElementById('pageInput').value;
  1212. if (!val) return;
  1213. const page = parseInt(val);
  1214. if (images.length === 0) {
  1215. alert('未找到该页码对应的图片');
  1216. return;
  1217. }
  1218. const currentVersion = images[currentIndex].genealogy_version;
  1219. const index = images.findIndex(img => img.page === page && img.genealogy_version === currentVersion);
  1220. if (index !== -1) {
  1221. currentIndex = index;
  1222. updateDisplay();
  1223. } else {
  1224. alert('未找到该页码对应的图片');
  1225. }
  1226. }
  1227. function closeAiLog() {
  1228. document.getElementById('aiLogPanel').style.display = 'none';
  1229. }
  1230. function toggleAiPanel() {
  1231. const panel = document.getElementById('aiLogPanel');
  1232. if (panel.style.display === 'none') {
  1233. panel.style.display = 'block';
  1234. } else {
  1235. panel.style.display = 'none';
  1236. }
  1237. }
  1238. function updateAiButtonState(hasResults) {
  1239. const btn = document.getElementById('aiBtn');
  1240. if (!btn) return;
  1241. if (hasResults) {
  1242. btn.innerHTML = '<i class="bi bi-list-check"></i> 查看识别结果';
  1243. btn.onclick = toggleAiPanel;
  1244. btn.classList.remove('btn-info');
  1245. btn.classList.add('btn-success');
  1246. } else {
  1247. // Revert state if needed (usually on new image load if we clear data)
  1248. btn.innerHTML = '<i class="bi bi-magic"></i> AI 识别';
  1249. btn.onclick = recognizeImage;
  1250. btn.classList.remove('btn-success');
  1251. btn.classList.add('btn-info');
  1252. }
  1253. }
  1254. function fillForm(index) {
  1255. window.lastFilledIndex = index;
  1256. const person = currentParsedPeople[index];
  1257. if (!person) return;
  1258. const form = document.querySelector('form');
  1259. form.reset(); // Clear previous data first
  1260. if (tomSelectInstance) {
  1261. tomSelectInstance.clear();
  1262. }
  1263. // Set Source Index
  1264. const sourceIndexInput = form.querySelector('[name="source_index"]');
  1265. if (sourceIndexInput) {
  1266. sourceIndexInput.value = index;
  1267. console.log('Set source_index:', index);
  1268. window.lastFilledIndex = index;
  1269. console.log('Set lastFilledIndex:', index);
  1270. }
  1271. // 1. 姓名
  1272. if (person.name) {
  1273. const nameInput = form.querySelector('[name="name"]');
  1274. nameInput.value = person.name;
  1275. nameInput.dispatchEvent(new Event('input')); // 触发重名检测
  1276. }
  1277. if (person.simplified_name) {
  1278. const snInput = form.querySelector('[name="simplified_name"]');
  1279. if (snInput) snInput.value = person.simplified_name;
  1280. } else {
  1281. // Fallback: if no simplified_name explicitly, generate it
  1282. if (person.name) {
  1283. const snInput = form.querySelector('[name="simplified_name"]');
  1284. if (snInput) snInput.value = cleanName(person.name);
  1285. }
  1286. }
  1287. // 2. 性别
  1288. if (person.sex) {
  1289. const sexSelect = form.querySelector('[name="sex"]');
  1290. if (person.sex.includes('女')) sexSelect.value = '2';
  1291. else if (person.sex.includes('男')) sexSelect.value = '1';
  1292. }
  1293. // 3. 生日 & 自动推断过世
  1294. // Reset unknown toggle first
  1295. const birthdayUnknownCb = document.getElementById('birthdayUnknown');
  1296. if (birthdayUnknownCb) {
  1297. birthdayUnknownCb.checked = false;
  1298. toggleBirthdayUnknown();
  1299. }
  1300. let isDeceased = false;
  1301. let isDeceasedUnknown = true;
  1302. if (person.birthday) {
  1303. let dateVal = person.birthday;
  1304. // Handle partial dates like "1890年" or "1890年?月?日"
  1305. const partialYearMatch = dateVal.match(/^(\d{4})[^\d]*$/) || dateVal.match(/(\d{4})年\s*[??Xxx]\s*月/i);
  1306. if (partialYearMatch) {
  1307. dateVal = `${partialYearMatch[1]}-01-01`;
  1308. } else {
  1309. // 尝试标准化完整日期
  1310. const dateMatch = dateVal.match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})/);
  1311. if (dateMatch) {
  1312. const y = dateMatch[1];
  1313. const m = dateMatch[2].padStart(2, '0');
  1314. const d = dateMatch[3].padStart(2, '0');
  1315. dateVal = `${y}-${m}-${d}`;
  1316. }
  1317. }
  1318. // 只有当日期格式正确时才填充
  1319. if (/^\d{4}-\d{2}-\d{2}$/.test(dateVal)) {
  1320. form.querySelector('[name="birthday"]').value = dateVal;
  1321. // Auto "Is Deceased" Logic (e.g. older than 100 years from now)
  1322. const birthYear = parseInt(dateVal.substring(0, 4));
  1323. const currentYear = new Date().getFullYear();
  1324. if (currentYear - birthYear > 100) {
  1325. isDeceased = true;
  1326. }
  1327. isDeceasedUnknown = false;
  1328. } else {
  1329. // Parse failed, set to unknown
  1330. if (birthdayUnknownCb) {
  1331. birthdayUnknownCb.checked = true;
  1332. toggleBirthdayUnknown();
  1333. }
  1334. }
  1335. } else {
  1336. // No birthday found, automatically check unknown
  1337. if (birthdayUnknownCb) {
  1338. birthdayUnknownCb.checked = true;
  1339. toggleBirthdayUnknown();
  1340. }
  1341. }
  1342. // 当自己年龄不详时,通过父母年龄推断是否在世
  1343. if (isDeceasedUnknown && person.matches && person.matches.father && person.matches.father.length > 0) {
  1344. const father = person.matches.father[0];
  1345. if (father.birthday) {
  1346. const fatherBirthYear = new Date(father.birthday * 1000).getFullYear();
  1347. const currentYear = new Date().getFullYear();
  1348. // 假设如果父亲是120年前出生的,子女大概率也已超过100岁
  1349. if (currentYear - fatherBirthYear > 120) {
  1350. isDeceased = true;
  1351. isDeceasedUnknown = false;
  1352. }
  1353. }
  1354. }
  1355. // 已故状态
  1356. const passAwaySelect = form.querySelector('[name="is_pass_away"]');
  1357. if (passAwaySelect) {
  1358. // "殁", "葬", "卒" in raw text usually means deceased. If AI extracted death_date, also true.
  1359. if (isDeceased || person.death_date) {
  1360. passAwaySelect.value = '1'; // 已故
  1361. } else if (isDeceasedUnknown) {
  1362. passAwaySelect.value = '2'; // 未知
  1363. } else {
  1364. passAwaySelect.value = '0'; // 默认健在,除非有证据
  1365. }
  1366. }
  1367. // 4. 婚姻状况
  1368. const maritalSelect = form.querySelector('[name="marital_status"]');
  1369. if (maritalSelect) {
  1370. if (person.spouse_name) {
  1371. maritalSelect.value = '2'; // 已婚
  1372. } else {
  1373. maritalSelect.value = '0'; // 未知
  1374. }
  1375. }
  1376. // 4. 代数 -> 堂内排行
  1377. if (person.generation) {
  1378. const genMatch = person.generation.match(/\d+/);
  1379. // 这里将 AI 解析的 'generation' 填入 'family_rank' (堂内排行)
  1380. // 'name_word_generation' (世系世代) 保持为空
  1381. form.querySelector('[name="family_rank"]').value = person.generation;
  1382. }
  1383. // 4.5 字辈 (name_word)
  1384. let zibei = person.name_word;
  1385. if (!zibei && person.name) {
  1386. // Heuristic: If name starts with "留" and is 3 chars long (e.g. 留学勤), Zibei is index 1.
  1387. // If name starts with "留" and is > 3 chars, we can't be sure, but index 1 is a good guess for generation char.
  1388. // "留学公" -> "留" + "学" + "公". Zibei "学".
  1389. // "留学勤" -> "留" + "学" + "勤". Zibei "学".
  1390. // Let's use a safe heuristic: if name starts with '留' and length >= 3
  1391. if (person.name.startsWith('留') && person.name.length >= 3) {
  1392. zibei = person.name.charAt(1);
  1393. }
  1394. }
  1395. if (zibei) {
  1396. form.querySelector('[name="name_word"]').value = zibei;
  1397. person.name_word = zibei; // Update data object for display
  1398. }
  1399. // 5. 其他信息
  1400. if (person.education) form.querySelector('[name="educational"]').value = person.education;
  1401. if (person.title) form.querySelector('[name="occupation"]').value = person.title;
  1402. // 个人成就/备注字段追加信息
  1403. let extraInfo = [];
  1404. if (person.father_name) extraInfo.push(`父亲: ${person.father_name}`);
  1405. if (person.spouse_name) extraInfo.push(`配偶: ${person.spouse_name}`);
  1406. // 将亲属关系存入 'notes' (人员备注) 字段
  1407. const notesField = form.querySelector('[name="notes"]');
  1408. const currentNotes = notesField.value;
  1409. const newInfo = extraInfo.join('; ');
  1410. if (newInfo && !currentNotes.includes(newInfo)) {
  1411. notesField.value = currentNotes ? (currentNotes + '\n' + newInfo) : newInfo;
  1412. }
  1413. // 确保无论如何都触发一遍自动匹配事件
  1414. notesField.dispatchEvent(new Event('input', {bubbles: true}));
  1415. if (window.checkSpouseInNotes) {
  1416. window.checkSpouseInNotes();
  1417. }
  1418. // --- Auto-Linking Logic ---
  1419. if (person.matches) {
  1420. // Priority: Father > Spouse (Configurable?)
  1421. // For now, if father matches, select father.
  1422. if (person.matches.father && person.matches.father.length > 0) {
  1423. // Pick the first one for now (could show UI to choose if multiple)
  1424. const father = person.matches.father[0];
  1425. const relTypeSelect = form.querySelector('[name="relation_type"]');
  1426. if (relTypeSelect) {
  1427. // Set the related member
  1428. document.getElementById('related-member-display').value = father.name;
  1429. document.getElementById('related_mid').value = father.id;
  1430. // Set relation type to father
  1431. relTypeSelect.value = '1'; // 父子
  1432. // Trigger the lineage generation reference display
  1433. const relatedMid = document.getElementById('related_mid').value;
  1434. if (relTypeSelect.value == 1 && relatedMid) {
  1435. fetch(`/manager/api/member/${relatedMid}`, {
  1436. credentials: 'include'
  1437. })
  1438. .then(response => response.json())
  1439. .then(data => {
  1440. if (data.member && data.member.name_word_generation) {
  1441. // 显示父亲的世系世代
  1442. const lineageContainer = document.getElementById('lineage-generations-container');
  1443. const fatherLineageDiv = document.getElementById('father-lineage');
  1444. if (!fatherLineageDiv) {
  1445. const newDiv = document.createElement('div');
  1446. newDiv.id = 'father-lineage';
  1447. newDiv.className = 'father-lineage-hint';
  1448. newDiv.innerHTML = `
  1449. <div class="d-flex align-items-center">
  1450. <i class="bi bi-info-circle me-2 text-info"></i>
  1451. <div>
  1452. <strong class="text-info">父亲世系世代参考:</strong>
  1453. <span>${data.member.name_word_generation}</span>
  1454. </div>
  1455. </div>
  1456. `;
  1457. // Find the add button container
  1458. const addButtonContainer = document.querySelector('#lineage-generations-container .d-flex.justify-content-between.align-items-center');
  1459. if (addButtonContainer) {
  1460. // Insert after the add button container
  1461. addButtonContainer.parentNode.insertBefore(newDiv, addButtonContainer.nextSibling);
  1462. } else if (lineageContainer.firstChild) {
  1463. // Insert after the first child
  1464. lineageContainer.insertBefore(newDiv, lineageContainer.firstChild.nextSibling);
  1465. } else {
  1466. // Append to container
  1467. lineageContainer.appendChild(newDiv);
  1468. }
  1469. } else {
  1470. fatherLineageDiv.innerHTML = `
  1471. <div class="d-flex align-items-center">
  1472. <i class="bi bi-info-circle me-2 text-info"></i>
  1473. <div>
  1474. <strong class="text-info">父亲世系世代参考:</strong>
  1475. <span>${data.member.name_word_generation}</span>
  1476. </div>
  1477. </div>
  1478. `;
  1479. }
  1480. }
  1481. });
  1482. }
  1483. }
  1484. } else if (person.matches.spouse && person.matches.spouse.length > 0) {
  1485. const spouse = person.matches.spouse[0];
  1486. const relTypeSelect = form.querySelector('[name="relation_type"]');
  1487. if (relTypeSelect) {
  1488. // Set the related member
  1489. document.getElementById('related-member-display').value = spouse.name;
  1490. document.getElementById('related_mid').value = spouse.id;
  1491. // Set relation type to spouse
  1492. relTypeSelect.value = '10'; // 夫妻
  1493. }
  1494. }
  1495. }
  1496. // --- Show Details Panel ---
  1497. const detailContainer = document.getElementById('aiCurrentDetail');
  1498. const detailContent = document.getElementById('aiDetailContent');
  1499. let html = '<ul class="list-unstyled mb-0 font-monospace" style="font-size: 0.85rem;">';
  1500. const getLabel = (k) => fieldMapping[k] || (k === 'children' ? '子女' : k);
  1501. // 遍历属性显示
  1502. for (const key in person) {
  1503. // 隐藏内部属性
  1504. if (key.startsWith('_')) continue;
  1505. let val = person[key];
  1506. const label = getLabel(key);
  1507. // 特殊处理 children
  1508. if (key === 'children') {
  1509. if (Array.isArray(val) && val.length > 0) {
  1510. let childrenHtml = '<div class="d-flex flex-wrap gap-1 mt-1">';
  1511. val.forEach(child => {
  1512. // 使用 child._originalIndex 进行跳转填充
  1513. childrenHtml += `<button class="btn btn-sm btn-outline-info py-0 px-2" style="font-size: 0.75rem;" onclick="fillForm(${child._originalIndex})">${child.name || '未知'}</button>`;
  1514. });
  1515. childrenHtml += '</div>';
  1516. html += `<li class="mb-1"><span class="text-info opacity-75">${label}:</span> ${childrenHtml}</li>`;
  1517. }
  1518. continue;
  1519. }
  1520. // 默认显示
  1521. if (!val || val === '') val = '-';
  1522. html += `<li class="mb-1"><span class="text-info opacity-75">${label}:</span> <span class="text-white ms-1">${val}</span></li>`;
  1523. }
  1524. html += '</ul>';
  1525. detailContent.innerHTML = html;
  1526. detailContainer.style.display = 'block';
  1527. // Visual feedback
  1528. const btn = document.getElementById(`btn-fill-${index}`);
  1529. if(btn) {
  1530. const originalHtml = btn.innerHTML;
  1531. btn.innerHTML = '<i class="bi bi-check"></i> 已填';
  1532. btn.classList.remove('btn-outline-info');
  1533. btn.classList.add('btn-info', 'text-white');
  1534. setTimeout(() => {
  1535. btn.innerHTML = originalHtml;
  1536. btn.classList.add('btn-outline-info');
  1537. btn.classList.remove('btn-info', 'text-white');
  1538. }, 1000);
  1539. }
  1540. }
  1541. // --- Pre-fill Logic from Backend (Async AI Result) ---
  1542. const prefilledContent = {{ prefilled_content | tojson | safe if prefilled_content else 'null' }};
  1543. const sourceOssUrl = "{{ source_oss_url if source_oss_url else '' }}";
  1544. const sourceRecordId = "{{ source_record_id if source_record_id else '' }}";
  1545. if (prefilledContent && (sourceOssUrl || sourceRecordId)) {
  1546. // We have prefilled content from DB, simulate "Recognize Image" success
  1547. document.addEventListener('DOMContentLoaded', async () => {
  1548. // Wait a bit for UI to settle
  1549. setTimeout(async () => {
  1550. // Find image index
  1551. let imgIndex = -1;
  1552. if (sourceRecordId) {
  1553. //优先根据sourceRecordId查找
  1554. imgIndex = images.findIndex(img => img.id == sourceRecordId);
  1555. }
  1556. if (imgIndex === -1 && sourceOssUrl) {
  1557. //如果根据sourceRecordId没找到,再根据sourceOssUrl查找
  1558. imgIndex = images.findIndex(img => img.url === sourceOssUrl);
  1559. }
  1560. if (imgIndex !== -1) {
  1561. currentIndex = imgIndex;
  1562. updateDisplay();
  1563. }
  1564. // Parse and display results
  1565. try {
  1566. let data = prefilledContent;
  1567. if (typeof data === 'string') {
  1568. try {
  1569. data = JSON.parse(data);
  1570. } catch(e) {
  1571. console.error("Prefilled content parse error", e);
  1572. return;
  1573. }
  1574. }
  1575. if (!Array.isArray(data)) data = [data];
  1576. await processAiData(data);
  1577. // Open the log panel to show results
  1578. const aiPanel = document.getElementById('aiLogPanel');
  1579. if (aiPanel) aiPanel.style.display = 'block';
  1580. const status = document.getElementById('reasoningStatus');
  1581. if(status) {
  1582. status.textContent = '已加载历史解析';
  1583. status.className = 'badge bg-success ms-2';
  1584. }
  1585. const logContent = document.getElementById('aiLogContent');
  1586. if(logContent) logContent.textContent = "已加载历史 AI 解析记录。";
  1587. } catch (e) {
  1588. console.error("Error processing prefilled content", e);
  1589. }
  1590. }, 500);
  1591. });
  1592. } else {
  1593. // No prefilled content, initialize display for the first image
  1594. document.addEventListener('DOMContentLoaded', () => {
  1595. updateDisplay();
  1596. });
  1597. }
  1598. // --- Name Cleaning Logic (Matching Backend) ---
  1599. // 仅做繁 -> 简转换,不动姓氏/“公”处理,用于配偶等非留氏族人
  1600. function manualSimplify(text) {
  1601. if (!text) return text;
  1602. text = text.trim();
  1603. const mapping = {
  1604. '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴',
  1605. '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
  1606. '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
  1607. '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
  1608. '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
  1609. '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
  1610. '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
  1611. '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
  1612. '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
  1613. '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
  1614. '劉': '刘'
  1615. };
  1616. let result = '';
  1617. for (const ch of text) {
  1618. result += mapping[ch] || ch;
  1619. }
  1620. return result;
  1621. }
  1622. // 留氏本人姓名清洗:在 manualSimplify 基础上,处理“留”姓和“公”
  1623. function cleanName(name) {
  1624. if (!name) return name;
  1625. name = manualSimplify(name);
  1626. const exceptions = ['学公', '留学公'];
  1627. if (exceptions.includes(name)) {
  1628. if (!name.startsWith('留')) {
  1629. name = '留' + name;
  1630. }
  1631. return name;
  1632. }
  1633. // Remove '公' suffix
  1634. if (name.endsWith('公')) {
  1635. name = name.slice(0, -1);
  1636. }
  1637. // Ensure '留' prefix
  1638. if (!name.startsWith('留')) {
  1639. name = '留' + name;
  1640. }
  1641. return name;
  1642. }
  1643. function isFemaleSex(sexValue) {
  1644. if (sexValue === null || sexValue === undefined) return false;
  1645. const s = String(sexValue).trim().toLowerCase();
  1646. return s === '女' || s === '2' || s === 'female' || s === 'f';
  1647. }
  1648. function normalizeLookupName(name) {
  1649. if (!name) return '';
  1650. return manualSimplify(String(name)).trim();
  1651. }
  1652. // Extracted function to process AI data and render tree
  1653. async function processAiData(data) {
  1654. const spouseNameSet = new Set();
  1655. data.forEach(p => {
  1656. const n = normalizeLookupName(p.spouse_name);
  1657. if (n) spouseNameSet.add(n);
  1658. });
  1659. // Clean Names First
  1660. data.forEach(p => {
  1661. // Determine "Original" (Raw) and "Simplified" (Cleaned)
  1662. let rawName = p.original_name || p.name;
  1663. let simName = p.name || p.original_name; // Prefer AI simplified name; fallback to raw
  1664. const ownName1 = normalizeLookupName(p.name);
  1665. const ownName2 = normalizeLookupName(p.original_name);
  1666. const isFemaleSpouse = isFemaleSex(p.sex) && (
  1667. !!normalizeLookupName(p.spouse_name) ||
  1668. (ownName1 && spouseNameSet.has(ownName1)) ||
  1669. (ownName2 && spouseNameSet.has(ownName2))
  1670. );
  1671. // 女性配偶:只繁转简,不拼接“留”;其他人维持原规则
  1672. p.simplified_name = isFemaleSpouse ? manualSimplify(simName) : cleanName(simName);
  1673. // Set the name to be the Raw Name for storage in 'name' column
  1674. p.name = rawName;
  1675. // 父亲:同族,用 cleanName(加“留”、去“公”)
  1676. if (p.father_name) p.father_name = cleanName(p.father_name);
  1677. // 配偶:只做繁体 -> 简体,不拼接“留”姓
  1678. if (p.spouse_name) p.spouse_name = manualSimplify(p.spouse_name);
  1679. });
  1680. // Call Relation Check API
  1681. try {
  1682. // Send simplified_name for checking relations if available, or name?
  1683. // The API checks against DB 'name' column.
  1684. // Wait, DB 'name' column is now Traditional Raw.
  1685. // But existing data in DB is Simplified Cleaned.
  1686. // New data will be Traditional Raw in 'name', Simplified Cleaned in 'simplified_name'.
  1687. // The check_relations API uses `WHERE name IN (...)`.
  1688. // The AI returns `father_name` as Simplified (usually).
  1689. // So we are checking Simplified Father Name against...
  1690. // If DB 'name' is mixed (Old Simplified, New Traditional), this is messy.
  1691. // But `check_relations` logic:
  1692. // `names_to_check.add(p['father_name'])` -> Simplified.
  1693. // `SELECT ... WHERE name IN ...`
  1694. // If DB 'name' contains Traditional, we won't find match if we search Simplified.
  1695. // Unless we search `simplified_name` column too?
  1696. // I should update `check_relations` in app.py to search both `name` and `simplified_name`.
  1697. const checkRes = await fetch('/manager/api/check_relations', {
  1698. method: 'POST',
  1699. headers: { 'Content-Type': 'application/json' },
  1700. body: JSON.stringify({ people: data })
  1701. });
  1702. const checkResult = await checkRes.json();
  1703. if (checkResult.success && checkResult.matches) {
  1704. // Merge matches into data
  1705. for (const idx in checkResult.matches) {
  1706. const match = checkResult.matches[idx];
  1707. if (data[idx]) {
  1708. data[idx].matches = match;
  1709. }
  1710. }
  1711. }
  1712. } catch (e) {
  1713. console.warn("Auto-linking failed:", e);
  1714. }
  1715. currentParsedPeople = data;
  1716. document.getElementById('resultCount').innerText = data.length;
  1717. // Update Button State to "View Results"
  1718. updateAiButtonState(true);
  1719. // Build Relationship Tree
  1720. const personMap = {};
  1721. const roots = [];
  1722. // 1. Initialize map
  1723. data.forEach((p, index) => {
  1724. p._originalIndex = index; // Store original index for fillForm
  1725. p.children = [];
  1726. // Use simplified_name as key if available, otherwise name (for consistent lookup)
  1727. const lookupKey = p.simplified_name || p.name;
  1728. personMap[lookupKey] = p;
  1729. });
  1730. // 2. Build Hierarchy
  1731. data.forEach(p => {
  1732. let parentFound = false;
  1733. if (p.father_name) {
  1734. // Try exact match using simplified name (since father_name is usually simplified)
  1735. let father = personMap[p.father_name];
  1736. // Try loose match
  1737. if (!father) {
  1738. for (const name in personMap) {
  1739. if (name.includes(p.father_name) || p.father_name.includes(name)) {
  1740. father = personMap[name];
  1741. break;
  1742. }
  1743. }
  1744. }
  1745. if (father && father !== p) {
  1746. father.children.push(p);
  1747. parentFound = true;
  1748. }
  1749. }
  1750. if (!parentFound) {
  1751. roots.push(p);
  1752. }
  1753. });
  1754. // 3. Recursive Render Function
  1755. function renderNode(p, level = 0) {
  1756. const indent = level * 20;
  1757. let html = `
  1758. <div class="card bg-dark border-secondary mb-1" style="margin-left: ${indent}px; background-color: #2c3034;">
  1759. <div class="card-body p-2 d-flex justify-content-between align-items-center">
  1760. <div class="text-white">
  1761. <div class="fw-bold">
  1762. ${level > 0 ? '<i class="bi bi-arrow-return-right text-secondary me-1"></i>' : ''}
  1763. ${p.name || '未知姓名'}
  1764. <span class="badge bg-secondary text-light ms-1" style="font-size: 0.7rem">${p.sex || '-'}</span>
  1765. </div>
  1766. <div class="small text-white-50" style="font-size: 0.75rem; padding-left: ${level > 0 ? 18 : 0}px;">
  1767. ${p.generation ? '第'+p.generation+'世 ' : ''}
  1768. ${p.father_name ? '父:'+p.father_name : ''}
  1769. </div>
  1770. </div>
  1771. <button id="btn-fill-${p._originalIndex}"
  1772. class="btn btn-sm ${p.is_imported ? 'btn-success disabled' : 'btn-outline-info'} text-nowrap ms-2"
  1773. onclick="${p.is_imported ? '' : `fillForm(${p._originalIndex})`}">
  1774. ${p.is_imported ? '<i class="bi bi-check-lg"></i> 已录入' : '<i class="bi bi-pencil-square"></i> 填充'}
  1775. </button>
  1776. </div>
  1777. </div>
  1778. `;
  1779. if (p.children && p.children.length > 0) {
  1780. p.children.forEach(child => {
  1781. html += renderNode(child, level + 1);
  1782. });
  1783. }
  1784. return html;
  1785. }
  1786. // Render List
  1787. const resultList = document.getElementById('aiResultList');
  1788. const resultSection = document.getElementById('aiResultSection');
  1789. resultList.innerHTML = '';
  1790. // Fix: Use data directly if root finding logic fails or returns empty but data exists
  1791. if (roots.length === 0 && data.length > 0) {
  1792. // Just dump everything flat if tree building fails
  1793. data.forEach(p => resultList.innerHTML += renderNode(p, 0));
  1794. } else {
  1795. roots.forEach(p => {
  1796. resultList.innerHTML += renderNode(p, 0);
  1797. });
  1798. }
  1799. resultSection.style.display = 'block';
  1800. }
  1801. async function recognizeImage() {
  1802. if (images.length === 0) {
  1803. alert('没有可用的图片');
  1804. return;
  1805. }
  1806. const currentImg = images[currentIndex];
  1807. const btn = document.getElementById('aiBtn');
  1808. const originalContent = btn.innerHTML;
  1809. const logPanel = document.getElementById('aiLogPanel');
  1810. const logContent = document.getElementById('aiLogContent');
  1811. const resultSection = document.getElementById('aiResultSection');
  1812. const resultList = document.getElementById('aiResultList');
  1813. const reasoningStatus = document.getElementById('reasoningStatus');
  1814. btn.disabled = true;
  1815. btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 识别中...';
  1816. // Reset UI
  1817. logContent.textContent = '';
  1818. resultList.innerHTML = '';
  1819. resultSection.style.display = 'none';
  1820. logPanel.style.display = 'block';
  1821. reasoningStatus.textContent = '连接中...';
  1822. reasoningStatus.className = 'badge bg-secondary ms-2';
  1823. // Ensure reasoning panel is open
  1824. const collapseReasoning = document.getElementById('collapseReasoning');
  1825. if (collapseReasoning && !collapseReasoning.classList.contains('show')) {
  1826. new bootstrap.Collapse(collapseReasoning, { show: true });
  1827. }
  1828. // Retry logic function
  1829. async function fetchAndParse(url, retryCount = 0) {
  1830. const MAX_RETRIES = 2;
  1831. let fullText = '';
  1832. let jsonPart = '';
  1833. let hasJsonStarted = false;
  1834. try {
  1835. if (retryCount > 0) {
  1836. logContent.textContent = `\n[System] 解析失败,正在进行第 ${retryCount} 次重试...\n` + logContent.textContent;
  1837. reasoningStatus.textContent = `重试 ${retryCount}...`;
  1838. }
  1839. const response = await fetch('/manager/api/recognize_image', {
  1840. method: 'POST',
  1841. headers: { 'Content-Type': 'application/json' },
  1842. body: JSON.stringify({ image_url: url })
  1843. });
  1844. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  1845. const reader = response.body.getReader();
  1846. const decoder = new TextDecoder();
  1847. const separator = "|||JSON_START|||";
  1848. while (true) {
  1849. const { value, done } = await reader.read();
  1850. if (done) break;
  1851. const chunk = decoder.decode(value, { stream: true });
  1852. fullText += chunk;
  1853. // Only update display if not parsing JSON part yet or just started
  1854. if (!hasJsonStarted) {
  1855. const sepIndex = fullText.indexOf(separator);
  1856. if (sepIndex !== -1) {
  1857. hasJsonStarted = true;
  1858. reasoningStatus.textContent = '解析中...';
  1859. reasoningStatus.className = 'badge bg-info ms-2';
  1860. // Split content for display - only once
  1861. const reasoningPart = fullText.substring(0, sepIndex);
  1862. logContent.textContent = reasoningPart;
  1863. if (collapseReasoning) {
  1864. new bootstrap.Collapse(collapseReasoning, { hide: true });
  1865. }
  1866. } else {
  1867. // Update reasoning text
  1868. logContent.textContent = fullText;
  1869. logContent.scrollTop = logContent.scrollHeight;
  1870. }
  1871. }
  1872. }
  1873. // Parsing Logic
  1874. if (hasJsonStarted) {
  1875. const sepIndex = fullText.indexOf(separator);
  1876. jsonPart = fullText.substring(sepIndex + separator.length);
  1877. reasoningStatus.textContent = '完成';
  1878. reasoningStatus.className = 'badge bg-success ms-2';
  1879. } else {
  1880. // Fallback
  1881. jsonPart = fullText;
  1882. }
  1883. // Clean JSON
  1884. // 1. Try finding [...] array
  1885. let start = jsonPart.indexOf('[');
  1886. let end = jsonPart.lastIndexOf(']');
  1887. // 2. If not found, try finding {...} object and wrap it
  1888. let isSingleObject = false;
  1889. if (start === -1 || end === -1 || end <= start) {
  1890. start = jsonPart.indexOf('{');
  1891. end = jsonPart.lastIndexOf('}');
  1892. isSingleObject = true;
  1893. }
  1894. if (start !== -1 && end !== -1 && end > start) {
  1895. jsonPart = jsonPart.substring(start, end + 1);
  1896. } else {
  1897. // Try to extract any JSON-like array/object structure using regex as fallback
  1898. const jsonMatch = jsonPart.match(/(\[.*\]|\{.*\})/s);
  1899. if (jsonMatch) {
  1900. jsonPart = jsonMatch[0];
  1901. if (jsonPart.trim().startsWith('{')) isSingleObject = true;
  1902. } else {
  1903. // No valid JSON structure found
  1904. console.warn("No JSON brackets found in:", jsonPart);
  1905. throw new Error("未找到有效的 JSON 数据结构");
  1906. }
  1907. }
  1908. let data;
  1909. try {
  1910. // Pre-clean: Remove common markdown code block markers if stuck inside
  1911. jsonPart = jsonPart.replace(/^```json\s*/, '').replace(/```$/, '');
  1912. data = JSON.parse(jsonPart);
  1913. } catch (e) {
  1914. // Attempt to fix common JSON errors (e.g. trailing commas, unclosed strings) - simplified
  1915. console.error("JSON parse error. Content:", jsonPart);
  1916. // Force retry on parse error
  1917. throw new Error("JSON 格式解析错误");
  1918. }
  1919. if (isSingleObject && !Array.isArray(data)) {
  1920. data = [data]; // Normalize to array
  1921. } else if (!Array.isArray(data)) {
  1922. data = [data];
  1923. }
  1924. return data;
  1925. } catch (error) {
  1926. if (retryCount < MAX_RETRIES) {
  1927. // Wait 1s and retry
  1928. await new Promise(r => setTimeout(r, 1000));
  1929. return fetchAndParse(url, retryCount + 1);
  1930. }
  1931. throw error;
  1932. }
  1933. }
  1934. try {
  1935. const data = await fetchAndParse(currentImg.url);
  1936. // Use shared processing function
  1937. await processAiData(data);
  1938. // Update local state for persistence during session
  1939. if (images[currentIndex]) {
  1940. images[currentIndex].ai_status = 2;
  1941. images[currentIndex].ai_content = data;
  1942. }
  1943. } catch (error) {
  1944. console.error(error);
  1945. // Append error to log instead of overwriting valid reasoning
  1946. logContent.textContent += `\n\n[Error] ${error.message}`;
  1947. alert('AI 识别过程失败,请重试。\n错误详情: ' + error.message);
  1948. } finally {
  1949. btn.innerHTML = originalContent;
  1950. btn.disabled = false;
  1951. }
  1952. }
  1953. // Check for duplicate name
  1954. let nameCheckTimeout = null;
  1955. const nameInput = document.getElementById('nameInput');
  1956. const nameCheckResult = document.getElementById('nameCheckResult');
  1957. if (nameInput) {
  1958. nameInput.addEventListener('input', function() {
  1959. clearTimeout(nameCheckTimeout);
  1960. const nameVal = this.value.trim();
  1961. if (!nameVal) {
  1962. nameCheckResult.innerHTML = '';
  1963. return;
  1964. }
  1965. nameCheckTimeout = setTimeout(() => {
  1966. fetch(`/manager/api/check_name?name=${encodeURIComponent(nameVal)}`)
  1967. .then(r => r.json())
  1968. .then(data => {
  1969. if (data.success && data.exists) {
  1970. let html = `<div class="alert alert-warning py-2 mb-0 mt-2 small">
  1971. <i class="bi bi-exclamation-triangle-fill"></i> 发现 <strong>${data.matches.length}</strong> 个同名记录,请确认是否为同一人:
  1972. <ul class="mb-0 mt-1 ps-3">`;
  1973. data.matches.forEach(m => {
  1974. let sex = m.sex === 1 ? '男' : (m.sex === 2 ? '女' : '未知');
  1975. let deadStr = m.is_pass_away == 1 ? ' (已故)' : (m.is_pass_away == 2 ? ' (未知)' : '');
  1976. html += `<li><a href="/manager/member_detail/${m.id}" target="_blank" class="alert-link">${m.name}</a> - ${sex} - 出生: ${m.birthday_str}${deadStr}</li>`;
  1977. });
  1978. html += `</ul></div>`;
  1979. nameCheckResult.innerHTML = html;
  1980. } else {
  1981. nameCheckResult.innerHTML = '';
  1982. }
  1983. })
  1984. .catch(err => console.error('Error checking name:', err));
  1985. }, 600);
  1986. });
  1987. }
  1988. // Auto-link spouse from notes if female
  1989. const notesInput = document.querySelector('textarea[name="notes"]');
  1990. const sexSelect = document.querySelector('select[name="sex"]');
  1991. // Attach to window so fillForm can explicitly call it
  1992. window.checkSpouseInNotes = function() {
  1993. if (!notesInput || !sexSelect) return;
  1994. const val = notesInput.value;
  1995. // Check for father information
  1996. const fatherMatch = val.match(/父亲[::\s]*([^\s;;,,。]+)/);
  1997. if (fatherMatch && fatherMatch[1]) {
  1998. const fatherName = fatherMatch[1].trim();
  1999. const relationTypeSelect = document.querySelector('select[name="relation_type"]');
  2000. if (relationTypeSelect) {
  2001. // Find the father in the members list
  2002. fetch(`/manager/api/members?search=${encodeURIComponent(fatherName)}`, {
  2003. credentials: 'include'
  2004. })
  2005. .then(response => response.json())
  2006. .then(data => {
  2007. if (data.members && data.members.length > 0) {
  2008. // Find the best match
  2009. let bestMatch = null;
  2010. for (const member of data.members) {
  2011. const optName = member.name;
  2012. const normalizedOpt = optName.replace(/公$/, '').replace(/^留/, '');
  2013. const normalizedFather = fatherName.replace(/公$/, '').replace(/^留/, '');
  2014. if (optName === fatherName || normalizedOpt === normalizedFather ||
  2015. (normalizedOpt && normalizedFather && (normalizedOpt.includes(normalizedFather) || normalizedFather.includes(normalizedOpt)))) {
  2016. bestMatch = member;
  2017. break;
  2018. }
  2019. }
  2020. if (bestMatch) {
  2021. // Set the related member
  2022. document.getElementById('related-member-display').value = bestMatch.name;
  2023. document.getElementById('related_mid').value = bestMatch.id;
  2024. // Set relation type to father
  2025. relationTypeSelect.value = '1';
  2026. // Trigger the lineage generation reference display
  2027. const relatedMid = document.getElementById('related_mid').value;
  2028. if (relationTypeSelect.value == 1 && relatedMid) {
  2029. fetch(`/manager/api/member/${relatedMid}`, {
  2030. credentials: 'include'
  2031. })
  2032. .then(response => response.json())
  2033. .then(data => {
  2034. if (data.member && data.member.name_word_generation) {
  2035. // 显示父亲的世系世代
  2036. const lineageContainer = document.getElementById('lineage-generations-container');
  2037. const fatherLineageDiv = document.getElementById('father-lineage');
  2038. if (!fatherLineageDiv) {
  2039. const newDiv = document.createElement('div');
  2040. newDiv.id = 'father-lineage';
  2041. newDiv.className = 'father-lineage-hint';
  2042. newDiv.innerHTML = `
  2043. <div class="d-flex align-items-center">
  2044. <i class="bi bi-info-circle me-2 text-info"></i>
  2045. <div>
  2046. <strong class="text-info">父亲世系世代参考:</strong>
  2047. <span>${data.member.name_word_generation}</span>
  2048. </div>
  2049. </div>
  2050. `;
  2051. // Find the add button container
  2052. const addButtonContainer = document.querySelector('#lineage-generations-container .d-flex.justify-content-between.align-items-center');
  2053. if (addButtonContainer) {
  2054. // Insert after the add button container
  2055. addButtonContainer.parentNode.insertBefore(newDiv, addButtonContainer.nextSibling);
  2056. } else if (lineageContainer.firstChild) {
  2057. // Insert after the first child
  2058. lineageContainer.insertBefore(newDiv, lineageContainer.firstChild.nextSibling);
  2059. } else {
  2060. // Append to container
  2061. lineageContainer.appendChild(newDiv);
  2062. }
  2063. } else {
  2064. fatherLineageDiv.innerHTML = `
  2065. <div class="d-flex align-items-center">
  2066. <i class="bi bi-info-circle me-2 text-info"></i>
  2067. <div>
  2068. <strong class="text-info">父亲世系世代参考:</strong>
  2069. <span>${data.member.name_word_generation}</span>
  2070. </div>
  2071. </div>
  2072. `;
  2073. }
  2074. }
  2075. });
  2076. }
  2077. }
  2078. }
  2079. });
  2080. }
  2081. }
  2082. // Only check for spouse if female
  2083. if (sexSelect.value === '2') {
  2084. const spouseMatch = val.match(/配偶[::\s]*([^\s;;,,。]+)/);
  2085. if (spouseMatch && spouseMatch[1]) {
  2086. const spouseName = spouseMatch[1].trim();
  2087. const relationTypeSelect = document.querySelector('select[name="relation_type"]');
  2088. if (relationTypeSelect) {
  2089. // Find the spouse in the members list
  2090. fetch(`/manager/api/members?search=${encodeURIComponent(spouseName)}`, {
  2091. credentials: 'include'
  2092. })
  2093. .then(response => response.json())
  2094. .then(data => {
  2095. if (data.members && data.members.length > 0) {
  2096. // Find the best match
  2097. let bestMatch = null;
  2098. for (const member of data.members) {
  2099. const optName = member.name;
  2100. const normalizedOpt = optName.replace(/公$/, '').replace(/^留/, '');
  2101. const normalizedSpouse = spouseName.replace(/公$/, '').replace(/^留/, '');
  2102. if (optName === spouseName || normalizedOpt === normalizedSpouse ||
  2103. (normalizedOpt && normalizedSpouse && (normalizedOpt.includes(normalizedSpouse) || normalizedSpouse.includes(normalizedOpt)))) {
  2104. bestMatch = member;
  2105. break;
  2106. }
  2107. }
  2108. if (bestMatch) {
  2109. // Set the related member
  2110. document.getElementById('related-member-display').value = bestMatch.name;
  2111. document.getElementById('related_mid').value = bestMatch.id;
  2112. // Set relation type to spouse
  2113. relationTypeSelect.value = '10';
  2114. }
  2115. }
  2116. });
  2117. }
  2118. }
  2119. }
  2120. };
  2121. if (notesInput && sexSelect) {
  2122. notesInput.addEventListener('input', window.checkSpouseInNotes);
  2123. sexSelect.addEventListener('change', window.checkSpouseInNotes);
  2124. }
  2125. </script>
  2126. <script>
  2127. document.addEventListener('DOMContentLoaded', function() {
  2128. const container = document.getElementById('lineage-generations-container');
  2129. const addButton = document.getElementById('add-lineage');
  2130. const inputForm = document.getElementById('lineage-input-form');
  2131. const lineageInput = document.getElementById('lineage-input');
  2132. const confirmButton = document.getElementById('confirm-lineage');
  2133. const cancelButton = document.getElementById('cancel-lineage');
  2134. if (!container || !addButton || !inputForm || !lineageInput || !confirmButton || !cancelButton) return;
  2135. function addLineageTag(value) {
  2136. const tag = document.createElement('span');
  2137. tag.className = 'lineage-tag badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 px-3 py-2 rounded-pill d-inline-flex align-items-center';
  2138. tag.style.fontSize = '0.85rem';
  2139. tag.innerHTML = `${value}<button type="button" class="btn-close ms-2 remove-lineage" style="font-size:0.55rem;filter:none;opacity:0.6;" aria-label="删除"></button><input type="hidden" name="lineage_generations[]" value="${value}">`;
  2140. container.insertBefore(tag, inputForm);
  2141. }
  2142. container.addEventListener('click', function(e) {
  2143. const removeBtn = e.target.closest('.remove-lineage');
  2144. if (removeBtn) {
  2145. e.preventDefault();
  2146. e.stopPropagation();
  2147. const tag = removeBtn.closest('.lineage-tag');
  2148. if (tag) tag.remove();
  2149. }
  2150. });
  2151. addButton.addEventListener('click', function(e) {
  2152. e.preventDefault();
  2153. e.stopPropagation();
  2154. inputForm.classList.remove('d-none');
  2155. addButton.classList.add('d-none');
  2156. lineageInput.value = '';
  2157. lineageInput.focus();
  2158. });
  2159. function confirmInput() {
  2160. const value = lineageInput.value.trim();
  2161. if (value) {
  2162. addLineageTag(value);
  2163. }
  2164. lineageInput.value = '';
  2165. inputForm.classList.add('d-none');
  2166. addButton.classList.remove('d-none');
  2167. }
  2168. confirmButton.addEventListener('click', function(e) {
  2169. e.preventDefault();
  2170. e.stopPropagation();
  2171. confirmInput();
  2172. });
  2173. lineageInput.addEventListener('keydown', function(e) {
  2174. if (e.key === 'Enter') {
  2175. e.preventDefault();
  2176. e.stopPropagation();
  2177. confirmInput();
  2178. }
  2179. if (e.key === 'Escape') {
  2180. e.preventDefault();
  2181. lineageInput.value = '';
  2182. inputForm.classList.add('d-none');
  2183. addButton.classList.remove('d-none');
  2184. }
  2185. });
  2186. cancelButton.addEventListener('click', function(e) {
  2187. e.preventDefault();
  2188. e.stopPropagation();
  2189. lineageInput.value = '';
  2190. inputForm.classList.add('d-none');
  2191. addButton.classList.remove('d-none');
  2192. });
  2193. });
  2194. // 加载成员数据
  2195. function loadMembers(page = 1, search = '') {
  2196. console.log('Loading members...', { page, search });
  2197. fetch(`/manager/api/members?page=${page}&search=${encodeURIComponent(search)}`, {
  2198. credentials: 'include'
  2199. })
  2200. .then(response => {
  2201. console.log('Response status:', response.status);
  2202. return response.json();
  2203. })
  2204. .then(data => {
  2205. console.log('Response data:', data);
  2206. // Check if it's an error response
  2207. if (data.success === false) {
  2208. console.error('API error:', data.message);
  2209. // If unauthorized, redirect to login
  2210. if (data.message === 'Unauthorized') {
  2211. window.location.href = '/manager/login';
  2212. }
  2213. return;
  2214. }
  2215. // Handle success response - check if members and total exist
  2216. if (data.members !== undefined && data.total !== undefined) {
  2217. membersData = data.members;
  2218. totalMembers = data.total;
  2219. totalPages = Math.ceil(totalMembers / 10);
  2220. currentPage = page;
  2221. // 更新成员列表
  2222. updateMemberList();
  2223. // 更新分页
  2224. updatePagination();
  2225. // 更新总数
  2226. document.getElementById('total-members').textContent = totalMembers;
  2227. } else {
  2228. console.error('Invalid response structure:', data);
  2229. // Set default values
  2230. membersData = [];
  2231. totalMembers = 0;
  2232. totalPages = 1;
  2233. currentPage = 1;
  2234. updateMemberList();
  2235. updatePagination();
  2236. document.getElementById('total-members').textContent = 0;
  2237. }
  2238. })
  2239. .catch(error => {
  2240. console.error('Error loading members:', error);
  2241. });
  2242. }
  2243. // 更新成员列表
  2244. function updateMemberList() {
  2245. const memberList = document.getElementById('member-list');
  2246. memberList.innerHTML = '';
  2247. if (membersData.length === 0) {
  2248. memberList.innerHTML = '<div class="list-group-item text-center text-muted">暂无成员数据</div>';
  2249. return;
  2250. }
  2251. membersData.forEach(function(member) {
  2252. const item = document.createElement('div');
  2253. item.className = 'list-group-item list-group-item-action shadow-sm hover:bg-light';
  2254. item.style.transition = 'all 0.2s ease';
  2255. item.onclick = function() {
  2256. selectMemberById(member.id);
  2257. };
  2258. item.innerHTML = `
  2259. <div class="d-flex justify-content-between align-items-center">
  2260. <div>
  2261. <h6 class="mb-0">
  2262. <a href="/manager/member_detail/${member.id}" target="_blank" class="text-primary text-decoration-none" onclick="event.stopPropagation();">
  2263. ${member.name} ${member.simplified_name && member.simplified_name !== member.name ? `(${member.simplified_name})` : ''}
  2264. </a>
  2265. </h6>
  2266. <small class="text-muted">ID: ${member.id} | ${member.sex === 1 ? '男' : '女'}</small>
  2267. ${member.name_word_generation ? `<small class="d-block text-muted">世系世代: ${member.name_word_generation}</small>` : ''}
  2268. ${member.father_name ? `
  2269. <small class="d-block text-muted">
  2270. 父亲: ${member.father_name} ${member.father_simplified_name && member.father_simplified_name !== member.father_name ? `(${member.father_simplified_name})` : ''}
  2271. ${member.father_generation ? ` | 世系世代: ${member.father_generation}` : ''}
  2272. </small>
  2273. ` : ''}
  2274. </div>
  2275. <button type="button" class="btn btn-sm btn-primary" onclick="event.stopPropagation(); selectMemberById(${member.id});">
  2276. 选择
  2277. </button>
  2278. </div>
  2279. `;
  2280. memberList.appendChild(item);
  2281. });
  2282. }
  2283. // 更新分页
  2284. function updatePagination() {
  2285. const pagination = document.querySelector('.pagination');
  2286. pagination.innerHTML = '';
  2287. // 上一页
  2288. const prevLi = document.createElement('li');
  2289. prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
  2290. prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1})">&laquo;</a>`;
  2291. pagination.appendChild(prevLi);
  2292. // 页码
  2293. for (let i = 1; i <= totalPages; i++) {
  2294. const li = document.createElement('li');
  2295. li.className = `page-item ${i === currentPage ? 'active' : ''}`;
  2296. li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i})">${i}</a>`;
  2297. pagination.appendChild(li);
  2298. }
  2299. // 下一页
  2300. const nextLi = document.createElement('li');
  2301. nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
  2302. nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1})">&raquo;</a>`;
  2303. pagination.appendChild(nextLi);
  2304. }
  2305. // 改变页码
  2306. function changePage(page) {
  2307. if (page < 1 || page > totalPages) return;
  2308. const search = document.getElementById('member-search').value;
  2309. loadMembers(page, search);
  2310. }
  2311. // 搜索成员
  2312. function searchMembers() {
  2313. const search = document.getElementById('member-search').value;
  2314. loadMembers(1, search);
  2315. }
  2316. // 选择成员(支持多条关系)
  2317. function selectMember(member) {
  2318. const index = window.currentRelationIndex || 0;
  2319. const rows = document.querySelectorAll('.relation-row');
  2320. if (rows[index]) {
  2321. const displayInput = rows[index].querySelector('.related-member-display');
  2322. const hiddenInput = rows[index].querySelector('.related_mid');
  2323. if (displayInput) {
  2324. let displayText = member.name;
  2325. if (member.simplified_name && member.simplified_name !== member.name) {
  2326. displayText += ` (${member.simplified_name})`;
  2327. }
  2328. if (member.name_word) {
  2329. displayText += ` · ${member.name_word}`;
  2330. }
  2331. displayInput.value = displayText;
  2332. }
  2333. if (hiddenInput) {
  2334. hiddenInput.value = member.id;
  2335. }
  2336. } else {
  2337. // Fallback for old single relation mode
  2338. const displayInput = document.getElementById('related-member-display');
  2339. const hiddenInput = document.getElementById('related_mid');
  2340. if (displayInput) {
  2341. displayInput.value = member.name;
  2342. }
  2343. if (hiddenInput) {
  2344. hiddenInput.value = member.id;
  2345. }
  2346. }
  2347. // 检查是否是父子关系,如果是,显示父亲的世系世代
  2348. const relationTypeSelect = rows[index] ? rows[index].querySelector('select.relation-type') : document.querySelector('select[name="relation_type"]');
  2349. const relationType = relationTypeSelect ? relationTypeSelect.value : '';
  2350. if (relationType == 1) { // 父子关系
  2351. fetch(`/manager/api/member/${member.id}`)
  2352. .then(response => response.json())
  2353. .then(data => {
  2354. if (data.member && data.member.name_word_generation) {
  2355. // 显示父亲的世系世代
  2356. const lineageContainer = document.getElementById('lineage-generations-container');
  2357. const fatherLineageDiv = document.getElementById('father-lineage');
  2358. if (!fatherLineageDiv) {
  2359. const newDiv = document.createElement('div');
  2360. newDiv.id = 'father-lineage';
  2361. newDiv.className = 'father-lineage-hint';
  2362. newDiv.innerHTML = `
  2363. <div class="d-flex align-items-center">
  2364. <i class="bi bi-info-circle me-2 text-info"></i>
  2365. <div>
  2366. <strong class="text-info">父亲世系世代参考:</strong>
  2367. <span>${data.member.name_word_generation}</span>
  2368. </div>
  2369. </div>
  2370. `;
  2371. // Find the add button container
  2372. const addButtonContainer = document.querySelector('#lineage-generations-container .d-flex.justify-content-between.align-items-center');
  2373. if (addButtonContainer) {
  2374. // Insert after the add button container
  2375. addButtonContainer.parentNode.insertBefore(newDiv, addButtonContainer.nextSibling);
  2376. } else if (lineageContainer.firstChild) {
  2377. // Insert after the first child
  2378. lineageContainer.insertBefore(newDiv, lineageContainer.firstChild.nextSibling);
  2379. } else {
  2380. // Append to container
  2381. lineageContainer.appendChild(newDiv);
  2382. }
  2383. } else {
  2384. fatherLineageDiv.innerHTML = `
  2385. <div class="d-flex align-items-center">
  2386. <i class="bi bi-info-circle me-2 text-info"></i>
  2387. <div>
  2388. <strong class="text-info">父亲世系世代参考:</strong>
  2389. <span>${data.member.name_word_generation}</span>
  2390. </div>
  2391. </div>
  2392. `;
  2393. }
  2394. }
  2395. });
  2396. }
  2397. // 关闭模态框
  2398. const modal = bootstrap.Modal.getInstance(document.getElementById('memberSelectModal'));
  2399. modal.hide();
  2400. }
  2401. // 通过ID选择成员
  2402. function selectMemberById(memberId) {
  2403. // 先在当前加载的成员中查找
  2404. const member = membersData.find(m => m.id === memberId);
  2405. if (member) {
  2406. selectMember(member);
  2407. } else {
  2408. // 如果没找到,从API获取
  2409. fetch(`/manager/api/member/${memberId}`)
  2410. .then(response => response.json())
  2411. .then(data => {
  2412. if (data.member) {
  2413. selectMember(data.member);
  2414. }
  2415. });
  2416. }
  2417. }
  2418. // 监听关系类型变化
  2419. document.addEventListener('DOMContentLoaded', function() {
  2420. // 监听关系类型变化
  2421. const relationTypeSelect = document.querySelector('select[name="relation_type"]');
  2422. if (relationTypeSelect) {
  2423. relationTypeSelect.addEventListener('change', function() {
  2424. const relatedMid = document.getElementById('related_mid').value;
  2425. if (this.value == 1 && relatedMid) {
  2426. // 如果选择了父子关系且已选择关联成员,显示父亲的世系世代
  2427. fetch(`/manager/api/member/${relatedMid}`, {
  2428. credentials: 'include'
  2429. })
  2430. .then(response => response.json())
  2431. .then(data => {
  2432. if (data.member && data.member.name_word_generation) {
  2433. const lineageContainer = document.getElementById('lineage-generations-container');
  2434. const fatherLineageDiv = document.getElementById('father-lineage');
  2435. if (!fatherLineageDiv) {
  2436. const newDiv = document.createElement('div');
  2437. newDiv.id = 'father-lineage';
  2438. newDiv.className = 'father-lineage-hint';
  2439. newDiv.innerHTML = `
  2440. <div class="d-flex align-items-center">
  2441. <i class="bi bi-info-circle me-2 text-info"></i>
  2442. <div>
  2443. <strong class="text-info">父亲世系世代参考:</strong>
  2444. <span>${data.member.name_word_generation}</span>
  2445. </div>
  2446. </div>
  2447. `;
  2448. // Find the add button container
  2449. const addButtonContainer = document.querySelector('#lineage-generations-container .d-flex.justify-content-between.align-items-center');
  2450. if (addButtonContainer) {
  2451. // Insert after the add button container
  2452. addButtonContainer.parentNode.insertBefore(newDiv, addButtonContainer.nextSibling);
  2453. } else if (lineageContainer.firstChild) {
  2454. // Insert after the first child
  2455. lineageContainer.insertBefore(newDiv, lineageContainer.firstChild.nextSibling);
  2456. } else {
  2457. // Append to container
  2458. lineageContainer.appendChild(newDiv);
  2459. }
  2460. } else {
  2461. fatherLineageDiv.innerHTML = `
  2462. <div class="d-flex align-items-center">
  2463. <i class="bi bi-info-circle me-2 text-info"></i>
  2464. <div>
  2465. <strong class="text-info">父亲世系世代参考:</strong>
  2466. <span>${data.member.name_word_generation}</span>
  2467. </div>
  2468. </div>
  2469. `;
  2470. }
  2471. }
  2472. });
  2473. }
  2474. });
  2475. }
  2476. // 监听成员选择模态框显示
  2477. const memberSelectModal = document.getElementById('memberSelectModal');
  2478. memberSelectModal.addEventListener('shown.bs.modal', function() {
  2479. loadMembers();
  2480. });
  2481. });
  2482. </script>
  2483. {% endblock %}