add_member.html 122 KB

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