add_member.html 152 KB

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